Skip to content

Commit 956d24e

Browse files
committed
fix(graphql-model-transformer): derive imported DynamoDB table key schema from @PrimaryKey
Imported Amplify-managed DynamoDB tables (Gen 1 -> Gen 2 migration via migratedAmplifyGen1DynamoDbTableMappings) hardcoded the table partition key to `id` when synthesizing the Custom::ImportedAmplifyDynamoDBTable resource. For owned tables the @PrimaryKey transformer corrects the key schema downstream via a CloudFormation property override, but that correction does not reach the TableManager import-validation path, which consumes the initial construct properties directly. As a result, any model declaring a custom @PrimaryKey (partition key other than `id`, and/or a sort key) failed import validation with "Imported table properties did not match the expected table properties", rolling back the whole branch stack (a failed CREATE that must then be deleted manually before retry). GSIs were unaffected because they are already derived from the model. Fix: in AmplifyDynamoModelResourceGenerator.createModelTable, for imported tables only, derive the partition key (and single/composite sort key) from the model's @PrimaryKey via getPrimaryKeyFieldNodes, mirroring how GSIs are derived. Owned tables are unchanged (still `id`, corrected downstream as before). Adds regression tests: imported table with a custom @PrimaryKey partition key, with a sort key, and the default-`id` no-regression case. Fixes #3489 Related: aws-amplify/amplify-backend#3281
1 parent caf03c8 commit 956d24e

2 files changed

Lines changed: 146 additions & 6 deletions

File tree

packages/amplify-graphql-model-transformer/src/__tests__/amplify-dynamodb-table-generator.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { parse } from 'graphql';
88
import { ModelTransformer } from '../graphql-model-transformer';
99
import { SearchableModelTransformer } from '@aws-amplify/graphql-searchable-transformer';
10+
import { PrimaryKeyTransformer } from '@aws-amplify/graphql-index-transformer';
1011
import { CUSTOM_DDB_CFN_TYPE, CUSTOM_IMPORTED_DDB_CFN_TYPE } from '../resources/amplify-dynamodb-table/amplify-dynamodb-table-construct';
1112
import { ITERATIVE_TABLE_STACK_NAME } from '../resources/amplify-dynamodb-table/amplify-dynamo-model-resource-generator';
1213

