From 247837292a5f71e19cfca5d057df7aba897dd820 Mon Sep 17 00:00:00 2001 From: Ahmed Hamouda Date: Mon, 20 Oct 2025 12:58:44 +0200 Subject: [PATCH 1/7] feat: add projection support for @index directive --- packages/amplify-graphql-api-construct/.jsii | 284 +++++++++++++----- .../__snapshots__/index.test.ts.snap | 13 +- .../src/directives/index-directive.ts | 13 +- .../amplify-graphql-index-transformer.test.ts | 95 ++++++ .../src/graphql-index-transformer.ts | 4 +- .../src/resolvers/resolvers.ts | 17 +- .../src/types.ts | 4 + .../amplify-table-manager-handler.ts | 2 +- 8 files changed, 345 insertions(+), 87 deletions(-) diff --git a/packages/amplify-graphql-api-construct/.jsii b/packages/amplify-graphql-api-construct/.jsii index 99bf87b8f7..acf0bf1b6f 100644 --- a/packages/amplify-graphql-api-construct/.jsii +++ b/packages/amplify-graphql-api-construct/.jsii @@ -287,6 +287,19 @@ } } }, + "aws-cdk-lib.aws_aiops": { + "targets": { + "dotnet": { + "package": "Amazon.CDK.AWS.AIOps" + }, + "java": { + "package": "software.amazon.awscdk.services.aiops" + }, + "python": { + "module": "aws_cdk.aws_aiops" + } + } + }, "aws-cdk-lib.aws_amazonmq": { "targets": { "dotnet": { @@ -534,6 +547,19 @@ } } }, + "aws-cdk-lib.aws_arcregionswitch": { + "targets": { + "dotnet": { + "package": "Amazon.CDK.AWS.ARCRegionSwitch" + }, + "java": { + "package": "software.amazon.awscdk.services.arcregionswitch" + }, + "python": { + "module": "aws_cdk.aws_arcregionswitch" + } + } + }, "aws-cdk-lib.aws_arczonalshift": { "targets": { "dotnet": { @@ -1679,6 +1705,19 @@ } } }, + "aws-cdk-lib.aws_evs": { + "targets": { + "dotnet": { + "package": "Amazon.CDK.AWS.EVS" + }, + "java": { + "package": "software.amazon.awscdk.services.evs" + }, + "python": { + "module": "aws_cdk.aws_evs" + } + } + }, "aws-cdk-lib.aws_finspace": { "targets": { "dotnet": { @@ -2615,6 +2654,19 @@ } } }, + "aws-cdk-lib.aws_mpa": { + "targets": { + "dotnet": { + "package": "Amazon.CDK.AWS.MPA" + }, + "java": { + "package": "software.amazon.awscdk.services.mpa" + }, + "python": { + "module": "aws_cdk.aws_mpa" + } + } + }, "aws-cdk-lib.aws_msk": { "targets": { "dotnet": { @@ -2745,6 +2797,32 @@ } } }, + "aws-cdk-lib.aws_observabilityadmin": { + "targets": { + "dotnet": { + "package": "Amazon.CDK.AWS.ObservabilityAdmin" + }, + "java": { + "package": "software.amazon.awscdk.services.observabilityadmin" + }, + "python": { + "module": "aws_cdk.aws_observabilityadmin" + } + } + }, + "aws-cdk-lib.aws_odb": { + "targets": { + "dotnet": { + "package": "Amazon.CDK.AWS.ODB" + }, + "java": { + "package": "software.amazon.awscdk.services.odb" + }, + "python": { + "module": "aws_cdk.aws_odb" + } + } + }, "aws-cdk-lib.aws_omics": { "targets": { "dotnet": { @@ -3889,6 +3967,19 @@ } } }, + "aws-cdk-lib.aws_workspacesinstances": { + "targets": { + "dotnet": { + "package": "Amazon.CDK.AWS.WorkspacesInstances" + }, + "java": { + "package": "software.amazon.awscdk.services.workspacesinstances" + }, + "python": { + "module": "aws_cdk.aws_workspacesinstances" + } + } + }, "aws-cdk-lib.aws_workspacesthinclient": { "targets": { "dotnet": { @@ -4137,7 +4228,7 @@ "kind": "interface", "locationInModule": { "filename": "src/types.ts", - "line": 972 + "line": 992 }, "name": "AddFunctionProps", "properties": [ @@ -4150,7 +4241,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 976 + "line": 996 }, "name": "dataSource", "type": { @@ -4166,7 +4257,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 981 + "line": 1001 }, "name": "name", "type": { @@ -4183,7 +4274,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 1016 + "line": 1036 }, "name": "code", "optional": true, @@ -4201,7 +4292,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 988 + "line": 1008 }, "name": "description", "optional": true, @@ -4219,7 +4310,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 995 + "line": 1015 }, "name": "requestMappingTemplate", "optional": true, @@ -4237,7 +4328,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 1002 + "line": 1022 }, "name": "responseMappingTemplate", "optional": true, @@ -4255,7 +4346,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 1009 + "line": 1029 }, "name": "runtime", "optional": true, @@ -5214,7 +5305,7 @@ "kind": "interface", "locationInModule": { "filename": "src/types.ts", - "line": 871 + "line": 891 }, "name": "AmplifyGraphqlApiCfnResources", "properties": [ @@ -5227,7 +5318,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 925 + "line": 945 }, "name": "additionalCfnResources", "type": { @@ -5248,7 +5339,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 910 + "line": 930 }, "name": "amplifyDynamoDbTables", "type": { @@ -5269,7 +5360,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 900 + "line": 920 }, "name": "cfnDataSources", "type": { @@ -5290,7 +5381,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 895 + "line": 915 }, "name": "cfnFunctionConfigurations", "type": { @@ -5311,7 +5402,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 920 + "line": 940 }, "name": "cfnFunctions", "type": { @@ -5332,7 +5423,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 875 + "line": 895 }, "name": "cfnGraphqlApi", "type": { @@ -5348,7 +5439,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 880 + "line": 900 }, "name": "cfnGraphqlSchema", "type": { @@ -5364,7 +5455,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 890 + "line": 910 }, "name": "cfnResolvers", "type": { @@ -5385,7 +5476,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 915 + "line": 935 }, "name": "cfnRoles", "type": { @@ -5406,7 +5497,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 905 + "line": 925 }, "name": "cfnTables", "type": { @@ -5427,7 +5518,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 885 + "line": 905 }, "name": "cfnApiKey", "optional": true, @@ -5450,7 +5541,7 @@ "kind": "interface", "locationInModule": { "filename": "src/types.ts", - "line": 781 + "line": 801 }, "name": "AmplifyGraphqlApiProps", "properties": [ @@ -5464,7 +5555,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 798 + "line": 818 }, "name": "authorizationModes", "type": { @@ -5481,7 +5572,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 786 + "line": 806 }, "name": "definition", "type": { @@ -5498,7 +5589,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 792 + "line": 812 }, "name": "apiName", "optional": true, @@ -5517,7 +5608,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 813 + "line": 833 }, "name": "conflictResolution", "optional": true, @@ -5535,7 +5626,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 857 + "line": 877 }, "name": "dataStoreConfiguration", "optional": true, @@ -5555,7 +5646,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 806 + "line": 826 }, "name": "functionNameMap", "optional": true, @@ -5578,7 +5669,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 828 + "line": 848 }, "name": "functionSlots", "optional": true, @@ -5612,7 +5703,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 862 + "line": 882 }, "name": "logging", "optional": true, @@ -5639,7 +5730,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 851 + "line": 871 }, "name": "outputStorageStrategy", "optional": true, @@ -5656,7 +5747,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 840 + "line": 860 }, "name": "predictionsBucket", "optional": true, @@ -5674,7 +5765,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 822 + "line": 842 }, "name": "stackMappings", "optional": true, @@ -5700,7 +5791,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 835 + "line": 855 }, "name": "transformerPlugins", "optional": true, @@ -5722,7 +5813,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 846 + "line": 866 }, "name": "translationBehavior", "optional": true, @@ -5745,7 +5836,7 @@ "kind": "interface", "locationInModule": { "filename": "src/types.ts", - "line": 934 + "line": 954 }, "name": "AmplifyGraphqlApiResources", "properties": [ @@ -5758,7 +5849,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 958 + "line": 978 }, "name": "cfnResources", "type": { @@ -5774,7 +5865,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 953 + "line": 973 }, "name": "functions", "type": { @@ -5795,7 +5886,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 938 + "line": 958 }, "name": "graphqlApi", "type": { @@ -5811,7 +5902,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 963 + "line": 983 }, "name": "nestedStacks", "type": { @@ -5832,7 +5923,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 948 + "line": 968 }, "name": "roles", "type": { @@ -5853,7 +5944,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 943 + "line": 963 }, "name": "tables", "type": { @@ -6950,7 +7041,7 @@ "kind": "interface", "locationInModule": { "filename": "src/types.ts", - "line": 709 + "line": 729 }, "name": "IAmplifyGraphqlDefinition", "properties": [ @@ -6965,7 +7056,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 734 + "line": 754 }, "name": "dataSourceStrategies", "type": { @@ -7002,7 +7093,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 720 + "line": 740 }, "name": "functionSlots", "type": { @@ -7036,7 +7127,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 714 + "line": 734 }, "name": "schema", "type": { @@ -7053,7 +7144,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 740 + "line": 760 }, "name": "customSqlDataSourceStrategies", "optional": true, @@ -7077,7 +7168,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 728 + "line": 748 }, "name": "referencedLambdaFunctions", "optional": true, @@ -7103,7 +7194,7 @@ "kind": "interface", "locationInModule": { "filename": "src/types.ts", - "line": 748 + "line": 768 }, "name": "IBackendOutputEntry", "properties": [ @@ -7116,7 +7207,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 757 + "line": 777 }, "name": "payload", "type": { @@ -7137,7 +7228,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 752 + "line": 772 }, "name": "version", "type": { @@ -7157,7 +7248,7 @@ "kind": "interface", "locationInModule": { "filename": "src/types.ts", - "line": 765 + "line": 785 }, "methods": [ { @@ -7168,7 +7259,7 @@ }, "locationInModule": { "filename": "src/types.ts", - "line": 772 + "line": 792 }, "name": "addBackendOutputEntry", "parameters": [ @@ -7652,14 +7743,15 @@ "assembly": "@aws-amplify/graphql-api-construct", "datatype": true, "docs": { + "remarks": "It's not a mapped type because this file is compiled using jsii which\ndoesn't support that.", "stability": "stable", - "summary": "A utility interface equivalent to Partial." + "summary": "A utility interface equivalent to Partial, plus some additional private fields." }, "fqn": "@aws-amplify/graphql-api-construct.PartialTranslationBehavior", "kind": "interface", "locationInModule": { "filename": "src/types.ts", - "line": 589 + "line": 605 }, "name": "PartialTranslationBehavior", "properties": [ @@ -7674,7 +7766,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 681 + "line": 702 }, "name": "allowDestructiveGraphqlSchemaUpdates", "optional": true, @@ -7692,7 +7784,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 601 + "line": 617 }, "name": "disableResolverDeduping", "optional": true, @@ -7714,7 +7806,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 646 + "line": 662 }, "name": "enableAutoIndexQueryNames", "optional": true, @@ -7726,14 +7818,31 @@ "abstract": true, "docs": { "default": "false", - "remarks": "Not recommended for use, prefer\nto use `Object.values(resources.additionalResources['AWS::Elasticsearch::Domain']).forEach((domain: CfnDomain) => {\n domain.NodeToNodeEncryptionOptions = { Enabled: True };\n});", "stability": "stable", - "summary": "If enabled, set nodeToNodeEncryption on the searchable domain (if one exists)." + "summary": "Whether server-side encryption is enabled on the ElasticSearch cluster." + }, + "immutable": true, + "locationInModule": { + "filename": "src/types.ts", + "line": 682 + }, + "name": "enableSearchEncryptionAtRest", + "optional": true, + "type": { + "primitive": "boolean" + } + }, + { + "abstract": true, + "docs": { + "default": "false", + "stability": "stable", + "summary": "Whether Node to Node encryption is enabled on the ElasticSearch cluster." }, "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 661 + "line": 675 }, "name": "enableSearchNodeToNodeEncryption", "optional": true, @@ -7751,7 +7860,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 667 + "line": 688 }, "name": "enableTransformerCfnOutputs", "optional": true, @@ -7769,7 +7878,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 626 + "line": 642 }, "name": "populateOwnerFieldForStaticGroupAuth", "optional": true, @@ -7781,14 +7890,14 @@ "abstract": true, "docs": { "default": "false", - "remarks": "When enabled, any global secondary index update operation will replace the table instead of iterative deployment, which will WIPE ALL\nEXISTING DATA but cost much less time for deployment This will only affect DynamoDB tables with provision strategy \"AMPLIFY_TABLE\".", + "remarks": "When enabled, any GSI update operation will replace the table instead of iterative deployment, which will WIPE ALL EXISTING DATA but\ncost much less time for deployment This will only affect DynamoDB tables with provision strategy \"AMPLIFY_TABLE\".", "stability": "experimental", "summary": "This behavior will only come into effect when both \"allowDestructiveGraphqlSchemaUpdates\" and this value are set to true." }, "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 691 + "line": 712 }, "name": "replaceTableUponGsiUpdate", "optional": true, @@ -7806,7 +7915,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 652 + "line": 668 }, "name": "respectPrimaryKeyAttributesOnConnectionField", "optional": true, @@ -7824,7 +7933,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 607 + "line": 623 }, "name": "sandboxModeEnabled", "optional": true, @@ -7845,7 +7954,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 639 + "line": 655 }, "name": "secondaryKeyAsGSI", "optional": true, @@ -7866,7 +7975,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 594 + "line": 610 }, "name": "shouldDeepMergeDirectiveConfigDefaults", "optional": true, @@ -7884,7 +7993,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 620 + "line": 636 }, "name": "subscriptionsInheritPrimaryAuth", "optional": true, @@ -7903,7 +8012,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 633 + "line": 649 }, "name": "suppressApiKeyGeneration", "optional": true, @@ -7921,7 +8030,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 614 + "line": 630 }, "name": "useSubUsernameForDefaultIdentityClaim", "optional": true, @@ -8133,7 +8242,7 @@ "kind": "enum", "locationInModule": { "filename": "../../node_modules/aws-cdk-lib/aws-logs/lib/log-group.d.ts", - "line": 239 + "line": 254 }, "members": [ { @@ -9185,7 +9294,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 571 + "line": 583 }, "name": "allowDestructiveGraphqlSchemaUpdates", "type": { @@ -9233,12 +9342,31 @@ { "abstract": true, "docs": { - "stability": "stable" + "default": "false", + "stability": "stable", + "summary": "Whether server-side encryption is enabled on the ElasticSearch cluster." + }, + "immutable": true, + "locationInModule": { + "filename": "src/types.ts", + "line": 563 + }, + "name": "enableSearchEncryptionAtRest", + "type": { + "primitive": "boolean" + } + }, + { + "abstract": true, + "docs": { + "default": "false", + "stability": "stable", + "summary": "Whether Node to Node encryption is enabled on the ElasticSearch cluster." }, "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 551 + "line": 556 }, "name": "enableSearchNodeToNodeEncryption", "type": { @@ -9255,7 +9383,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 557 + "line": 569 }, "name": "enableTransformerCfnOutputs", "type": { @@ -9290,7 +9418,7 @@ "immutable": true, "locationInModule": { "filename": "src/types.ts", - "line": 581 + "line": 593 }, "name": "replaceTableUponGsiUpdate", "type": { @@ -9539,5 +9667,5 @@ } }, "version": "1.20.3", - "fingerprint": "IW0P0JYVA+diTo23Fc0HRgkPQV5yGpfvNeoIu6/qYkQ=" + "fingerprint": "5m8/7bxDKQwkD//CcvTvk4A4luBIlR+hyiUVq9EgbIU=" } \ No newline at end of file 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 4eaf5b5d57..a669555462 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 @@ -432,7 +432,18 @@ exports[`Directive Definitions IndexDirective 1`] = ` Object { "defaults": Object {}, "definition": " - directive @index(name: String, sortKeyFields: [String], queryField: String) repeatable on FIELD_DEFINITION + directive @index(name: String, sortKeyFields: [String], queryField: String, projection: ProjectionInput) repeatable on FIELD_DEFINITION + + input ProjectionInput { + type: ProjectionType! + nonKeyAttributes: [String] + } + + enum ProjectionType { + ALL + KEYS_ONLY + INCLUDE + } ", "name": "index", } diff --git a/packages/amplify-graphql-directives/src/directives/index-directive.ts b/packages/amplify-graphql-directives/src/directives/index-directive.ts index bdc6695da8..e8e766e60f 100644 --- a/packages/amplify-graphql-directives/src/directives/index-directive.ts +++ b/packages/amplify-graphql-directives/src/directives/index-directive.ts @@ -2,7 +2,18 @@ import { Directive } from './directive'; const name = 'index'; const definition = /* GraphQL */ ` - directive @${name}(name: String, sortKeyFields: [String], queryField: String) repeatable on FIELD_DEFINITION + directive @${name}(name: String, sortKeyFields: [String], queryField: String, projection: ProjectionInput) repeatable on FIELD_DEFINITION + + input ProjectionInput { + type: ProjectionType! + nonKeyAttributes: [String] + } + + enum ProjectionType { + ALL + KEYS_ONLY + INCLUDE + } `; const defaults = {}; diff --git a/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts b/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts index 4c9b7210fe..0061c6fe37 100644 --- a/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts +++ b/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts @@ -1607,3 +1607,98 @@ describe('auth', () => { expect(out.resolvers['Query.testsByDescription.postAuth.1.res.vtl']).toMatchSnapshot(); }); }); + +test('@index with KEYS_ONLY projection creates GSI with correct projection type', () => { + const inputSchema = ` + type Product @model { + id: ID! + name: String! @index(name: "byName", queryField: "productsByName", projection: { type: KEYS_ONLY }) + category: String! + price: Float! + }`; + const out = testTransform({ + schema: inputSchema, + transformers: [new ModelTransformer(), new IndexTransformer()], + }); + const stack = out.stacks.Product; + + AssertionTemplate.fromJSON(stack).hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: [ + { + IndexName: 'byName', + Projection: { + ProjectionType: 'KEYS_ONLY', + }, + }, + ], + }); +}); + +test('@index with INCLUDE projection creates GSI with nonKeyAttributes', () => { + const inputSchema = ` + type Product @model { + id: ID! + name: String! + category: String! @index(name: "byCategory", queryField: "productsByCategory", projection: { type: INCLUDE, nonKeyAttributes: ["name", "price"] }) + price: Float! + inStock: Boolean! + }`; + const out = testTransform({ + schema: inputSchema, + transformers: [new ModelTransformer(), new IndexTransformer()], + }); + const stack = out.stacks.Product; + + AssertionTemplate.fromJSON(stack).hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: [ + { + IndexName: 'byCategory', + Projection: { + ProjectionType: 'INCLUDE', + NonKeyAttributes: ['name', 'price'], + }, + }, + ], + }); +}); + +test('@index with ALL projection creates GSI with ALL projection type', () => { + const inputSchema = ` + type Product @model { + id: ID! + name: String! + category: String! @index(name: "byCategory", queryField: "productsByCategory", projection: { type: ALL }) + price: Float! + }`; + const out = testTransform({ + schema: inputSchema, + transformers: [new ModelTransformer(), new IndexTransformer()], + }); + const stack = out.stacks.Product; + + AssertionTemplate.fromJSON(stack).hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: [ + { + IndexName: 'byCategory', + Projection: { + ProjectionType: 'ALL', + }, + }, + ], + }); +}); + +test('@index throws error when INCLUDE projection has no nonKeyAttributes', () => { + const inputSchema = ` + type Product @model { + id: ID! + category: String! @index(name: "byCategory", projection: { type: INCLUDE }) + }`; + + expect(() => + testTransform({ + schema: inputSchema, + transformers: [new ModelTransformer(), new IndexTransformer()], + }), + ).toThrow("@index 'byCategory': nonKeyAttributes must be specified when projection type is INCLUDE"); +}); diff --git a/packages/amplify-graphql-index-transformer/src/graphql-index-transformer.ts b/packages/amplify-graphql-index-transformer/src/graphql-index-transformer.ts index 0320d737d2..8676bea02e 100644 --- a/packages/amplify-graphql-index-transformer/src/graphql-index-transformer.ts +++ b/packages/amplify-graphql-index-transformer/src/graphql-index-transformer.ts @@ -49,9 +49,9 @@ export class IndexTransformer extends TransformerPluginBase { object: parent as ObjectTypeDefinitionNode, field: definition, directive, - } as IndexDirectiveConfiguration, + } as Required, generateGetArgumentsInput(context.transformParameters), - ); + ) as Required; /** * Impute Optional Fields diff --git a/packages/amplify-graphql-index-transformer/src/resolvers/resolvers.ts b/packages/amplify-graphql-index-transformer/src/resolvers/resolvers.ts index ddcdacc354..35793ff787 100644 --- a/packages/amplify-graphql-index-transformer/src/resolvers/resolvers.ts +++ b/packages/amplify-graphql-index-transformer/src/resolvers/resolvers.ts @@ -368,7 +368,7 @@ export const validateSortDirectionInput = (config: PrimaryKeyDirectiveConfigurat * appendSecondaryIndex */ export const appendSecondaryIndex = (config: IndexDirectiveConfiguration, ctx: TransformerContextProvider): void => { - const { name, object, primaryKeyField } = config; + const { name, object, primaryKeyField, projection } = config; if (isSqlModel(ctx, object.name.value)) { return; } @@ -382,11 +382,19 @@ export const appendSecondaryIndex = (config: IndexDirectiveConfiguration, ctx: T const partitionKeyType = attrDefs.find((attr) => attr.attributeName === partitionKeyName)?.attributeType ?? 'S'; const sortKeyType = sortKeyName ? attrDefs.find((attr) => attr.attributeName === sortKeyName)?.attributeType ?? 'S' : undefined; + const projectionType = projection?.type || 'ALL'; + const nonKeyAttributes = projection?.nonKeyAttributes || []; + + if (projectionType === 'INCLUDE' && (!nonKeyAttributes || nonKeyAttributes.length === 0)) { + throw new Error(`@index '${name}': nonKeyAttributes must be specified when projection type is INCLUDE`); + } + if (!ctx.transformParameters.secondaryKeyAsGSI && primaryKeyPartitionKeyName === partitionKeyName) { // Create an LSI. table.addLocalSecondaryIndex({ indexName: name, - projectionType: 'ALL', + projectionType, + ...(projectionType === 'INCLUDE' ? { nonKeyAttributes } : {}), sortKey: sortKeyName ? { name: sortKeyName, @@ -398,7 +406,8 @@ export const appendSecondaryIndex = (config: IndexDirectiveConfiguration, ctx: T // Create a GSI. table.addGlobalSecondaryIndex({ indexName: name, - projectionType: 'ALL', + projectionType, + ...(projectionType === 'INCLUDE' ? { nonKeyAttributes } : {}), partitionKey: { name: partitionKeyName, type: partitionKeyType, @@ -419,7 +428,7 @@ export const appendSecondaryIndex = (config: IndexDirectiveConfiguration, ctx: T const newIndex = { indexName: name, keySchema, - projection: { projectionType: 'ALL' }, + projection: { projectionType, ...(projectionType === 'INCLUDE' ? { nonKeyAttributes } : {}) }, provisionedThroughput: cdk.Fn.conditionIf(ResourceConstants.CONDITIONS.ShouldUsePayPerRequestBilling, cdk.Fn.ref('AWS::NoValue'), { ReadCapacityUnits: cdk.Fn.ref(ResourceConstants.PARAMETERS.DynamoDBModelTableReadIOPS), WriteCapacityUnits: cdk.Fn.ref(ResourceConstants.PARAMETERS.DynamoDBModelTableWriteIOPS), diff --git a/packages/amplify-graphql-index-transformer/src/types.ts b/packages/amplify-graphql-index-transformer/src/types.ts index 68bc7438ab..e3407f8cc0 100644 --- a/packages/amplify-graphql-index-transformer/src/types.ts +++ b/packages/amplify-graphql-index-transformer/src/types.ts @@ -13,4 +13,8 @@ export type IndexDirectiveConfiguration = PrimaryKeyDirectiveConfiguration & { name: string | null; queryField: string | null; primaryKeyField: FieldDefinitionNode; + projection?: { + type: 'ALL' | 'KEYS_ONLY' | 'INCLUDE'; + nonKeyAttributes?: string[]; + }; }; diff --git a/packages/amplify-graphql-model-transformer/src/resources/amplify-dynamodb-table/amplify-table-manager-lambda/amplify-table-manager-handler.ts b/packages/amplify-graphql-model-transformer/src/resources/amplify-dynamodb-table/amplify-table-manager-lambda/amplify-table-manager-handler.ts index ba1c8e726c..f1f420ddaa 100644 --- a/packages/amplify-graphql-model-transformer/src/resources/amplify-dynamodb-table/amplify-table-manager-lambda/amplify-table-manager-handler.ts +++ b/packages/amplify-graphql-model-transformer/src/resources/amplify-dynamodb-table/amplify-table-manager-lambda/amplify-table-manager-handler.ts @@ -1045,7 +1045,7 @@ const usePascalCaseForObjectKeys = (obj: { [key: string]: any }): { [key: string const value = obj[key]; if (Array.isArray(value)) { - result[capitalizedKey] = value.map((v) => usePascalCaseForObjectKeys(v)); + result[capitalizedKey] = value.map((v) => (typeof v === 'object' && v !== null ? usePascalCaseForObjectKeys(v) : v)); } else if (typeof value === 'object' && value !== null) { // If the value is an object, recursively capitalize its keys result[capitalizedKey] = usePascalCaseForObjectKeys(value); From f64e4ad8a92cc3d9e2fbe38bd4bb8b1b19638fcf Mon Sep 17 00:00:00 2001 From: Ahmed Hamouda Date: Tue, 4 Nov 2025 13:39:41 +0100 Subject: [PATCH 2/7] test: add E2E tests for @index projection support - Add comprehensive E2E tests for KEYS_ONLY, INCLUDE, and ALL projection types - Tests validate GSI creation and query functionality for each projection type - Addresses reviewer feedback requesting E2E test coverage --- .../schemas/index_projection_all.graphql | 11 ++ .../schemas/index_projection_include.graphql | 11 ++ .../index_projection_keys_only.graphql | 10 ++ .../graphql-v2/index-projection.test.ts | 118 ++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 packages/amplify-e2e-tests/schemas/index_projection_all.graphql create mode 100644 packages/amplify-e2e-tests/schemas/index_projection_include.graphql create mode 100644 packages/amplify-e2e-tests/schemas/index_projection_keys_only.graphql create mode 100644 packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts diff --git a/packages/amplify-e2e-tests/schemas/index_projection_all.graphql b/packages/amplify-e2e-tests/schemas/index_projection_all.graphql new file mode 100644 index 0000000000..282d8b394c --- /dev/null +++ b/packages/amplify-e2e-tests/schemas/index_projection_all.graphql @@ -0,0 +1,11 @@ +input AMPLIFY { + globalAuthRule: AuthRule = { allow: public } +} + +type Product @model { + id: ID! + name: String! + category: String! @index(name: "byCategory", queryField: "productsByCategory", projection: { type: ALL }) + price: Float! + inStock: Boolean! +} diff --git a/packages/amplify-e2e-tests/schemas/index_projection_include.graphql b/packages/amplify-e2e-tests/schemas/index_projection_include.graphql new file mode 100644 index 0000000000..f9216b292f --- /dev/null +++ b/packages/amplify-e2e-tests/schemas/index_projection_include.graphql @@ -0,0 +1,11 @@ +input AMPLIFY { + globalAuthRule: AuthRule = { allow: public } +} + +type Product @model { + id: ID! + name: String! + category: String! @index(name: "byCategory", queryField: "productsByCategory", projection: { type: INCLUDE, nonKeyAttributes: ["name", "price"] }) + price: Float! + inStock: Boolean +} diff --git a/packages/amplify-e2e-tests/schemas/index_projection_keys_only.graphql b/packages/amplify-e2e-tests/schemas/index_projection_keys_only.graphql new file mode 100644 index 0000000000..054573b607 --- /dev/null +++ b/packages/amplify-e2e-tests/schemas/index_projection_keys_only.graphql @@ -0,0 +1,10 @@ +input AMPLIFY { + globalAuthRule: AuthRule = { allow: public } +} + +type Product @model { + id: ID! + name: String! + category: String! @index(name: "byCategory", queryField: "productsByCategory", projection: { type: KEYS_ONLY }) + price: Float +} diff --git a/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts new file mode 100644 index 0000000000..957d989e18 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts @@ -0,0 +1,118 @@ +import { + initJSProjectWithProfile, + deleteProject, + amplifyPush, + addApiWithBlankSchema, + updateApiSchema, + createNewProjectDir, + deleteProjectDir, + getProjectMeta, +} from 'amplify-category-api-e2e-core'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import gql from 'graphql-tag'; + +(global as any).fetch = require('node-fetch'); +(global as any).WebSocket = require('ws'); + +const projectName = 'indexprojection'; +const providerName = 'awscloudformation'; + +describe('Index Projection Tests', () => { + let projRoot: string; + + beforeEach(async () => { + projRoot = await createNewProjectDir(projectName); + await initJSProjectWithProfile(projRoot, { name: projectName }); + await addApiWithBlankSchema(projRoot, { transformerVersion: 2 }); + }); + + afterEach(async () => { + await deleteProject(projRoot); + deleteProjectDir(projRoot); + }); + + it('creates GSI with KEYS_ONLY projection and queries successfully', async () => { + updateApiSchema(projRoot, projectName, 'index_projection_keys_only.graphql'); + await amplifyPush(projRoot); + + const meta = getProjectMeta(projRoot); + const region = meta.providers[providerName].Region as string; + const { output } = meta.api[projectName]; + const url = output.GraphQLAPIEndpointOutput as string; + const apiKey = output.GraphQLAPIKeyOutput as string; + + const api = new AWSAppSyncClient({ url, region, disableOffline: true, auth: { type: AUTH_TYPE.API_KEY, apiKey } }); + + await api.mutate({ + mutation: gql`mutation CreateProduct($input: CreateProductInput!) { createProduct(input: $input) { id name category } }`, + fetchPolicy: 'no-cache', + variables: { input: { name: 'Laptop', category: 'Electronics' } }, + }); + + const result = await api.query({ + query: gql`query ProductsByCategory($category: String!) { productsByCategory(category: $category) { items { id category } } }`, + fetchPolicy: 'no-cache', + variables: { category: 'Electronics' }, + }); + + expect((result as any).data.productsByCategory.items.length).toEqual(1); + expect((result as any).data.productsByCategory.items[0].category).toEqual('Electronics'); + }); + + it('creates GSI with INCLUDE projection and queries projected fields', async () => { + updateApiSchema(projRoot, projectName, 'index_projection_include.graphql'); + await amplifyPush(projRoot); + + const meta = getProjectMeta(projRoot); + const region = meta.providers[providerName].Region as string; + const { output } = meta.api[projectName]; + const url = output.GraphQLAPIEndpointOutput as string; + const apiKey = output.GraphQLAPIKeyOutput as string; + + const api = new AWSAppSyncClient({ url, region, disableOffline: true, auth: { type: AUTH_TYPE.API_KEY, apiKey } }); + + await api.mutate({ + mutation: gql`mutation CreateProduct($input: CreateProductInput!) { createProduct(input: $input) { id name category price } }`, + fetchPolicy: 'no-cache', + variables: { input: { name: 'Phone', category: 'Electronics', price: 999.99 } }, + }); + + const result = await api.query({ + query: gql`query ProductsByCategory($category: String!) { productsByCategory(category: $category) { items { id category name price } } }`, + fetchPolicy: 'no-cache', + variables: { category: 'Electronics' }, + }); + + expect((result as any).data.productsByCategory.items.length).toEqual(1); + expect((result as any).data.productsByCategory.items[0].name).toEqual('Phone'); + expect((result as any).data.productsByCategory.items[0].price).toEqual(999.99); + }); + + it('creates GSI with ALL projection (default behavior)', async () => { + updateApiSchema(projRoot, projectName, 'index_projection_all.graphql'); + await amplifyPush(projRoot); + + const meta = getProjectMeta(projRoot); + const region = meta.providers[providerName].Region as string; + const { output } = meta.api[projectName]; + const url = output.GraphQLAPIEndpointOutput as string; + const apiKey = output.GraphQLAPIKeyOutput as string; + + const api = new AWSAppSyncClient({ url, region, disableOffline: true, auth: { type: AUTH_TYPE.API_KEY, apiKey } }); + + await api.mutate({ + mutation: gql`mutation CreateProduct($input: CreateProductInput!) { createProduct(input: $input) { id name category price inStock } }`, + fetchPolicy: 'no-cache', + variables: { input: { name: 'Tablet', category: 'Electronics', price: 499.99, inStock: true } }, + }); + + const result = await api.query({ + query: gql`query ProductsByCategory($category: String!) { productsByCategory(category: $category) { items { id category name price inStock } } }`, + fetchPolicy: 'no-cache', + variables: { category: 'Electronics' }, + }); + + expect((result as any).data.productsByCategory.items.length).toEqual(1); + expect((result as any).data.productsByCategory.items[0].inStock).toEqual(true); + }); +}); From 57ead08545f27f734bdd85003f009288c3af5680 Mon Sep 17 00:00:00 2001 From: Ahmed Hamouda Date: Tue, 4 Nov 2025 16:25:00 +0100 Subject: [PATCH 3/7] fix: fix e2e test formatting --- .../graphql-v2/index-projection.test.ts | 73 +++++++++++++++++-- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts index 957d989e18..b5e67fc251 100644 --- a/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts +++ b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts @@ -11,7 +11,9 @@ import { import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; import gql from 'graphql-tag'; +// to deal with bug in cognito-identity-js (global as any).fetch = require('node-fetch'); +// to deal with subscriptions in node env (global as any).WebSocket = require('ws'); const projectName = 'indexprojection'; @@ -44,13 +46,30 @@ describe('Index Projection Tests', () => { const api = new AWSAppSyncClient({ url, region, disableOffline: true, auth: { type: AUTH_TYPE.API_KEY, apiKey } }); await api.mutate({ - mutation: gql`mutation CreateProduct($input: CreateProductInput!) { createProduct(input: $input) { id name category } }`, + mutation: gql` + mutation CreateProduct($input: CreateProductInput!) { + createProduct(input: $input) { + id + name + category + } + } + `, fetchPolicy: 'no-cache', variables: { input: { name: 'Laptop', category: 'Electronics' } }, }); const result = await api.query({ - query: gql`query ProductsByCategory($category: String!) { productsByCategory(category: $category) { items { id category } } }`, + query: gql` + query ProductsByCategory($category: String!) { + productsByCategory(category: $category) { + items { + id + category + } + } + } + `, fetchPolicy: 'no-cache', variables: { category: 'Electronics' }, }); @@ -72,13 +91,33 @@ describe('Index Projection Tests', () => { const api = new AWSAppSyncClient({ url, region, disableOffline: true, auth: { type: AUTH_TYPE.API_KEY, apiKey } }); await api.mutate({ - mutation: gql`mutation CreateProduct($input: CreateProductInput!) { createProduct(input: $input) { id name category price } }`, + mutation: gql` + mutation CreateProduct($input: CreateProductInput!) { + createProduct(input: $input) { + id + name + category + price + } + } + `, fetchPolicy: 'no-cache', variables: { input: { name: 'Phone', category: 'Electronics', price: 999.99 } }, }); const result = await api.query({ - query: gql`query ProductsByCategory($category: String!) { productsByCategory(category: $category) { items { id category name price } } }`, + query: gql` + query ProductsByCategory($category: String!) { + productsByCategory(category: $category) { + items { + id + category + name + price + } + } + } + `, fetchPolicy: 'no-cache', variables: { category: 'Electronics' }, }); @@ -101,13 +140,35 @@ describe('Index Projection Tests', () => { const api = new AWSAppSyncClient({ url, region, disableOffline: true, auth: { type: AUTH_TYPE.API_KEY, apiKey } }); await api.mutate({ - mutation: gql`mutation CreateProduct($input: CreateProductInput!) { createProduct(input: $input) { id name category price inStock } }`, + mutation: gql` + mutation CreateProduct($input: CreateProductInput!) { + createProduct(input: $input) { + id + name + category + price + inStock + } + } + `, fetchPolicy: 'no-cache', variables: { input: { name: 'Tablet', category: 'Electronics', price: 499.99, inStock: true } }, }); const result = await api.query({ - query: gql`query ProductsByCategory($category: String!) { productsByCategory(category: $category) { items { id category name price inStock } } }`, + query: gql` + query ProductsByCategory($category: String!) { + productsByCategory(category: $category) { + items { + id + category + name + price + inStock + } + } + } + `, fetchPolicy: 'no-cache', variables: { category: 'Electronics' }, }); From 24d86a17228e01b9b63744a9ce3afd483c44697c Mon Sep 17 00:00:00 2001 From: Ahmed Hamouda Date: Tue, 4 Nov 2025 22:20:17 +0100 Subject: [PATCH 4/7] chore: add index-projection E2E test to workflow --- codebuild_specs/e2e_workflow.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/codebuild_specs/e2e_workflow.yml b/codebuild_specs/e2e_workflow.yml index 25a82f00bf..71afd92f4d 100644 --- a/codebuild_specs/e2e_workflow.yml +++ b/codebuild_specs/e2e_workflow.yml @@ -81,25 +81,25 @@ batch: depend-on: - publish_to_local_registry - identifier: >- - api_5_schema_function_1_generate_ts_data_schema_resolvers_sync_query_datastore + api_5_schema_function_1_generate_ts_data_schema_index_projection_resolvers buildspec: codebuild_specs/run_e2e_tests.yml env: compute-type: BUILD_GENERAL1_LARGE variables: NODE_OPTIONS: '--max-old-space-size=14848' TEST_SUITE: >- - src/__tests__/api_5.test.ts|src/__tests__/schema-function-1.test.ts|src/__tests__/generate_ts_data_schema.test.ts|src/__tests__/resolvers.test.ts|src/__tests__/graphql-v2/sync_query_datastore.test.ts - CLI_REGION: ap-northeast-1 + src/__tests__/api_5.test.ts|src/__tests__/schema-function-1.test.ts|src/__tests__/generate_ts_data_schema.test.ts|src/__tests__/graphql-v2/index-projection.test.ts|src/__tests__/resolvers.test.ts + CLI_REGION: ap-east-1 depend-on: - publish_to_local_registry - - identifier: api_6_api_lambda_auth + - identifier: sync_query_datastore_api_6_api_lambda_auth buildspec: codebuild_specs/run_e2e_tests.yml env: compute-type: BUILD_GENERAL1_LARGE variables: NODE_OPTIONS: '--max-old-space-size=14848' TEST_SUITE: >- - src/__tests__/api_6.test.ts|src/__tests__/graphql-v2/api_lambda_auth.test.ts + src/__tests__/graphql-v2/sync_query_datastore.test.ts|src/__tests__/api_6.test.ts|src/__tests__/graphql-v2/api_lambda_auth.test.ts CLI_REGION: ca-central-1 depend-on: - publish_to_local_registry @@ -680,7 +680,7 @@ batch: variables: NODE_OPTIONS: '--max-old-space-size=6656' TEST_SUITE: src/__tests__/schema-auth-5.test.ts - CLI_REGION: ap-northeast-1 + CLI_REGION: ap-northeast-2 depend-on: - publish_to_local_registry - identifier: schema_searchable From bf104bb86f2d7eccb2f7497440610a03cfd46ad8 Mon Sep 17 00:00:00 2001 From: Ahmed Hamouda Date: Tue, 4 Nov 2025 23:45:45 +0100 Subject: [PATCH 5/7] test: add E2E test for non-projected field error --- .../schemas/index_projection_include.graphql | 3 +- .../graphql-v2/index-projection.test.ts | 51 +++++++++++++++++++ .../amplify-graphql-index-transformer.test.ts | 2 +- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/amplify-e2e-tests/schemas/index_projection_include.graphql b/packages/amplify-e2e-tests/schemas/index_projection_include.graphql index f9216b292f..c3684ea4f9 100644 --- a/packages/amplify-e2e-tests/schemas/index_projection_include.graphql +++ b/packages/amplify-e2e-tests/schemas/index_projection_include.graphql @@ -5,7 +5,8 @@ input AMPLIFY { type Product @model { id: ID! name: String! - category: String! @index(name: "byCategory", queryField: "productsByCategory", projection: { type: INCLUDE, nonKeyAttributes: ["name", "price"] }) + category: String! + @index(name: "byCategory", queryField: "productsByCategory", projection: { type: INCLUDE, nonKeyAttributes: ["name", "price"] }) price: Float! inStock: Boolean } diff --git a/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts index b5e67fc251..5cb47e3c19 100644 --- a/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts +++ b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts @@ -176,4 +176,55 @@ describe('Index Projection Tests', () => { expect((result as any).data.productsByCategory.items.length).toEqual(1); expect((result as any).data.productsByCategory.items[0].inStock).toEqual(true); }); + + it('returns error when querying non-projected fields with INCLUDE projection', async () => { + updateApiSchema(projRoot, projectName, 'index_projection_include.graphql'); + await amplifyPush(projRoot); + + const meta = getProjectMeta(projRoot); + const region = meta.providers[providerName].Region as string; + const { output } = meta.api[projectName]; + const url = output.GraphQLAPIEndpointOutput as string; + const apiKey = output.GraphQLAPIKeyOutput as string; + + const api = new AWSAppSyncClient({ url, region, disableOffline: true, auth: { type: AUTH_TYPE.API_KEY, apiKey } }); + + await api.mutate({ + mutation: gql` + mutation CreateProduct($input: CreateProductInput!) { + createProduct(input: $input) { + id + name + category + price + inStock + } + } + `, + fetchPolicy: 'no-cache', + variables: { input: { name: 'Phone', category: 'Electronics', price: 999.99, inStock: false } }, + }); + + try { + await api.query({ + query: gql` + query ProductsByCategory($category: String!) { + productsByCategory(category: $category) { + items { + id + category + inStock + } + } + } + `, + fetchPolicy: 'no-cache', + variables: { category: 'Electronics' }, + }); + fail('Expected query to fail when requesting non-projected field'); + } catch (error: any) { + expect(error.graphQLErrors).toBeDefined(); + expect(error.graphQLErrors.length).toBeGreaterThan(0); + } + }); }); diff --git a/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts b/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts index 0061c6fe37..5d03a9a740 100644 --- a/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts +++ b/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts @@ -1694,7 +1694,7 @@ test('@index throws error when INCLUDE projection has no nonKeyAttributes', () = id: ID! category: String! @index(name: "byCategory", projection: { type: INCLUDE }) }`; - + expect(() => testTransform({ schema: inputSchema, From 76370506c76ed7df69e6080e767739f5e2cb1669 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 5 Nov 2025 03:42:24 +0100 Subject: [PATCH 6/7] fix: make projection type optional and remove unnecessary cast --- .../src/directives/index-directive.ts | 2 +- .../amplify-graphql-index-transformer.test.ts | 26 +++++++++++++++++++ .../src/graphql-index-transformer.ts | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/amplify-graphql-directives/src/directives/index-directive.ts b/packages/amplify-graphql-directives/src/directives/index-directive.ts index e8e766e60f..474fd0791f 100644 --- a/packages/amplify-graphql-directives/src/directives/index-directive.ts +++ b/packages/amplify-graphql-directives/src/directives/index-directive.ts @@ -5,7 +5,7 @@ const definition = /* GraphQL */ ` directive @${name}(name: String, sortKeyFields: [String], queryField: String, projection: ProjectionInput) repeatable on FIELD_DEFINITION input ProjectionInput { - type: ProjectionType! + type: ProjectionType nonKeyAttributes: [String] } diff --git a/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts b/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts index 5d03a9a740..19e1381171 100644 --- a/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts +++ b/packages/amplify-graphql-index-transformer/src/__tests__/amplify-graphql-index-transformer.test.ts @@ -1702,3 +1702,29 @@ test('@index throws error when INCLUDE projection has no nonKeyAttributes', () = }), ).toThrow("@index 'byCategory': nonKeyAttributes must be specified when projection type is INCLUDE"); }); + +test('@index without projection defaults to ALL projection type', () => { + const inputSchema = ` + type Product @model { + id: ID! + name: String! + category: String! @index(name: "byCategory", queryField: "productsByCategory") + price: Float! + }`; + const out = testTransform({ + schema: inputSchema, + transformers: [new ModelTransformer(), new IndexTransformer()], + }); + const stack = out.stacks.Product; + + AssertionTemplate.fromJSON(stack).hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: [ + { + IndexName: 'byCategory', + Projection: { + ProjectionType: 'ALL', + }, + }, + ], + }); +}); diff --git a/packages/amplify-graphql-index-transformer/src/graphql-index-transformer.ts b/packages/amplify-graphql-index-transformer/src/graphql-index-transformer.ts index 8676bea02e..d894c9e73e 100644 --- a/packages/amplify-graphql-index-transformer/src/graphql-index-transformer.ts +++ b/packages/amplify-graphql-index-transformer/src/graphql-index-transformer.ts @@ -51,7 +51,7 @@ export class IndexTransformer extends TransformerPluginBase { directive, } as Required, generateGetArgumentsInput(context.transformParameters), - ) as Required; + ); /** * Impute Optional Fields From 3088d853e749d20c8be8f75e7716a4685b29110d Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 5 Nov 2025 14:35:34 +0100 Subject: [PATCH 7/7] fix: update test snapshot --- .../src/__tests__/__snapshots__/index.test.ts.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a669555462..59e7adb167 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 @@ -435,7 +435,7 @@ Object { directive @index(name: String, sortKeyFields: [String], queryField: String, projection: ProjectionInput) repeatable on FIELD_DEFINITION input ProjectionInput { - type: ProjectionType! + type: ProjectionType nonKeyAttributes: [String] }