diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts index e777b4b24a9..440d0a89735 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts @@ -1264,4 +1264,312 @@ describe('FunctionGenerator', () => { const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); await expect(generator.plan()).rejects.toThrow("unsupported runtime 'python3.9'"); }); + + it('uses original model name casing from schema for table grants and env vars', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + api: { + myApi: { service: 'AppSync' }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ + Resources: { + AmplifyResourcesPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Effect: 'Allow', Action: ['dynamodb:GetItem'], Resource: [{ Ref: 'apiMyApiTable' }] }], + }, + }, + }, + }, + }); + jest.spyOn(gen1App, 'file').mockImplementation((filePath: string) => { + if (filePath.includes('build/schema.graphql')) { + return 'type ModelrandomItemConnection { items: [randomItem] }\ntype ModelMealPlanConnection { items: [MealPlan] }'; + } + return '{}'; + }); + jest.spyOn(gen1App, 'fileExists').mockReturnValue(false); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_RANDOMITEMTABLE_NAME: 'randomItem-abc-main', + API_MYAPI_RANDOMITEMTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/randomItem-abc-main', + API_MYAPI_MEALPLANTABLE_NAME: 'MealPlan-abc-main', + API_MYAPI_MEALPLANTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/MealPlan-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + + // The table reference should use original model names, not naive capitalization + expect(resourceTs).toContain('randomItem'); + expect(resourceTs).toContain('MealPlan'); + // Should NOT contain incorrectly cased versions + expect(resourceTs).not.toContain('Randomitem'); + expect(resourceTs).not.toContain('Mealplan'); + }); + + it('falls back to raw schema regex when build/schema.graphql is not available', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + api: { + myApi: { service: 'AppSync' }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ + Resources: { + AmplifyResourcesPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Effect: 'Allow', Action: ['dynamodb:GetItem'], Resource: [{ Ref: 'apiMyApiTable' }] }], + }, + }, + }, + }, + }); + jest.spyOn(gen1App, 'file').mockImplementation((filePath: string) => { + if (filePath.includes('build/schema.graphql')) { + throw new Error('File not found'); + } + if (filePath.includes('schema.graphql')) { + return 'type randomItem @model { id: ID! }\ntype MealPlan @model { id: ID! }'; + } + return '{}'; + }); + jest.spyOn(gen1App, 'fileExists').mockImplementation((filePath: string) => { + return filePath.includes('schema.graphql') && !filePath.includes('build'); + }); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_RANDOMITEMTABLE_NAME: 'randomItem-abc-main', + API_MYAPI_RANDOMITEMTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/randomItem-abc-main', + API_MYAPI_MEALPLANTABLE_NAME: 'MealPlan-abc-main', + API_MYAPI_MEALPLANTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/MealPlan-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + expect(resourceTs).toContain('randomItem'); + expect(resourceTs).toContain('MealPlan'); + expect(resourceTs).not.toContain('Randomitem'); + expect(resourceTs).not.toContain('Mealplan'); + }); + + it('handles models with multiple directives before @model in raw schema fallback', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + api: { + myApi: { service: 'AppSync' }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ + Resources: { + AmplifyResourcesPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Effect: 'Allow', Action: ['dynamodb:GetItem'], Resource: [{ Ref: 'apiMyApiTable' }] }], + }, + }, + }, + }, + }); + const rawSchema = [ + 'type todoItem @auth(rules: [{allow: owner}]) @model { id: ID! title: String! }', + 'type BlogPost @searchable @auth(rules: [{allow: public}]) @model { id: ID! content: String }', + ].join('\n'); + jest.spyOn(gen1App, 'file').mockImplementation((filePath: string) => { + if (filePath.includes('build/schema.graphql')) { + throw new Error('File not found'); + } + if (filePath.includes('schema.graphql')) { + return rawSchema; + } + return '{}'; + }); + jest.spyOn(gen1App, 'fileExists').mockImplementation((filePath: string) => { + return filePath.includes('schema.graphql') && !filePath.includes('build'); + }); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_TODOITEMTABLE_NAME: 'todoItem-abc-main', + API_MYAPI_TODOITEMTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/todoItem-abc-main', + API_MYAPI_BLOGPOSTTABLE_NAME: 'BlogPost-abc-main', + API_MYAPI_BLOGPOSTTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/BlogPost-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + expect(resourceTs).toContain('todoItem'); + expect(resourceTs).toContain('BlogPost'); + expect(resourceTs).not.toContain('Todoitem'); + expect(resourceTs).not.toContain('Blogpost'); + }); + + it('reads model names from schema directory when schema.graphql does not exist', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + api: { + myApi: { service: 'AppSync' }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ + Resources: { + AmplifyResourcesPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Effect: 'Allow', Action: ['dynamodb:GetItem'], Resource: [{ Ref: 'apiMyApiTable' }] }], + }, + }, + }, + }, + }); + + // Create a schema directory with multiple .graphql files inside ccbDir + const fsExtra = require('fs-extra'); + const schemaDirPath = require('path').join(gen1App.ccbDir, 'api', 'myApi', 'schema'); + fsExtra.mkdirpSync(schemaDirPath); + fsExtra.writeFileSync(require('path').join(schemaDirPath, 'todo.graphql'), 'type todoItem @model { id: ID! title: String! }'); + fsExtra.writeFileSync(require('path').join(schemaDirPath, 'blog.graphql'), 'type BlogPost @model { id: ID! content: String }'); + + jest.spyOn(gen1App, 'file').mockImplementation((filePath: string) => { + if (filePath.includes('build/schema.graphql')) { + throw new Error('File not found'); + } + return require('fs').readFileSync(require('path').join(gen1App.ccbDir, filePath), 'utf8'); + }); + jest.spyOn(gen1App, 'fileExists').mockImplementation((filePath: string) => { + if (filePath.includes('schema.graphql')) return false; + return false; + }); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_TODOITEMTABLE_NAME: 'todoItem-abc-main', + API_MYAPI_TODOITEMTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/todoItem-abc-main', + API_MYAPI_BLOGPOSTTABLE_NAME: 'BlogPost-abc-main', + API_MYAPI_BLOGPOSTTABLE_ARN: 'arn:aws:dynamodb:us-east-1:123:table/BlogPost-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + expect(resourceTs).toContain('todoItem'); + expect(resourceTs).toContain('BlogPost'); + expect(resourceTs).not.toContain('Todoitem'); + expect(resourceTs).not.toContain('Blogpost'); + }); + + it('returns empty model names gracefully when no API category exists', async () => { + const gen1App = await createGen1App({ + providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, + function: { + myFunc: { + service: 'Lambda', + output: { Name: 'myFunc-main-abc', Arn: 'arn:aws:lambda:us-east-1:123:function:myFunc-main-abc' }, + }, + }, + }); + jest.spyOn(gen1App, 'resourceMetaOutput').mockReturnValue('myFunc-main-abc'); + jest.spyOn(gen1App, 'json').mockReturnValue({ Resources: {} }); + jest.spyOn(gen1App, 'fileExists').mockReturnValue(false); + jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ + FunctionName: 'myFunc-main-abc', + Handler: 'index.handler', + Timeout: 30, + MemorySize: 128, + Runtime: 'nodejs18.x', + Environment: { + Variables: { + API_MYAPI_SOMETABLE_NAME: 'SomeTable-abc-main', + }, + }, + }); + jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); + + const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); + const ops = await generator.plan(); + await ops[0].execute(); + + const resourceTs = writtenFile('resource.ts'); + // Without model names, falls back to naive capitalization + expect(resourceTs).toContain('Some'); + }); }); diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts index 6a38bafc61b..33dedcb8324 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts @@ -1,5 +1,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { globSync } from 'glob'; import { AmplifyMigrationOperation } from '../../../_common/operation'; import { AmplifyError, JSONUtilities } from '@aws-amplify/amplify-cli-core'; import { Planner } from '../../../_common/planner'; @@ -35,6 +37,7 @@ export class FunctionGenerator implements Planner { private readonly resource: DiscoveredResource; private readonly renderer: FunctionRenderer; private readonly logger: SpinningLogger; + private cachedModelNames: readonly string[] | undefined; public constructor(options: FunctionGeneratorOptions) { this.gen1App = options.gen1App; @@ -53,6 +56,16 @@ export class FunctionGenerator implements Planner { this.s3Generator = s3Generator; } + /** + * Returns model names extracted from the GraphQL schema. + * Caches the result so the schema is read at most once per generator. + */ + private readModelNames(): readonly string[] { + if (this.cachedModelNames) return this.cachedModelNames; + this.cachedModelNames = readSchemaModelNames(this.gen1App); + return this.cachedModelNames; + } + public async plan(): Promise { const resourceName = this.resource.resourceName; const deployedName = this.gen1App.resourceMetaOutput(this.resource, 'Name'); @@ -76,7 +89,7 @@ export class FunctionGenerator implements Planner { this.logger.debug(`Fetching Lambda function schedule '${deployedName}'`); const schedule = await this.gen1App.aws.fetchFunctionSchedule(deployedName); const entry = TS.extractFilePathFromHandler(config.Handler ?? 'index.js'); - const { literalEnvVars, dynamicEnvVars } = classifyEnvVars(config.Environment?.Variables ?? {}); + const { literalEnvVars, dynamicEnvVars } = classifyEnvVars(config.Environment?.Variables ?? {}, this.readModelNames()); const dynamoActions = this.extractDynamoActions(); const kinesisActions = this.extractKinesisActions(); @@ -104,6 +117,7 @@ export class FunctionGenerator implements Planner { dataTriggerModels, storageTriggerTables, unMappedAuthActions, + modelNames: this.readModelNames(), kinesisConfig: hasAnalytics ? { resourceName: this.gen1App.singleResourceName('analytics', 'Kinesis'), @@ -447,3 +461,95 @@ function resolveAuthAccess(cognitoActions: string[]): { permissions: AuthPermiss const unMapped = cognitoActions.filter((a) => !covered.has(a)); return { permissions: result as AuthPermissions, unMapped: unMapped }; } + +/** + * Reads model names from the Gen1 app's GraphQL schema. + * Returns an empty array when no AppSync API exists. + * + * Strategy: + * 1. Primary: Read `build/schema.graphql` (transformer output) and extract names + * from the standardised `ModelXConnection` types. This is the most reliable + * approach as it is not affected by directive ordering in the raw schema. + * 2. Fallback: Read the raw user schema (single file or directory) and match + * `type ... @model` with a parser that handles interleaved directives. + */ +function readSchemaModelNames(gen1App: Gen1App): readonly string[] { + const apiCategory = gen1App.categoryMeta('api'); + if (!apiCategory) return []; + const apiEntry = Object.entries(apiCategory).find(([, v]) => (v as Record).service === 'AppSync'); + if (!apiEntry) return []; + const [apiName] = apiEntry; + + // Primary: use build/schema.graphql (transformer standardised output) + const buildSchemaPath = path.join('api', apiName, 'build', 'schema.graphql'); + try { + const buildSchema = gen1App.file(buildSchemaPath); + const connectionRegex = /type\s+Model(\w+)Connection\b/g; + const names: string[] = []; + let match: RegExpExecArray | null; + while ((match = connectionRegex.exec(buildSchema)) !== null) { + names.push(match[1]); + } + if (names.length > 0) return names; + } catch { + // build schema not available — fall through to raw schema + } + + // Fallback: read raw user schema (single file or directory) + try { + const schema = collectUserSchema(gen1App, apiName); + return extractModelNamesFromRawSchema(schema); + } catch { + return []; + } +} + +/** + * Collects all user-authored GraphQL schema content from either the single + * `schema.graphql` file or the `schema/` directory (multi-file pattern). + */ +function collectUserSchema(gen1App: Gen1App, apiName: string): string { + const schemaFilePath = path.join('api', apiName, 'schema.graphql'); + if (gen1App.fileExists(schemaFilePath)) { + return gen1App.file(schemaFilePath); + } + + const schemaDirPath = path.join('api', apiName, 'schema'); + const fullDirPath = path.join(gen1App.ccbDir, schemaDirPath); + if (!existsSync(fullDirPath)) return ''; + const files = globSync('**/*.graphql', { cwd: fullDirPath }).sort(); + return files.map((f) => gen1App.file(path.join(schemaDirPath, f))).join('\n'); +} + +/** + * Extracts @model type names from a raw GraphQL schema string. + * Handles directives with nested braces (e.g., @auth(rules: [{...}])) + * appearing between the type name and @model. + */ +function extractModelNamesFromRawSchema(schema: string): string[] { + const names: string[] = []; + const typeRegex = /\btype\s+(\w+)/g; + let match: RegExpExecArray | null; + while ((match = typeRegex.exec(schema)) !== null) { + const typeName = match[1]; + const afterName = schema.slice(match.index + match[0].length); + let depth = 0; + let foundModel = false; + for (let i = 0; i < afterName.length; i++) { + const ch = afterName[i]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + } else if (depth === 0 && ch === '{') { + break; + } + if (depth === 0 && afterName.slice(i).startsWith('@model')) { + foundModel = true; + break; + } + } + if (foundModel) names.push(typeName); + } + return names; +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts index cdc726b3cd1..cdec51a0045 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts @@ -25,6 +25,7 @@ export interface FunctionRenderOptions { readonly kinesisConfig?: KinesisConfig; readonly unMappedAuthActions: readonly string[]; readonly storageTriggerTables: readonly string[]; + readonly modelNames?: readonly string[]; } export interface KinesisConfig { @@ -186,7 +187,7 @@ export class FunctionRenderer { const tableNames = new Set(); for (const hatch of opts.dynamicEnvVars) { if (hatch.name.startsWith('API_') && hatch.name.includes('TABLE_')) { - const tableName = extractTableName(hatch.name); + const tableName = extractTableName(hatch.name, opts.modelNames); if (tableName) tableNames.add(tableName); } } @@ -363,7 +364,10 @@ export class FunctionRenderer { * - retained: stay in the defineFunction() environment block * - escapeHatches: become addEnvironment() calls in applyEscapeHatches */ -export function classifyEnvVars(variables: Record): { +export function classifyEnvVars( + variables: Record, + modelNames: readonly string[] = [], +): { readonly literalEnvVars: Record; readonly dynamicEnvVars: readonly DynamicEnvVar[]; } { @@ -382,11 +386,11 @@ export function classifyEnvVars(variables: Record): { { suffix: '_GRAPHQLAPIIDOUTPUT', build: () => backendPath('data', 'apiId') }, { suffix: 'TABLE_ARN', - build: (envVar) => backendTableProp(extractTableName(envVar) ?? 'unknown', 'tableArn'), + build: (envVar) => backendTableProp(extractTableName(envVar, modelNames) ?? 'unknown', 'tableArn'), }, { suffix: 'TABLE_NAME', - build: (envVar) => backendTableProp(extractTableName(envVar) ?? 'unknown', 'tableName'), + build: (envVar) => backendTableProp(extractTableName(envVar, modelNames) ?? 'unknown', 'tableName'), }, ], }, @@ -513,11 +517,25 @@ function nonNull(expr: ts.Expression): ts.Expression { return factory.createNonNullExpression(expr); } -/** Extracts the table name from an API_*TABLE_* env var. */ -export function extractTableName(envVar: string): string | undefined { +/** + * Extracts the model name from an API_*TABLE_* env var by matching the + * uppercase segment against known model names from the GraphQL schema. + * + * When model names are provided, performs a case-insensitive lookup to + * recover the original casing (e.g., `RANDOMITEM` → `randomItem`). + * Falls back to capitalizing the first letter when no match is found. + * + * @example + * extractTableName('API_MYAPI_RANDOMITEMTABLE_ARN', ['randomItem', 'Meal']) // → 'randomItem' + * extractTableName('API_MYAPI_MEALTABLE_ARN', ['randomItem', 'Meal']) // → 'Meal' + */ +export function extractTableName(envVar: string, modelNames: readonly string[] = []): string | undefined { const match = envVar.match(/API_.*_(.+?)TABLE_/); if (!match) return undefined; const raw = match[1]; + const upperRaw = raw.toUpperCase(); + const found = modelNames.find((name) => name.toUpperCase() === upperRaw); + if (found) return found; return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase(); }