@@ -171,4 +172,101 @@ describe('ModelTransformer:', () => {
171172
validateModelSchema(parse(out.schema));
172173
parse(out.schema);
173174
});
175+
176+
it('derives the imported table key schema from a custom @primaryKey (partition key other than id)', async () => {
177+
// An imported Amplify-managed table whose model declares a custom @primaryKey must synthesize
178+
// its expected keySchema/attributeDefinitions from the model key, not the hardcoded `id`.
179+
// Otherwise the TableManager import validation rejects the (correct) live Gen 1 table with
180+
// "Imported table properties did not match the expected table properties" and the migration
181+
// stack rolls back.
182+
const validSchema = `
183+
type AuthSessionToken @model {
184+
userAuthenticating: ID! @primaryKey
185+
sessionToken: String!
186+
}
187+
`;
188+
const out = testTransform({
189+
schema: validSchema,
190+
transformers: [new ModelTransformer(), new PrimaryKeyTransformer()],
191+
dataSourceStrategies: {
192+
AuthSessionToken: {
193+
dbType: 'DYNAMODB' as const,
194+
provisionStrategy: 'IMPORTED_AMPLIFY_TABLE' as const,
195+
tableName: 'AuthSessionToken-myApiId-myEnv',
196+
},
197+
},
198+
});
199+
expect(out).toBeDefined();
200+
const stack = out.stacks['AuthSessionToken'];
201+
expect(stack).toBeDefined();
202+
const table = stack.Resources?.AuthSessionTokenTable;
203+
expect(table).toBeDefined();
204+
expect(table.Type).toBe(CUSTOM_IMPORTED_DDB_CFN_TYPE);
205+
expect(table.Properties.isImported).toBe(true);
206+
// Key schema and attribute definitions must reflect the custom @primaryKey, not `id`.
207+
expect(table.Properties.keySchema).toEqual([{ attributeName: 'userAuthenticating', keyType: 'HASH' }]);
208+
expect(table.Properties.attributeDefinitions).toEqual([{ attributeName: 'userAuthenticating', attributeType: 'S' }]);
209+
validateModelSchema(parse(out.schema));
210+
});
211+
212+
it('derives the imported table key schema from a custom @primaryKey with a sort key', async () => {
213+
const validSchema = `
214+
type Event @model {
215+
tenantId: ID! @primaryKey(sortKeyFields: ["createdAt"])
216+
createdAt: String!
217+
name: String
218+
}
219+
`;
220+
const out = testTransform({
221+
schema: validSchema,
222+
transformers: [new ModelTransformer(), new PrimaryKeyTransformer()],
223+
dataSourceStrategies: {
224+
Event: {
225+
dbType: 'DYNAMODB' as const,
226+
provisionStrategy: 'IMPORTED_AMPLIFY_TABLE' as const,
227+
tableName: 'Event-myApiId-myEnv',
228+
},
229+
},
230+
});
231+
const table = out.stacks['Event'].Resources?.EventTable;
232+
expect(table).toBeDefined();
233+
expect(table.Type).toBe(CUSTOM_IMPORTED_DDB_CFN_TYPE);
234+
expect(table.Properties.keySchema).toEqual([
235+
{ attributeName: 'tenantId', keyType: 'HASH' },
236+
{ attributeName: 'createdAt', keyType: 'RANGE' },
237+
]);
238+
expect(table.Properties.attributeDefinitions).toEqual(
239+
expect.arrayContaining([
240+
{ attributeName: 'tenantId', attributeType: 'S' },
241+
{ attributeName: 'createdAt', attributeType: 'S' },
242+
]),
243+
);
244+
validateModelSchema(parse(out.schema));
245+
});
246+
247+
it('keeps the default id key schema for an imported table without a custom @primaryKey', async () => {
248+
const validSchema = `
249+
type Note @model {
250+
id: ID!
251+
body: String
252+
}
253+
`;
254+
const out = testTransform({
255+
schema: validSchema,
256+
transformers: [new ModelTransformer(), new PrimaryKeyTransformer()],
257+
dataSourceStrategies: {
258+
Note: {
259+
dbType: 'DYNAMODB' as const,
260+
provisionStrategy: 'IMPORTED_AMPLIFY_TABLE' as const,
261+
tableName: 'Note-myApiId-myEnv',
262+
},
263+
},
264+
});
265+
const table = out.stacks['Note'].Resources?.NoteTable;
266+
expect(table).toBeDefined();
267+
expect(table.Type).toBe(CUSTOM_IMPORTED_DDB_CFN_TYPE);
268+
expect(table.Properties.keySchema).toEqual([{ attributeName: 'id', keyType: 'HASH' }]);
269+
expect(table.Properties.attributeDefinitions).toEqual([{ attributeName: 'id', attributeType: 'S' }]);
270+
validateModelSchema(parse(out.schema));
271+
});
174272
});

