From 5ec863cff8c6951ca027e62b08949098f862085e Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sun, 28 Jan 2024 12:04:30 +0000 Subject: [PATCH] feat: Add `unsafe_readResult()` and `maskFragments()` utilities (#43) --- .changeset/dry-seals-laugh.md | 5 + .changeset/witty-moose-kiss.md | 5 + src/__tests__/api.test-d.ts | 139 +++++++++++++++++- src/api.ts | 109 +++++++++++++- src/index.ts | 9 +- src/namespace.ts | 13 ++ .../content/docs/reference/gql-tada-api.mdx | 101 +++++++++++++ 7 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 .changeset/dry-seals-laugh.md create mode 100644 .changeset/witty-moose-kiss.md diff --git a/.changeset/dry-seals-laugh.md b/.changeset/dry-seals-laugh.md new file mode 100644 index 00000000..554c34db --- /dev/null +++ b/.changeset/dry-seals-laugh.md @@ -0,0 +1,5 @@ +--- +'gql.tada': minor +--- + +Add `maskFragments` to cast data to fragment masks of a given set of fragments. diff --git a/.changeset/witty-moose-kiss.md b/.changeset/witty-moose-kiss.md new file mode 100644 index 00000000..9d179d47 --- /dev/null +++ b/.changeset/witty-moose-kiss.md @@ -0,0 +1,5 @@ +--- +'gql.tada': minor +--- + +Add `unsafe_readResult` to unsafely cast data to the result data of a given document. diff --git a/src/__tests__/api.test-d.ts b/src/__tests__/api.test-d.ts index c31d014e..13551b9f 100644 --- a/src/__tests__/api.test-d.ts +++ b/src/__tests__/api.test-d.ts @@ -4,8 +4,10 @@ import type { simpleSchema } from './fixtures/simpleSchema'; import type { simpleIntrospection } from './fixtures/simpleIntrospection'; import type { parseDocument } from '../parser'; -import type { $tada } from '../namespace'; -import { readFragment, initGraphQLTada } from '../api'; +import type { $tada, getFragmentsOfDocumentsRec } from '../namespace'; +import type { obj } from '../utils'; + +import { readFragment, maskFragments, unsafe_readResult, initGraphQLTada } from '../api'; import type { ResultOf, @@ -226,3 +228,136 @@ describe('readFragment', () => { expectTypeOf().toEqualTypeOf>(); }); }); + +describe('maskFragments', () => { + it('should not mask empty objects', () => { + type fragment = parseDocument<` + fragment Fields on Todo { + id + } + `>; + + type document = getDocumentNode; + // @ts-expect-error + const result = maskFragments([{} as document], {}); + expectTypeOf().toBeNever(); + }); + + it('masks fragments', () => { + type fragment = parseDocument<` + fragment Fields on Todo { + id + } + `>; + + type document = getDocumentNode; + const result = maskFragments([{} as document], { id: 'id' }); + expectTypeOf().toEqualTypeOf>(); + }); + + it('masks arrays of fragment data', () => { + type fragment = parseDocument<` + fragment Fields on Todo { + id + } + `>; + + type document = getDocumentNode; + const result = maskFragments([{} as document], [{ id: 'id' }]); + expectTypeOf().toEqualTypeOf[]>(); + }); + + it('masks multiple fragments', () => { + type fragmentA = parseDocument<` + fragment FieldsA on Todo { + a: id + } + `>; + + type fragmentB = parseDocument<` + fragment FieldsB on Todo { + b: id + } + `>; + + type documentA = getDocumentNode; + type documentB = getDocumentNode; + const result = maskFragments([{} as documentA, {} as documentB], { a: 'id', b: 'id' }); + type expected = obj & FragmentOf>; + expectTypeOf().toEqualTypeOf(); + }); + + it('should behave correctly on unmasked fragments', () => { + type fragment = parseDocument<` + fragment Fields on Todo @_unmask { + id + } + `>; + + type document = getDocumentNode; + const result = maskFragments([{} as document], { id: 'id' }); + expectTypeOf().toEqualTypeOf>(); + }); +}); + +describe('unsafe_readResult', () => { + it('should cast result data', () => { + type fragment = parseDocument<` + fragment Fields on Todo { + fields: id + } + `>; + + type query = parseDocument<` + query Test { + latestTodo { + id + ...Fields + } + } + `>; + + type fragmentDoc = getDocumentNode; + type document = getDocumentNode>; + + const result = unsafe_readResult({} as document, { + latestTodo: { + id: 'id', + fields: 'id', + }, + }); + + expectTypeOf().toEqualTypeOf>(); + }); + + it('should cast result data of arrays', () => { + type fragment = parseDocument<` + fragment Fields on Todo { + fields: id + } + `>; + + type query = parseDocument<` + query Test { + todos { + id + ...Fields + } + } + `>; + + type fragmentDoc = getDocumentNode; + type document = getDocumentNode>; + + const result = unsafe_readResult({} as document, { + todos: [ + { + id: 'id', + fields: 'id', + }, + ], + }); + + expectTypeOf().toEqualTypeOf>(); + }); +}); diff --git a/src/api.ts b/src/api.ts index a3b685c4..b7b5d1d1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -12,13 +12,14 @@ import type { getFragmentsOfDocumentsRec, makeDefinitionDecoration, decorateFragmentDef, + omitFragmentRefsRec, makeFragmentRef, } from './namespace'; import type { getDocumentType } from './selection'; import type { getVariablesType } from './variables'; import type { parseDocument, DocumentNodeLike } from './parser'; -import type { stringLiteral, matchOr, writable, DocumentDecoration } from './utils'; +import type { stringLiteral, obj, matchOr, writable, DocumentDecoration } from './utils'; /** Abstract configuration type input for your schema and scalars. * @@ -289,12 +290,28 @@ export type mirrorFragmentTypeRec = Fragment extends (infer Valu ? undefined : Data; +type fragmentRefsOfFragmentsRec = Fragments extends readonly [ + infer Fragment, + ...infer Rest, +] + ? obj & fragmentRefsOfFragmentsRec> + : {}; + +type resultOfFragmentsRec = Fragments extends readonly [ + infer Fragment, + ...infer Rest, +] + ? ResultOf & resultOfFragmentsRec + : {}; + type fragmentOfTypeRec = | readonly fragmentOfTypeRec[] | FragmentOf | undefined | null; +type resultOfTypeRec = readonly resultOfTypeRec[] | Data | undefined | null; + /** Unmasks a fragment mask for a given fragment document and data. * * @param _document - A GraphQL document of a fragment, created using {@link graphql}. @@ -355,9 +372,97 @@ function readFragment< return fragment as any; } +/** For testing, masks fragment data for given data and fragments. + * + * @param _fragments - A list of GraphQL documents of fragments, created using {@link graphql}. + * @param data - The combined result data of the fragments, which can be wrapped in arrays. + * @returns The masked data of the fragments. + * + * @remarks + * When creating test data, you may define data for fragments that’s unmasked, making it + * unusable in parent fragments or queries that require masked data. + * + * This means that you may have to use {@link maskFragments} to mask your data first + * for TypeScript to not report an error. + * + * @example + * ``` + * import { FragmentOf, ResultOf, graphql, maskFragments } from 'gql.tada'; + * + * const bookFragment = graphql(` + * fragment BookComponent on Book { + * id + * title + * } + * `); + * + * const data = maskFragments([bookFragment], { id: 'id', title: 'book' }); + * ``` + * + * @see {@link readFragment} for how to read from fragment masks (i.e. the reverse) + */ +function maskFragments< + const Fragments extends readonly [...makeDefinitionDecoration[]], + const Data extends resultOfTypeRec>, +>( + _fragments: Fragments, + data: Data +): resultOfTypeRec> extends Data + ? never + : mirrorFragmentTypeRec> { + return data as any; +} + +/** For testing, converts document data without fragment refs to their result type. + * + * @param _document - A GraphQL document, created using {@link graphql}. + * @param data - The result data of the GraphQL document with optional fragment refs. + * @returns The masked result data of the document. + * + * @remarks + * When creating test data, you may define data for documents that’s unmasked, but + * need to cast the data to match the result type of your document. + * + * This means that you may have to use {@link unsafe_readResult} to cast + * them to the result type, instead of doing `as any as ResultOf`. + * + * This function is inherently unsafe, since it doesn't check that your document + * actually contains the masked fragment data! + * + * @example + * ``` + * import { FragmentOf, ResultOf, graphql, unsafe_readResult } from 'gql.tada'; + * + * const bookFragment = graphql(` + * fragment BookComponent on Book { + * id + * title + * } + * `); + * + * const query = graphql(` + * query { + * book { + * ...BookComponent + * } + * } + * `, [bookFragment]); + * + * const data = unsafe_readResult(query, { book: { id: 'id', title: 'book' } }); + * ``` + * + * @see {@link readFragment} for how to read from fragment masks (i.e. the reverse) + */ +function unsafe_readResult< + const Document extends DocumentDecoration, + const Data extends omitFragmentRefsRec>, +>(_document: Document, data: Data): ResultOf { + return data as any; +} + const graphql: GraphQLTadaAPI> = initGraphQLTada(); -export { parse, graphql, readFragment, initGraphQLTada }; +export { parse, graphql, readFragment, maskFragments, unsafe_readResult, initGraphQLTada }; export type { setupSchema, diff --git a/src/index.ts b/src/index.ts index e3c94f8b..5c64a700 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,11 @@ -export { parse, graphql, readFragment, initGraphQLTada } from './api'; +export { + parse, + graphql, + readFragment, + maskFragments, + unsafe_readResult, + initGraphQLTada, +} from './api'; export type { setupSchema, diff --git a/src/namespace.ts b/src/namespace.ts index 0f5c5d85..6e863dae 100644 --- a/src/namespace.ts +++ b/src/namespace.ts @@ -96,6 +96,18 @@ type makeFragmentRef = Document extends { [$tada.definition]?: infer D : never : never; +type omitFragmentRefsRec = Data extends readonly (infer Value)[] + ? readonly omitFragmentRefsRec[] + : Data extends null + ? null + : Data extends undefined + ? undefined + : Data extends {} + ? { + [Key in Exclude]: omitFragmentRefsRec; + } + : Data; + type makeUndefinedFragmentRef = { [$tada.fragmentRefs]: { [Name in FragmentName]: 'Undefined Fragment'; @@ -110,6 +122,7 @@ export type { $tada, decorateFragmentDef, getFragmentsOfDocumentsRec, + omitFragmentRefsRec, makeDefinitionDecoration, makeFragmentRef, makeUndefinedFragmentRef, diff --git a/website/src/content/docs/reference/gql-tada-api.mdx b/website/src/content/docs/reference/gql-tada-api.mdx index beb603a0..1602c7a6 100644 --- a/website/src/content/docs/reference/gql-tada-api.mdx +++ b/website/src/content/docs/reference/gql-tada-api.mdx @@ -98,6 +98,107 @@ const getQuery = (data: ResultOf) => { }; ``` +### `maskFragments()` + +| | Description | +| ----------- | ----------- | +| `_fragments` argument | A list of GraphQL documents of fragments, created using [`graphql()`](#graphql). | +| `data` argument | The combined result data of the fragments, which can be wrapped in arrays. | +| returns | The masked data of the fragments. | + +:::note +While useful, `maskFragments()` is mostly meant to be used in tests or as +an escape hatch to convert data to masked fragments. + +You shouldn’t have to use it in your regular component code. +::: + +When [`graphql()`](#graphql) is used to compose fragments into another fragment or +operation, the resulting type will by default be masked, [unless the `@_unmask` +directive is used.](../../guides/fragment-colocation/#fragment-masking) + +This means that when we’re writing tests or are creating “fake data” without +inferring types from a full document, the types in TypeScript may not match, +since our testing data will not be masked and will be equal to [the result type](#resultof) +of the fragments. + +To address this, the `maskFragments` utility takes a list of fragments and masks data (or an array of data) +to match the masked fragment types of the fragments. + +- [Read more about fragment masking on the “Writing GraphQL” page.](../../get-started/writing-graphql/#fragment-masking) +- [For the reverse operation, see `readFragment()`.](#readfragment) + +#### Example + +```ts +import { graphql, maskFragments } from 'gql.tada'; + +const bookFragment = graphql(` + fragment BookComponent on Book { + id + title + } +`); + +// `data` will be typed as the fragment mask of `bookFragment`. +const data = maskFragments([bookFragment], { id: 'id', title: 'book' }); +``` + +### `unsafe_readResult()` + +| | Description | +| ----------- | ----------- | +| `_document` argument | A GraphQL document, created using [`graphql()`](#graphql). | +| `data` argument | The result data of the GraphQL document with optional fragment refs. | +| returns | The masked result data of the document. | + +:::caution +Unlike, [`maskFragments()`](#maskfragments), this utility is unsafe, and +should only be used when you know that data matches the expected shape +of a GraphQL query you created. + +While useful, this utility is only a slightly safer alternative to `as any` +and doesn’t type check the result shape against the masked fragments in your +document. + +You shouldn’t have to use it in your regular app code. +::: + +When [`graphql()`](#graphql) is used to compose fragments into a document, +the resulting type will by default be masked, [unless the `@_unmask` +directive is used.](../../guides/fragment-colocation/#fragment-masking) + +This means that when we’re writing tests and are creating “fake data”, +for instance for a query, that we cannot convert this data to the query’s +result type, if it contains masked fragment refs. + +To address this, the `unsafe_readResult` utility accepts the document and +converts a query’s data to masked data. + +#### Example + +```ts +import { graphql, unsafe_readResult } from 'gql.tada'; + +const bookFragment = graphql(` + fragment BookComponent on Book { + id + title + } +`); + +const query = graphql(` + query { + book { + ...BookComponent + } + } +`, [bookFragment]); + +// `data` will be cast (unsafely!) to the result type of `query`. +const data = unsafe_readResult(query, { book: { id: 'id', title: 'book' } }); +``` + ### `initGraphQLTada()` | | Description |