diff --git a/packages/amplify-graphql-default-value-transformer/src/__tests__/__snapshots__/amplify-grapphql-default-value-transformer.test.ts.snap b/packages/amplify-graphql-default-value-transformer/src/__tests__/__snapshots__/amplify-grapphql-default-value-transformer.test.ts.snap index 320a27b4e9..ee5dfc9faf 100644 --- a/packages/amplify-graphql-default-value-transformer/src/__tests__/__snapshots__/amplify-grapphql-default-value-transformer.test.ts.snap +++ b/packages/amplify-graphql-default-value-transformer/src/__tests__/__snapshots__/amplify-grapphql-default-value-transformer.test.ts.snap @@ -231,6 +231,222 @@ $util.qr($context.args.input.put(\\"stringValue\\", $util.defaultIfNull($ctx.arg {}" `; +exports[`DefaultValueModelTransformer: should successfully transform a schema that implies auto-increment 1`] = ` +"type TestAutoIncrement { + id: ID! + value: Int +} + +input ModelStringInput { + ne: String + eq: String + le: String + lt: String + ge: String + gt: String + contains: String + notContains: String + between: [String] + beginsWith: String + attributeExists: Boolean + attributeType: ModelAttributeTypes + size: ModelSizeInput +} + +input ModelIntInput { + ne: Int + eq: Int + le: Int + lt: Int + ge: Int + gt: Int + between: [Int] + attributeExists: Boolean + attributeType: ModelAttributeTypes +} + +input ModelFloatInput { + ne: Float + eq: Float + le: Float + lt: Float + ge: Float + gt: Float + between: [Float] + attributeExists: Boolean + attributeType: ModelAttributeTypes +} + +input ModelBooleanInput { + ne: Boolean + eq: Boolean + attributeExists: Boolean + attributeType: ModelAttributeTypes +} + +input ModelIDInput { + ne: ID + eq: ID + le: ID + lt: ID + ge: ID + gt: ID + contains: ID + notContains: ID + between: [ID] + beginsWith: ID + attributeExists: Boolean + attributeType: ModelAttributeTypes + size: ModelSizeInput +} + +input ModelSubscriptionStringInput { + ne: String + eq: String + le: String + lt: String + ge: String + gt: String + contains: String + notContains: String + between: [String] + beginsWith: String + in: [String] + notIn: [String] +} + +input ModelSubscriptionIntInput { + ne: Int + eq: Int + le: Int + lt: Int + ge: Int + gt: Int + between: [Int] + in: [Int] + notIn: [Int] +} + +input ModelSubscriptionFloatInput { + ne: Float + eq: Float + le: Float + lt: Float + ge: Float + gt: Float + between: [Float] + in: [Float] + notIn: [Float] +} + +input ModelSubscriptionBooleanInput { + ne: Boolean + eq: Boolean +} + +input ModelSubscriptionIDInput { + ne: ID + eq: ID + le: ID + lt: ID + ge: ID + gt: ID + contains: ID + notContains: ID + between: [ID] + beginsWith: ID + in: [ID] + notIn: [ID] +} + +enum ModelAttributeTypes { + binary + binarySet + bool + list + map + number + numberSet + string + stringSet + _null +} + +input ModelSizeInput { + ne: Int + eq: Int + le: Int + lt: Int + ge: Int + gt: Int + between: [Int] +} + +enum ModelSortDirection { + ASC + DESC +} + +type ModelTestAutoIncrementConnection { + items: [TestAutoIncrement]! + nextToken: String +} + +input ModelTestAutoIncrementFilterInput { + id: ModelIDInput + value: ModelIntInput + and: [ModelTestAutoIncrementFilterInput] + or: [ModelTestAutoIncrementFilterInput] + not: ModelTestAutoIncrementFilterInput +} + +type Query { + getTestAutoIncrement(id: ID!): TestAutoIncrement + listTestAutoIncrements(id: ID, filter: ModelTestAutoIncrementFilterInput, limit: Int, nextToken: String, sortDirection: ModelSortDirection): ModelTestAutoIncrementConnection +} + +input ModelTestAutoIncrementConditionInput { + value: ModelIntInput + and: [ModelTestAutoIncrementConditionInput] + or: [ModelTestAutoIncrementConditionInput] + not: ModelTestAutoIncrementConditionInput +} + +input CreateTestAutoIncrementInput { + id: ID + value: Int +} + +input UpdateTestAutoIncrementInput { + id: ID! + value: Int +} + +input DeleteTestAutoIncrementInput { + id: ID! +} + +type Mutation { + createTestAutoIncrement(input: CreateTestAutoIncrementInput!, condition: ModelTestAutoIncrementConditionInput): TestAutoIncrement + updateTestAutoIncrement(input: UpdateTestAutoIncrementInput!, condition: ModelTestAutoIncrementConditionInput): TestAutoIncrement + deleteTestAutoIncrement(input: DeleteTestAutoIncrementInput!, condition: ModelTestAutoIncrementConditionInput): TestAutoIncrement +} + +input ModelSubscriptionTestAutoIncrementFilterInput { + id: ModelSubscriptionIDInput + value: ModelSubscriptionIntInput + and: [ModelSubscriptionTestAutoIncrementFilterInput] + or: [ModelSubscriptionTestAutoIncrementFilterInput] +} + +type Subscription { + onCreateTestAutoIncrement(filter: ModelSubscriptionTestAutoIncrementFilterInput): TestAutoIncrement @aws_subscribe(mutations: [\\"createTestAutoIncrement\\"]) + onUpdateTestAutoIncrement(filter: ModelSubscriptionTestAutoIncrementFilterInput): TestAutoIncrement @aws_subscribe(mutations: [\\"updateTestAutoIncrement\\"]) + onDeleteTestAutoIncrement(filter: ModelSubscriptionTestAutoIncrementFilterInput): TestAutoIncrement @aws_subscribe(mutations: [\\"deleteTestAutoIncrement\\"]) +} +" +`; + exports[`DefaultValueModelTransformer: should successfully transform simple valid schema 1`] = ` "type Post { id: ID! diff --git a/packages/amplify-graphql-default-value-transformer/src/__tests__/amplify-grapphql-default-value-transformer.test.ts b/packages/amplify-graphql-default-value-transformer/src/__tests__/amplify-grapphql-default-value-transformer.test.ts index 344b350d5f..477b848d54 100644 --- a/packages/amplify-graphql-default-value-transformer/src/__tests__/amplify-grapphql-default-value-transformer.test.ts +++ b/packages/amplify-graphql-default-value-transformer/src/__tests__/amplify-grapphql-default-value-transformer.test.ts @@ -1,5 +1,10 @@ import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; -import { constructDataSourceStrategies, getResourceNamesForStrategy, validateModelSchema } from '@aws-amplify/graphql-transformer-core'; +import { + constructDataSourceStrategies, + getResourceNamesForStrategy, + POSTGRES_DB_TYPE, + validateModelSchema, +} from '@aws-amplify/graphql-transformer-core'; import { parse } from 'graphql'; import { mockSqlDataSourceStrategy, testTransform } from '@aws-amplify/graphql-transformer-test-utils'; import { PrimaryKeyTransformer } from '@aws-amplify/graphql-index-transformer'; @@ -41,180 +46,35 @@ describe('DefaultValueModelTransformer:', () => { ).toThrow('The @default directive may only be added to scalar or enum field types.'); }); - it('throws if @default is used with a null value', () => { + it.each([ + { + type: 'String', + value: undefined, + expectedError: 'The @default directive requires a value property on non Postgres datasources.', + }, + { type: 'Int', value: '"text"', expectedError: 'Default value "text" is not a valid Int.' }, + { type: 'Boolean', value: '"text"', expectedError: 'Default value "text" is not a valid Boolean.' }, + { type: 'AWSJSON', value: '"text"', expectedError: 'Default value "text" is not a valid AWSJSON.' }, + { type: 'AWSDate', value: '"text"', expectedError: 'Default value "text" is not a valid AWSDate.' }, + { type: 'AWSDateTime', value: '"text"', expectedError: 'Default value "text" is not a valid AWSDateTime.' }, + { type: 'AWSTime', value: '"text"', expectedError: 'Default value "text" is not a valid AWSTime.' }, + { type: 'AWSTimestamp', value: '"text"', expectedError: 'Default value "text" is not a valid AWSTimestamp.' }, + { type: 'AWSURL', value: '"text"', expectedError: 'Default value "text" is not a valid AWSURL.' }, + { type: 'AWSPhone', value: '"text"', expectedError: 'Default value "text" is not a valid AWSPhone.' }, + { type: 'AWSIPAddress', value: '"text"', expectedError: 'Default value "text" is not a valid AWSIPAddress.' }, + ])(`throws if @default is used with invalid type. %type check.`, ({ type, value, expectedError }) => { const schema = ` type Test @model { - id: ID! - name: String @default - } - `; - - expect(() => - testTransform({ - schema, - transformers: [new ModelTransformer(), new DefaultValueTransformer()], - }), - ).toThrow('Directive "@default" argument "value" of type "String!" is required, but it was not provided.'); - }); - - it('throws if @default is used with invalid type. Int check.', () => { - const schema = ` - type Test @model { - id: ID! - value: Int @default(value: "text") - } - `; - - expect(() => - testTransform({ - schema, - transformers: [new ModelTransformer(), new DefaultValueTransformer()], - }), - ).toThrow('Default value "text" is not a valid Int.'); - }); - - it('throws if @default is used with invalid type. Boolean check.', () => { - const schema = ` - type Test @model { - id: ID! - value: Boolean @default(value: "text") - } - `; - - expect(() => - testTransform({ - schema, - transformers: [new ModelTransformer(), new DefaultValueTransformer()], - }), - ).toThrow('Default value "text" is not a valid Boolean.'); - }); - - it('throws if @default is used with invalid type. AWSJSON check.', () => { - const schema = ` - type Test @model { - id: ID! - value: AWSJSON @default(value: "text") - } - `; - - expect(() => - testTransform({ - schema, - transformers: [new ModelTransformer(), new DefaultValueTransformer()], - }), - ).toThrow('Default value "text" is not a valid AWSJSON.'); - }); - - it('throws if @default is used with invalid type. AWSDate check.', () => { - const schema = ` - type Test @model { - id: ID! - value: AWSDate @default(value: "text") - } - `; - - expect(() => - testTransform({ - schema, - transformers: [new ModelTransformer(), new DefaultValueTransformer()], - }), - ).toThrow('Default value "text" is not a valid AWSDate.'); - }); - - it('throws if @default is used with invalid type. AWSDateTime check.', () => { - const schema = ` - type Test @model { - id: ID! - value: AWSDateTime @default(value: "text") + id: ID! + value: ${type} ${value !== undefined ? `@default(value: ${value})` : '@default'} } `; - expect(() => testTransform({ schema, transformers: [new ModelTransformer(), new DefaultValueTransformer()], }), - ).toThrow('Default value "text" is not a valid AWSDateTime.'); - }); - - it('throws if @default is used with invalid type. AWSTime check.', () => { - const schema = ` - type Test @model { - id: ID! - value: AWSTime @default(value: "text") - } - `; - - expect(() => - testTransform({ - schema, - transformers: [new ModelTransformer(), new DefaultValueTransformer()], - }), - ).toThrow('Default value "text" is not a valid AWSTime.'); - }); - - it('throws if @default is used with invalid type. AWSTimestamp check.', () => { - const schema = ` - type Test @model { - id: ID! - value: AWSTimestamp @default(value: "text") - } - `; - - expect(() => - testTransform({ - schema, - transformers: [new ModelTransformer(), new DefaultValueTransformer()], - }), - ).toThrow('Default value "text" is not a valid AWSTimestamp.'); - }); - - it('throws if @default is used with invalid type. AWSURL check.', () => { - const schema = ` - type Test @model { - id: ID! - value: AWSURL @default(value: "text") - } - `; - - expect(() => - testTransform({ - schema, - transformers: [new ModelTransformer(), new DefaultValueTransformer()], - }), - ).toThrow('Default value "text" is not a valid AWSURL.'); - }); - - it('throws if @default is used with invalid type. AWSPhone check.', () => { - const schema = ` - type Test @model { - id: ID! - value: AWSPhone @default(value: "text") - } - `; - - expect(() => - testTransform({ - schema, - transformers: [new ModelTransformer(), new DefaultValueTransformer()], - }), - ).toThrow('Default value "text" is not a valid AWSPhone.'); - }); - - it('throws if @default is used with invalid type. AWSIPAddress check.', () => { - const schema = ` - type Test @model { - id: ID! - value: AWSIPAddress @default(value: "text") - } - `; - - expect(() => - testTransform({ - schema, - transformers: [new ModelTransformer(), new DefaultValueTransformer()], - }), - ).toThrow('Default value "text" is not a valid AWSIPAddress.'); + ).toThrow(expectedError); }); it('should validate enum values', async () => { @@ -338,4 +198,70 @@ describe('DefaultValueModelTransformer:', () => { expect(out.resolvers['Mutation.createNote.init.1.req.vtl']).toBeDefined(); expect(out.resolvers['Mutation.createNote.init.2.req.vtl']).toBeUndefined(); }); + + it.each([{ strategy: mockSqlDataSourceStrategy() }])( + 'throws if auto-increment is implied on a non-Postgres datasource', + ({ strategy }) => { + const schema = ` + type CoffeeQueue @model { + id: ID! @primaryKey + orderNumber: Int! @default + name: String + }`; + expect(() => { + testTransform({ + schema: schema, + transformers: [new ModelTransformer(), new DefaultValueTransformer(), new PrimaryKeyTransformer()], + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + }); + }).toThrow('The @default directive requires a value property on non Postgres datasources.'); + }, + ); + + it.each([ + { typeStr: 'Boolean' }, + { typeStr: 'AWSJSON' }, + { typeStr: 'AWSDate' }, + { typeStr: 'AWSDateTime' }, + { typeStr: 'AWSTime' }, + { typeStr: 'AWSTime' }, + { typeStr: 'AWSURL' }, + { typeStr: 'AWSPhone' }, + { typeStr: 'AWSIPAddress' }, + ])('throws if auto-increment is implied on non-int types', ({ typeStr }) => { + const strategy = mockSqlDataSourceStrategy({ dbType: POSTGRES_DB_TYPE }); + + expect(() => { + const schema = ` + type Test @model { + id: ID! @primaryKey + value: ${typeStr} @default + } + `; + testTransform({ + schema, + transformers: [new ModelTransformer(), new DefaultValueTransformer(), new PrimaryKeyTransformer()], + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + }); + }).toThrow('The empty @default (auto-increment) may only be applied to integer fields.'); + }); + + it('should successfully transform a schema that implies auto-increment', async () => { + const schema = ` + type TestAutoIncrement @model { + id: ID! @primaryKey + value: Int @default + } + `; + const strategy = mockSqlDataSourceStrategy({ dbType: POSTGRES_DB_TYPE }); + const out = testTransform({ + schema, + transformers: [new ModelTransformer(), new DefaultValueTransformer(), new PrimaryKeyTransformer()], + dataSourceStrategies: constructDataSourceStrategies(schema, strategy), + }); + expect(out).toBeDefined(); + expect(out.schema).toMatchSnapshot(); + const parsedSchema = parse(out.schema); + validateModelSchema(parsedSchema); + }); }); diff --git a/packages/amplify-graphql-default-value-transformer/src/graphql-default-value-transformer.ts b/packages/amplify-graphql-default-value-transformer/src/graphql-default-value-transformer.ts index 15c76e22a5..40c373a4f2 100644 --- a/packages/amplify-graphql-default-value-transformer/src/graphql-default-value-transformer.ts +++ b/packages/amplify-graphql-default-value-transformer/src/graphql-default-value-transformer.ts @@ -4,6 +4,7 @@ import { InputObjectDefinitionWrapper, InvalidDirectiveError, isDynamoDbModel, + isPostgresModel, MappingTemplate, TransformerPluginBase, } from '@aws-amplify/graphql-transformer-core'; @@ -22,7 +23,6 @@ import { Kind, ObjectTypeDefinitionNode, StringValueNode, - TypeNode, } from 'graphql'; import { methodCall, printBlock, qref, raw, ref, str } from 'graphql-mapping-template'; import { getBaseType, isEnum, isListType, isScalarOrEnum, ModelResourceIDs, toCamelCase } from 'graphql-transformer-common'; @@ -31,16 +31,24 @@ import { TypeValidators } from './validators'; const nonStringTypes = ['Int', 'Float', 'Boolean', 'AWSTimestamp', 'AWSJSON']; -const validateFieldType = (ctx: TransformerSchemaVisitStepContextProvider, type: TypeNode): void => { +const validateFieldType = (ctx: TransformerSchemaVisitStepContextProvider, config: DefaultValueDirectiveConfiguration): void => { + const type = config.field.type; + const argc = config.directive.arguments!.length; const enums = ctx.output.getTypeDefinitionsOfKind(Kind.ENUM_TYPE_DEFINITION) as EnumTypeDefinitionNode[]; if (isListType(type) || !isScalarOrEnum(type, enums)) { throw new InvalidDirectiveError('The @default directive may only be added to scalar or enum field types.'); } + if (isPostgresModel(ctx, config.object.name.value) && argc === 0 && getBaseType(type) !== 'Int') { + throw new InvalidDirectiveError('The empty @default (auto-increment) may only be applied to integer fields.'); + } }; -const validateDirectiveArguments = (directive: DirectiveNode): void => { - if (directive.arguments!.length === 0) throw new InvalidDirectiveError('The @default directive must have a value property'); - if (directive.arguments!.length > 1) throw new InvalidDirectiveError('The @default directive only takes a value property'); +const validateDirectiveArguments = (ctx: TransformerSchemaVisitStepContextProvider, config: DefaultValueDirectiveConfiguration): void => { + const argc = config.directive.arguments!.length; + const isPostgres = isPostgresModel(ctx, config.object.name.value); + if (!isPostgres && argc === 0) + throw new InvalidDirectiveError('The @default directive requires a value property on non Postgres datasources.'); + if (argc > 1) throw new InvalidDirectiveError('The @default directive only takes a value property'); }; const validateModelDirective = (config: DefaultValueDirectiveConfiguration): void => { @@ -74,8 +82,8 @@ const validateDefaultValueType = (ctx: TransformerSchemaVisitStepContextProvider const validate = (ctx: TransformerSchemaVisitStepContextProvider, config: DefaultValueDirectiveConfiguration): void => { validateModelDirective(config); - validateFieldType(ctx, config.field.type); - validateDirectiveArguments(config.directive); + validateFieldType(ctx, config); + validateDirectiveArguments(ctx, config); // Validate the default values only for the DynamoDB datasource. // For SQL, the database determines and sets the default value. We will not validate the value in transformers. @@ -123,6 +131,10 @@ export class DefaultValueTransformer extends TransformerPluginBase { const input = InputObjectDefinitionWrapper.fromObject(name, config.object, ctx.inputDocument); const fieldWrapper = input.fields.find((f) => f.name === config.field.name.value); fieldWrapper?.makeNullable(); + + if (isPostgresModel(ctx, typeName)) { + ctx.output.updateInput(input.serialize()); + } } } }; diff --git a/packages/amplify-graphql-directives/src/__tests__/__snapshots__/index.test.ts.snap b/packages/amplify-graphql-directives/src/__tests__/__snapshots__/index.test.ts.snap index 8e5b36eecb..c0a20f5bdf 100644 --- a/packages/amplify-graphql-directives/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/amplify-graphql-directives/src/__tests__/__snapshots__/index.test.ts.snap @@ -255,7 +255,7 @@ exports[`Directive Definitions DefaultDirective 1`] = ` Object { "defaults": Object {}, "definition": " - directive @default(value: String!) on FIELD_DEFINITION + directive @default(value: String) on FIELD_DEFINITION ", "name": "default", } diff --git a/packages/amplify-graphql-directives/src/directives/default.ts b/packages/amplify-graphql-directives/src/directives/default.ts index 4aa8c33020..c378c08073 100644 --- a/packages/amplify-graphql-directives/src/directives/default.ts +++ b/packages/amplify-graphql-directives/src/directives/default.ts @@ -2,7 +2,7 @@ import { Directive } from './directive'; const name = 'default'; const definition = /* GraphQL */ ` - directive @${name}(value: String!) on FIELD_DEFINITION + directive @${name}(value: String) on FIELD_DEFINITION `; const defaults = {}; diff --git a/packages/amplify-graphql-transformer-core/API.md b/packages/amplify-graphql-transformer-core/API.md index 36a9607632..a00f28b4cd 100644 --- a/packages/amplify-graphql-transformer-core/API.md +++ b/packages/amplify-graphql-transformer-core/API.md @@ -480,6 +480,12 @@ export const isMutationNode: (obj: DefinitionNode) => obj is ObjectTypeDefinitio // @public (undocumented) export const isObjectTypeDefinitionNode: (obj: DefinitionNode) => obj is ObjectTypeDefinitionNode; +// @public (undocumented) +export const isPostgresDbType: (dbType: ModelDataSourceStrategyDbType) => dbType is ModelDataSourceStrategySqlDbType; + +// @public (undocumented) +export const isPostgresModel: (ctx: DataSourceStrategiesProvider, typename: string) => boolean; + // @public (undocumented) export const isQueryNode: (obj: DefinitionNode) => obj is ObjectTypeDefinitionNode | (InterfaceTypeDefinitionNode & { name: { diff --git a/packages/amplify-graphql-transformer-core/src/index.ts b/packages/amplify-graphql-transformer-core/src/index.ts index c01aa322ea..e7284aca32 100644 --- a/packages/amplify-graphql-transformer-core/src/index.ts +++ b/packages/amplify-graphql-transformer-core/src/index.ts @@ -61,6 +61,8 @@ export { isDefaultDynamoDbModelDataSourceStrategy, isDynamoDbModel, isDynamoDbType, + isPostgresModel, + isPostgresDbType, isModelType, isMutationNode, isObjectTypeDefinitionNode, diff --git a/packages/amplify-graphql-transformer-core/src/utils/model-datasource-strategy-utils.ts b/packages/amplify-graphql-transformer-core/src/utils/model-datasource-strategy-utils.ts index 5e460a0b7d..6c8a53f033 100644 --- a/packages/amplify-graphql-transformer-core/src/utils/model-datasource-strategy-utils.ts +++ b/packages/amplify-graphql-transformer-core/src/utils/model-datasource-strategy-utils.ts @@ -141,6 +141,27 @@ export const isSqlModel = (ctx: DataSourceStrategiesProvider, typename: string): return isSqlDbType(modelDataSourceType.dbType); }; +/** + * Checks if the given model is a SQL model + * @param ctx Transformer Context + * @param typename Model name + * @returns boolean + */ +export const isPostgresModel = (ctx: DataSourceStrategiesProvider, typename: string): boolean => { + if (isBuiltInGraphqlType(typename)) { + return false; + } + const modelDataSourceType = getModelDataSourceStrategy(ctx, typename); + return isPostgresDbType(modelDataSourceType.dbType); +}; + +/** + * Type predicate that returns true if `dbType` is a supported SQL database type + */ +export const isPostgresDbType = (dbType: ModelDataSourceStrategyDbType): dbType is ModelDataSourceStrategySqlDbType => { + return dbType === POSTGRES_DB_TYPE; +}; + /** * Type predicate that returns true if `obj` is a SQLLambdaModelDataSourceStrategy */