Skip to content

Commit d7aa924

Browse files
feat(data-schema): add support for GSI projection types (#628)
1 parent 1a62514 commit d7aa924

File tree

6 files changed

+284
-9
lines changed

6 files changed

+284
-9
lines changed

.changeset/red-tips-watch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@aws-amplify/data-schema': minor
3+
---
4+
5+
add support for GSI projection types

packages/data-schema/__tests__/ModelIndex.test.ts

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { expectTypeTestsToPassAsync } from 'jest-tsd';
2-
import { ClientSchema, a } from '../src/index';
3-
import { ExtractModelMeta, Prettify } from '@aws-amplify/data-schema-types';
2+
import { a } from '../src/index';
43

54
// evaluates type defs in corresponding test-d.ts file
65
it('should not produce static type errors', async () => {
@@ -75,6 +74,132 @@ describe('secondary index schema generation', () => {
7574
});
7675
});
7776

77+
describe('GSI projection functionality', () => {
78+
it('generates correct schema for KEYS_ONLY projection', () => {
79+
const schema = a
80+
.schema({
81+
Product: a
82+
.model({
83+
id: a.id().required(),
84+
name: a.string().required(),
85+
category: a.string().required(),
86+
price: a.float().required(),
87+
inStock: a.boolean().required(),
88+
})
89+
.secondaryIndexes((index) => [
90+
index('category').projection('KEYS_ONLY'),
91+
]),
92+
})
93+
.authorization((allow) => allow.publicApiKey());
94+
95+
const transformed = schema.transform().schema;
96+
97+
expect(transformed).toContain('projection: { type: KEYS_ONLY }');
98+
expect(transformed).not.toContain('nonKeyAttributes');
99+
expect(transformed).toMatchSnapshot();
100+
});
101+
102+
it('generates correct schema for INCLUDE projection with nonKeyAttributes', () => {
103+
const schema = a
104+
.schema({
105+
Product: a
106+
.model({
107+
id: a.id().required(),
108+
name: a.string().required(),
109+
category: a.string().required(),
110+
price: a.float().required(),
111+
inStock: a.boolean().required(),
112+
})
113+
.secondaryIndexes((index) => [
114+
index('category').projection('INCLUDE', ['name', 'price']),
115+
]),
116+
})
117+
.authorization((allow) => allow.publicApiKey());
118+
119+
const transformed = schema.transform().schema;
120+
121+
expect(transformed).toContain(
122+
'projection: { type: INCLUDE, nonKeyAttributes: ["name", "price"] }',
123+
);
124+
expect(transformed).toMatchSnapshot();
125+
});
126+
127+
it('generates correct schema for ALL projection', () => {
128+
const schema = a
129+
.schema({
130+
Product: a
131+
.model({
132+
id: a.id().required(),
133+
name: a.string().required(),
134+
category: a.string().required(),
135+
price: a.float().required(),
136+
inStock: a.boolean().required(),
137+
})
138+
.secondaryIndexes((index) => [index('category').projection('ALL')]),
139+
})
140+
.authorization((allow) => allow.publicApiKey());
141+
142+
const transformed = schema.transform().schema;
143+
144+
// When projection is ALL and no explicit projection is set, it may be omitted from output
145+
expect(transformed).toContain('@index');
146+
expect(transformed).not.toContain('nonKeyAttributes');
147+
expect(transformed).toMatchSnapshot();
148+
});
149+
150+
it('generates correct schema for multiple indexes with different projection types', () => {
151+
const schema = a
152+
.schema({
153+
Order: a
154+
.model({
155+
id: a.id().required(),
156+
customerId: a.string().required(),
157+
status: a.string().required(),
158+
total: a.float().required(),
159+
createdAt: a.datetime().required(),
160+
})
161+
.secondaryIndexes((index) => [
162+
index('customerId').projection('ALL'),
163+
index('status').projection('INCLUDE', ['customerId', 'total']),
164+
index('createdAt').projection('KEYS_ONLY'),
165+
]),
166+
})
167+
.authorization((allow) => allow.publicApiKey());
168+
169+
const transformed = schema.transform().schema;
170+
171+
expect(transformed).toContain(
172+
'projection: { type: INCLUDE, nonKeyAttributes: ["customerId", "total"] }',
173+
);
174+
expect(transformed).toContain('projection: { type: KEYS_ONLY }');
175+
expect(transformed).toMatchSnapshot();
176+
});
177+
178+
it('generates correct schema without projection (defaults to ALL)', () => {
179+
const schema = a
180+
.schema({
181+
Product: a
182+
.model({
183+
id: a.id().required(),
184+
name: a.string().required(),
185+
category: a.string().required(),
186+
price: a.float().required(),
187+
})
188+
.secondaryIndexes((index) => [
189+
index('category'), // No projection specified, should default to ALL
190+
]),
191+
})
192+
.authorization((allow) => allow.publicApiKey());
193+
194+
const transformed = schema.transform().schema;
195+
196+
// When no projection is specified, it defaults to ALL and may be omitted from output
197+
expect(transformed).toContain('@index');
198+
expect(transformed).not.toContain('nonKeyAttributes');
199+
expect(transformed).toMatchSnapshot();
200+
});
201+
});
202+
78203
describe('SchemaProcessor validation against secondary indexes', () => {
79204
it('throws error when a.ref() used as the index partition key points to a non-existing type', () => {
80205
const schema = a.schema({
@@ -138,9 +263,7 @@ describe('SchemaProcessor validation against secondary indexes', () => {
138263
content: a.string(),
139264
status: a.enum(['open', 'in_progress', 'completed']),
140265
})
141-
.secondaryIndexes((index) => [
142-
index('status').sortKeys(['title'])
143-
]),
266+
.secondaryIndexes((index) => [index('status').sortKeys(['title'])]),
144267
})
145268
.authorization((allow) => allow.publicApiKey());
146269

@@ -157,7 +280,9 @@ describe('SchemaProcessor validation against secondary indexes', () => {
157280
status: a.enum(['open', 'in_progress', 'completed']),
158281
})
159282
.secondaryIndexes((index) => [
160-
index('status').sortKeys(['title']).queryField('userDefinedQueryField')
283+
index('status')
284+
.sortKeys(['title'])
285+
.queryField('userDefinedQueryField'),
161286
]),
162287
})
163288
.authorization((allow) => allow.publicApiKey());
@@ -175,7 +300,7 @@ describe('SchemaProcessor validation against secondary indexes', () => {
175300
status: a.enum(['open', 'in_progress', 'completed']),
176301
})
177302
.secondaryIndexes((index) => [
178-
index('status').sortKeys(['title']).queryField(null)
303+
index('status').sortKeys(['title']).queryField(null),
179304
]),
180305
})
181306
.authorization((allow) => allow.publicApiKey());

packages/data-schema/__tests__/__snapshots__/ModelIndex.test.ts.snap

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,59 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`GSI projection functionality generates correct schema for ALL projection 1`] = `
4+
"type Product @model @auth(rules: [{allow: public, provider: apiKey}])
5+
{
6+
id: ID! @primaryKey
7+
name: String!
8+
category: String! @index(queryField: "listProductByCategory")
9+
price: Float!
10+
inStock: Boolean!
11+
}"
12+
`;
13+
14+
exports[`GSI projection functionality generates correct schema for INCLUDE projection with nonKeyAttributes 1`] = `
15+
"type Product @model @auth(rules: [{allow: public, provider: apiKey}])
16+
{
17+
id: ID! @primaryKey
18+
name: String!
19+
category: String! @index(queryField: "listProductByCategory", projection: { type: INCLUDE, nonKeyAttributes: ["name", "price"] })
20+
price: Float!
21+
inStock: Boolean!
22+
}"
23+
`;
24+
25+
exports[`GSI projection functionality generates correct schema for KEYS_ONLY projection 1`] = `
26+
"type Product @model @auth(rules: [{allow: public, provider: apiKey}])
27+
{
28+
id: ID! @primaryKey
29+
name: String!
30+
category: String! @index(queryField: "listProductByCategory", projection: { type: KEYS_ONLY })
31+
price: Float!
32+
inStock: Boolean!
33+
}"
34+
`;
35+
36+
exports[`GSI projection functionality generates correct schema for multiple indexes with different projection types 1`] = `
37+
"type Order @model @auth(rules: [{allow: public, provider: apiKey}])
38+
{
39+
id: ID! @primaryKey
40+
customerId: String! @index(queryField: "listOrderByCustomerId")
41+
status: String! @index(queryField: "listOrderByStatus", projection: { type: INCLUDE, nonKeyAttributes: ["customerId", "total"] })
42+
total: Float!
43+
createdAt: AWSDateTime! @index(queryField: "listOrderByCreatedAt", projection: { type: KEYS_ONLY })
44+
}"
45+
`;
46+
47+
exports[`GSI projection functionality generates correct schema without projection (defaults to ALL) 1`] = `
48+
"type Product @model @auth(rules: [{allow: public, provider: apiKey}])
49+
{
50+
id: ID! @primaryKey
51+
name: String!
52+
category: String! @index(queryField: "listProductByCategory")
53+
price: Float!
54+
}"
55+
`;
56+
357
exports[`SchemaProcessor validation against secondary indexes creates a queryField with a default name 1`] = `
458
"type Todo @model @auth(rules: [{allow: public, provider: apiKey}])
559
{

packages/data-schema/src/ModelIndex.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,26 @@ import { Brand, brand } from './util';
22

33
const brandName = 'modelIndexType';
44

5+
/**
6+
* DynamoDB Global Secondary Index (GSI) projection types.
7+
* - `KEYS_ONLY`: Only the index and primary keys are projected into the index.
8+
* - `INCLUDE`: In addition to the attributes in KEYS_ONLY, includes specified non-key attributes.
9+
* - `ALL`: All attributes from the base table are projected into the index.
10+
*/
11+
export type GSIProjectionType = 'KEYS_ONLY' | 'INCLUDE' | 'ALL';
12+
13+
/**
14+
* Configuration data for a model's secondary index.
15+
*/
516
export type ModelIndexData = {
617
partitionKey: string;
718
sortKeys: readonly unknown[];
819
indexName: string;
920
queryField: string | null;
21+
/** The projection type for the GSI. Defaults to 'ALL' if not specified. */
22+
projectionType?: GSIProjectionType;
23+
/** Non-key attributes to include when projectionType is 'INCLUDE'. */
24+
nonKeyAttributes?: readonly string[];
1025
};
1126

1227
export type InternalModelIndexType = ModelIndexType<any, any, any, any> & {
@@ -36,6 +51,13 @@ export type ModelIndexType<
3651
>(
3752
field: QF,
3853
): ModelIndexType<MF, PK, SK, QF, K | 'queryField'>;
54+
projection<
55+
PT extends GSIProjectionType,
56+
NKA extends PT extends 'INCLUDE' ? readonly string[] : never = never,
57+
>(
58+
type: PT,
59+
...args: PT extends 'INCLUDE' ? [nonKeyAttributes: NKA] : []
60+
): ModelIndexType<ModelFieldKeys, PK, SK, QueryField, K | 'projection'>;
3961
},
4062
K
4163
> &
@@ -52,6 +74,8 @@ function _modelIndex<
5274
sortKeys: [],
5375
indexName: '',
5476
queryField: '',
77+
projectionType: 'ALL',
78+
nonKeyAttributes: undefined,
5579
};
5680

5781
const builder = {
@@ -70,6 +94,14 @@ function _modelIndex<
7094

7195
return this;
7296
},
97+
projection(type, ...args) {
98+
data.projectionType = type;
99+
if (type === 'INCLUDE') {
100+
data.nonKeyAttributes = args[0];
101+
}
102+
103+
return this;
104+
},
73105
...brand(brandName),
74106
} as ModelIndexType<ModelFieldKeys, PK, SK, QueryField>;
75107

packages/data-schema/src/SchemaProcessor.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,8 @@ const transformedSecondaryIndexesForModel = (
11171117
sortKeys: readonly string[],
11181118
indexName: string,
11191119
queryField: string | null,
1120+
projectionType?: string,
1121+
nonKeyAttributes?: readonly string[],
11201122
): string => {
11211123
for (const keyName of [partitionKey, ...sortKeys]) {
11221124
const field = modelFields[keyName];
@@ -1131,7 +1133,7 @@ const transformedSecondaryIndexesForModel = (
11311133
}
11321134
}
11331135

1134-
if (!sortKeys.length && !indexName && !queryField && queryField !== null) {
1136+
if (!sortKeys.length && !indexName && !queryField && queryField !== null && !projectionType) {
11351137
return `@index(queryField: "${secondaryIndexDefaultQueryField(
11361138
modelName,
11371139
partitionKey,
@@ -1165,13 +1167,23 @@ const transformedSecondaryIndexesForModel = (
11651167
);
11661168
}
11671169

