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 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..c3684ea4f9 --- /dev/null +++ b/packages/amplify-e2e-tests/schemas/index_projection_include.graphql @@ -0,0 +1,12 @@ +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..5cb47e3c19 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/index-projection.test.ts @@ -0,0 +1,230 @@ +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'; + +// 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'; +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); + }); + + 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-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..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 @@ -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..474fd0791f 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..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 @@ -1607,3 +1607,124 @@ 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"); +}); + +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 0320d737d2..d894c9e73e 100644 --- a/packages/amplify-graphql-index-transformer/src/graphql-index-transformer.ts +++ b/packages/amplify-graphql-index-transformer/src/graphql-index-transformer.ts @@ -49,7 +49,7 @@ export class IndexTransformer extends TransformerPluginBase { object: parent as ObjectTypeDefinitionNode, field: definition, directive, - } as IndexDirectiveConfiguration, + } as Required, generateGetArgumentsInput(context.transformParameters), ); 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);