diff --git a/examples/simple/src/ent/contact/actions/edit_contact_action.ts b/examples/simple/src/ent/contact/actions/edit_contact_action.ts index d6496f911..2f582759f 100644 --- a/examples/simple/src/ent/contact/actions/edit_contact_action.ts +++ b/examples/simple/src/ent/contact/actions/edit_contact_action.ts @@ -1,9 +1,35 @@ +import { ContactEmail } from "src/ent"; import { EditContactActionBase, ContactEditInput, + EditContactActionTriggers, } from "../../generated/contact/actions/edit_contact_action_base"; +import EditContactEmailAction from "src/ent/contact_email/actions/edit_contact_email_action"; export { ContactEditInput }; // we're only writing this once except with --force and packageName provided -export default class EditContactAction extends EditContactActionBase {} +export default class EditContactAction extends EditContactActionBase { + getTriggers(): EditContactActionTriggers { + return [ + { + async changeset(builder, input) { + if (!input.emails) { + return; + } + return Promise.all( + input.emails.map(async (emailInput) => { + const email = await ContactEmail.loadX( + builder.viewer, + emailInput.id, + ); + return EditContactEmailAction.create(builder.viewer, email, { + ...emailInput, + }).changeset(); + }), + ); + }, + }, + ]; + } +} diff --git a/examples/simple/src/ent/generated/contact/actions/edit_contact_action_base.ts b/examples/simple/src/ent/generated/contact/actions/edit_contact_action_base.ts index bbc08c9b3..8495b2aeb 100644 --- a/examples/simple/src/ent/generated/contact/actions/edit_contact_action_base.ts +++ b/examples/simple/src/ent/generated/contact/actions/edit_contact_action_base.ts @@ -19,13 +19,22 @@ import { } from "@snowtop/ent/action"; import { Contact } from "../../.."; import { ContactBuilder } from "./contact_builder"; +import { ContactInfo, ContactLabel } from "../../types"; import { ExampleViewer as ExampleViewerAlias } from "../../../../viewer/viewer"; +interface customEmailInput { + id: ID; + extra?: ContactInfo | null; + emailAddress?: string; + label?: ContactLabel; +} + export interface ContactEditInput { emailIds?: ID[]; phoneNumberIds?: ID[]; firstName?: string; lastName?: string; + emails?: customEmailInput[] | null; } export type EditContactActionTriggers = ( diff --git a/examples/simple/src/ent/tests/contact.test.ts b/examples/simple/src/ent/tests/contact.test.ts index 324a2820f..6c3e64184 100644 --- a/examples/simple/src/ent/tests/contact.test.ts +++ b/examples/simple/src/ent/tests/contact.test.ts @@ -1,4 +1,4 @@ -import { User, Contact } from "../../ent"; +import { User, Contact, ContactEmail } from "../../ent"; import { randomEmail, randomPhoneNumber } from "../../util/random"; import CreateUserAction from "../user/actions/create_user_action"; import CreateContactAction, { @@ -238,6 +238,28 @@ test("multiple emails", async () => { ); expect(r3.length).toBe(1); expect(r3[0].id).toBe(contact.id); + + const email1 = emails[0]; + const newEmail = randomEmail(); + const editedContact = await EditContactAction.create( + contact.viewer, + contact, + { + firstName: "Aegon", + lastName: "Targaryen", + emails: [ + { + id: email1.id, + emailAddress: newEmail, + }, + ], + }, + ).saveX(); + expect(editedContact.firstName).toBe("Aegon"); + expect(editedContact.lastName).toBe("Targaryen"); + + const email1Reloaded = await ContactEmail.loadX(contact.viewer, email1.id); + expect(email1Reloaded.emailAddress).toBe(newEmail); }); test("multiple phonenumbers", async () => { diff --git a/examples/simple/src/graphql/generated/mutations/contact/contact_edit_type.ts b/examples/simple/src/graphql/generated/mutations/contact/contact_edit_type.ts index c008c8829..efe4240b2 100644 --- a/examples/simple/src/graphql/generated/mutations/contact/contact_edit_type.ts +++ b/examples/simple/src/graphql/generated/mutations/contact/contact_edit_type.ts @@ -21,7 +21,8 @@ import { Contact } from "../../../../ent"; import EditContactAction, { ContactEditInput, } from "../../../../ent/contact/actions/edit_contact_action"; -import { ContactType } from "../../../resolvers"; +import { ContactInfoInputType } from "../input/contact_info_input_type"; +import { ContactLabelType, ContactType } from "../../../resolvers"; import { ExampleViewer as ExampleViewerAlias } from "../../../../viewer/viewer"; interface customContactEditInput extends ContactEditInput { @@ -32,6 +33,24 @@ interface ContactEditPayload { contact: Contact; } +export const EmailContactEditInput = new GraphQLInputObjectType({ + name: "EmailContactEditInput", + fields: (): GraphQLInputFieldConfigMap => ({ + id: { + type: new GraphQLNonNull(GraphQLID), + }, + extra: { + type: ContactInfoInputType, + }, + emailAddress: { + type: new GraphQLNonNull(GraphQLString), + }, + label: { + type: new GraphQLNonNull(ContactLabelType), + }, + }), +}); + export const ContactEditInputType = new GraphQLInputObjectType({ name: "ContactEditInput", fields: (): GraphQLInputFieldConfigMap => ({ @@ -51,6 +70,9 @@ export const ContactEditInputType = new GraphQLInputObjectType({ lastName: { type: GraphQLString, }, + emails: { + type: new GraphQLList(new GraphQLNonNull(EmailContactEditInput)), + }, }), }); @@ -96,6 +118,7 @@ export const ContactEditType: GraphQLFieldConfig< : undefined, firstName: input.firstName, lastName: input.lastName, + emails: input.emails, }, ); return { contact }; diff --git a/examples/simple/src/graphql/generated/schema.gql b/examples/simple/src/graphql/generated/schema.gql index 59d677f7a..dbc344370 100644 --- a/examples/simple/src/graphql/generated/schema.gql +++ b/examples/simple/src/graphql/generated/schema.gql @@ -1054,6 +1054,7 @@ input ContactEditInput { phoneNumberIds: [ID!] firstName: String lastName: String + emails: [EmailContactEditInput!] } type ContactEditPayload { @@ -1196,6 +1197,13 @@ input EmailContactCreateInput { label: ContactLabel! } +input EmailContactEditInput { + id: ID! + extra: ContactInfoInput + emailAddress: String! + label: ContactLabel! +} + input EventAddHostInput { """id of Event""" id: ID! diff --git a/examples/simple/src/graphql/generated/schema.ts b/examples/simple/src/graphql/generated/schema.ts index 5d43a962c..aa1664c1a 100644 --- a/examples/simple/src/graphql/generated/schema.ts +++ b/examples/simple/src/graphql/generated/schema.ts @@ -29,6 +29,7 @@ import { import { ContactEditInputType, ContactEditPayloadType, + EmailContactEditInput, } from "./mutations/contact/contact_edit_type"; import { ContactEmailCreateInputType, @@ -377,6 +378,7 @@ export default new GraphQLSchema({ EditPhoneNumberInputType, EditPhoneNumberPayloadType, EmailContactCreateInput, + EmailContactEditInput, EventAddHostInputType, EventAddHostPayloadType, EventArgInputType, diff --git a/examples/simple/src/schema/contact_schema.ts b/examples/simple/src/schema/contact_schema.ts index a325faaf8..7047fa7f2 100644 --- a/examples/simple/src/schema/contact_schema.ts +++ b/examples/simple/src/schema/contact_schema.ts @@ -64,6 +64,15 @@ const ContactSchema = new EntSchema({ }, { operation: ActionOperation.Edit, + actionOnlyFields: [ + { + name: "emails", + list: true, + nullable: true, + type: "Object", + actionName: "EditContactEmailAction", + }, + ], }, { operation: ActionOperation.Delete, diff --git a/internal/action/action.go b/internal/action/action.go index 3c927c7ec..b97a651e0 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -30,7 +30,7 @@ func parseActionsFromInput(cfg codegenapi.Config, nodeName string, action *input // create/edit/delete concreteAction, ok := typ.(concreteNodeActionType) if ok { - fields, err := getFieldsForAction(nodeName, action, fieldInfo, concreteAction) + fields, primaryKeyField, err := getFieldsForAction(nodeName, action, fieldInfo, concreteAction) if err != nil { return nil, err } @@ -40,6 +40,8 @@ func parseActionsFromInput(cfg codegenapi.Config, nodeName string, action *input return nil, err } + opt.primaryKeyField = primaryKeyField + commonInfo := getCommonInfo( cfg, nodeName, @@ -82,10 +84,11 @@ func getActionsForMutationsType(cfg codegenapi.Config, nodeName string, fieldInf var actions []Action createTyp := &createActionType{} - fields, err := getFieldsForAction(nodeName, action, fieldInfo, createTyp) + fields, primaryKeyField, err := getFieldsForAction(nodeName, action, fieldInfo, createTyp) if err != nil { return nil, err } + opt.primaryKeyField = primaryKeyField actions = append(actions, getCreateAction( getCommonInfo( cfg, @@ -102,10 +105,11 @@ func getActionsForMutationsType(cfg codegenapi.Config, nodeName string, fieldInf )) editTyp := &editActionType{} - fields, err = getFieldsForAction(nodeName, action, fieldInfo, editTyp) + fields, primaryKeyField, err = getFieldsForAction(nodeName, action, fieldInfo, editTyp) if err != nil { return nil, err } + opt.primaryKeyField = primaryKeyField actions = append(actions, getEditAction( getCommonInfo( cfg, @@ -122,7 +126,8 @@ func getActionsForMutationsType(cfg codegenapi.Config, nodeName string, fieldInf )) deleteTyp := &deleteActionType{} - fields, err = getFieldsForAction(nodeName, action, fieldInfo, deleteTyp) + fields, primaryKeyField, err = getFieldsForAction(nodeName, action, fieldInfo, deleteTyp) + opt.primaryKeyField = primaryKeyField if err != nil { return nil, err } @@ -146,10 +151,21 @@ func getActionsForMutationsType(cfg codegenapi.Config, nodeName string, fieldInf // provides a way to say this action doesn't have any fields const NO_FIELDS = "__NO_FIELDS__" -func getFieldsForAction(nodeName string, action *input.Action, fieldInfo *field.FieldInfo, typ concreteNodeActionType) ([]*field.Field, error) { +func getFieldsForAction(nodeName string, action *input.Action, fieldInfo *field.FieldInfo, typ concreteNodeActionType) ([]*field.Field, *field.Field, error) { + var primaryKeyField *field.Field + + if fieldInfo != nil && typ.mutatingExistingObject() { + for _, f := range fieldInfo.EntFields() { + if f.SingleFieldPrimaryKey() { + primaryKeyField = f + break + } + } + } + var fields []*field.Field if !typ.supportsFieldsFromEnt() { - return fields, nil + return fields, primaryKeyField, nil } fieldNames := action.Fields @@ -170,11 +186,11 @@ func getFieldsForAction(nodeName string, action *input.Action, fieldInfo *field. } if len(fieldNames) != 0 && len(excludedFields) != 0 { - return nil, fmt.Errorf("cannot provide both fields and excluded fields") + return nil, nil, fmt.Errorf("cannot provide both fields and excluded fields") } if noFields { - return fields, nil + return fields, primaryKeyField, nil } getField := func(f *field.Field, fieldName string) (*field.Field, error) { @@ -236,7 +252,7 @@ func getFieldsForAction(nodeName string, action *input.Action, fieldInfo *field. if f.ExposeToActionsByDefault() && f.EditableField(typ.getEditableFieldContext()) && !excludedFields[f.FieldName] { f2, err := getField(f, f.FieldName) if err != nil { - return nil, err + return nil, nil, err } fields = append(fields, f2) } @@ -246,15 +262,16 @@ func getFieldsForAction(nodeName string, action *input.Action, fieldInfo *field. for _, fieldName := range fieldNames { f, err := getField(nil, fieldName) if err != nil { - return nil, err + return nil, nil, err } if !f.EditableField(typ.getEditableFieldContext()) { - return nil, fmt.Errorf("field %s is not editable and cannot be added to action", fieldName) + return nil, nil, fmt.Errorf("field %s is not editable and cannot be added to action", fieldName) } fields = append(fields, f) } } - return fields, nil + + return fields, primaryKeyField, nil } func getNonEntFieldsFromInput(cfg codegenapi.Config, nodeName string, action *input.Action, typ concreteNodeActionType) ([]*field.NonEntField, error) { @@ -496,6 +513,7 @@ func getCommonInfo( NodeInfo: nodeinfo.GetNodeInfo(nodeName), Operation: typ.getOperation(), tranformsDelete: opt.transformsDelete, + primaryKeyField: opt.primaryKeyField, } } diff --git a/internal/action/action_types.go b/internal/action/action_types.go index 0af644a74..4476df1fa 100644 --- a/internal/action/action_types.go +++ b/internal/action/action_types.go @@ -29,6 +29,7 @@ type concreteNodeActionType interface { getDefaultGraphQLInputName(cfg codegenapi.Config, nodeName string) string getEditableFieldContext() field.EditableContext supportsFieldsFromEnt() bool + mutatingExistingObject() bool } type concreteEdgeActionType interface { @@ -81,6 +82,10 @@ func (action *createActionType) supportsFieldsFromEnt() bool { return true } +func (action *createActionType) mutatingExistingObject() bool { + return false +} + func (action *createActionType) getActionVerb() string { return "create" } @@ -141,6 +146,10 @@ func (action *editActionType) getEditableFieldContext() field.EditableContext { return field.EditEditableContext } +func (action *editActionType) mutatingExistingObject() bool { + return true +} + var _ concreteNodeActionType = &editActionType{} type deleteActionType struct { @@ -186,6 +195,10 @@ func (action *deleteActionType) getEditableFieldContext() field.EditableContext return field.DeleteEditableContext } +func (action *deleteActionType) mutatingExistingObject() bool { + return true +} + var _ concreteNodeActionType = &deleteActionType{} type mutationsActionType struct { diff --git a/internal/action/interface.go b/internal/action/interface.go index 2b5d6867c..618a7ade3 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -28,6 +28,8 @@ import ( type Action interface { GetFields() []*field.Field + // get public fields. this is GetFields() + id for editable fields + GetPublicAPIFields() []*field.Field GetGraphQLFields() []*field.Field GetNonEntFields() []*field.NonEntField GetGraphQLNonEntFields() []*field.NonEntField @@ -60,6 +62,7 @@ type Action interface { } type ActionField interface { + SingleFieldPrimaryKey() bool GetFieldType() enttype.Type TsFieldName(cfg codegenapi.Config) string TsBuilderType(cfg codegenapi.Config) string @@ -139,6 +142,7 @@ type commonActionInfo struct { gqlEnums []*enum.GQLEnum nodeinfo.NodeInfo tranformsDelete bool + primaryKeyField *field.Field canViewerDo *input.CanViewerDo canFail bool } @@ -184,7 +188,18 @@ func (action *commonActionInfo) GetFields() []*field.Field { return action.Fields } +func (action *commonActionInfo) GetPublicAPIFields() []*field.Field { + var ret []*field.Field + if action.primaryKeyField != nil { + ret = append(ret, action.primaryKeyField) + } + ret = append(ret, action.Fields...) + + return ret +} + func (action *commonActionInfo) GetGraphQLFields() []*field.Field { + // TODO update this to use GetPublicAPIFields? var ret []*field.Field for _, f := range action.Fields { if f.EditableGraphQLField() { @@ -380,6 +395,7 @@ type EdgeGroupAction struct { type option struct { transformsDelete bool + primaryKeyField *field.Field } type Option func(*option) @@ -558,6 +574,10 @@ func GetEdgesFromEdges(edges []*edge.AssociationEdge) []EdgeActionTemplateInfo { } func IsRequiredField(action Action, field ActionField) bool { + if field.SingleFieldPrimaryKey() { + return true + } + if field.ForceRequiredInAction() { return true } diff --git a/internal/field/field.go b/internal/field/field.go index 05c95a0cc..586d094fa 100644 --- a/internal/field/field.go +++ b/internal/field/field.go @@ -914,6 +914,7 @@ func Nullable() Option { return func(f *Field) { f.nullable = true f.forceOptionalInAction = true + f.graphqlNullable = true } } diff --git a/internal/field/non_ent_field.go b/internal/field/non_ent_field.go index b3cd6a7c0..af436f828 100644 --- a/internal/field/non_ent_field.go +++ b/internal/field/non_ent_field.go @@ -145,3 +145,7 @@ func (f *NonEntField) GetTsTypeImports() []*tsimport.ImportPath { func (f *NonEntField) GetTSGraphQLTypeForFieldImports(input bool) []*tsimport.ImportPath { return f.fieldType.GetTSGraphQLImports(input) } + +func (f *NonEntField) SingleFieldPrimaryKey() bool { + return false +} diff --git a/internal/graphql/generate_ts_code.go b/internal/graphql/generate_ts_code.go index e3656a34a..04efeb644 100644 --- a/internal/graphql/generate_ts_code.go +++ b/internal/graphql/generate_ts_code.go @@ -2466,6 +2466,7 @@ func buildActionInputNode(processor *codegen.Processor, nodeData *schema.NodeDat } // add id field for edit and delete mutations + // TODO use GetPublicAPIFields and remove this check everywhere that's doing this check here if a.MutatingExistingObject() { id, err := getIDField(processor, nodeData) if err != nil { diff --git a/internal/schema/schema.go b/internal/schema/schema.go index d3134824a..f3f9ff74c 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -1667,28 +1667,26 @@ func (s *Schema) addActionFields(info *NodeDataInfo) error { typ := f.GetFieldType() t := typ.(enttype.TSTypeWithActionFields) - for _, f2 := range a2.GetFields() { + for _, f2 := range a2.GetPublicAPIFields() { if f2.EmbeddableInParentAction() && !excludedFields[f2.FieldName] { - f3 := f2 + var opts []field.Option if action.IsRequiredField(a2, f2) { - var err error - f3, err = f2.Clone(field.Required()) - if err != nil { - return err - } + opts = append(opts, field.Required()) + } else { + // e.g. if field is not currently required + // e.g. field in edit action or optional non-nullable field in action + + // force optional in action. fake the field as nullable + // this is kinda like a hack because we have nullable and optional + // conflated in so many places. + // we use field.Nullable when rendering interfaces in interface.tmpl to determine if optional + opts = append(opts, field.Nullable()) } - // force optional in action. fake the field as nullable - // this is kinda like a hack because we have nullable and optional - // conflated in so many places. - // we use field.Nullable when rendering interfaces in interface.tmpl to determine if optional - - if f2.ForceOptionalInAction() { - var err error - f3, err = f2.Clone(field.Nullable()) - if err != nil { - return err - } + var err error + f3, err := f2.Clone(opts...) + if err != nil { + return err } a.AddCustomField(t, f3) }