From a26f76e56fb66b004bc158a2b444fcbc7c5d6685 Mon Sep 17 00:00:00 2001 From: Hein Jeong Date: Fri, 17 Feb 2023 00:50:16 +0000 Subject: [PATCH] fix: improve join table identification --- .../lib/__tests__/__utils__/mock-schemas.ts | 402 ++++++++++++++++++ .../__tests__/generic-from-datastore.test.ts | 14 +- .../codegen-ui/lib/generic-from-datastore.ts | 126 ++++-- 3 files changed, 514 insertions(+), 28 deletions(-) diff --git a/packages/codegen-ui/lib/__tests__/__utils__/mock-schemas.ts b/packages/codegen-ui/lib/__tests__/__utils__/mock-schemas.ts index 2eefc390a..4b9bbad1d 100644 --- a/packages/codegen-ui/lib/__tests__/__utils__/mock-schemas.ts +++ b/packages/codegen-ui/lib/__tests__/__utils__/mock-schemas.ts @@ -2681,3 +2681,405 @@ export const schemaWithHasManyBelongsTo: Schema = { codegenVersion: '3.3.5', version: 'f2f8e885f81740e5be20b201c850fa05', }; + +/** + type Box @model { + id: ID! + name: String + crateID: ID! @index(name: "byCrate") + Crate: Crate @belongsTo(fields: ["crateID"]) +} + +type Crate @model { + id: ID! + destination: String + Boxes: [Box] @hasMany(indexName: "byCrate", fields: ["id"]) +} + +type User @model { + id: ID! + Entries: [Entry] @hasMany(indexName: "byUser", fields: ["id"]) + Images: [Image] @hasMany(indexName: "byUser", fields: ["id"]) +} + +type Entry @model { + id: ID! + userID: ID! @index(name: "byUser") + User: User @belongsTo(fields: ["userID"]) + Images: [Image] @hasMany(indexName: "byEntry", fields: ["id"]) +} + +type Image @model { + id: ID! + userID: ID! @index(name: "byUser") + entryID: ID! @index(name: "byEntry") + User: User @belongsTo(fields: ["userID"]) + Entry: Entry @belongsTo(fields: ["entryID"]) +} + */ +export const schemaWithoutJoinTables: Schema = { + models: { + User: { + name: 'User', + fields: { + id: { + name: 'id', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + Entries: { + name: 'Entries', + isArray: true, + type: { + model: 'Entry', + }, + isRequired: false, + attributes: [], + isArrayNullable: true, + association: { + connectionType: 'HAS_MANY', + associatedWith: ['User'], + }, + }, + Images: { + name: 'Images', + isArray: true, + type: { + model: 'Image', + }, + isRequired: false, + attributes: [], + isArrayNullable: true, + association: { + connectionType: 'HAS_MANY', + associatedWith: ['User'], + }, + }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + }, + syncable: true, + pluralName: 'Users', + attributes: [ + { + type: 'model', + properties: {}, + }, + ], + }, + Entry: { + name: 'Entry', + fields: { + id: { + name: 'id', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + userID: { + name: 'userID', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + User: { + name: 'User', + isArray: false, + type: { + model: 'User', + }, + isRequired: false, + attributes: [], + association: { + connectionType: 'BELONGS_TO', + targetNames: ['userID'], + }, + }, + Images: { + name: 'Images', + isArray: true, + type: { + model: 'Image', + }, + isRequired: false, + attributes: [], + isArrayNullable: true, + association: { + connectionType: 'HAS_MANY', + associatedWith: ['Entry'], + }, + }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + }, + syncable: true, + pluralName: 'Entries', + attributes: [ + { + type: 'model', + properties: {}, + }, + { + type: 'key', + properties: { + name: 'byUser', + fields: ['userID'], + }, + }, + ], + }, + Image: { + name: 'Image', + fields: { + id: { + name: 'id', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + userID: { + name: 'userID', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + entryID: { + name: 'entryID', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + User: { + name: 'User', + isArray: false, + type: { + model: 'User', + }, + isRequired: false, + attributes: [], + association: { + connectionType: 'BELONGS_TO', + targetNames: ['userID'], + }, + }, + Entry: { + name: 'Entry', + isArray: false, + type: { + model: 'Entry', + }, + isRequired: false, + attributes: [], + association: { + connectionType: 'BELONGS_TO', + targetNames: ['entryID'], + }, + }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + }, + syncable: true, + pluralName: 'Images', + attributes: [ + { + type: 'model', + properties: {}, + }, + { + type: 'key', + properties: { + name: 'byUser', + fields: ['userID'], + }, + }, + { + type: 'key', + properties: { + name: 'byEntry', + fields: ['entryID'], + }, + }, + ], + }, + Box: { + name: 'Box', + fields: { + id: { + name: 'id', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + name: { + name: 'name', + isArray: false, + type: 'String', + isRequired: false, + attributes: [], + }, + crateID: { + name: 'crateID', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + Crate: { + name: 'Crate', + isArray: false, + type: { + model: 'Crate', + }, + isRequired: false, + attributes: [], + association: { + connectionType: 'BELONGS_TO', + targetNames: ['crateID'], + }, + }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + }, + syncable: true, + pluralName: 'Boxes', + attributes: [ + { + type: 'model', + properties: {}, + }, + { + type: 'key', + properties: { + name: 'byCrate', + fields: ['crateID'], + }, + }, + ], + }, + Crate: { + name: 'Crate', + fields: { + id: { + name: 'id', + isArray: false, + type: 'ID', + isRequired: true, + attributes: [], + }, + destination: { + name: 'destination', + isArray: false, + type: 'String', + isRequired: false, + attributes: [], + }, + Boxes: { + name: 'Boxes', + isArray: true, + type: { + model: 'Box', + }, + isRequired: false, + attributes: [], + isArrayNullable: true, + association: { + connectionType: 'HAS_MANY', + associatedWith: ['Crate'], + }, + }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + }, + syncable: true, + pluralName: 'Crates', + attributes: [ + { + type: 'model', + properties: {}, + }, + ], + }, + }, + enums: {}, + nonModels: {}, + codegenVersion: '3.3.5', + version: '925d97d5ee6e402764bce3a9c0e546c1', +}; diff --git a/packages/codegen-ui/lib/__tests__/generic-from-datastore.test.ts b/packages/codegen-ui/lib/__tests__/generic-from-datastore.test.ts index d82d96b26..8e81664a7 100644 --- a/packages/codegen-ui/lib/__tests__/generic-from-datastore.test.ts +++ b/packages/codegen-ui/lib/__tests__/generic-from-datastore.test.ts @@ -24,6 +24,7 @@ import { schemaWithCPK, schemaWithCompositeKeys, schemaWithHasManyBelongsTo, + schemaWithoutJoinTables, } from './__utils__/mock-schemas'; describe('getGenericFromDataStore', () => { @@ -234,12 +235,17 @@ describe('getGenericFromDataStore', () => { }); it('should correctly identify join tables', () => { - const genericSchema = getGenericFromDataStore(schemaWithRelationships); - const joinTables = Object.entries(genericSchema.models) + const genericSchemaWithJoinTable = getGenericFromDataStore(schemaWithRelationships); + const joinTables1 = Object.entries(genericSchemaWithJoinTable.models) + .filter(([, model]) => model.isJoinTable) + .map(([name]) => name); + expect(joinTables1).toStrictEqual(['StudentTeacher']); + + const genericSchemaWithoutJoinTable = getGenericFromDataStore(schemaWithoutJoinTables); + const joinTables2 = Object.entries(genericSchemaWithoutJoinTable.models) .filter(([, model]) => model.isJoinTable) .map(([name]) => name); - expect(joinTables).toHaveLength(1); - expect(joinTables).toStrictEqual(['StudentTeacher']); + expect(joinTables2).toHaveLength(0); }); it('should correctly identify primary keys', () => { diff --git a/packages/codegen-ui/lib/generic-from-datastore.ts b/packages/codegen-ui/lib/generic-from-datastore.ts index 6c9cd8fb4..2024b314b 100644 --- a/packages/codegen-ui/lib/generic-from-datastore.ts +++ b/packages/codegen-ui/lib/generic-from-datastore.ts @@ -13,14 +13,104 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type { - Schema as DataStoreSchema, - ModelField, - SchemaModel as DataStoreSchemaModel, -} from '@aws-amplify/datastore'; +import { Schema as DataStoreSchema, ModelField, SchemaModel as DataStoreSchemaModel } from '@aws-amplify/datastore'; import { InvalidInputError } from './errors'; import { GenericDataField, GenericDataRelationshipType, GenericDataSchema } from './types'; +const isFieldModelType = (field: ModelField): field is ModelField & { type: { model: string } } => + typeof field.type === 'object' && 'model' in field.type; + +const getAssociatedFieldNames = (field: ModelField): string[] => { + if (!field.association || !('associatedWith' in field.association)) { + return []; + } + + return Array.isArray(field.association.associatedWith) + ? field.association.associatedWith + : [field.association.associatedWith]; +}; + +/** + Disclaimer: there's no 100% sure way of telling if something's a join table. + This is best effort. + Feature request w/ amplify-codegen: https://github.com/aws-amplify/amplify-codegen/issues/543 + After fulfilled, this can be fallback + */ +function checkIsModelAJoinTable(modelName: string, schema: DataStoreSchema) { + const model = schema.models[modelName]; + if (!model) { + return false; + } + + let numberOfKeyTypeAttributes = 0; + + const allowedNonModelFields: string[] = ['id', 'createdAt', 'updatedAt']; + + model.attributes?.forEach((attribute) => { + if (attribute.type === 'key') { + numberOfKeyTypeAttributes += 1; + if (attribute.properties && 'fields' in attribute.properties && Array.isArray(attribute.properties.fields)) { + allowedNonModelFields.push(...attribute.properties.fields); + } + } + }); + + // should have 2 keys + if (numberOfKeyTypeAttributes !== 2) { + return false; + } + + const modelFieldTuples: [string, ModelField][] = []; + let allFieldsAllowed = true; + + Object.entries(model.fields).forEach((field) => { + const [name, value] = field; + if (isFieldModelType(value)) { + modelFieldTuples.push(field); + } else if (!allowedNonModelFields.includes(name)) { + allFieldsAllowed = false; + } + }); + + // non-model fields should be limited + if (!allFieldsAllowed) { + return false; + } + + // should have 2 model fields + if (modelFieldTuples.length !== 2) { + return false; + } + + return modelFieldTuples.every(([fieldName, fieldValue]) => { + // should be required + if (!fieldValue.isRequired) { + return false; + } + + // should be BELONGS_TO + if (fieldValue.association?.connectionType !== 'BELONGS_TO') { + return false; + } + const relatedModel = isFieldModelType(fieldValue) && schema.models[fieldValue.type.model]; + + if (!relatedModel) { + return false; + } + + // should be bidirectional with HAS_MANY + // that has a different model type + return Object.values(relatedModel.fields).some((field) => { + if (!isFieldModelType(field) || field.association?.connectionType !== 'HAS_MANY') { + return false; + } + + const associatedFieldNames = getAssociatedFieldNames(field); + return associatedFieldNames.length === 1 && associatedFieldNames.includes(fieldName); + }); + }); +} + function getGenericDataField(field: ModelField): GenericDataField { return { dataType: field.type, @@ -99,7 +189,7 @@ export function getGenericFromDataStore(dataStoreSchema: DataStoreSchema): Gener const genericField = getGenericDataField(field); // handle relationships - if (typeof field.type === 'object' && 'model' in field.type) { + if (isFieldModelType(field)) { if (field.association) { const relationshipType = field.association.connectionType; @@ -110,9 +200,7 @@ export function getGenericFromDataStore(dataStoreSchema: DataStoreSchema): Gener if (relationshipType === 'HAS_MANY' && 'associatedWith' in field.association) { const associatedModel = dataStoreSchema.models[relatedModelName]; - const associatedFieldNames = Array.isArray(field.association?.associatedWith) - ? field.association.associatedWith - : [field.association.associatedWith]; + const associatedFieldNames = getAssociatedFieldNames(field); let canUnlinkAssociatedModel = true; associatedFieldNames.forEach((associatedFieldName) => { @@ -121,22 +209,15 @@ export function getGenericFromDataStore(dataStoreSchema: DataStoreSchema): Gener if (associatedField?.isRequired) { canUnlinkAssociatedModel = false; } + // if the associated model is a join table, update relatedModelName to the actual related model - if ( - associatedField && - typeof associatedField.type === 'object' && - 'model' in associatedField.type && - associatedField.type.model === model.name - ) { + if (associatedModel && checkIsModelAJoinTable(associatedModel.name, dataStoreSchema)) { joinTableNames.push(associatedModel.name); const relatedJoinField = Object.values(associatedModel.fields).find( - (joinField) => - joinField.name !== associatedFieldName && - typeof joinField.type === 'object' && - 'model' in joinField.type, + (joinField) => joinField.name !== associatedFieldName && isFieldModelType(joinField), ); - if (relatedJoinField && typeof relatedJoinField.type === 'object' && 'model' in relatedJoinField.type) { + if (relatedJoinField && isFieldModelType(relatedJoinField)) { relatedJoinTableName = relatedModelName; relatedModelName = relatedJoinField.type.model; relatedJoinFieldName = relatedJoinField.name; @@ -153,10 +234,7 @@ export function getGenericFromDataStore(dataStoreSchema: DataStoreSchema): Gener const belongsToFieldOnRelatedModelTuple = Object.entries(associatedModel?.fields ?? {}).find( ([, f]) => - typeof f.type === 'object' && - 'model' in f.type && - f.type.model === model.name && - f.association?.connectionType === 'BELONGS_TO', + isFieldModelType(f) && f.type.model === model.name && f.association?.connectionType === 'BELONGS_TO', ); if (belongsToFieldOnRelatedModelTuple && belongsToFieldOnRelatedModelTuple[1].isRequired) {