1170+
// Add projection attributes if specified
1171+
if (projectionType && projectionType !== 'ALL') {
1172+
if (projectionType === 'KEYS_ONLY') {
1173+
attributes.push(`projection: { type: KEYS_ONLY }`);
1174+
} else if (projectionType === 'INCLUDE' && nonKeyAttributes?.length) {
1175+
const nonKeyAttrsStr = nonKeyAttributes.map(attr => `"${attr}"`).join(', ');
1176+
attributes.push(`projection: { type: INCLUDE, nonKeyAttributes: [${nonKeyAttrsStr}] }`);
1177+
}
1178+
}
1179+
11681180
return `@index(${attributes.join(', ')})`;
11691181
};
11701182

11711183
return secondaryIndexes.reduce(
11721184
(
11731185
acc: TransformedSecondaryIndexes,
1174-
{ data: { partitionKey, sortKeys, indexName, queryField } },
1186+
{ data: { partitionKey, sortKeys, indexName, queryField, projectionType, nonKeyAttributes } },
11751187
) => {
11761188
acc[partitionKey] = acc[partitionKey] || [];
11771189
acc[partitionKey].push(
@@ -1180,6 +1192,8 @@ const transformedSecondaryIndexesForModel = (
11801192
sortKeys as readonly string[],
11811193
indexName,
11821194
queryField,
1195+
projectionType,
1196+
nonKeyAttributes,
11831197
),
11841198
);
11851199

0 commit comments

Comments
 (0)