diff --git a/.changeset/dirty-knives-teach.md b/.changeset/dirty-knives-teach.md new file mode 100644 index 00000000..ebd63d9c --- /dev/null +++ b/.changeset/dirty-knives-teach.md @@ -0,0 +1,5 @@ +--- +'gql.tada': patch +--- + +Format `TadaDocumentNode` output’s third generic differently. The output of fragment definitions will now be more readable (e.g. `{ fragment: 'Name', on: 'Type', masked: true }`) diff --git a/.changeset/little-dragons-repeat.md b/.changeset/little-dragons-repeat.md new file mode 100644 index 00000000..351f80e3 --- /dev/null +++ b/.changeset/little-dragons-repeat.md @@ -0,0 +1,5 @@ +--- +'gql.tada': minor +--- + +Add support for `@_unmask` directive on fragments causing the fragment type to not be masked. `FragmentOf<>` will return the full result type of fragments when they’re annotated with `@_unmask` and spreading these unmasked fragments into parent documents will use their full type. diff --git a/src/__tests__/api.test-d.ts b/src/__tests__/api.test-d.ts index 35c148f2..d665b218 100644 --- a/src/__tests__/api.test-d.ts +++ b/src/__tests__/api.test-d.ts @@ -1,14 +1,106 @@ import { describe, it, expectTypeOf } from 'vitest'; import type { simpleSchema } from './fixtures/simpleSchema'; +import type { simpleIntrospection } from './fixtures/simpleIntrospection'; + import type { parseDocument } from '../parser'; -import type { ResultOf, FragmentOf, mirrorFragmentTypeRec, getDocumentNode } from '../api'; -import { readFragment } from '../api'; +import type { $tada } from '../namespace'; +import { readFragment, initGraphQLTada } from '../api'; + +import type { + ResultOf, + VariablesOf, + FragmentOf, + mirrorFragmentTypeRec, + getDocumentNode, +} from '../api'; type schema = simpleSchema; type value = { __value: true }; type data = { __data: true }; +describe('Public API', () => { + const graphql = initGraphQLTada<{ introspection: typeof simpleIntrospection }>(); + + it('should create a fragment mask on masked fragments', () => { + const fragment = graphql(` + fragment Fields on Todo { + id + text + } + `); + + const query = graphql( + ` + query Test($limit: Int) { + todos(limit: $limit) { + ...Fields + } + } + `, + [fragment] + ); + + expectTypeOf>().toEqualTypeOf<{ + [$tada.fragmentRefs]: { + Fields: $tada.ref; + }; + }>(); + + expectTypeOf>().toEqualTypeOf<{ + todos: + | ({ + [$tada.fragmentRefs]: { + Fields: $tada.ref; + }; + } | null)[] + | null; + }>(); + + expectTypeOf>().toEqualTypeOf<{ + limit?: number | null; + }>(); + }); + + it('should create a fragment type on unmasked fragments', () => { + const fragment = graphql(` + fragment Fields on Todo @_unmask { + id + text + } + `); + + const query = graphql( + ` + query Test($limit: Int) { + todos(limit: $limit) { + ...Fields + } + } + `, + [fragment] + ); + + expectTypeOf>().toEqualTypeOf<{ + id: string | number; + text: string; + }>(); + + expectTypeOf>().toEqualTypeOf<{ + todos: + | ({ + id: string | number; + text: string; + } | null)[] + | null; + }>(); + + expectTypeOf>().toEqualTypeOf<{ + limit?: number | null; + }>(); + }); +}); + describe('mirrorFragmentTypeRec', () => { it('mirrors null and undefined', () => { expectTypeOf>().toEqualTypeOf(); @@ -108,4 +200,16 @@ describe('readFragment', () => { const result = readFragment({} as document, {} as FragmentOf); expectTypeOf().toEqualTypeOf>(); }); + + it('should behave correctly on unmasked fragments', () => { + type fragment = parseDocument<` + fragment Fields on Todo @_unmask { + id + } + `>; + + type document = getDocumentNode; + const result = readFragment({} as document, {} as FragmentOf); + expectTypeOf().toEqualTypeOf>(); + }); }); diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index 2782abd3..d5647c2d 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -1,11 +1,36 @@ -import { describe, test } from 'vitest'; +import { Kind } from '@0no-co/graphql.web'; +import { describe, it, test, expect } from 'vitest'; import * as ts from './tsHarness'; +import type { simpleIntrospection } from './fixtures/simpleIntrospection'; +import { initGraphQLTada } from '../api'; const testTypeHost = test.each([ { strictNullChecks: false, noImplicitAny: false }, { strictNullChecks: true }, ]); +describe('graphql function', () => { + it('should strip @_unmask from fragment documents', () => { + const graphql = initGraphQLTada<{ introspection: typeof simpleIntrospection }>(); + + const todoFragment = graphql(` + fragment TodoData on Todo @_unmask { + id + text + } + `); + + expect(todoFragment).toMatchObject({ + definitions: [ + { + kind: Kind.FRAGMENT_DEFINITION, + directives: [], + }, + ], + }); + }); +}); + describe('declare setupSchema configuration', () => { testTypeHost('creates simple documents (%o)', (options) => { const virtualHost = ts.createVirtualHost({ @@ -46,16 +71,19 @@ describe('declare setupSchema configuration', () => { const result: ResultOf = {} as any; - expectTypeOf().toMatchTypeOf<{ + expectTypeOf().toEqualTypeOf<{ todos: ({ id: string | number; complete: boolean | null; + [$tada.fragmentRefs]: { + TodoData: $tada.ref; + }; } | null)[] | null; }>(); const todo = readFragment(todoFragment, result?.todos?.[0]); - expectTypeOf().toMatchTypeOf<{ + expectTypeOf().toEqualTypeOf<{ id: string | number; text: string; } | undefined | null>(); @@ -108,16 +136,80 @@ describe('initGraphQLTada configuration', () => { const result: ResultOf = {} as any; - expectTypeOf().toMatchTypeOf<{ + expectTypeOf().toEqualTypeOf<{ + todos: ({ + id: string | number; + complete: boolean | null; + [$tada.fragmentRefs]: { + TodoData: $tada.ref; + }; + } | null)[] | null; + }>(); + + const todo = readFragment(todoFragment, result?.todos?.[0]); + + expectTypeOf().toEqualTypeOf<{ + id: string | number; + text: string; + } | undefined | null>(); + `, + }); + + ts.runDiagnostics( + ts.createTypeHost({ + ...options, + rootNames: ['index.ts'], + host: virtualHost, + }) + ); + }); + + testTypeHost('creates simple documents with unmasked fragments (%o)', (options) => { + const virtualHost = ts.createVirtualHost({ + ...ts.readVirtualModule('expect-type'), + ...ts.readVirtualModule('@0no-co/graphql.web'), + ...ts.readSourceFolders(['.']), + 'simpleIntrospection.ts': ts.readFileFromRoot( + 'src/__tests__/fixtures/simpleIntrospection.ts' + ), + 'index.ts': ` + import { expectTypeOf } from 'expect-type'; + import { simpleIntrospection } from './simpleIntrospection'; + import { ResultOf, FragmentOf, initGraphQLTada, readFragment } from './api'; + import { $tada } from './namespace'; + + const graphql = initGraphQLTada<{ introspection: typeof simpleIntrospection; }>(); + + const todoFragment = graphql(\` + fragment TodoData on Todo @_unmask { + id + text + } + \`); + + const todoQuery = graphql(\` + query { + todos { + id + complete + ...TodoData + } + } + \`, [todoFragment]); + + const result: ResultOf = {} as any; + + expectTypeOf().toEqualTypeOf<{ todos: ({ id: string | number; complete: boolean | null; + text: string; } | null)[] | null; }>(); const todo = readFragment(todoFragment, result?.todos?.[0]); - expectTypeOf().toMatchTypeOf<{ + expectTypeOf().toEqualTypeOf<{ id: string | number; text: string; } | undefined | null>(); diff --git a/src/__tests__/namespace.test-d.ts b/src/__tests__/namespace.test-d.ts index ebf20952..cc9c4352 100644 --- a/src/__tests__/namespace.test-d.ts +++ b/src/__tests__/namespace.test-d.ts @@ -28,7 +28,29 @@ describe('decorateFragmentDef', () => { }; type actual = decorateFragmentDef; - type expected = { + + expectTypeOf().toMatchTypeOf<{ + fragment: 'TodoFragment'; + on: 'Todo'; + }>(); + }); +}); + +describe('getFragmentsOfDocumentsRec', () => { + type actual = getFragmentsOfDocumentsRec< + [ + { + [$tada.definition]?: { + fragment: 'TodoFragment'; + on: 'Todo'; + masked: true; + }; + }, + ] + >; + + type expected = { + TodoFragment: { kind: Kind.FRAGMENT_DEFINITION; name: { kind: Kind.NAME; @@ -41,36 +63,13 @@ describe('decorateFragmentDef', () => { value: 'Todo'; }; }; - readonly [$tada.fragmentId]: symbol; - }; - - expectTypeOf().toMatchTypeOf(); - }); -}); - -describe('getFragmentsOfDocumentsRec', () => { - type inputFragmentDef = { - kind: Kind.FRAGMENT_DEFINITION; - name: { - kind: Kind.NAME; - value: 'TodoFragment'; - }; - typeCondition: { - kind: Kind.NAMED_TYPE; - name: { - kind: Kind.NAME; - value: 'Todo'; + [$tada.ref]: { + [$tada.fragmentRefs]: { + TodoFragment: $tada.ref; + }; }; }; - readonly [$tada.fragmentId]: unique symbol; }; - type input = { - [$tada.fragmentDef]?: inputFragmentDef; - }; - - type actual = getFragmentsOfDocumentsRec<[input]>; - type expected = { TodoFragment: inputFragmentDef }; - expectTypeOf().toMatchTypeOf(); }); diff --git a/src/__tests__/selection.test-d.ts b/src/__tests__/selection.test-d.ts index 58af4a07..06c3c608 100644 --- a/src/__tests__/selection.test-d.ts +++ b/src/__tests__/selection.test-d.ts @@ -9,7 +9,7 @@ import type { $tada, decorateFragmentDef, getFragmentsOfDocumentsRec, - makeFragmentDefDecoration, + makeDefinitionDecoration, } from '../namespace'; type schema = simpleSchema; @@ -203,7 +203,7 @@ test('infers fragment spreads', () => { expectTypeOf().toEqualTypeOf(); }); -test('infers fragment spreads for fragment refs', () => { +test('infers fragment spreads for masked fragment refs', () => { type fragment = parseDocument; @@ -213,16 +213,11 @@ test('infers fragment spreads for fragment refs', () => { `>; type extraFragments = getFragmentsOfDocumentsRec< - [makeFragmentDefDecoration>] + [makeDefinitionDecoration>] >; type actual = getDocumentType; - - type expected = { - [$tada.fragmentRefs]: { - Fields: extraFragments['Fields'][$tada.fragmentId]; - }; - }; + type expected = extraFragments['Fields'][$tada.ref]; expectTypeOf().toEqualTypeOf(); }); diff --git a/src/api.ts b/src/api.ts index 7b09eee0..0952a601 100644 --- a/src/api.ts +++ b/src/api.ts @@ -9,19 +9,17 @@ import type { } from './introspection'; import type { - FragmentDefDecorationLike, DocumentDefDecorationLike, getFragmentsOfDocumentsRec, - makeFragmentDefDecoration, + makeDefinitionDecoration, decorateFragmentDef, makeFragmentRef, - $tada, } from './namespace'; import type { getDocumentType } from './selection'; import type { getVariablesType } from './variables'; import type { parseDocument, DocumentNodeLike } from './parser'; -import type { stringLiteral, matchOr, DocumentDecoration } from './utils'; +import type { stringLiteral, matchOr, writable, DocumentDecoration } from './utils'; /** Abstract configuration type input for your schema and scalars. * @@ -159,8 +157,8 @@ type schemaOfConfig = mapIntrospection< function initGraphQLTada() { type Schema = schemaOfConfig; - return function graphql(input: string, fragments?: readonly DocumentDefDecorationLike[]): any { - const definitions = _parse(input).definitions as DefinitionNode[]; + return function graphql(input: string, fragments?: readonly TadaDocumentNode[]): any { + const definitions = _parse(input).definitions as writable[]; const seen = new Set(); for (const document of fragments || []) { for (const definition of document.definitions) { @@ -170,7 +168,14 @@ function initGraphQLTada() { } } } - return { kind: Kind.DOCUMENT, definitions: [...definitions] } as any; + + if (definitions[0].kind === Kind.FRAGMENT_DEFINITION && definitions[0].directives) { + definitions[0].directives = definitions[0].directives.filter( + (directive) => directive.name.value !== '_unmask' + ); + } + + return { kind: Kind.DOCUMENT, definitions }; } as GraphQLTadaAPI; } @@ -222,7 +227,7 @@ interface TadaDocumentNode< Decoration = never, > extends DocumentNode, DocumentDecoration, - makeFragmentDefDecoration {} + makeDefinitionDecoration {} /** A utility type returning the `Result` type of typed GraphQL documents. * @@ -230,9 +235,7 @@ interface TadaDocumentNode< * This accepts a {@link TadaDocumentNode} and returns the attached `Result` type * of GraphQL documents. */ -type ResultOf = Document extends DocumentDecoration - ? Result - : never; +type ResultOf = Document extends DocumentDecoration ? Result : never; /** A utility type returning the `Variables` type of typed GraphQL documents. * @@ -240,7 +243,7 @@ type ResultOf = Document extends DocumentDecoration = Document extends DocumentDecoration +type VariablesOf = Document extends DocumentDecoration ? Variables : never; @@ -275,12 +278,7 @@ type VariablesOf = Document extends DocumentDecoration = Exclude< - Document[$tada.fragmentDef], - undefined -> extends infer FragmentDef extends FragmentDefDecorationLike - ? makeFragmentRef - : never; +type FragmentOf = makeFragmentRef; export type mirrorFragmentTypeRec = Fragment extends (infer Value)[] ? mirrorFragmentTypeRec[] diff --git a/src/namespace.ts b/src/namespace.ts index 43b0770f..c065f214 100644 --- a/src/namespace.ts +++ b/src/namespace.ts @@ -1,6 +1,6 @@ -import type { Kind, DocumentNode } from '@0no-co/graphql.web'; +import type { Kind } from '@0no-co/graphql.web'; import type { DocumentNodeLike } from './parser'; -import type { obj } from './utils'; +import type { DocumentDecoration } from './utils'; /** Private namespace holding our symbols for markers. * @@ -14,35 +14,42 @@ declare namespace $tada { const fragmentRefs: unique symbol; export type fragmentRefs = typeof fragmentRefs; - const fragmentDef: unique symbol; - export type fragmentDef = typeof fragmentDef; + const definition: unique symbol; + export type definition = typeof definition; - const fragmentId: unique symbol; - export type fragmentId = typeof fragmentId; + const ref: unique symbol; + export type ref = typeof ref; } interface FragmentDefDecorationLike { - readonly [$tada.fragmentId]: symbol; - kind: Kind.FRAGMENT_DEFINITION; - name: any; - typeCondition: any; + fragment: any; + on: any; + masked: any; } -interface DocumentDefDecorationLike extends DocumentNode { - [$tada.fragmentDef]?: FragmentDefDecorationLike; +interface DocumentDefDecorationLike { + [$tada.definition]?: FragmentDefDecorationLike; } +type isMaskedRec = Directives extends readonly [ + infer Directive, + ...infer Rest, +] + ? Directive extends { kind: Kind.DIRECTIVE; name: any } + ? Directive['name']['value'] extends '_unmask' + ? false + : isMaskedRec + : isMaskedRec + : true; + type decorateFragmentDef = Document['definitions'][0] extends { kind: Kind.FRAGMENT_DEFINITION; name: any; - typeCondition: any; } ? { - // NOTE: This is a shortened definition for readability in LSP hovers - kind: Kind.FRAGMENT_DEFINITION; - name: Document['definitions'][0]['name']; - typeCondition: Document['definitions'][0]['typeCondition']; - readonly [$tada.fragmentId]: unique symbol; + fragment: Document['definitions'][0]['name']['value']; + on: Document['definitions'][0]['typeCondition']['name']['value']; + masked: isMaskedRec; } : never; @@ -50,23 +57,48 @@ type getFragmentsOfDocumentsRec = Documents extends readonly [ infer Document, ...infer Rest, ] - ? (Document extends { [$tada.fragmentDef]?: any } - ? Exclude extends infer FragmentDef extends { - kind: Kind.FRAGMENT_DEFINITION; - name: any; - typeCondition: any; - } - ? { [Name in FragmentDef['name']['value']]: FragmentDef } + ? (Document extends { [$tada.definition]?: any } + ? Exclude extends infer Definition extends + FragmentDefDecorationLike + ? { + [Name in Definition['fragment']]: { + kind: Kind.FRAGMENT_DEFINITION; + name: { + kind: Kind.NAME; + value: Definition['fragment']; + }; + typeCondition: { + kind: Kind.NAMED_TYPE; + name: { + kind: Kind.NAME; + value: Definition['on']; + }; + }; + [$tada.ref]: makeFragmentRef; + }; + } : {} : {}) & getFragmentsOfDocumentsRec : {}; -type makeFragmentRef = obj<{ - [$tada.fragmentRefs]: { - [Name in Definition['name']['value']]: Definition[$tada.fragmentId]; - }; -}>; +type makeFragmentRef = Document extends { [$tada.definition]?: infer Definition } + ? Definition extends FragmentDefDecorationLike + ? Definition['masked'] extends false + ? Document extends DocumentDecoration + ? Result + : { + [$tada.fragmentRefs]: { + [Name in Definition['fragment']]: $tada.ref; + }; + } + : { + [$tada.fragmentRefs]: { + [Name in Definition['fragment']]: $tada.ref; + }; + } + : never + : never; type makeUndefinedFragmentRef = { [$tada.fragmentRefs]: { @@ -74,8 +106,8 @@ type makeUndefinedFragmentRef = { }; }; -type makeFragmentDefDecoration = { - [$tada.fragmentDef]?: Definition extends DocumentDefDecorationLike[$tada.fragmentDef] +type makeDefinitionDecoration = { + [$tada.definition]?: Definition extends DocumentDefDecorationLike[$tada.definition] ? Definition : never; }; @@ -84,7 +116,7 @@ export type { $tada, decorateFragmentDef, getFragmentsOfDocumentsRec, - makeFragmentDefDecoration, + makeDefinitionDecoration, makeFragmentRef, makeUndefinedFragmentRef, FragmentDefDecorationLike, diff --git a/src/selection.ts b/src/selection.ts index a2528901..92199d74 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -9,11 +9,7 @@ import type { import type { obj, objValues } from './utils'; import type { DocumentNodeLike } from './parser'; -import type { - FragmentDefDecorationLike, - makeUndefinedFragmentRef, - makeFragmentRef, -} from './namespace'; +import type { $tada, makeUndefinedFragmentRef } from './namespace'; import type { IntrospectionListTypeRef, @@ -84,8 +80,8 @@ type getFragmentSelection< ? getSelection : Node extends { kind: Kind.FRAGMENT_SPREAD; name: any } ? Node['name']['value'] extends keyof Fragments - ? Fragments[Node['name']['value']] extends FragmentDefDecorationLike - ? makeFragmentRef + ? Fragments[Node['name']['value']] extends { [$tada.ref]: any } + ? Fragments[Node['name']['value']][$tada.ref] : getSelection< Fragments[Node['name']['value']]['selectionSet']['selections'], Type, diff --git a/src/utils.ts b/src/utils.ts index 5467a1a1..bb268a94 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -27,6 +27,9 @@ export type objValues = T[keyof T] extends infer U : U : never; +/** Marks all properties as writable */ +export type writable = { -readonly [K in keyof T]: T[K] }; + /** Annotations for GraphQL’s `DocumentNode` with attached generics for its result data and variables types. * * @remarks