packages/amplify-graphql-model-transformer/src/resources/amplify-dynamodb-table/amplify-dynamo-model-resource-generator.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import * as cdk from 'aws-cdk-lib';
22
import { TransformerContextProvider } from '@aws-amplify/graphql-transformer-interfaces';
3-
import { ModelResourceIDs, ResourceConstants } from 'graphql-transformer-common';
3+
import { ModelResourceIDs, ResourceConstants, attributeTypeFromScalar } from 'graphql-transformer-common';
44
import { ObjectTypeDefinitionNode } from 'graphql';
5-
import { setResourceName, isImportedAmplifyDynamoDbModelDataSourceStrategy } from '@aws-amplify/graphql-transformer-core';
5+
import {
6+
setResourceName,
7+
isImportedAmplifyDynamoDbModelDataSourceStrategy,
8+
getPrimaryKeyFieldNodes,
9+
} from '@aws-amplify/graphql-transformer-core';
610
import { AttributeType, StreamViewType, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
711
import { Construct } from 'constructs';
812
import { Duration, aws_iam, aws_lambda } from 'aws-cdk-lib';
@@ -172,6 +176,46 @@ export class AmplifyDynamoModelResourceGenerator extends DynamoModelResourceGene
172176
const isTableImported = isImportedAmplifyDynamoDbModelDataSourceStrategy(strategy);
173177
const tableName = isTableImported ? strategy.tableName : context.resourceHelper.generateTableName(modelName);
174178

179+
// Determine the table's key schema (partition key, and sort key when declared).
180+
//
181+
// For owned (Amplify-managed) tables we default to a `id` partition key; the `@primaryKey`
182+
// transformer (see `replaceDdbPrimaryKey` in the index transformer) corrects the key schema
183+
// downstream via a CloudFormation property override before the table is created.
184+
//
185+
// For imported tables that downstream correction does not reach the TableManager import
186+
// validation, which consumes the initial construct properties directly. If we left the key
187+
// schema hardcoded to `id`, any model declaring a custom `@primaryKey` (partition key other
188+
// than `id`, and/or a sort key) would fail import with "Imported table properties did not
189+
// match the expected table properties". So for imported tables we derive the key schema from
190+
// the model definition up front, mirroring how GSIs are already derived from the model.
191+
let partitionKey = {
192+
name: 'id',
193+
type: AttributeType.STRING,
194+
};
195+
let sortKey: { name: string; type: AttributeType } | undefined;
196+
if (isTableImported) {
197+
const [primaryKeyFieldNode, ...sortKeyFieldNodes] = getPrimaryKeyFieldNodes(def);
198+
partitionKey = {
199+
name: primaryKeyFieldNode.name.value,
200+
type: attributeTypeFromScalar(primaryKeyFieldNode.type) === 'N' ? AttributeType.NUMBER : AttributeType.STRING,
201+
};
202+
if (sortKeyFieldNodes.length === 1) {
203+
// A single sort key field maps directly to a sort key attribute of the field's scalar type.
204+
sortKey = {
205+
name: sortKeyFieldNodes[0].name.value,
206+
type: attributeTypeFromScalar(sortKeyFieldNodes[0].type) === 'N' ? AttributeType.NUMBER : AttributeType.STRING,
207+
};
208+
} else if (sortKeyFieldNodes.length > 1) {
209+
// Composite sort keys are stored as a single string attribute whose name is the sort key
210+
// field names joined by the model composite key separator (matches `getSortKeyName` in the
211+
// index transformer's `replaceDdbPrimaryKey`).
212+
sortKey = {
213+
name: sortKeyFieldNodes.map((node) => node.name.value).join(ModelResourceIDs.ModelCompositeKeySeparator()),
214+
type: AttributeType.STRING,
215+
};
216+
}
217+
}
218+
175219
// Add parameters.
176220
const { readIops, writeIops, billingMode, pointInTimeRecovery } = this.createDynamoDBParameters(scope, true);
177221

@@ -194,10 +238,8 @@ export class AmplifyDynamoModelResourceGenerator extends DynamoModelResourceGene
194238
allowDestructiveGraphqlSchemaUpdates: context.transformParameters.allowDestructiveGraphqlSchemaUpdates,
195239
replaceTableUponGsiUpdate: context.transformParameters.replaceTableUponGsiUpdate,
196240
tableName,
197-
partitionKey: {
198-
name: 'id',
199-
type: AttributeType.STRING,
200-
},
241+
partitionKey,
242+
...(sortKey ? { sortKey } : undefined),
201243
stream: StreamViewType.NEW_AND_OLD_IMAGES,
202244
encryption: TableEncryption.DEFAULT,
203245
removalPolicy,

0 commit comments

Comments
 (0)