diff --git a/packages/api-graphql/__tests__/APIClient.test.ts b/packages/api-graphql/__tests__/APIClient.test.ts index e44cf9fbcae..9ed33dbea8d 100644 --- a/packages/api-graphql/__tests__/APIClient.test.ts +++ b/packages/api-graphql/__tests__/APIClient.test.ts @@ -294,6 +294,32 @@ describe('flattenItems', () => { expect(selSet).toEqual(expected); }); + + test('mix of related and non-related fields in a nested model creates a nested object with all necessary fields', () => { + const selSet = customSelectionSetToIR( + modelIntroSchema.models, + 'CommunityPost', + ['poll.question', 'poll.answers.answer', 'poll.answers.votes.id'] + ); + + const expected = { + poll: { + question: '', + answers: { + items: { + answer: '', + votes: { + items: { + id: '', + }, + }, + }, + }, + }, + }; + + expect(selSet).toEqual(expected); + }); }); describe('generateSelectionSet', () => { diff --git a/packages/api-graphql/__tests__/fixtures/modeled/amplifyconfiguration.js b/packages/api-graphql/__tests__/fixtures/modeled/amplifyconfiguration.js index b9262629e6b..d00b387db1a 100644 --- a/packages/api-graphql/__tests__/fixtures/modeled/amplifyconfiguration.js +++ b/packages/api-graphql/__tests__/fixtures/modeled/amplifyconfiguration.js @@ -623,6 +623,342 @@ const amplifyConfig = { sortKeyFieldNames: ['cpk_sort_key'], }, }, + + // + + CommunityPoll: { + name: 'CommunityPoll', + fields: { + id: { + name: 'id', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + question: { + name: 'question', + isArray: false, + type: 'String', + isRequired: true, + attributes: [], + }, + answers: { + name: 'answers', + isArray: true, + type: { + model: 'CommunityPollAnswer', + }, + isRequired: true, + attributes: [], + isArrayNullable: false, + association: { + connectionType: 'HAS_MANY', + associatedWith: ['communityPollAnswersId'], + }, + }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: true, + attributes: [], + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: true, + attributes: [], + }, + }, + syncable: true, + pluralName: 'CommunityPolls', + attributes: [ + { + type: 'model', + properties: {}, + }, + { + type: 'key', + properties: { + fields: ['id'], + }, + }, + { + type: 'auth', + properties: { + rules: [ + { + allow: 'public', + provider: 'apiKey', + }, + ], + }, + }, + ], + primaryKeyInfo: { + isCustomPrimaryKey: false, + primaryKeyFieldName: 'id', + sortKeyFieldNames: [], + }, + }, + CommunityPollAnswer: { + name: 'CommunityPollAnswer', + fields: { + id: { + name: 'id', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + answer: { + name: 'answer', + isArray: false, + type: 'String', + isRequired: true, + attributes: [], + }, + votes: { + name: 'votes', + isArray: true, + type: { + model: 'CommunityPollVote', + }, + isRequired: true, + attributes: [], + isArrayNullable: false, + association: { + connectionType: 'HAS_MANY', + associatedWith: ['communityPollAnswerVotesId'], + }, + }, + communityPollAnswersId: { + name: 'communityPollAnswersId', + isArray: false, + type: 'ID', + isRequired: false, + attributes: [], + }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: true, + attributes: [], + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: true, + attributes: [], + }, + }, + syncable: true, + pluralName: 'CommunityPollAnswers', + attributes: [ + { + type: 'model', + properties: {}, + }, + { + type: 'key', + properties: { + fields: ['id'], + }, + }, + { + type: 'key', + properties: { + name: 'gsi-CommunityPoll.answers', + fields: ['communityPollAnswersId'], + }, + }, + { + type: 'auth', + properties: { + rules: [ + { + allow: 'public', + provider: 'apiKey', + }, + ], + }, + }, + ], + primaryKeyInfo: { + isCustomPrimaryKey: false, + primaryKeyFieldName: 'id', + sortKeyFieldNames: [], + }, + }, + CommunityPollVote: { + name: 'CommunityPollVote', + fields: { + id: { + name: 'id', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + communityPollAnswerVotesId: { + name: 'communityPollAnswerVotesId', + isArray: false, + type: 'ID', + isRequired: false, + attributes: [], + }, + owner: { + name: 'owner', + isArray: false, + type: 'String', + isRequired: false, + attributes: [], + }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: true, + attributes: [], + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: true, + attributes: [], + }, + }, + syncable: true, + pluralName: 'CommunityPollVotes', + attributes: [ + { + type: 'model', + properties: {}, + }, + { + type: 'key', + properties: { + fields: ['id'], + }, + }, + { + type: 'key', + properties: { + name: 'gsi-CommunityPollAnswer.votes', + fields: ['communityPollAnswerVotesId'], + }, + }, + { + type: 'auth', + properties: { + rules: [ + { + provider: 'userPools', + ownerField: 'owner', + allow: 'owner', + identityClaim: 'cognito:username', + operations: ['create', 'update', 'delete', 'read'], + }, + ], + }, + }, + ], + primaryKeyInfo: { + isCustomPrimaryKey: false, + primaryKeyFieldName: 'id', + sortKeyFieldNames: [], + }, + }, + CommunityPost: { + name: 'CommunityPost', + fields: { + id: { + name: 'id', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + content: { + name: 'content', + isArray: false, + type: 'String', + isRequired: false, + attributes: [], + }, + poll: { + name: 'poll', + isArray: false, + type: { + model: 'CommunityPoll', + }, + isRequired: false, + attributes: [], + association: { + connectionType: 'HAS_ONE', + associatedWith: ['id'], + targetNames: ['communityPostPollId'], + }, + }, + + communityPostPollId: { + name: 'communityPostPollId', + isArray: false, + type: 'ID', + isRequired: false, + attributes: [], + }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: true, + attributes: [], + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: true, + attributes: [], + }, + }, + syncable: true, + pluralName: 'CommunityPosts', + attributes: [ + { + type: 'model', + properties: {}, + }, + { + type: 'key', + properties: { + fields: ['id'], + }, + }, + { + type: 'auth', + properties: { + rules: [ + { + allow: 'public', + provider: 'apiKey', + }, + ], + }, + }, + ], + primaryKeyInfo: { + isCustomPrimaryKey: false, + primaryKeyFieldName: 'id', + sortKeyFieldNames: [], + }, + }, }, enums: { Status: { diff --git a/packages/api-graphql/__tests__/fixtures/modeled/schema.ts b/packages/api-graphql/__tests__/fixtures/modeled/schema.ts index 4fffd0d3439..bc98a3fb7d8 100644 --- a/packages/api-graphql/__tests__/fixtures/modeled/schema.ts +++ b/packages/api-graphql/__tests__/fixtures/modeled/schema.ts @@ -7,7 +7,7 @@ const schema = a.schema({ description: a.string(), notes: a.hasMany('Note'), meta: a.hasOne('TodoMetadata'), - status: a.enum(["NOT_STARTED", "STARTED", "DONE", "CANCELED"]), + status: a.enum(['NOT_STARTED', 'STARTED', 'DONE', 'CANCELED']), tags: a.string().array(), }) .authorization([a.allow.public('apiKey'), a.allow.owner()]), @@ -52,6 +52,24 @@ const schema = a.schema({ otherField: a.string(), }) .identifier(['cpk_cluster_key', 'cpk_sort_key']), + + CommunityPost: a.model({ + id: a.id().required(), + poll: a.hasOne('CommunityPoll'), + }), + CommunityPoll: a.model({ + id: a.id().required(), + question: a.string().required(), + answers: a.hasMany('CommunityPollAnswer').arrayRequired().valueRequired(), + }), + CommunityPollAnswer: a.model({ + id: a.id().required(), + answer: a.string().required(), + votes: a.hasMany('CommunityPollVote').arrayRequired().valueRequired(), + }), + CommunityPollVote: a + .model({ id: a.id().required() }) + .authorization([a.allow.public('apiKey'), a.allow.owner()]), }); export type Schema = ClientSchema; diff --git a/packages/api-graphql/src/internals/APIClient.ts b/packages/api-graphql/src/internals/APIClient.ts index 17b75319c05..f5510684146 100644 --- a/packages/api-graphql/src/internals/APIClient.ts +++ b/packages/api-graphql/src/internals/APIClient.ts @@ -360,57 +360,64 @@ export function customSelectionSetToIR( modelName: string, selectionSet: string[] ): Record { - const modelDefinition = modelDefinitions[modelName]; - const { fields: modelFields } = modelDefinition; + const dotNotationToObject = ( + path: string, + modelName: string + ): Record => { + const [fieldName, ...rest] = path.split('.'); - return selectionSet.reduce((resultObj: Record, path) => { - const [fieldName, nested, ...rest] = path.split('.'); + let result: Record = {}; - if (nested) { - const fieldType = modelFields[fieldName]?.type as ModelFieldType; - const relatedModel = fieldType.model; + if (rest.length === 0) { + result = { [fieldName]: FIELD_IR }; + } else { + const nested = rest[0]; + const modelDefinition = modelDefinitions[modelName]; + const modelFields = modelDefinition.fields; + const relatedModel = (modelFields[fieldName]?.type as ModelFieldType)?.model; if (!relatedModel) { // TODO: may need to change this to support custom types throw Error(`${fieldName} is not a model field`); } - const relatedModelDefinition = modelDefinitions[relatedModel]; + if (nested === SELECTION_SET_WILDCARD) { + const relatedModelDefinition = modelDefinitions[relatedModel]; - const selectionSet = - nested === SELECTION_SET_WILDCARD - ? defaultSelectionSetIR(relatedModelDefinition) - : // if we have a path like 'field.anotherField' recursively build up selection set IR - customSelectionSetToIR(modelDefinitions, relatedModel, [ - [nested, ...rest].join('.'), - ]); - - if (modelFields[fieldName]?.isArray) { - const existing = resultObj[fieldName] || { - items: {}, + result = { + [fieldName]: defaultSelectionSetIR(relatedModelDefinition), }; - const merged = { ...existing.items, ...selectionSet }; + } else { + const exists = Boolean(modelFields[fieldName]); + if (!exists) { + throw Error(`${fieldName} is not a field of model ${modelName}`); + } - resultObj[fieldName] = { items: merged }; - return resultObj; + result = { + [fieldName]: dotNotationToObject(rest.join('.'), relatedModel), + }; } - const existingItems = resultObj[fieldName] || {}; - const merged = { ...existingItems, ...selectionSet }; - - resultObj[fieldName] = merged; - return resultObj; + if (modelFields[fieldName]?.isArray) { + result = { + [fieldName]: { + items: result[fieldName], + }, + }; + } } - const exists = Boolean(modelFields[fieldName]); - - if (!exists) { - throw Error(`${fieldName} is not a field of model ${modelName}`); - } + return result; + }; - resultObj[fieldName] = FIELD_IR; - return resultObj; - }, {}); + return selectionSet.reduce( + (resultObj, path) => + deepMergeSelectionSetObjects( + dotNotationToObject(path, modelName), + resultObj + ), + {} as Record + ); } const defaultSelectionSetIR = (relatedModelDefinition: SchemaModel) => { @@ -472,6 +479,34 @@ export function selectionSetIRToString( return res.join(' '); } +/** + * Recursively merges selection set objects from `source` onto `target`. + * + * `target` will be updated. `source` will be left alone. + * + * @param source The object to merge into target. + * @param target The object to be mutated. + */ +function deepMergeSelectionSetObjects>( + source: T, + target: T +) { + const isObject = (obj: any) => obj && typeof obj === 'object'; + + for (const key in source) { + // This verification avoids 'Prototype Pollution' issue + if (!source.hasOwnProperty(key)) continue; + + if (target.hasOwnProperty(key) && isObject(target[key])) { + deepMergeSelectionSetObjects(source[key], target[key]); + } else { + target[key] = source[key]; + } + } + + return target; +} + export function generateSelectionSet( modelDefinitions: SchemaModels, modelName: string, diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index b13c5d3b1a3..62bc512ed2e 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -325,7 +325,7 @@ "name": "[API] generateClient (AppSync)", "path": "./dist/esm/api/index.mjs", "import": "{ generateClient }", - "limit": "35.89 kB" + "limit": "36 kB" }, { "name": "[API] REST API handlers",