diff --git a/.changeset/large-squids-rescue.md b/.changeset/large-squids-rescue.md new file mode 100644 index 000000000..8a578d723 --- /dev/null +++ b/.changeset/large-squids-rescue.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/data-schema': patch +--- + +fix: custom sel. set return type for array custom types diff --git a/.changeset/selfish-comics-kiss.md b/.changeset/selfish-comics-kiss.md new file mode 100644 index 000000000..391cbfab0 --- /dev/null +++ b/.changeset/selfish-comics-kiss.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/data-schema': minor +--- + +Disable additional .array() modifier on model field definition diff --git a/packages/data-schema/__tests__/ModelField.test.ts b/packages/data-schema/__tests__/ModelField.test.ts index ba8f609a7..878ae66ab 100644 --- a/packages/data-schema/__tests__/ModelField.test.ts +++ b/packages/data-schema/__tests__/ModelField.test.ts @@ -99,3 +99,8 @@ describe('field level auth', () => { expect(field.data.authorization).toMatchSnapshot(); }); }); + +it('array modifier becomes unavailable after being used once', () => { + // @ts-expect-error .array() is not a valid modifier after being used once + a.model({ values: a.string().required().array().required().array().required() }); +}); diff --git a/packages/data-schema/docs/data-schema.modelfield.md b/packages/data-schema/docs/data-schema.modelfield.md index 947f9a0e6..5f47985a2 100644 --- a/packages/data-schema/docs/data-schema.modelfield.md +++ b/packages/data-schema/docs/data-schema.modelfield.md @@ -12,8 +12,9 @@ Public API for the chainable builder methods exposed by Model Field. The type is export type ModelField = Omit<{ [__auth]?: Auth; [brandSymbol]: typeof brandName; + [internal](): ModelField; required(): ModelField, UsedMethod | 'required'>; - array(): ModelField, Exclude>; + array(): ModelField, Exclude | 'array'>; default(value?: ModelFieldTypeParamOuter): ModelField; authorization>(callback: (allow: Omit) => AuthRuleType | AuthRuleType[]): ModelField; }, UsedMethod>; diff --git a/packages/data-schema/src/ModelField.ts b/packages/data-schema/src/ModelField.ts index 4ad9ccae9..e76bff894 100644 --- a/packages/data-schema/src/ModelField.ts +++ b/packages/data-schema/src/ModelField.ts @@ -14,6 +14,7 @@ export const __auth = Symbol('__auth'); export const __generated = Symbol('__generated'); const brandName = 'modelField'; +const internal = Symbol('internal'); export enum ModelFieldType { Id = 'ID', @@ -82,7 +83,7 @@ export type BaseModelField< export type UsableModelFieldKey = satisfy< methodKeyOf, - 'required' | 'default' | 'authorization' + 'required' | 'default' | 'authorization' | 'array' >; /** @@ -102,6 +103,14 @@ export type ModelField< [__auth]?: Auth; [brandSymbol]: typeof brandName; + /** + * Internal non-omittable method that allows `BaseModelField` to retain a reference to `T` type arg in `ModelField`. + * Since all public methods are omittable, the evaluated `BaseModelField` loses type information unless + * some property on the type is guaranteed to reference `T` + * Context: https://github.com/aws-amplify/amplify-api-next/pull/406/files#r1869481467 + */ + [internal](): ModelField; + /** * Marks a field as required. */ @@ -110,7 +119,7 @@ export type ModelField< /** * Converts a field type definition to an array of the field type. */ - array(): ModelField, Exclude>; + array(): ModelField, Exclude | 'array'>; // TODO: should be T, but .array breaks this constraint. Fix later /** * Sets a default value for the scalar type. @@ -199,6 +208,9 @@ function _field(fieldType: ModelFieldType) { return this; }, ...brand(brandName), + [internal]() { + return this; + }, } as ModelField; // this double cast gives us a Subtyping Constraint i.e., hides `data` from the public API, diff --git a/packages/data-schema/src/runtime/client/index.ts b/packages/data-schema/src/runtime/client/index.ts index 6b6cd19da..382e8c2c4 100644 --- a/packages/data-schema/src/runtime/client/index.ts +++ b/packages/data-schema/src/runtime/client/index.ts @@ -84,21 +84,52 @@ type ReturnValue< * This mapped type traverses the SelectionSetReturnValue result and the original FlatModel, restoring array types * that were flattened in DeepPickFromPath * - * Note: custom type field arrays are already handled correctly and don't need to be "restored", hence the `Result[K] extends Array` check + * @typeParam Result - this is the result of applying the selection set path to FlatModel; return type of UnionToIntersection> + * @typeParam FlatModel - the reference model shape; return type of ResolvedModel * + * Note: we wrap `Result` and `FlatModel` in NonNullable, because recursive invocations of this mapped type + * can result in the type arguments containing `{} | null | undefined` which breaks indexed access, e.g. Result[K] + * + * Using NonNullable<> directly inside the mapped type is significantly more performant here than attempting to pre-compute in the type params, + * e.g., `type RestoreArrays, NonNullableFlatModel = NonNullable> = {...}` */ type RestoreArrays = { - [K in keyof Result]: K extends keyof FlatModel - ? FlatModel[K] extends Array - ? Result[K] extends Array - ? Result[K] - : Array>> - : FlatModel[K] extends Record - ? RestoreArrays - : Result[K] + [K in keyof NonNullable]: K extends keyof NonNullable + ? Array extends NonNullable[K] + ? HandleArrayNullability< + NonNullable[K], + NonNullable[K] + > + : NonNullable[K] extends Record + ? RestoreArrays[K], NonNullable[K]> + : NonNullable[K] : never; }; +/** + * This mapped type gets called by RestoreArrays and it restores the expected + * nullability in array fields (e.g. nullable vs. required value & nullable vs. required array) + */ +type HandleArrayNullability = + Array extends Result + ? // If Result is already an array, return it as is. + Result + : NonNullable extends Array + ? // is the array nullable? + null extends FlatModel + ? // is the value nullable? + null extends InnerValue + ? // value and array are nullable - a.ref('SomeType').array() + Array> | null> | null + : // value required; array nullable - a.ref('SomeType').required().array() + Array>> | null + : null extends InnerValue + ? // value nullable; array required - a.ref('SomeType').array().required() + Array> | null> + : // value required; array required - a.ref('SomeType').required().array().required() + Array>> + : never; + /** * Generates flattened, readonly return type using specified custom sel. set */ diff --git a/packages/integration-tests/__tests__/custom-selection-set.test-d.ts b/packages/integration-tests/__tests__/custom-selection-set.test-d.ts deleted file mode 100644 index 5a85f100f..000000000 --- a/packages/integration-tests/__tests__/custom-selection-set.test-d.ts +++ /dev/null @@ -1,620 +0,0 @@ -import { a, ClientSchema } from '@aws-amplify/data-schema'; -import { Expect, Equal } from '@aws-amplify/data-schema-types'; -import { generateClient, SelectionSet } from 'aws-amplify/api'; - -type Json = null | string | number | boolean | object | any[]; - -describe('Custom Selection Set', () => { - describe('Basic, single model', () => { - const schema = a.schema({ - Post: a.model({ - title: a.string().required(), - description: a.string(), - }), - }); - - type Schema = ClientSchema; - - test('can specify custom selection set for all fields', async () => { - const client = generateClient(); - - const posts = await client.models.Post.list({ - selectionSet: ['id', 'title', 'description', 'createdAt', 'updatedAt'], - }); - - type ExpectedType = { - readonly id: string; - readonly title: string; - readonly description: string | null; - readonly createdAt: string; - readonly updatedAt: string; - }[]; - - type test = Expect>; - }); - - test('can specify custom selection set for a subset of fields', async () => { - const client = generateClient(); - const posts = await client.models.Post.list({ - selectionSet: ['id', 'title'], - }); - - type ExpectedType = { - readonly id: string; - readonly title: string; - }[]; - - type test = Expect>; - }); - - test('can specify custom selection set through variable', async () => { - const client = generateClient(); - - const selSet = ['id', 'title'] as const; - - const posts = await client.models.Post.list({ - selectionSet: selSet, - }); - - type ExpectedType = { - readonly id: string; - readonly title: string; - }[]; - - type test = Expect>; - - type WithUtil = SelectionSet[]; - - type test2 = Expect>; - }); - - test('SelectionSet util return type matches actual', async () => { - const client = generateClient(); - const posts = await client.models.Post.list({ - selectionSet: ['id', 'title'], - }); - - type Post = Schema['Post']['type']; - - type ExpectedType = SelectionSet[]; - - type test = Expect>; - }); - - test('error when specifying a non-existent field', () => { - const client = generateClient(); - client.models.Post.list({ - // @ts-expect-error - selectionSet: ['id', 'does-not-exist'], - }); - }); - }); - - describe('Model with hasOne relationship', () => { - const schema = a.schema({ - Post: a.model({ - title: a.string().required(), - description: a.string(), - author: a.hasOne('Author', 'postId'), - }), - Author: a.model({ - name: a.string().required(), - postId: a.id(), - post: a.belongsTo('Post', 'postId'), - }), - }); - - type Schema = ClientSchema; - - test('can specify custom selection set for all fields on related model explicitly', async () => { - const client = generateClient(); - - const posts = await client.models.Post.list({ - selectionSet: [ - 'id', - 'author.id', - 'author.name', - 'author.createdAt', - 'author.updatedAt', - ], - }); - - type ExpectedType = { - readonly id: string; - readonly author: { - readonly name: string; - readonly id: string; - readonly createdAt: string; - readonly updatedAt: string; - }; - }[]; - - type test = Expect>; - }); - - test('can specify custom selection set for all fields on related model with wildcard `.*`', async () => { - const client = generateClient(); - - const posts = await client.models.Post.list({ - selectionSet: [ - 'id', - 'title', - 'description', - 'createdAt', - 'updatedAt', - 'author.*', - ], - }); - - type ExpectedType = { - readonly id: string; - readonly title: string; - readonly description: string | null; - readonly createdAt: string; - readonly updatedAt: string; - readonly author: { - readonly name: string; - readonly id: string; - readonly postId: string | null; - readonly createdAt: string; - readonly updatedAt: string; - }; - }[]; - - type test = Expect>; - }); - - test('SelectionSet util return type matches actual', async () => { - const client = generateClient(); - - const posts = await client.models.Post.list({ - selectionSet: [ - 'id', - 'title', - 'description', - 'createdAt', - 'updatedAt', - 'author.*', - ], - }); - - type Post = Schema['Post']['type']; - - type ExpectedType = SelectionSet< - Post, - ['id', 'title', 'description', 'createdAt', 'updatedAt', 'author.*'] - >[]; - - type test = Expect>; - }); - }); - - describe('Model with bi-directional hasMany relationship', () => { - const schema = a.schema({ - Post: a.model({ - title: a.string().required(), - description: a.string(), - comments: a.hasMany('Comment', 'postId'), - }), - Comment: a.model({ - content: a.string().required(), - postId: a.id(), - post: a.belongsTo('Post', 'postId'), - }), - }); - - type Schema = ClientSchema; - - test('specifying wildcard selection set on relationship returns only non-relationship fields', async () => { - const client = generateClient(); - - const posts = await client.models.Post.list({ - selectionSet: ['id', 'comments.*'], - }); - - type ExpectedType = { - readonly id: string; - readonly comments: { - readonly content: string; - readonly id: string; - readonly postId: string | null; - readonly createdAt: string; - readonly updatedAt: string; - // post is omitted; - }[]; - }[]; - - type test = Expect>; - }); - - test('custom selection set path can go up to 6 levels deep', async () => { - const client = generateClient(); - - const posts = await client.models.Post.list({ - selectionSet: [ - 'id', - 'comments.post.comments.post.comments.post.comments.*', - ], - }); - - type ExpectedType2 = { - readonly id: string; - readonly comments: { - readonly post: { - readonly comments: { - readonly post: { - readonly comments: { - readonly post: { - readonly comments: { - readonly content: string; - readonly id: string; - readonly postId: string | null; - readonly createdAt: string; - readonly updatedAt: string; - }[]; - }; - }[]; - }; - }[]; - }; - }[]; - }[]; - - type test = Expect>; - }); - - test('SelectionSet util return type matches actual', async () => { - const client = generateClient(); - - const posts = await client.models.Post.list({ - selectionSet: [ - 'id', - 'comments.post.comments.post.comments.post.comments.*', - ], - }); - - type ExpectedType = SelectionSet< - Schema['Post']['type'], - ['id', 'comments.post.comments.post.comments.post.*'] - >[]; - - type test = Expect>; - }); - }); - - describe('Complex relationship', () => { - const schema = a.schema({ - Blog: a.model({ - posts: a.hasMany('Post', 'blogId'), - }), - Post: a.model({ - title: a.string().required(), - description: a.string(), - meta: a.string().array(), - blogId: a.id(), - blog: a.belongsTo('Blog', 'blogId'), - comments: a.hasMany('Comment', 'postId'), - comments2: a.hasMany('Comment', 'postId'), - }), - Comment: a.model({ - content: a.string().required(), - postId: a.id(), - post: a.belongsTo('Post', 'postId'), - meta: a.hasMany('CommentMeta', 'commentId'), - }), - CommentMeta: a.model({ - metaData: a.json(), - commentId: a.id(), - comment: a.belongsTo('Comment', 'commentId'), - }), - }); - - type Schema = ClientSchema; - - test('a mix of everything', async () => { - const client = generateClient(); - - const blogs = await client.models.Blog.list({ - selectionSet: [ - 'id', - 'updatedAt', - 'posts.id', - 'posts.updatedAt', - 'posts.meta', - 'posts.comments.content', - 'posts.comments.createdAt', - 'posts.comments2.content', - 'posts.comments.meta.*', - 'posts.comments2.meta.*', - ], - }); - - type ExpectedType = { - readonly id: string; - readonly updatedAt: string; - readonly posts: { - readonly id: string; - readonly meta: (string | null)[] | null; - readonly updatedAt: string; - readonly comments: { - readonly createdAt: string; - readonly content: string; - readonly meta: { - readonly id: string; - readonly commentId: string | null; - readonly createdAt: string; - readonly updatedAt: string; - readonly metaData: Json; - }[]; - }[]; - readonly comments2: { - readonly content: string; - readonly meta: { - readonly id: string; - readonly commentId: string | null; - readonly createdAt: string; - readonly updatedAt: string; - readonly metaData: Json; - }[]; - }[]; - }[]; - }[]; - - type test = Expect>; - }); - - test('a mix of everything', async () => { - const client = generateClient(); - - const blogs = await client.models.Blog.list({ - selectionSet: [ - 'id', - 'updatedAt', - 'posts.id', - 'posts.updatedAt', - 'posts.meta', - 'posts.comments.content', - 'posts.comments.createdAt', - 'posts.comments2.content', - 'posts.comments.meta.*', - 'posts.comments2.meta.*', - ], - }); - - type ExpectedType = SelectionSet< - Schema['Blog']['type'], - [ - 'id', - 'updatedAt', - 'posts.id', - 'posts.updatedAt', - 'posts.meta', - 'posts.comments.content', - 'posts.comments.createdAt', - 'posts.comments2.content', - 'posts.comments.meta.*', - 'posts.comments2.meta.*', - ] - >[]; - - type test = Expect>; - }); - }); - - describe('Custom Types', () => { - const schema = a.schema({ - Post: a.model({ - title: a.string().required(), - description: a.string(), - location: a.ref('Location').required(), - }), - - Post2: a.model({ - title: a.string().required(), - description: a.string(), - location: a.ref('Location'), - }), - - Post3: a.model({ - title: a.string().required(), - description: a.string(), - altLocation: a.customType({ - lat: a.float(), - long: a.float(), - }), - }), - - Post4: a.model({ - title: a.string().required(), - description: a.string(), - meta: a.customType({ - status: a.enum(['published', 'unpublished']), - tags: a.string().array().required(), - location: a.customType({ - lat: a.float(), - long: a.float(), - }), - }), - }), - - Post5: a.model({ - title: a.string().required(), - description: a.string(), - meta: a.customType({ - status: a.enum(['published', 'unpublished']), - tags: a.string().array().required(), - location: a.ref('Location').required(), - }), - }), - - Post6: a.model({ - title: a.string().required(), - description: a.string(), - meta: a.ref('Meta').required(), - }), - - Location: a.customType({ - lat: a.float(), - long: a.float(), - }), - - Meta: a.customType({ - tags: a.string().array(), - requiredTags: a.string().required().array().required(), - }), - }); - - type Schema = ClientSchema; - const client = generateClient(); - - test('custom selection set on required custom type', async () => { - const { data: posts } = await client.models.Post.list({ - selectionSet: ['title', 'location.lat'], - }); - - type ExpectedType = { - readonly title: string; - readonly location: { - readonly lat: number | null; - }; - }[]; - - type test = Expect>; - }); - - test('custom selection set on nullable custom type', async () => { - const { data: posts } = await client.models.Post2.list({ - selectionSet: ['title', 'location.lat'], - }); - - type ExpectedType = { - readonly title: string; - readonly location: { - readonly lat: number | null; - } | null; - }[]; - - type test = Expect>; - }); - - test('custom selection set on implicit nullable custom type', async () => { - const { data: posts } = await client.models.Post3.list({ - selectionSet: ['title', 'altLocation.lat'], - }); - - type ExpectedType = { - readonly title: string; - readonly altLocation: { - readonly lat: number | null; - } | null; - }[]; - - type test = Expect>; - }); - - test('custom selection set on nested nullable custom type', async () => { - const { data: posts } = await client.models.Post4.list({ - selectionSet: ['title', 'meta.tags', 'meta.location.lat'], - }); - - type ExpectedType = { - readonly title: string; - readonly meta: { - readonly tags: (string | null)[]; - readonly location: { - readonly lat: number | null; - } | null; - } | null; - }[]; - - type _ = Expect>; - }); - - test('custom selection set on nested non-nullable custom type', async () => { - const { data: posts } = await client.models.Post5.list({ - selectionSet: ['title', 'meta.tags', 'meta.location.lat'], - }); - - type ExpectedType = { - readonly title: string; - readonly meta: { - readonly tags: (string | null)[]; - readonly location: { - readonly lat: number | null; - }; - } | null; - }[]; - - type _ = Expect>; - }); - - // https://github.com/aws-amplify/amplify-category-api/issues/2368 - number 4. - test('custom selection set on explicit non-nullable custom type with array fields', async () => { - const selSet = ['title', 'meta.tags', 'meta.requiredTags'] as const; - - const { data: posts } = await client.models.Post6.list({ - selectionSet: selSet, - }); - - type ActualType = typeof posts; - - type ExpectedType = { - readonly title: string; - readonly meta: { - readonly tags: (string | null)[] | null; - readonly requiredTags: string[]; - }; - }[]; - - type _ = Expect>; - }); - }); - - describe('Enums', () => { - const schema = a.schema({ - Post: a.model({ - title: a.string().required(), - description: a.string(), - status: a.ref('Status'), - visibility: a.enum(['PRIVATE', 'PUBLIC']), - }), - - Status: a.enum(['DRAFT', 'PENDING', 'PUBLISHED']), - }); - - type Schema = ClientSchema; - const client = generateClient(); - - test('custom selection set on shorthand enum field', async () => { - const { data: post } = await client.models.Post.get( - { id: 'abc' }, - { - selectionSet: ['title', 'visibility'], - }, - ); - - type ExpectedType = { - readonly title: string; - readonly visibility: 'PRIVATE' | 'PUBLIC' | null; - } | null; - - type test = Expect>; - }); - - test('custom selection set on enum ref', async () => { - const { data: post } = await client.models.Post.get( - { id: 'abc' }, - { - selectionSet: ['title', 'status'], - }, - ); - - type ExpectedType = { - readonly title: string; - readonly status: 'DRAFT' | 'PENDING' | 'PUBLISHED' | null; - } | null; - - type test = Expect>; - }); - }); -}); diff --git a/packages/integration-tests/__tests__/custom-selection-set.test.ts b/packages/integration-tests/__tests__/custom-selection-set.test.ts deleted file mode 100644 index df15df33c..000000000 --- a/packages/integration-tests/__tests__/custom-selection-set.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { expectTypeTestsToPassAsync } from 'jest-tsd'; - -// evaluates type defs in corresponding test-d.ts file -it('should not produce static type errors', async () => { - await expectTypeTestsToPassAsync(__filename); -}); diff --git a/packages/integration-tests/__tests__/defined-behavior/1-patterns/add-fields.ts b/packages/integration-tests/__tests__/defined-behavior/1-patterns/add-fields.ts index 31eb2d5bb..1603a2abd 100644 --- a/packages/integration-tests/__tests__/defined-behavior/1-patterns/add-fields.ts +++ b/packages/integration-tests/__tests__/defined-behavior/1-patterns/add-fields.ts @@ -681,3 +681,18 @@ describe('Specify an enum field type', () => { }); }); }); + +test('Disallow additional array modifier', () => { + a.schema({ + ToDo: a.model({ + values: a + .string() + .required() + .array() + .required() + // @ts-expect-error + .array() + .required(), + }), + }) +}); diff --git a/packages/integration-tests/__tests__/defined-behavior/3-exhaustive/custom-selection-set.ts b/packages/integration-tests/__tests__/defined-behavior/3-exhaustive/custom-selection-set.ts new file mode 100644 index 000000000..1b256ffe9 --- /dev/null +++ b/packages/integration-tests/__tests__/defined-behavior/3-exhaustive/custom-selection-set.ts @@ -0,0 +1,1110 @@ +import { a, ClientSchema } from '@aws-amplify/data-schema'; +import { Amplify } from 'aws-amplify'; +import { + buildAmplifyConfig, + mockedGenerateClient, + expectSelectionSetEquals, +} from '../../utils'; +import { Expect, Equal } from '@aws-amplify/data-schema-types'; +import { SelectionSet } from 'aws-amplify/data'; + +describe('Custom selection set edge cases', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Models', () => { + let client; + + const schema = a + .schema({ + Todo: a.model({ + description: a.string(), + details: a.hasOne('Details', ['todoId']), + steps: a.hasMany('Step', ['todoId']), + done: a.boolean(), + priority: a.enum(['low', 'medium', 'high']), + }), + Details: a.model({ + content: a.string(), + todoId: a.id(), + todo: a.belongsTo('Todo', ['todoId']), + }), + Step: a.model({ + description: a.string().required(), + todoId: a.id().required(), + todo: a.belongsTo('Todo', ['todoId']), + }), + }) + .authorization((allow) => [allow.owner()]); + type Schema = ClientSchema; + + async function getMockedClient(sampleTodo: Record) { + const { spy, generateClient } = mockedGenerateClient([ + { + data: { + listTodos: { + items: [sampleTodo], + }, + }, + }, + ]); + const config = await buildAmplifyConfig(schema); + Amplify.configure(config); + client = generateClient(); + return { client, spy }; + } + + describe('with all top-level fields selected', () => { + const sampleTodo = { + id: 'some-id', + description: 'something something', + done: true, + createdAt: '2024-09-05T16:04:32.404Z', + updatedAt: '2024-09-05T16:04:32.404Z', + priority: 'high', + details: { + id: 'detail-id', + content: 'some details content', + todoId: 'some-id', + createdAt: '2024-09-05T16:04:32.404Z', + updatedAt: '2024-09-05T16:04:32.404Z', + }, + steps: { + items: [ + { + id: 'step-id-123', + todoId: 'some-id', + description: 'first step', + owner: 'harry-f-potter', + createdAt: '2024-09-05T16:04:32.404Z', + updatedAt: '2024-09-05T16:04:32.404Z', + }, + ], + }, + }; + + async function mockedOperation() { + const { client, spy } = await getMockedClient(sampleTodo); + + const { data } = await client.models.Todo.list({ + selectionSet: [ + 'id', + 'description', + 'done', + 'createdAt', + 'updatedAt', + 'priority', + 'details.*', + 'steps.*', + ], + }); + + return { data, spy }; + } + + test('is reflected in the graphql selection set', async () => { + const { spy } = await mockedOperation(); + + const expectedSelectionSet = /* GraphQL */ ` + items { + id + description + done + createdAt + updatedAt + priority + details { + id + content + todoId + createdAt + updatedAt + owner + } + steps { + items { + id + description + todoId + createdAt + updatedAt + owner + } + } + } + nextToken + __typename + `; + + expectSelectionSetEquals(spy, expectedSelectionSet); + }); + + test('returns the selected fields at runtime', async () => { + const { data } = await mockedOperation(); + + const sampleTodoFinalResult = [ + { + ...sampleTodo, + steps: [...sampleTodo.steps.items], + }, + ]; + + expect(data).toEqual(sampleTodoFinalResult); + }); + + test('has a matching return type', async () => { + const { data } = await mockedOperation(); + + type ExpectedTodoType = { + readonly description: string | null; + readonly done: boolean | null; + readonly priority: 'low' | 'medium' | 'high' | null; + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly details: { + readonly content: string | null; + readonly todoId: string | null; + readonly id: string; + readonly owner: string | null; + readonly createdAt: string; + readonly updatedAt: string; + }; + readonly steps: { + readonly description: string; + readonly todoId: string; + readonly id: string; + readonly owner: string | null; + readonly createdAt: string; + readonly updatedAt: string; + }[]; + }[]; + + type ActualType = typeof data; + + type _test = Expect>; + }); + }); + + describe('with a subset of fields selected', () => { + const sampleTodo = { + description: 'something something', + details: { + content: 'some details content', + }, + steps: { + items: [ + { + description: 'first step', + }, + ], + }, + }; + + async function mockedOperation() { + const { client, spy } = await getMockedClient(sampleTodo); + + const { data } = await client.models.Todo.list({ + selectionSet: ['description', 'details.content', 'steps.description'], + }); + + return { data, spy }; + } + + test('is reflected in the graphql selection set', async () => { + const { spy } = await mockedOperation(); + + const expectedSelectionSet = /* GraphQL */ ` + items { + description + details { + content + } + steps { + items { + description + } + } + } + nextToken + __typename + `; + + expectSelectionSetEquals(spy, expectedSelectionSet); + }); + + test('returns the selected fields at runtime', async () => { + const { data } = await mockedOperation(); + + const sampleTodoFinalResult = [ + { + ...sampleTodo, + steps: [...sampleTodo.steps.items], + }, + ]; + + expect(data).toEqual(sampleTodoFinalResult); + }); + + test('has a matching return type', async () => { + const { data } = await mockedOperation(); + + type ExpectedTodoType = { + readonly description: string | null; + readonly details: { + readonly content: string | null; + }; + readonly steps: { + readonly description: string; + }[]; + }[]; + + type ActualType = typeof data; + + type _test = Expect>; + }); + }); + + describe('with selection set specified through variable', () => { + const sampleTodo = { + description: 'something something', + details: { + content: 'some details content', + }, + steps: { + items: [ + { + description: 'first step', + }, + ], + }, + }; + + const selectionSet = [ + 'description', + 'details.content', + 'steps.description', + ] as const; + + async function mockedOperation() { + const { client, spy } = await getMockedClient(sampleTodo); + + const { data } = await client.models.Todo.list({ + selectionSet, + }); + + return { data, spy }; + } + + test('is reflected in the graphql selection set', async () => { + const { spy } = await mockedOperation(); + + const expectedSelectionSet = /* GraphQL */ ` + items { + description + details { + content + } + steps { + items { + description + } + } + } + nextToken + __typename + `; + + expectSelectionSetEquals(spy, expectedSelectionSet); + }); + + test('returns the selected fields at runtime', async () => { + const { data } = await mockedOperation(); + + const sampleTodoFinalResult = [ + { + ...sampleTodo, + steps: [...sampleTodo.steps.items], + }, + ]; + + expect(data).toEqual(sampleTodoFinalResult); + }); + + test('has a matching return type', async () => { + const { data } = await mockedOperation(); + + type ExpectedTodoType = { + readonly description: string | null; + readonly details: { + readonly content: string | null; + }; + readonly steps: { + readonly description: string; + }[]; + }[]; + + type ActualType = typeof data; + + type _test = Expect>; + }); + + test('has a return type that matches util', async () => { + const { data } = await mockedOperation(); + + type ExpectedTodoType = SelectionSet< + Schema['Todo']['type'], + typeof selectionSet + >[]; + + type ActualType = typeof data; + + type _test = Expect>; + }); + }); + + describe('with 6 level deep selection set', () => { + const sampleTodo = { + id: 'some-id', + steps: { + items: [ + { + todo: { + steps: { + items: [ + { + todo: { + steps: { + items: [ + { + todo: { + steps: { + items: [ + { + id: 'step-id-123', + todoId: 'some-id', + description: 'first step', + owner: 'harry-f-potter', + createdAt: '2024-09-05T16:04:32.404Z', + updatedAt: '2024-09-05T16:04:32.404Z', + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }; + + async function mockedOperation() { + const { client, spy } = await getMockedClient(sampleTodo); + + const { data } = await client.models.Todo.list({ + selectionSet: ['id', 'steps.todo.steps.todo.steps.todo.steps.*'], + }); + + return { data, spy }; + } + + test('is reflected in the graphql selection set', async () => { + const { spy } = await mockedOperation(); + + const expectedSelectionSet = /* GraphQL */ ` + items { + id + steps { + items { + todo { + steps { + items { + todo { + steps { + items { + todo { + steps { + items { + id + description + todoId + createdAt + updatedAt + owner + + } + } + } + } + } + } + } + } + } + } + } + } + nextToken + __typename + `; + + expectSelectionSetEquals(spy, expectedSelectionSet); + }); + + test('returns only the selected fields, without lazy loaders', async () => { + const { data } = await mockedOperation(); + + const sampleTodoFinalResult = [ + { + id: sampleTodo.id, + steps: [ + { + todo: { + steps: [ + { + todo: { + steps: [ + { + todo: { + steps: [ + { + id: 'step-id-123', + todoId: 'some-id', + description: 'first step', + owner: 'harry-f-potter', + createdAt: '2024-09-05T16:04:32.404Z', + updatedAt: '2024-09-05T16:04:32.404Z', + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + ]; + + expect(data).toEqual(sampleTodoFinalResult); + }); + + test('has a matching return type', async () => { + const { data } = await mockedOperation(); + + type ExpectedTodoType = { + readonly id: string; + readonly steps: { + readonly todo: { + readonly steps: { + readonly todo: { + readonly steps: { + readonly todo: { + readonly steps: { + readonly description: string; + readonly todoId: string; + readonly id: string; + readonly owner: string | null; + readonly createdAt: string; + readonly updatedAt: string; + }[]; + }; + }[]; + }; + }[]; + }; + }[]; + }[]; + + type ActualType = typeof data; + + type _test = Expect>; + }); + }); + + describe('with a nonexistent field selected', () => { + const sampleTodo = {}; + + test('is surfaced as runtime exception and type error', async () => { + await expect(async () => { + const { client } = await getMockedClient(sampleTodo); + + await client.models.Todo.list({ + // @ts-expect-error + selectionSet: ['perfect-field'], + }); + }).rejects.toThrow('perfect-field is not a field of model Todo'); + }); + }); + }); + + describe('Custom types', () => { + const schema = a + .schema({ + ModelWithInlineCustomType: a.model({ + location: a.customType({ + lat: a.float(), + long: a.float(), + locationMeta: a.customType({ + tags: a.string().array(), + requiredTags: a.string().required().array().required(), + }), + }), + }), + Location: a.customType({ + lat: a.float(), + long: a.float(), + }), + Meta: a.customType({ + tags: a.string().array(), + requiredTags: a.string().required().array().required(), + }), + LocationWithMeta: a.customType({ + lat: a.float(), + long: a.float(), + locationMeta: a.ref('Meta'), + }), + ModelWithReferencedCustomTypes: a.model({ + title: a.string().required(), + location: a.ref('Location'), + location2: a.ref('Location').required(), + // Testing every permutation of array custom types + // https://github.com/aws-amplify/amplify-category-api/issues/2809 + meta: a.ref('Meta').array(), + meta2: a.ref('Meta').required().array(), + meta3: a.ref('Meta').array().required(), + meta4: a.ref('Meta').required().array().required(), + locationMeta: a.ref('LocationWithMeta').array(), + }), + }) + .authorization((allow) => allow.guest()); + + type Schema = ClientSchema; + + async function getMockedClient( + operationName: string, + mockedResult: object, + ) { + const { spy, generateClient } = mockedGenerateClient([ + { + data: { + [operationName]: { + items: [mockedResult], + }, + }, + }, + ]); + + const config = await buildAmplifyConfig(schema); + Amplify.configure(config); + const client = generateClient(); + return { client, spy }; + } + + describe('Defined inline', () => { + const sampleModelInline = { + id: 'some-id', + location: { + lat: 1.23, + long: 4.56, + locationMeta: { + tags: ['a', 'b'], + requiredTags: ['residential'], + }, + }, + }; + + async function mockedOperation() { + const { client, spy } = await getMockedClient( + 'listModelWithInlineCustomTypes', + sampleModelInline, + ); + + const { data } = await client.models.ModelWithInlineCustomType.list({ + selectionSet: [ + 'id', + 'location.lat', + 'location.long', + 'location.locationMeta.*', + ], + }); + + return { data, spy }; + } + + test('is reflected in the graphql selection set', async () => { + const { spy } = await mockedOperation(); + + const expectedSelectionSet = /* GraphQL */ ` + items { + id + location { + lat + long + locationMeta { + tags + requiredTags + } + } + } + nextToken + __typename + `; + + expectSelectionSetEquals(spy, expectedSelectionSet); + }); + + test('returns the selected fields at runtime', async () => { + const { data } = await mockedOperation(); + + const sampleTodoFinalResult = [ + { + ...sampleModelInline, + }, + ]; + + expect(data).toEqual(sampleTodoFinalResult); + }); + + test('has a matching return type', async () => { + const { data } = await mockedOperation(); + + type ExpectedTodoType = { + readonly id: string; + readonly location: { + readonly lat: number | null; + readonly long: number | null; + readonly locationMeta: { + readonly requiredTags: string[]; + readonly tags: (string | null)[] | null; + } | null; + } | null; + }[]; + + type ActualType = typeof data; + + type _test = Expect>; + }); + }); + + describe('Defined through ref; all fields', () => { + const sampleModelInline = { + id: 'some-id', + location: { + lat: 1.23, + long: 4.56, + }, + location2: { + lat: 1.23, + long: 4.56, + }, + meta: { + tags: ['a', 'b'], + requiredTags: ['residential'], + }, + meta2: { + tags: ['a', 'b'], + requiredTags: ['residential'], + }, + meta3: { + tags: ['a', 'b'], + requiredTags: ['residential'], + }, + meta4: { + tags: ['a', 'b'], + requiredTags: ['residential'], + }, + locationMeta: { + lat: 1.23, + long: 4.56, + locationMeta: { + tags: ['a', 'b'], + requiredTags: ['residential'], + }, + }, + }; + + async function mockedOperation() { + const { client, spy } = await getMockedClient( + 'listModelWithReferencedCustomTypes', + sampleModelInline, + ); + + const { data } = + await client.models.ModelWithReferencedCustomTypes.list({ + selectionSet: [ + 'id', + 'location.*', + 'location2.*', + 'meta.*', + 'meta2.*', + 'meta3.*', + 'meta4.*', + 'locationMeta.*', + ], + }); + + return { data, spy }; + } + + test('is reflected in the graphql selection set', async () => { + const { spy } = await mockedOperation(); + + const expectedSelectionSet = /* GraphQL */ ` + items { + id + location { + lat + long + } + location2 { + lat + long + } + meta { + tags + requiredTags + } + meta2 { + tags + requiredTags + } + meta3 { + tags + requiredTags + } + meta4 { + tags + requiredTags + } + locationMeta { + lat + long + locationMeta { + tags + requiredTags + } + } + } + nextToken + __typename + `; + + expectSelectionSetEquals(spy, expectedSelectionSet); + }); + + test('returns the selected fields at runtime', async () => { + const { data } = await mockedOperation(); + + const sampleTodoFinalResult = [ + { + ...sampleModelInline, + }, + ]; + + expect(data).toEqual(sampleTodoFinalResult); + }); + + test('has a matching return type', async () => { + const { data } = await mockedOperation(); + + type ExpectedTodoType = { + readonly id: string; + readonly location: { + readonly lat: number | null; + readonly long: number | null; + } | null; + readonly location2: { + readonly lat: number | null; + readonly long: number | null; + }; + readonly meta: + | ({ + readonly requiredTags: string[]; + readonly tags: (string | null)[] | null; + } | null)[] + | null; + readonly meta2: + | { + readonly requiredTags: string[]; + readonly tags: (string | null)[] | null; + }[] + | null; + readonly meta3: ({ + readonly requiredTags: string[]; + readonly tags: (string | null)[] | null; + } | null)[]; + readonly meta4: { + readonly requiredTags: string[]; + readonly tags: (string | null)[] | null; + }[]; + readonly locationMeta: + | ({ + readonly lat: number | null; + readonly long: number | null; + readonly locationMeta: { + readonly requiredTags: string[]; + readonly tags: (string | null)[] | null; + } | null; + } | null)[] + | null; + }[]; + + type ActualType = typeof data; + + type _test = Expect>; + }); + }); + + describe('Defined through ref; subset of fields', () => { + const sampleModelInline = { + id: 'some-id', + location: { + lat: 1.23, + }, + location2: { + lat: 1.23, + }, + meta: { + requiredTags: ['residential'], + }, + meta2: { + requiredTags: ['residential'], + }, + meta3: { + requiredTags: ['residential'], + }, + meta4: { + requiredTags: ['residential'], + }, + locationMeta: { + lat: 1.23, + locationMeta: { + requiredTags: ['residential'], + }, + }, + }; + + async function mockedOperation() { + const { client, spy } = await getMockedClient( + 'listModelWithReferencedCustomTypes', + sampleModelInline, + ); + + const { data } = + await client.models.ModelWithReferencedCustomTypes.list({ + selectionSet: [ + 'id', + 'location.lat', + 'location2.lat', + 'meta.requiredTags', + 'meta2.requiredTags', + 'meta3.requiredTags', + 'meta4.requiredTags', + 'locationMeta.lat', + 'locationMeta.locationMeta.requiredTags', + ], + }); + + return { data, spy }; + } + + test('is reflected in the graphql selection set', async () => { + const { spy } = await mockedOperation(); + + const expectedSelectionSet = /* GraphQL */ ` + items { + id + location { + lat + } + location2 { + lat + } + meta { + requiredTags + } + meta2 { + requiredTags + } + meta3 { + requiredTags + } + meta4 { + requiredTags + } + locationMeta { + lat + locationMeta { + requiredTags + } + } + } + nextToken + __typename + `; + + expectSelectionSetEquals(spy, expectedSelectionSet); + }); + + test('returns the selected fields at runtime', async () => { + const { data } = await mockedOperation(); + + const sampleTodoFinalResult = [ + { + ...sampleModelInline, + }, + ]; + + expect(data).toEqual(sampleTodoFinalResult); + }); + + test('has a matching return type', async () => { + const { data } = await mockedOperation(); + + type ExpectedTodoType = { + readonly id: string; + readonly location: { + readonly lat: number | null; + } | null; + readonly location2: { + readonly lat: number | null; + }; + readonly meta: + | ({ + readonly requiredTags: string[]; + } | null)[] + | null; + readonly meta2: + | { + readonly requiredTags: string[]; + }[] + | null; + readonly meta3: ({ + readonly requiredTags: string[]; + } | null)[]; + readonly meta4: { + readonly requiredTags: string[]; + }[]; + readonly locationMeta: + | ({ + readonly lat: number | null; + readonly locationMeta: { + readonly requiredTags: string[]; + } | null; + } | null)[] + | null; + }[]; + + type ActualType = typeof data; + + type _test = Expect>; + }); + }); + }); + + describe('Enums', () => { + const schema = a + .schema({ + ModelWithEnums: a.model({ + title: a.string().required(), + status: a.ref('Status').required(), + visibility: a.enum(['PRIVATE', 'PUBLIC']), + }), + Status: a.enum(['DRAFT', 'PENDING', 'PUBLISHED']), + }) + .authorization((allow) => allow.guest()); + + type Schema = ClientSchema; + + async function getMockedClient( + operationName: string, + mockedResult: object, + ) { + const { spy, generateClient } = mockedGenerateClient([ + { + data: { + [operationName]: { + items: [mockedResult], + }, + }, + }, + ]); + + const config = await buildAmplifyConfig(schema); + Amplify.configure(config); + const client = generateClient(); + return { client, spy }; + } + + const sampleModelInline = { + title: 'some title', + status: 'PENDING', + visibility: 'PRIVATE', + }; + + async function mockedOperation() { + const { client, spy } = await getMockedClient( + 'listModelWithEnums', + sampleModelInline, + ); + + const { data } = await client.models.ModelWithEnums.list({ + selectionSet: ['title', 'status', 'visibility'], + }); + + return { data, spy }; + } + + test('is reflected in the graphql selection set', async () => { + const { spy } = await mockedOperation(); + + const expectedSelectionSet = /* GraphQL */ ` + items { + title + status + visibility + } + nextToken + __typename + `; + + expectSelectionSetEquals(spy, expectedSelectionSet); + }); + + test('returns the selected fields at runtime', async () => { + const { data } = await mockedOperation(); + + const sampleTodoFinalResult = [ + { + ...sampleModelInline, + }, + ]; + + expect(data).toEqual(sampleTodoFinalResult); + }); + + test('has a matching return type', async () => { + const { data } = await mockedOperation(); + + type ExpectedTodoType = { + readonly title: string; + readonly status: 'DRAFT' | 'PENDING' | 'PUBLISHED'; + readonly visibility: 'PRIVATE' | 'PUBLIC' | null; + }[]; + + type ActualType = typeof data; + + type _test = Expect>; + }); + }); +});