Skip to content

Instantly share code, notes, and snippets.

@barthap
Created October 28, 2020 10:03
Show Gist options
  • Save barthap/cd0d819454b0e42b90cb8ee973451e94 to your computer and use it in GitHub Desktop.
Save barthap/cd0d819454b0e42b90cb8ee973451e94 to your computer and use it in GitHub Desktop.
Simple TypeScript GraphQL query builder

A simple GraphQL Query Builder experiment.

graphql folder structure

  • __tests__
  • queries - Domain specific Query API, e.g. UserQuery
  • mutations - Same as above, but mutations.
  • types - TypeScript interfaces directly corresponding to GQL schema types, e.g. User. Can be generated typedefs also
  • GraphqlClient.ts - GraphQL client + error handling. Can be any client, I use @urlq/core here.
  • QueryBuilder.ts - Query Builder implementation and some TypeScript magic

QueryBuilder usage.

When we define new query in graphql/queries/UserQuery.ts, we return a new QueryBuilder instance there and provide a execute function. The function receives built fragments and query string and is supposed to return a promise of GQL result data.

export class UserQuery {
  static byId(id: string) {
    return new QueryBuilder<User>('User', {
      query: ({ fields }) => gql(`{
        user {
          byId(id: "${id}") { ${queryString} } 
        }
      }`),
      resolve: data => data.user.byId
    });
  }
}

We can also define fragments:

type ContactData = Pick<User, 'phone'> & { address: Pick<UserAddress, 'street' | 'zip'> };
export const contactData = createFragment<User, ContactData>(
    'ContactDataFragment',
    'fragment ContactDataFragment on User { phone, address { street, zip } }'
);

Usage

const fetchedUser = await UserQuery.byId('someId')
  .withFields('id', 'name') // IntelliSense suggests correct field names here
  .withFragments(contactData) // this method has an alias: withEdges()
  .executeAsync();

