From 6669bda3fe7158cfc8d64dbef37e1c15653b06e4 Mon Sep 17 00:00:00 2001 From: MURAKAMI Masahiko Date: Wed, 8 Oct 2025 01:00:54 +0900 Subject: [PATCH 1/2] feat: support field-level resolvers in the model --- .changeset/silver-impalas-design.md | 5 + .../__tests__/ModelField.test-d.ts | 5 + .../data-schema/__tests__/ModelField.test.ts | 15 ++ .../__snapshots__/ModelField.test.ts.snap | 7 + .../docs/data-schema.modelfield.md | 1 + packages/data-schema/src/ModelField.ts | 23 ++- packages/data-schema/src/SchemaProcessor.ts | 182 ++++++++++++------ 7 files changed, 178 insertions(+), 60 deletions(-) create mode 100644 .changeset/silver-impalas-design.md diff --git a/.changeset/silver-impalas-design.md b/.changeset/silver-impalas-design.md new file mode 100644 index 000000000..a49182e7b --- /dev/null +++ b/.changeset/silver-impalas-design.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/data-schema': minor +--- + +feat: support field-level resolvers to be added to the model diff --git a/packages/data-schema/__tests__/ModelField.test-d.ts b/packages/data-schema/__tests__/ModelField.test-d.ts index f4af18ec1..a3b31db41 100644 --- a/packages/data-schema/__tests__/ModelField.test-d.ts +++ b/packages/data-schema/__tests__/ModelField.test-d.ts @@ -19,6 +19,8 @@ import { Json, type BaseModelField, } from '../src/ModelField'; +import { defineFunctionStub } from './utils'; +import { a } from '../src/index'; /** * Extracts first type arg from ModelField @@ -53,6 +55,9 @@ test('string() produces expected type args', () => { type FieldReqArrayReq = GetFieldTypeArg; type optArrOptTest = Expect>>; + + const handler = defineFunctionStub({}); + string().handler(a.handler.function(handler)); }); test('ModelField can be cast to InternalField', () => { diff --git a/packages/data-schema/__tests__/ModelField.test.ts b/packages/data-schema/__tests__/ModelField.test.ts index 878ae66ab..5c4a29288 100644 --- a/packages/data-schema/__tests__/ModelField.test.ts +++ b/packages/data-schema/__tests__/ModelField.test.ts @@ -6,6 +6,7 @@ import { InternalRelationshipField, } from '../src/ModelRelationshipField'; import { Authorization, ImpliedAuthFields } from '../src/Authorization'; +import { defineFunctionStub } from './utils'; // evaluates type defs in corresponding test-d.ts file it('should not produce static type errors', async () => { @@ -100,6 +101,20 @@ describe('field level auth', () => { }); }); +describe('field handlers', () => { + it('should allow adding a handler to a field', () => { + const handler = defineFunctionStub({}); + const s = a + .schema({ + Asset: a.model({ + content: a.string().handler(a.handler.function(handler)), + }).authorization((allow) => allow.publicApiKey()), + }); + const result = s.transform().schema; + expect(result).toMatchSnapshot(); + }); +}); + it('array modifier becomes unavailable after being used once', () => { // @ts-expect-error .array() is not a valid modifier after being used once a.model({ values: a.string().required().array().required().array().required() }); diff --git a/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap b/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap index 073aa927f..034e07694 100644 --- a/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap +++ b/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap @@ -1,5 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`field handlers should allow adding a handler to a field 1`] = ` +"type Asset @model @auth(rules: [{allow: public, provider: apiKey}]) +{ + content: String @function(name: "FnAssetContent") +}" +`; + exports[`field level auth implied fields objects can be extracted 1`] = ` [ { diff --git a/packages/data-schema/docs/data-schema.modelfield.md b/packages/data-schema/docs/data-schema.modelfield.md index ecea83102..88665a71e 100644 --- a/packages/data-schema/docs/data-schema.modelfield.md +++ b/packages/data-schema/docs/data-schema.modelfield.md @@ -18,6 +18,7 @@ export type ModelField; authorization>(callback: (allow: Omit) => AuthRuleType | AuthRuleType[]): ModelField; validate(callback: (v: FieldTypeToValidationBuilder) => void): ModelField; + handler(handlers: HandlerInputType): ModelField; }, UsedMethod>; ``` **References:** [ModelFieldType](./data-schema.modelfieldtype.md), [ModelField](./data-schema.modelfield.md), [Authorization](./data-schema.authorization.md) diff --git a/packages/data-schema/src/ModelField.ts b/packages/data-schema/src/ModelField.ts index f82371058..5a9c10666 100644 --- a/packages/data-schema/src/ModelField.ts +++ b/packages/data-schema/src/ModelField.ts @@ -7,6 +7,14 @@ import { FieldTypeToValidationBuilder, createValidationBuilder, } from './Validate'; +import { AsyncFunctionHandler, CustomHandler, FunctionHandler, HandlerType as Handler } from './Handler'; + +type FieldHandler = Exclude; + +type HandlerInputType = + | FunctionHandler[] + | CustomHandler[] + | FieldHandler; /** * Used to "attach" auth types to ModelField without exposing them on the builder. @@ -58,6 +66,7 @@ type FieldData = { default: undefined | symbol | ModelFieldTypeParamOuter; authorization: Authorization[]; validation: ValidationRule[]; + handlers: FieldHandler[]; }; type ModelFieldTypeParamInner = string | number | boolean | Date | Json | null; @@ -90,7 +99,7 @@ export type BaseModelField< export type UsableModelFieldKey = satisfy< methodKeyOf, - 'required' | 'default' | 'authorization' | 'array' | 'validate' + 'required' | 'default' | 'authorization' | 'array' | 'validate' | 'handler' >; /** @@ -165,6 +174,11 @@ export type ModelField< validate( callback: (v: FieldTypeToValidationBuilder) => void ): ModelField; + /** + * Configures field-level handlers. + * @param handlers - An array of handler functions to be invoked for this field + */ + handler(handlers: HandlerInputType): ModelField; }, UsedMethod >; @@ -209,6 +223,7 @@ function _field( default: undefined, authorization: [], validation: [], + handlers: [], }; const builder = { @@ -254,6 +269,12 @@ function _field( return this; }, + handler(handlers: HandlerInputType) { + data.handlers = Array.isArray(handlers) ? handlers : [handlers] as FieldHandler[]; + _meta.lastInvokedMethod = 'handler'; + + return this; + }, ...brand(brandName), [internal]() { return this; diff --git a/packages/data-schema/src/SchemaProcessor.ts b/packages/data-schema/src/SchemaProcessor.ts index 5e6f53131..b6393d75a 100644 --- a/packages/data-schema/src/SchemaProcessor.ts +++ b/packages/data-schema/src/SchemaProcessor.ts @@ -163,6 +163,9 @@ function scalarFieldToGql( fieldDef: ScalarFieldDef, identifier?: readonly string[], secondaryIndexes: string[] = [], + databaseType: DatabaseType = 'dynamodb', + opType: string = '', + typeName: string = '', ) { const { fieldType, @@ -171,6 +174,7 @@ function scalarFieldToGql( arrayRequired, default: _default, validation = [], + handlers = [], } = fieldDef; let field: string = fieldType; @@ -193,7 +197,7 @@ function scalarFieldToGql( field += ` @default`; } - return field; + return { gqlField: field, lambdaFunctionDefinition: {}, customSqlDataSourceStrategy: undefined }; } if (required === true) { @@ -220,7 +224,10 @@ function scalarFieldToGql( // Add validation directives for each validation rule for (const validationRule of validation) { - const valueStr = typeof validationRule.value === 'number' ? validationRule.value.toString() : validationRule.value; + const valueStr = + typeof validationRule.value === 'number' + ? validationRule.value.toString() + : validationRule.value; if (validationRule.errorMessage) { field += ` @validate(type: ${validationRule.type}, value: "${valueStr}", errorMessage: ${escapeGraphQlString(validationRule.errorMessage)})`; } else { @@ -228,7 +235,15 @@ function scalarFieldToGql( } } - return field; + const { + gqlHandlerContent, + lambdaFunctionDefinition, + customSqlDataSourceStrategy, + } = handlerToGql({ handlers, databaseType, opType, typeName }); + if (gqlHandlerContent) { + field += ` ${gqlHandlerContent}`; + } + return { gqlField: field, lambdaFunctionDefinition, customSqlDataSourceStrategy }; } function modelFieldToGql(fieldDef: ModelFieldDef) { @@ -426,7 +441,8 @@ function customOperationToGql( return returnTypeName; } else if (isScalarField(returnType)) { - return scalarFieldToGql(returnType?.data); + const { gqlField } = scalarFieldToGql(returnType?.data); + return gqlField; } else { throw new Error(`Unrecognized return type on ${typeName}`); } @@ -472,41 +488,12 @@ function customOperationToGql( callSignature += `(${argDefinitions.join(', ')})`; } - const handler = handlers && handlers[0]; - const brand = handler && getBrand(handler); - - let gqlHandlerContent = ''; - let lambdaFunctionDefinition: LambdaFunctionDefinition = {}; - let customSqlDataSourceStrategy: CustomSqlDataSourceStrategy | undefined; - - if (isFunctionHandler(handlers)) { - ({ gqlHandlerContent, lambdaFunctionDefinition } = transformFunctionHandler( - handlers, - typeName, - )); - } else if (databaseType === 'sql' && handler && brand === 'inlineSql') { - gqlHandlerContent = `@sql(statement: ${escapeGraphQlString( - String(getHandlerData(handler)), - )}) `; - customSqlDataSourceStrategy = { - typeName: opType as `Query` | `Mutation`, - fieldName: typeName, - }; - } else if (isSqlReferenceHandler(handlers)) { - const handlerData = getHandlerData(handlers[0]); - const entry = resolveEntryPath( - handlerData, - 'Could not determine import path to construct absolute code path for sql reference handler. Consider using an absolute path instead.', - ); - const reference = typeof entry === 'string' ? entry : entry.relativePath; - - customSqlDataSourceStrategy = { - typeName: opType as `Query` | `Mutation`, - fieldName: typeName, - entry, - }; - gqlHandlerContent = `@sql(reference: "${reference}") `; - } + const { + gqlHandlerContent: fieldGqlHandlerContent, + lambdaFunctionDefinition, + customSqlDataSourceStrategy, + } = handlerToGql({ handlers, databaseType, opType, typeName }); + let gqlHandlerContent = fieldGqlHandlerContent; if (opType === 'Subscription') { const subscriptionSources = subscriptionSource @@ -573,6 +560,69 @@ function customOperationToGql( }; } +function handlerToGql({ + handlers, + databaseType, + typeName, + opType, +}: { + handlers: HandlerType[] | null; + databaseType: DatabaseType; + opType: string; + typeName: string; + }) { + if (!handlers || handlers.length === 0) { + return { + gqlHandlerContent: '', + lambdaFunctionDefinition: {}, + customSqlDataSourceStrategy: undefined, + }; + } + const handler = handlers && handlers[0]; + const brand = handler && getBrand(handler); + + let gqlHandlerContent = ''; + let lambdaFunctionDefinition: LambdaFunctionDefinition = {}; + let customSqlDataSourceStrategy: CustomSqlDataSourceStrategy | undefined; + + if (isFunctionHandler(handlers)) { + ({ gqlHandlerContent, lambdaFunctionDefinition } = transformFunctionHandler( + handlers, + opType === 'Query' || opType === 'Mutation' + ? typeName + : `${opType}${capitalize(typeName)}`, + )); + } else if (databaseType === 'sql' && handler && brand === 'inlineSql') { + gqlHandlerContent = `@sql(statement: ${escapeGraphQlString( + String(getHandlerData(handler)), + )}) `; + customSqlDataSourceStrategy = { + typeName: opType as `Query` | `Mutation`, + fieldName: typeName, + }; + } else if (isSqlReferenceHandler(handlers)) { + const handlerData = getHandlerData(handlers[0]); + const entry = resolveEntryPath( + handlerData, + 'Could not determine import path to construct absolute code path for sql reference handler. Consider using an absolute path instead.', + ); + const reference = typeof entry === 'string' ? entry : entry.relativePath; + + customSqlDataSourceStrategy = { + typeName: opType as `Query` | `Mutation`, + fieldName: typeName, + entry, + }; + gqlHandlerContent = `@sql(reference: "${reference}") `; + } + + return { + lambdaFunctionDefinition, + customSqlDataSourceStrategy, + gqlHandlerContent, + }; +} + /** * Escape a string that will be used inside of a graphql string. * @param str The input string to be escaped @@ -907,9 +957,9 @@ function mapToNativeAppSyncAuthDirectives( const provider = getAppSyncAuthDirectiveFromRule(rule); if (rule.groups) { - if(!groupProvider.has(provider)) { + if (!groupProvider.has(provider)) { groupProvider.set(provider, new Set()); - }; + } rule.groups.forEach((group) => groupProvider.get(provider)?.add(group)); } else { generalProviderUsed.add(provider); @@ -918,15 +968,16 @@ function mapToNativeAppSyncAuthDirectives( } groupProvider.forEach((groups, provider) => { - if(!generalProviderUsed.has(provider)) { + if (!generalProviderUsed.has(provider)) { rules.add( - `${provider}(cognito_groups: [${Array.from(groups).reduce((acc, group) => - acc == "" ? `"${group}"` : `${acc}, "${group}"` - , "")}])` + `${provider}(cognito_groups: [${Array.from(groups).reduce( + (acc, group) => (acc == '' ? `"${group}"` : `${acc}, "${group}"`), + '', + )}])`, ); // example: (cognito_groups: ["Bloggers", "Readers"]) } - }) + }); const authString = [...rules].join(' '); @@ -1016,6 +1067,8 @@ function processFields( // stores nested, field-level type definitions (custom types and enums) // the need to be hoisted to top-level schema types and processed accordingly const implicitTypes: [string, any][] = []; + const lambdaFunctions: LambdaFunctionDefinition = {}; + const customSqlDataSourceStrategies: CustomSqlDataSourceStrategy[] = []; validateImpliedFields(fields, impliedFields); validateDBGeneration(fields, databaseEngine); @@ -1032,12 +1085,13 @@ function processFields( ); } else if (isScalarField(fieldDef)) { if (fieldName === partitionKey) { + const { gqlField } = scalarFieldToGql( + fieldDef.data, + identifier, + secondaryIndexes[fieldName], + ); gqlFields.push( - `${fieldName}: ${scalarFieldToGql( - fieldDef.data, - identifier, - secondaryIndexes[fieldName], - )}${fieldAuth}`, + `${fieldName}: ${gqlField}${fieldAuth}`, ); } else if (isRefField(fieldDef)) { gqlFields.push( @@ -1064,20 +1118,30 @@ function processFields( gqlFields.push(`${fieldName}: ${customTypeName}`); } else { - gqlFields.push( - `${fieldName}: ${scalarFieldToGql( + const { gqlField, lambdaFunctionDefinition, customSqlDataSourceStrategy } = scalarFieldToGql( (fieldDef as any).data, undefined, secondaryIndexes[fieldName], - )}${fieldAuth}`, + databaseEngine === 'dynamodb' ? 'dynamodb' : 'sql', + typeName, + fieldName, + ); + gqlFields.push( + `${fieldName}: ${gqlField}${fieldAuth}`, ); + + Object.assign(lambdaFunctions, lambdaFunctionDefinition); + + if (customSqlDataSourceStrategy) { + customSqlDataSourceStrategies.push(customSqlDataSourceStrategy); + } } } else { throw new Error(`Unexpected field definition: ${fieldDef}`); } } - return { gqlFields, implicitTypes }; + return { gqlFields, implicitTypes, lambdaFunctions, customSqlDataSourceStrategies }; } type TransformedSecondaryIndexes = { @@ -1150,10 +1214,9 @@ const transformedSecondaryIndexesForModel = ( ); } - if(queryField === null) { + if (queryField === null) { attributes.push(`queryField: null`); - } - else if (queryField) { + } else if (queryField) { attributes.push(`queryField: "${queryField}"`); } else { attributes.push( @@ -1543,7 +1606,8 @@ function generateInputTypes( }); } } else if (isScalarField(argDef)) { - argDefinitions.push(`${argName}: ${scalarFieldToGql(argDef.data)}`); + const { gqlField } = scalarFieldToGql(argDef.data); + argDefinitions.push(`${argName}: ${gqlField}`); } else { throw new Error(`Unsupported argument type for ${argName}`); } From 7d203b990ade350190817137c9ecd93603620525 Mon Sep 17 00:00:00 2001 From: MURAKAMI Masahiko Date: Wed, 22 Oct 2025 00:08:14 +0900 Subject: [PATCH 2/2] test: enhance field handler tests with additional scenarios and update snapshots --- .../data-schema/__tests__/ModelField.test.ts | 61 ++++++++++++++++++- .../__snapshots__/ModelField.test.ts.snap | 30 ++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/data-schema/__tests__/ModelField.test.ts b/packages/data-schema/__tests__/ModelField.test.ts index 5c4a29288..88fe774ea 100644 --- a/packages/data-schema/__tests__/ModelField.test.ts +++ b/packages/data-schema/__tests__/ModelField.test.ts @@ -7,6 +7,16 @@ import { } from '../src/ModelRelationshipField'; import { Authorization, ImpliedAuthFields } from '../src/Authorization'; import { defineFunctionStub } from './utils'; +import { configure } from '../src/internals'; + +const fakeSecret = () => ({}) as any; + +const datasourceConfigMySQL = { + engine: 'mysql', + connectionUri: fakeSecret(), +} as const; + +const aSql = configure({ database: datasourceConfigMySQL }); // evaluates type defs in corresponding test-d.ts file it('should not produce static type errors', async () => { @@ -102,7 +112,7 @@ describe('field level auth', () => { }); describe('field handlers', () => { - it('should allow adding a handler to a field', () => { + it('should allow adding a lambda handler to a field', () => { const handler = defineFunctionStub({}); const s = a .schema({ @@ -113,6 +123,55 @@ describe('field handlers', () => { const result = s.transform().schema; expect(result).toMatchSnapshot(); }); + + it('should allow adding multiple handlers to a field', () => { + const handler = defineFunctionStub({}); + const handler2 = defineFunctionStub({}); + const s = a + .schema({ + Asset: a.model({ + content: a.string().handler([a.handler.function(handler), a.handler.function(handler2)]), + }).authorization((allow) => allow.publicApiKey()), + }); + const result = s.transform().schema; + expect(result).toMatchSnapshot(); + }); + + it('should allow adding a handler with auth to a field', () => { + const handler = defineFunctionStub({}); + const s = a + .schema({ + Asset: a.model({ + content: a + .string() + .handler(a.handler.function(handler)).authorization((allow) => allow.authenticated()), + }).authorization((allow) => allow.publicApiKey()), + }); + const result = s.transform().schema; + expect(result).toMatchSnapshot(); + }); + + it('should allow adding a inline sql handler to a field', () => { + const s = aSql + .schema({ + Asset: a.model({ + content: a.string().handler(a.handler.inlineSql('SELECT content FROM TESTTABLE;')), + }).authorization((allow) => allow.publicApiKey()), + }); + const result = s.transform().schema; + expect(result).toMatchSnapshot(); + }); + + it('should allow adding a sql reference handler to a field', () => { + const s = aSql + .schema({ + Asset: a.model({ + content: a.string().handler(a.handler.sqlReference('getContent.sql')), + }).authorization((allow) => allow.publicApiKey()), + }); + const result = s.transform().schema; + expect(result).toMatchSnapshot(); + }); }); it('array modifier becomes unavailable after being used once', () => { diff --git a/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap b/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap index 034e07694..8a70b25c1 100644 --- a/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap +++ b/packages/data-schema/__tests__/__snapshots__/ModelField.test.ts.snap @@ -1,12 +1,40 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`field handlers should allow adding a handler to a field 1`] = ` +exports[`field handlers should allow adding a handler with auth to a field 1`] = ` +"type Asset @model @auth(rules: [{allow: public, provider: apiKey}]) +{ + content: String @function(name: "FnAssetContent") @auth(rules: [{allow: private}]) +}" +`; + +exports[`field handlers should allow adding a inline sql handler to a field 1`] = ` +"type Asset @model(timestamps: null) @auth(rules: [{allow: public, provider: apiKey}]) +{ + content: String @sql(statement: "SELECT content FROM TESTTABLE;") +}" +`; + +exports[`field handlers should allow adding a lambda handler to a field 1`] = ` "type Asset @model @auth(rules: [{allow: public, provider: apiKey}]) { content: String @function(name: "FnAssetContent") }" `; +exports[`field handlers should allow adding a sql reference handler to a field 1`] = ` +"type Asset @model(timestamps: null) @auth(rules: [{allow: public, provider: apiKey}]) +{ + content: String @sql(reference: "getContent.sql") +}" +`; + +exports[`field handlers should allow adding multiple handlers to a field 1`] = ` +"type Asset @model @auth(rules: [{allow: public, provider: apiKey}]) +{ + content: String @function(name: "FnAssetContent") @function(name: "FnAssetContent2") +}" +`; + exports[`field level auth implied fields objects can be extracted 1`] = ` [ {