Skip to content
This repository has been archived by the owner on Jul 6, 2020. It is now read-only.

Commit

Permalink
(feat) - schema awareness (#58)
Browse files Browse the repository at this point in the history
* (chore) - document idea's

* (docs) - further document thought process

* (chore) - document implementation details

* (chore) - remove schemaFetching

* (docs) - add wall of text about offline and partials

* (feat) - find the rootFields in the schema or use sensible defaults

* (chore) - start adding a test and define schema in our iterator

* (feat) - see if a field is nullable

* (feat) - add first implementation to exchange.ts

* (refactor) - move to separate schemaPredicate class

* (refactor) - add second argument to our query and write methods

* (feat) - trigger heuristic matching when we are handling the no schema case

* (feat) - implement heuristic/schema fragment matching and nullability. This also includes some readability improvements to the schemaPredicates class and uses the store.schemaPredicates instead of an additional pass

* (tests) - add tests for our new SchemaPredicates class

* (chore) - update paths for new schema

* (chore) - update simpleSchema to include a union (this also updates snapshots for this case)

* (tests) - test reexecution of operation on partial queryResult

* (chore) - resolve typing issue and convert some Query ref to the rootKey from schemaPredicates

* (refactor) - move rootKey logic to store

* (refactor) - use graphql-js internals

* (chore) - remove comment blocks

* (refactor) - cleanup some unreachable code paths

* (chore) - remove redundant comments

* Add improved warning and invariant to SchemaPredicates.isFieldNullable

* Add improved invariant to SchemaPredicates.isInterfaceOfType

* Fix case for null type condition

* Fix some types in schemaPredicates

* Add warning on heuristic fragment matching

* Add new partial handling to exchange

* Fix partial query behaviour

- When Query has no fields then return null and deem it incomplete (EMPTY)
- When partial fields are found they still need to be set to null

* Fix exchange outcome marking and tests

* Add missing hasFields setter for invalid entities

* Add limit for partial Query result to partial results

* (tests) - update assertions of query.test.ts
  • Loading branch information
JoviDeCroock authored and kitten committed Sep 4, 2019
1 parent 646b319 commit fce8b0e
Show file tree
Hide file tree
Showing 14 changed files with 1,848 additions and 86 deletions.
12 changes: 0 additions & 12 deletions src/ast/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
SelectionSetNode,
InlineFragmentNode,
FieldNode,
OperationDefinitionNode,
FragmentDefinitionNode,
Kind,
} from 'graphql';
Expand All @@ -15,17 +14,6 @@ import { SelectionSet } from '../types';
/** Returns the name of a given node */
export const getName = (node: { name: NameNode }): string => node.name.value;

export const getOperationName = (node: OperationDefinitionNode) => {
switch (node.operation) {
case 'query':
return 'Query';
case 'mutation':
return 'Mutation';
case 'subscription':
return 'Subscription';
}
};

export const getFragmentTypeName = (node: FragmentDefinitionNode): string =>
node.typeCondition.name.value;

Expand Down
32 changes: 32 additions & 0 deletions src/ast/schemaPredicates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { SchemaPredicates } from './schemaPredicates';

describe('SchemaPredicates', () => {
let schemaPredicates;

beforeAll(() => {
// eslint-disable-next-line
const schema = require('../test-utils/simple_schema.json');
schemaPredicates = new SchemaPredicates(schema);
});

it('should match fragments by interface/union', () => {
expect(schemaPredicates.isInterfaceOfType('ITodo', 'BigTodo')).toBeTruthy();
expect(
schemaPredicates.isInterfaceOfType('ITodo', 'SmallTodo')
).toBeTruthy();
expect(
schemaPredicates.isInterfaceOfType('Search', 'BigTodo')
).toBeTruthy();
expect(
schemaPredicates.isInterfaceOfType('Search', 'SmallTodo')
).toBeTruthy();
expect(schemaPredicates.isInterfaceOfType('ITodo', 'Todo')).toBeFalsy();
expect(schemaPredicates.isInterfaceOfType('Search', 'Todo')).toBeFalsy();
});

it('should indicate nullability', () => {
expect(schemaPredicates.isFieldNullable('Todo', 'text')).toBeFalsy();
expect(schemaPredicates.isFieldNullable('Todo', 'complete')).toBeTruthy();
expect(schemaPredicates.isFieldNullable('Todo', 'author')).toBeTruthy();
});
});
89 changes: 89 additions & 0 deletions src/ast/schemaPredicates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import invariant from 'invariant';
import warning from 'warning';

import {
buildClientSchema,
isNullableType,
GraphQLSchema,
GraphQLAbstractType,
GraphQLObjectType,
GraphQLInterfaceType,
GraphQLUnionType,
} from 'graphql';

