Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1264,4 +1264,63 @@ 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').mockReturnValue('type randomItem @model { id: ID! }\ntype MealPlan @model { id: ID! }');
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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;
Expand All @@ -53,6 +54,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<AmplifyMigrationOperation[]> {
const resourceName = this.resource.resourceName;
const deployedName = this.gen1App.resourceMetaOutput(this.resource, 'Name');
Expand All @@ -76,7 +87,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();
Expand Down Expand Up @@ -104,6 +115,7 @@ export class FunctionGenerator implements Planner {
dataTriggerModels,
storageTriggerTables,
unMappedAuthActions,
modelNames: this.readModelNames(),
kinesisConfig: hasAnalytics
? {
resourceName: this.gen1App.singleResourceName('analytics', 'Kinesis'),
Expand Down Expand Up @@ -447,3 +459,27 @@ function resolveAuthAccess(cognitoActions: string[]): { permissions: AuthPermiss
const unMapped = cognitoActions.filter((a) => !covered.has(a));
return { permissions: result as AuthPermissions, unMapped: unMapped };
}

/**
* Reads the GraphQL schema from the Gen1 app and extracts model names.
* Returns an empty array when no AppSync API exists.
*/
function readSchemaModelNames(gen1App: Gen1App): readonly string[] {
const apiCategory = gen1App.categoryMeta('api');
if (!apiCategory) return [];
const apiEntry = Object.entries(apiCategory).find(([, v]) => (v as Record<string, unknown>).service === 'AppSync');
if (!apiEntry) return [];
const [apiName] = apiEntry;
try {
const schema = gen1App.file(path.join('api', apiName, 'schema.graphql'));
const modelRegex = /type\s+(\w+)\s+@model/g;
const names: string[] = [];
let match: RegExpExecArray | null;
while ((match = modelRegex.exec(schema)) !== null) {
names.push(match[1]);
}
return names;
} catch {
return [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -186,7 +187,7 @@ export class FunctionRenderer {
const tableNames = new Set<string>();
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);
}
}
Expand Down Expand Up @@ -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<string, string>): {
export function classifyEnvVars(
variables: Record<string, string>,
modelNames: readonly string[] = [],
): {
readonly literalEnvVars: Record<string, string>;
readonly dynamicEnvVars: readonly DynamicEnvVar[];
} {
Expand All @@ -382,11 +386,11 @@ export function classifyEnvVars(variables: Record<string, string>): {
{ 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'),
},
],
},
Expand Down Expand Up @@ -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();
}

Expand Down
Loading