Skip to content

Commit

Permalink
feat: Add unsafe_readResult() and maskFragments() utilities (#43)
Browse files Browse the repository at this point in the history
  • Loading branch information
kitten authored Jan 28, 2024
1 parent caa94dc commit 5ec863c
Show file tree
Hide file tree
Showing 7 changed files with 376 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .changeset/dry-seals-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gql.tada': minor
---

Add `maskFragments` to cast data to fragment masks of a given set of fragments.
5 changes: 5 additions & 0 deletions .changeset/witty-moose-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gql.tada': minor
---

Add `unsafe_readResult` to unsafely cast data to the result data of a given document.
139 changes: 137 additions & 2 deletions src/__tests__/api.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -226,3 +228,136 @@ describe('readFragment', () => {
expectTypeOf<typeof result>().toEqualTypeOf<ResultOf<document>>();
});
});

describe('maskFragments', () => {
it('should not mask empty objects', () => {
type fragment = parseDocument<`
fragment Fields on Todo {
id
}
`>;

type document = getDocumentNode<fragment, schema>;
// @ts-expect-error
const result = maskFragments([{} as document], {});
expectTypeOf<typeof result>().toBeNever();
});

it('masks fragments', () => {
type fragment = parseDocument<`
fragment Fields on Todo {
id
}
`>;

type document = getDocumentNode<fragment, schema>;
const result = maskFragments([{} as document], { id: 'id' });
expectTypeOf<typeof result>().toEqualTypeOf<FragmentOf<document>>();
});

it('masks arrays of fragment data', () => {
type fragment = parseDocument<`
fragment Fields on Todo {
id
}
`>;

type document = getDocumentNode<fragment, schema>;
const result = maskFragments([{} as document], [{ id: 'id' }]);
expectTypeOf<typeof result>().toEqualTypeOf<readonly FragmentOf<document>[]>();
});

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<fragmentA, schema>;
type documentB = getDocumentNode<fragmentB, schema>;
const result = maskFragments([{} as documentA, {} as documentB], { a: 'id', b: 'id' });
type expected = obj<FragmentOf<documentA> & FragmentOf<documentB>>;
expectTypeOf<typeof result>().toEqualTypeOf<expected>();
});

it('should behave correctly on unmasked fragments', () => {
type fragment = parseDocument<`
fragment Fields on Todo @_unmask {
id
}
`>;

type document = getDocumentNode<fragment, schema>;
const result = maskFragments([{} as document], { id: 'id' });
expectTypeOf<typeof result>().toEqualTypeOf<ResultOf<document>>();
});
});

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<fragment, schema>;
type document = getDocumentNode<query, schema, getFragmentsOfDocumentsRec<[fragmentDoc]>>;

const result = unsafe_readResult({} as document, {
latestTodo: {
id: 'id',
fields: 'id',
},
});

expectTypeOf<typeof result>().toEqualTypeOf<ResultOf<document>>();
});

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<fragment, schema>;
type document = getDocumentNode<query, schema, getFragmentsOfDocumentsRec<[fragmentDoc]>>;

const result = unsafe_readResult({} as document, {
todos: [
{
id: 'id',
fields: 'id',
},
],
});

expectTypeOf<typeof result>().toEqualTypeOf<ResultOf<document>>();
});
});
109 changes: 107 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -289,12 +290,28 @@ export type mirrorFragmentTypeRec<Fragment, Data> = Fragment extends (infer Valu
? undefined
: Data;

type fragmentRefsOfFragmentsRec<Fragments extends readonly any[]> = Fragments extends readonly [
infer Fragment,
...infer Rest,
]
? obj<makeFragmentRef<Fragment> & fragmentRefsOfFragmentsRec<Rest>>
: {};

type resultOfFragmentsRec<Fragments extends readonly any[]> = Fragments extends readonly [
infer Fragment,
...infer Rest,
]
? ResultOf<Fragment> & resultOfFragmentsRec<Rest>
: {};

type fragmentOfTypeRec<Document extends makeDefinitionDecoration> =
| readonly fragmentOfTypeRec<Document>[]
| FragmentOf<Document>
| undefined
| null;

type resultOfTypeRec<Data> = readonly resultOfTypeRec<Data>[] | 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}.
Expand Down Expand Up @@ -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<resultOfFragmentsRec<Fragments>>,
>(
_fragments: Fragments,
data: Data
): resultOfTypeRec<resultOfFragmentsRec<Fragments>> extends Data
? never
: mirrorFragmentTypeRec<Data, fragmentRefsOfFragmentsRec<Fragments>> {
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<typeof document>`.
*
* 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<any, any>,
const Data extends omitFragmentRefsRec<ResultOf<Document>>,
>(_document: Document, data: Data): ResultOf<Document> {
return data as any;
}

const graphql: GraphQLTadaAPI<schemaOfConfig<setupSchema>> = initGraphQLTada();

export { parse, graphql, readFragment, initGraphQLTada };
export { parse, graphql, readFragment, maskFragments, unsafe_readResult, initGraphQLTada };

export type {
setupSchema,
Expand Down
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
export { parse, graphql, readFragment, initGraphQLTada } from './api';
export {
parse,
graphql,
readFragment,
maskFragments,
unsafe_readResult,
initGraphQLTada,
} from './api';

export type {
setupSchema,
Expand Down
13 changes: 13 additions & 0 deletions src/namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ type makeFragmentRef<Document> = Document extends { [$tada.definition]?: infer D
: never
: never;

type omitFragmentRefsRec<Data> = Data extends readonly (infer Value)[]
? readonly omitFragmentRefsRec<Value>[]
: Data extends null
? null
: Data extends undefined
? undefined
: Data extends {}
? {
[Key in Exclude<keyof Data, $tada.fragmentRefs>]: omitFragmentRefsRec<Data[Key]>;
}
: Data;

type makeUndefinedFragmentRef<FragmentName extends string> = {
[$tada.fragmentRefs]: {
[Name in FragmentName]: 'Undefined Fragment';
Expand All @@ -110,6 +122,7 @@ export type {
$tada,
decorateFragmentDef,
getFragmentsOfDocumentsRec,
omitFragmentRefsRec,
makeDefinitionDecoration,
makeFragmentRef,
makeUndefinedFragmentRef,
Expand Down
Loading

0 comments on commit 5ec863c

Please sign in to comment.