export class SchemaPredicates {
schema: GraphQLSchema;

constructor(schema) {
this.schema = buildClientSchema(schema);
}

isFieldNullable(typename: string, fieldName: string): boolean {
const type = this.schema.getType(typename);
expectObjectType(type, typename);

const object = type as GraphQLObjectType;
if (object === undefined) {
warning(
false,
'Invalid type: The type `%s` is not a type in the defined schema, ' +
'but the GraphQL document expects it to exist.\n' +
'Traversal will continue, however this may lead to undefined behavior!',
typename
);

return false;
}

const field = object.getFields()[fieldName];
if (field === undefined) {
warning(
false,
'Invalid field: The field `%s` does not exist on `%s`, ' +
'but the GraphQL document expects it to exist.\n' +
'Traversal will continue, however this may lead to undefined behavior!',
fieldName,
typename
);

return false;
}

return isNullableType(field.type);
}

isInterfaceOfType(
typeCondition: null | string,
typename: string | void
): boolean {
if (!typename || !typeCondition) return false;
if (typename === typeCondition) return true;

const abstractType = this.schema.getType(typeCondition);
expectAbstractType(abstractType, typeCondition);
const objectType = this.schema.getType(typename);
expectObjectType(objectType, typename);

const abstractNode = abstractType as GraphQLAbstractType;
const concreteNode = objectType as GraphQLObjectType;
return this.schema.isPossibleType(abstractNode, concreteNode);
}
}

const expectObjectType = (type: any, typename: string) => {
invariant(
type instanceof GraphQLObjectType,
'Invalid type: The type `%s` is not an object in the defined schema, ' +
'but the GraphQL document is traversing it.',
typename
);
};

const expectAbstractType = (type: any, typename: string) => {
invariant(
type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType,
'Invalid type: The type `%s` is not an Interface or Union type in the defined schema, ' +
'but a fragment in the GraphQL document is using it as a type condition.',
typename
);
};
133 changes: 133 additions & 0 deletions src/exchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,3 +622,136 @@ it('follows nested resolvers for mutations', () => {
'AwesomeGQL',
]);
});

it.only('reexecutes query and returns data on partial result', () => {
jest.useFakeTimers();
const client = createClient({ url: '' });
const [ops$, next] = makeSubject<Operation>();
const reexec = jest
.spyOn(client, 'reexecuteOperation')
// Empty mock to avoid going in an endless loop, since we would again return
// partial data.
.mockImplementation(() => {});

const query = gql`
query {
todos {
id
text
complete
author {
id
name
__typename
}
__typename
}
}
`;

const queryOperation = client.createRequestOperation('query', {
key: 1,
query,
});

const queryData = {
__typename: 'Query',
todos: [
{
__typename: 'Todo',
id: '123',
text: 'Learn',
},
{
__typename: 'Todo',
id: '456',
text: 'Teach',
},
],
};

const response = jest.fn(
(forwardOp: Operation): OperationResult => {
if (forwardOp.key === 1) {
return { operation: queryOperation, data: queryData };
}

return undefined as any;
}
);

const result = jest.fn();
const forward: ExchangeIO = ops$ =>
pipe(
ops$,
delay(1),
map(response)
);

pipe(
cacheExchange({
// eslint-disable-next-line
schema: require('./test-utils/simple_schema.json'),
})({ forward, client })(ops$),
tap(result),
publish
);

next(queryOperation);
jest.runAllTimers();
expect(response).toHaveBeenCalledTimes(1);
expect(reexec).toHaveBeenCalledTimes(0);
expect(result.mock.calls[0][0].data).toEqual({
__typename: 'Query',
todos: [
{
__typename: 'Todo',
author: null,
complete: null,
id: '123',
text: 'Learn',
},
{
__typename: 'Todo',
author: null,
complete: null,
id: '456',
text: 'Teach',
},
],
});

expect(result.mock.calls[0][0]).toHaveProperty(
'operation.context.meta',
undefined
);

next(queryOperation);
jest.runAllTimers();
expect(result).toHaveBeenCalledTimes(2);
expect(reexec).toHaveBeenCalledTimes(1);
expect(result.mock.calls[1][0].data).toEqual({
__typename: 'Query',
todos: [
{
__typename: 'Todo',
author: null,
complete: null,
id: '123',
text: 'Learn',
},
{
__typename: 'Todo',
author: null,
complete: null,
id: '456',
text: 'Teach',
},
],
});

expect(result.mock.calls[1][0]).toHaveProperty(
'operation.context.meta.cacheOutcome',
'partial'
);
});
Loading

0 comments on commit fce8b0e

Please sign in to comment.