fetchedUser.  // IntelliSense works here. Displays `id`, `name` and type returned by `contactData`
import { UserQuery } from './graphql/queries/UserQuery';
// ...
const fetchedUser = await UserQuery.byId('someId')
.withFields('id', 'name') // IntelliSense suggests correct field names here
.withFragments(contactData) // this method has an alias: withEdges()
.executeAsync();
fetchedUser. // IntelliSense works here. Displays `id`, `name` and type returned by `contactData`
import { DocumentNode, FragmentDefinitionNode, OperationDefinitionNode } from 'graphql';
import gql from 'graphql-tag';
import { withErrorHandlingAsync } from '../client';
import * as queryTools from '../QueryBuilder';
type TestEntity = {
id: string;
name: string;
age: number;
};
const testEntity: TestEntity = {
id: '0ff-abcd-1234',
name: 'Jester',
age: 21,
};
const FRAG_NAME = 'FragmentName';
const FRAG_DEF = 'fragment FragmentName on TestEntity { name, age }';
const testFragment = queryTools.createFragment(FRAG_NAME, FRAG_DEF);
jest.mock('../client', () => ({
graphqlClient: {
query: (anything: any) => {
return {
toPromise: () => Promise.resolve(anything),
};
},
},
withErrorHandlingAsync: jest.fn(),
}));
describe('Fragment creation', () => {
test('fragmentFromFields creates proper query', () => {
const EXPECTED_NAME = 'name_ageFrag';
const testFragment = queryTools.fragmentFromFields<TestEntity, keyof TestEntity>('TestEntity', [
'name',
'age',
]);
expect(testFragment.name).toEqual(EXPECTED_NAME);
// parse fragmment as GraphQL document node
const parsedFragment = gql(testFragment.query);
expect(parsedFragment.kind).toBe('Document');
expect(parsedFragment.definitions.length).toBe(1);
// check if its fragment definition with correct name
const parsedDefinition = parsedFragment.definitions[0] as FragmentDefinitionNode;
expect(parsedDefinition.kind).toBe('FragmentDefinition');
expect(parsedDefinition.name.value).toEqual(EXPECTED_NAME);
expect(parsedDefinition.typeCondition.name.value).toEqual('TestEntity');
// check if it selects desired fields
const { selections } = parsedDefinition.selectionSet;
expect(selections.length).toBe(2);
expect(selections.map((it: any) => it.name.value)).toEqual(
expect.arrayContaining(['name', 'age'])
);
});
test('createFragment constructs fragment object', () => {
const fragment = queryTools.createFragment(FRAG_NAME, FRAG_DEF);
expect(fragment).toMatchObject({
__type: undefined,
name: FRAG_NAME,
query: FRAG_DEF,
});
});
});
// TypeScript magic is not tested here
describe('QueryBuilder', () => {
test('calls execute function provided by user', async () => {
const mockExecuteFn = jest.fn();
await new queryTools.QueryBuilder<TestEntity>('TestEntity', {
executeFn: mockExecuteFn,
}).executeAsync();
expect(mockExecuteFn).toHaveBeenCalled();
});
test('execute function result is returned to the caller', async () => {
const mockExecuteFn = jest.fn().mockReturnValue(Promise.resolve(testEntity));
const result = await new queryTools.QueryBuilder<TestEntity>('TestEntity', {
executeFn: mockExecuteFn,
})
.withFields('name', 'age')
.executeAsync();
expect(result).toMatchObject(testEntity);
});
test('withFragment adds fragment to list', () => {
const builder = new queryTools.QueryBuilder<TestEntity>('TestEntity', {
executeFn: jest.fn(),
}).withFragments(testFragment);
expect(builder['fragments'].length).toBe(1);
expect(builder['fragments'][0].name).toBe(FRAG_NAME);
});
test('withFields adds fragment to list', () => {
const builder = new queryTools.QueryBuilder<TestEntity>('TestEntity', {
executeFn: jest.fn(),
}).withFields('name');
expect(builder['fragments'].length).toBe(1);
});
test('executeFn is called with built fragment data', async () => {
const mockExecuteFn = jest.fn().mockImplementation(({ definitions, fields }) => {
const parsedDefinitions = gql(definitions);
const fragmentUsages = fields.split(', ') as string[];
// test definitions
expect(parsedDefinitions.definitions.length).toBe(2);
parsedDefinitions.definitions.forEach(it => expect(it.kind).toBe('FragmentDefinition'));
// test query string
expect(fragmentUsages.length).toBe(2);
expect(fragmentUsages).toEqual(expect.arrayContaining(['...FragmentName', '...idFrag']));
return Promise.resolve(testEntity);
});
await new queryTools.QueryBuilder<TestEntity>('TestEntity', {
executeFn: mockExecuteFn,
})
.withFields('id')
.withFragments(testFragment)
.executeAsync();
});
test('executeFn is created when user provides query and resolve config', async () => {
const RESOLVED_DATA = { data: 'resolved ' };
(withErrorHandlingAsync as jest.Mock).mockImplementation(async promise => {
const query = (await promise) as DocumentNode;
expect(query.definitions.length).toBe(2);
expect(query.definitions.some(it => it.kind === 'FragmentDefinition')).toBeTruthy();
const queryDefinition = query.definitions.find(
it => it.kind === 'OperationDefinition'
) as OperationDefinitionNode;
expect(queryDefinition.operation).toBe('query');
expect(queryDefinition.selectionSet.selections.length).toBe(1);
return RESOLVED_DATA;
});
const queryFn = jest.fn().mockImplementation(({ fields }) => `{ test { ${fields} } }`);
const resolveFn = jest.fn().mockReturnValue(testEntity);
const finalResult = await new queryTools.QueryBuilder<TestEntity>('TestEntity', {
query: queryFn,
resolve: resolveFn,
})
.withFragments(testFragment)
.executeAsync();
expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ fields: `...${FRAG_NAME}` }));
expect(resolveFn).toHaveBeenCalledWith(RESOLVED_DATA);
expect(finalResult).toMatchObject(testEntity);
});
});
import {
CombinedError as GraphqlError,
OperationResult,
createClient as createUrqlClient,
} from '@urql/core';
import fetch from 'node-fetch';
export const graphqlClient = createUrqlClient({
url: getExpoApiBaseUrl() + '/--/graphql',
fetch,
fetchOptions: () => ({}),
});
export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult<T>>): Promise<T> {
const { data, error } = await promise;
if (error) {
throw error;
}
// Check for malfolmed response. This only checks the root query existence,
// It doesn't affect returning responses with empty resultset.
if (!data) {
throw new Error('Returned query result data is null!');
}
return data;
}
import gql from 'graphql-tag';
import { QueryBuilder, createFragment } from '../QueryBuilder';
import { User, UserAddress } from '../types/User';
type ContactData = Pick<User, 'phone'> & { address: Pick<UserAddress, 'street' | 'zip'> };
const contactData = createFragment<User, ContactData>(
'ContactDataFragment',
'fragment ContactDataFragment on User { phone, address { street, zip } }'
);
export const UserFragments = {
ContactData: contactData
};
export class UserQuery {
static byId(id: string) {
return new QueryBuilder<User>('User', {
query: ({ fields }) => gql(`{
user {
byId(id: "${id}") { ${queryString} }
}
}`),
resolve: data => data.user.byId
});
}
}
import { DocumentNode } from 'graphql';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from './GraphqlClient';
// utility types
type ArrayElement<T> = T extends (infer R)[] ? R : T;
type MakeArray<Test, Elem> = Test extends any[] ? Elem[] : Elem;
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
? I
: never;
// Query fragment
type Fragment<Entity, T> = {
__type: T; //utility, only for holding type, value doesn't matter
name: string;
query: string;
};
// Types directly related to QueryBuilder
type BuiltFragments = {
definitions: string;
fields: string;
};
type ExecuteFn<Entity, T> = (built: BuiltFragments) => Promise<MakeArray<Entity, T>>;
interface QueryBuilderAutoConfig {
/**
* A function that should query string for GraphQL.
*
* @param ({ fields }) - generated selection set
*
* @example
* query: ({ fields }) => `{ currentUser { ${fields} } }`
*/
query: (built: BuiltFragments) => string | DocumentNode;
/**
* A resolver function for mapping fetched data.
* It's dependant on the query we've written
*
* @example
* query: ({ fields}) => `{ builds { byId(...) { ${fields} } } }`
* resolve: data => data.builds.byId
*/
resolve: (data: any) => any;
}
interface QueryBuilderExecuteFnConfig<Entity, T> {
/**
* Instead of specifying `query` and `resolve`, we can provide whole execute function
* This can be used when we want to make a custom query
*
* @example
* executeFn: async ({ definitions, fields}) => {
* const { data } = await graphqlClient.query(gql(
* `${definitions}
* {
* xyz { byId(id: "${id}") { ${fields} } }
* }`
* )).toPromise();
* return data.xyz.byId;
* }
*/
executeFn: ExecuteFn<Entity, T>;
}
type QueryBuilderConfig<Entity, T> =
| QueryBuilderExecuteFnConfig<Entity, T>
| QueryBuilderAutoConfig;
function configHasExecuteFn<T, U>(
config: QueryBuilderConfig<T, U>
): config is QueryBuilderExecuteFnConfig<T, U> {
return 'executeFn' in config;
}
/**
* Type based query builder for GraphQL. Besides TypeScript magic, its job is to
* collect desired fields, then build graphql query fragments
* and provide them to execute function provided by user.
*
* All these fancy TypeScript declarations are needed to provide proper
* return type for execute function, based on data user wants to query
*/
export class QueryBuilder<Entity, T = object> {
private fragments: Fragment<Entity, T>[];
private executeFn: ExecuteFn<Entity, T>;
constructor(private entityName: string, config: QueryBuilderConfig<Entity, T>) {
this.fragments = [];
this.executeFn = configHasExecuteFn(config) ? config.executeFn : this.createExecuteFn(config);
}
withFragments<U extends Fragment<ArrayElement<Entity>, unknown>[]>(
...fragments: U
): QueryBuilder<Entity, T & UnionToIntersection<typeof fragments[number]['__type']>> {
this.fragments = [...this.fragments, ...fragments] as Fragment<ArrayElement<Entity>, T>[];
return (this as unknown) as QueryBuilder<
Entity,
T & UnionToIntersection<typeof fragments[number]['__type']>
>;
}
/**
* Alias for `withFragments`
*/
withEdges<U extends Fragment<ArrayElement<Entity>, unknown>[]>(...edges: U) {
return this.withFragments(...edges);
}
withFields<U extends keyof ArrayElement<Entity>>(
...keys: U[]
): QueryBuilder<Entity, T & Pick<ArrayElement<Entity>, U>> {
const frag = fragmentFromFields<ArrayElement<Entity>, U>(this.entityName, keys);
this.fragments = [...this.fragments, frag] as Fragment<ArrayElement<Entity>, T>[];
return (this as unknown) as QueryBuilder<Entity, T & Pick<ArrayElement<Entity>, U>>;
}
async executeAsync(): Promise<MakeArray<Entity, T>> {
const builtFragments = {
definitions: this.buildFragmentDefinitions(),
fields: this.buildQueryFields(),
};
return this.executeFn(builtFragments);
}
private buildFragmentDefinitions(): string {
return this.fragments.map(f => f.query).join('\n');
}
private buildQueryFields(): string {
return this.fragments
.map(f => f.name)
.map(it => `...${it}`)
.join(', ');
}
private createExecuteFn(config: QueryBuilderAutoConfig): ExecuteFn<Entity, T> {
return async built => {
const fullQuery = gql`
${built.definitions}
${config.query(built)}
`;
const data = await withErrorHandlingAsync(graphqlClient.query(fullQuery).toPromise());
return config.resolve(data) as MakeArray<Entity, T>;
};
}
}
/**
* Creates query fragment for Entity based on its direct fields
* @param entityName Name of the entity GraphQL type
* @param keys list of fields to fetch from the entity
*
* @example
* const fragment = fragmentFromFields<User, 'name' | 'age'>('User', ['name', 'age']);
*/
export function fragmentFromFields<Entity, Key extends keyof Entity>(
entityName: string,
keys: Key[]
): Fragment<Entity, Pick<Entity, Key>> {
const __type = (undefined as unknown) as Pick<Entity, typeof keys[number]>;
const name = keys.join('_') + 'Frag';
const query = `fragment ${name} on ${entityName} { ${keys.join(', ')} }`;
return { __type, name, query };
}
/**
* Creates GraphQL query fragment that can be used by QueryBuilder
* @param name Fragment unique name
* @param query Fragment definition string
*
* It's also important to provide resulting TypeScript type:
* @example
* type ResultType = { accounts: { name: string; }[] };
* const fragment = createFragment<User, ResultType>(
* 'UserAccountNameFrag',
* 'fragment UserAccountNameFrag on User { accounts { name } }'
* );
*/
export function createFragment<Entity, T>(name: string, query: string): Fragment<Entity, T> {
return {
__type: (undefined as unknown) as T,
name,
query,
};
}
export interface UserAddress {
zip: string;
street: string;
}
export interface User {
id: string;
name: string;
phone?: string;
address?: UserAddress;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment