diff --git a/src/ast/schemaPredicates.ts b/src/ast/schemaPredicates.ts index add9f9a..8228fd2 100644 --- a/src/ast/schemaPredicates.ts +++ b/src/ast/schemaPredicates.ts @@ -4,6 +4,8 @@ import warning from 'warning'; import { buildClientSchema, isNullableType, + isListType, + isNonNullType, GraphQLSchema, GraphQLAbstractType, GraphQLObjectType, @@ -19,39 +21,18 @@ export class SchemaPredicates { } 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; - } - + const field = getField(this.schema, typename, fieldName); + if (field === undefined) return false; return isNullableType(field.type); } + isListNullable(typename: string, fieldName: string): boolean { + const field = getField(this.schema, typename, fieldName); + if (field === undefined) return false; + const ofType = isNonNullType(field.type) ? field.type.ofType : field.type; + return isListType(ofType) && isNullableType(ofType.ofType); + } + isInterfaceOfType( typeCondition: null | string, typename: string | void @@ -70,6 +51,44 @@ export class SchemaPredicates { } } +const getField = ( + schema: GraphQLSchema, + typename: string, + fieldName: string +) => { + const type = 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 undefined; + } + + 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 undefined; + } + + return field; +}; + const expectObjectType = (type: any, typename: string) => { invariant( type instanceof GraphQLObjectType, diff --git a/src/exchange.test.ts b/src/exchange.test.ts index 19d13f5..e938430 100644 --- a/src/exchange.test.ts +++ b/src/exchange.test.ts @@ -497,6 +497,7 @@ it('follows nested resolvers for mutations', () => { { __typename: 'Author', id: '123', + book: null, name: '[REDACTED ONLINE]', }, { @@ -519,6 +520,7 @@ it('follows nested resolvers for mutations', () => { __typename: 'Author', id: '123', name: '[REDACTED ONLINE]', + book: null, }, { __typename: 'Author', @@ -537,8 +539,7 @@ it('follows nested resolvers for mutations', () => { (forwardOp: Operation): OperationResult => { if (forwardOp.key === 1) { return { operation: queryOperation, data: queryData }; - } - if (forwardOp.key === 2) { + } else if (forwardOp.key === 2) { return { operation: mutationOperation, data: mutationData }; } @@ -623,7 +624,7 @@ it('follows nested resolvers for mutations', () => { ]); }); -it.only('reexecutes query and returns data on partial result', () => { +it('reexecutes query and returns data on partial result', () => { jest.useFakeTimers(); const client = createClient({ url: '' }); const [ops$, next] = makeSubject(); @@ -633,6 +634,16 @@ it.only('reexecutes query and returns data on partial result', () => { // partial data. .mockImplementation(() => {}); + const initialQuery = gql` + query { + todos { + id + text + __typename + } + } + `; + const query = gql` query { todos { @@ -649,8 +660,13 @@ it.only('reexecutes query and returns data on partial result', () => { } `; - const queryOperation = client.createRequestOperation('query', { + const initialQueryOperation = client.createRequestOperation('query', { key: 1, + query: initialQuery, + }); + + const queryOperation = client.createRequestOperation('query', { + key: 2, query, }); @@ -673,6 +689,8 @@ it.only('reexecutes query and returns data on partial result', () => { const response = jest.fn( (forwardOp: Operation): OperationResult => { if (forwardOp.key === 1) { + return { operation: initialQueryOperation, data: queryData }; + } else if (forwardOp.key === 2) { return { operation: queryOperation, data: queryData }; } @@ -697,7 +715,7 @@ it.only('reexecutes query and returns data on partial result', () => { publish ); - next(queryOperation); + next(initialQueryOperation); jest.runAllTimers(); expect(response).toHaveBeenCalledTimes(1); expect(reexec).toHaveBeenCalledTimes(0); @@ -706,15 +724,11 @@ it.only('reexecutes query and returns data on partial result', () => { todos: [ { __typename: 'Todo', - author: null, - complete: null, id: '123', text: 'Learn', }, { __typename: 'Todo', - author: null, - complete: null, id: '456', text: 'Teach', }, diff --git a/src/exchange.ts b/src/exchange.ts index bc03902..e757d82 100644 --- a/src/exchange.ts +++ b/src/exchange.ts @@ -8,7 +8,8 @@ import { } from 'urql'; import { filter, map, merge, pipe, share, tap } from 'wonka'; -import { query, write, writeOptimistic, readOperation } from './operations'; +import { query, write, writeOptimistic } from './operations'; +import { SchemaPredicates } from './ast/schemaPredicates'; import { Store } from './store'; import { @@ -17,7 +18,6 @@ import { OptimisticMutationConfig, KeyingConfig, } from './types'; -import { SchemaPredicates } from './ast/schemaPredicates'; type OperationResultWithMeta = OperationResult & { outcome: CacheOutcome; @@ -96,13 +96,8 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ }) => { if (!opts) opts = {}; - let schemaPredicates; - if (opts.schema) { - schemaPredicates = new SchemaPredicates(opts.schema); - } - const store = new Store( - schemaPredicates, + opts.schema ? new SchemaPredicates(opts.schema) : undefined, opts.resolvers, opts.updates, opts.optimistic, @@ -175,22 +170,19 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ operation: Operation ): OperationResultWithMeta => { const policy = getRequestPolicy(operation); - const { data, dependencies, completeness } = query(store, operation); + const { data, dependencies, partial } = query(store, operation); let cacheOutcome: CacheOutcome; - if (completeness === 'FULL' || policy === 'cache-only') { - updateDependencies(operation, dependencies); - cacheOutcome = 'hit'; - } else if (completeness === 'PARTIAL') { - updateDependencies(operation, dependencies); - cacheOutcome = 'partial'; - } else { + if (data === null) { cacheOutcome = 'miss'; + } else { + updateDependencies(operation, dependencies); + cacheOutcome = !partial || policy === 'cache-only' ? 'hit' : 'partial'; } return { - operation, outcome: cacheOutcome, + operation, data, }; }; @@ -217,7 +209,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ data = queryResult.data; queryDependencies = queryResult.dependencies; } else { - data = readOperation(store, operation, data).data; + data = query(store, operation, data).data; } } diff --git a/src/operations/index.ts b/src/operations/index.ts index 6964da2..71f31e8 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -1,2 +1,2 @@ -export { query, readOperation } from './query'; +export { query, read } from './query'; export { write, writeOptimistic, writeFragment } from './write'; diff --git a/src/operations/query.test.ts b/src/operations/query.test.ts index 30cd48d..97feb5c 100644 --- a/src/operations/query.test.ts +++ b/src/operations/query.test.ts @@ -45,7 +45,7 @@ describe('Query', () => { it('test partial results', () => { const result = query(store, { query: TODO_QUERY }); - expect(result.completeness).toEqual('PARTIAL'); + expect(result.partial).toBe(true); expect(result.data).toEqual({ __typename: 'Query', todos: [ diff --git a/src/operations/query.ts b/src/operations/query.ts index dcbd570..77b39d3 100644 --- a/src/operations/query.ts +++ b/src/operations/query.ts @@ -17,7 +17,6 @@ import { DataField, Link, SelectionSet, - Completeness, OperationRequest, NullArray, } from '../types'; @@ -35,90 +34,61 @@ import { joinKeys, keyOfField } from '../helpers'; import { SchemaPredicates } from '../ast/schemaPredicates'; export interface QueryResult { - completeness: Completeness; dependencies: Set; + partial: boolean; data: null | Data; } interface Context { - result: QueryResult; + partial: boolean; store: Store; variables: Variables; fragments: Fragments; schemaPredicates?: SchemaPredicates; } -/** Reads a request entirely from the store */ -export const query = (store: Store, request: OperationRequest): QueryResult => { +export const query = ( + store: Store, + request: OperationRequest, + data?: Data +): QueryResult => { initStoreState(0); - - const result = startQuery(store, request); + const result = read(store, request, data); clearStoreState(); return result; }; -export const startQuery = (store: Store, request: OperationRequest) => { - const operation = getMainOperation(request.query); - const root: Data = Object.create(null); - const result: QueryResult = { - completeness: 'FULL', - dependencies: getCurrentDependencies(), - data: root, - }; - - const ctx: Context = { - variables: normalizeVariables(operation, request.variables), - fragments: getFragments(request.query), - result, - store, - schemaPredicates: store.schemaPredicates, - }; - - result.data = readSelection( - ctx, - ctx.store.getRootKey('query'), - getSelectionSet(operation), - root - ); - - return result; -}; - -export const readOperation = ( +export const read = ( store: Store, request: OperationRequest, - data: Data -) => { - initStoreState(0); - + input?: Data +): QueryResult => { const operation = getMainOperation(request.query); - - const result: QueryResult = { - completeness: 'FULL', - dependencies: getCurrentDependencies(), - data: null, - }; + const rootKey = store.getRootKey(operation.operation); + const rootSelect = getSelectionSet(operation); const ctx: Context = { variables: normalizeVariables(operation, request.variables), fragments: getFragments(request.query), - result, + partial: false, store, schemaPredicates: store.schemaPredicates, }; - result.data = readRoot( - ctx, - ctx.store.getRootKey(operation.operation), - getSelectionSet(operation), - data - ); + let data = input || Object.create(null); + data = + rootKey !== 'Query' + ? readRoot(ctx, rootKey, rootSelect, data) + : readSelection(ctx, rootKey, rootSelect, data); - clearStoreState(); - return result; + return { + dependencies: getCurrentDependencies(), + partial: data === undefined ? false : ctx.partial, + data: data === undefined ? null : data, + }; }; -export const readRoot = ( +const readRoot = ( ctx: Context, entityKey: string, select: SelectionSet, @@ -169,8 +139,11 @@ const readRootField = ( // Write entity to key that falls back to the given parentFieldKey const entityKey = ctx.store.keyOfEntity(originalData); if (entityKey !== null) { - const data: Data = Object.create(null); - return readSelection(ctx, entityKey, select, data); + // We assume that since this is used for result data this can never be undefined, + // since the result data has already been written to the cache + const newData = Object.create(null); + const fieldValue = readSelection(ctx, entityKey, select, newData); + return fieldValue === undefined ? null : fieldValue; } else { const typename = originalData.__typename; return readRoot(ctx, typename, select, originalData); @@ -182,7 +155,7 @@ const readSelection = ( entityKey: string, select: SelectionSet, data: Data -): Data | null => { +): Data | undefined => { const { store, variables, schemaPredicates } = ctx; const isQuery = entityKey === store.getRootKey('query'); if (!isQuery) addDependency(entityKey); @@ -192,8 +165,7 @@ const readSelection = ( ? store.getRootKey('query') : store.getField(entityKey, '__typename'); if (typeof typename !== 'string') { - ctx.result.completeness = 'EMPTY'; - return null; + return undefined; } data.__typename = typename; @@ -201,6 +173,7 @@ const readSelection = ( let node; let hasFields = false; + let hasPartials = false; while ((node = iter.next()) !== undefined) { // Derive the needed data from our node. const fieldName = getName(node); @@ -211,110 +184,133 @@ const readSelection = ( if (isQuery) addDependency(fieldKey); + // We temporarily store the data field in here, but undefined + // means that the value is missing from the cache + let dataFieldValue: void | DataField; + const resolvers = store.resolvers[typename]; - if (resolvers !== undefined && resolvers.hasOwnProperty(fieldName)) { + if (resolvers !== undefined && typeof resolvers[fieldName] === 'function') { // We have a resolver for this field. - hasFields = true; // Prepare the actual fieldValue, so that the resolver can use it if (fieldValue !== undefined) { data[fieldAlias] = fieldValue; } - const resolverValue = resolvers[fieldName]( + let resolverValue: DataField | undefined = resolvers[fieldName]( data, fieldArgs || {}, store, ctx ); - if (node.selectionSet === undefined) { - // If it doesn't have a selection set we have resolved a property. - // We assume that a resolver for scalar values implies that this - // field is always present, so completeness won't be set to EMPTY here - data[fieldAlias] = resolverValue !== undefined ? resolverValue : null; - } else { + if (node.selectionSet !== undefined) { // When it has a selection set we are resolving an entity with a // subselection. This can either be a list or an object. - const fieldSelect = getSelectionSet(node); - - data[fieldAlias] = resolveResolverResult( + resolverValue = resolveResolverResult( ctx, resolverValue, + typename, + fieldName, fieldKey, - fieldSelect, + getSelectionSet(node), data[fieldAlias] as Data | Data[] ); } - } else if (node.selectionSet === undefined) { - // The field is a scalar and can be retrieved directly - if ( - fieldValue === undefined && - schemaPredicates !== undefined && - schemaPredicates.isFieldNullable(typename, fieldName) - ) { - // Cache Incomplete: A missing field means it wasn't cached - ctx.result.completeness = 'PARTIAL'; - data[fieldAlias] = null; - } else if (fieldValue === undefined) { - ctx.result.completeness = 'EMPTY'; - data[fieldAlias] = null; + + // When we have a schema we check for a user's resolver whether the field is nullable + // Otherwise we trust the resolver and assume that it is + const isNull = resolverValue === undefined || resolverValue === null; + if (isNull && schemaPredicates !== undefined) { + dataFieldValue = undefined; } else { - // Not dealing with undefined means it's a cached field - data[fieldAlias] = fieldValue; - hasFields = true; + dataFieldValue = isNull ? null : resolverValue; } + } else if (node.selectionSet === undefined) { + // The field is a scalar and can be retrieved directly + dataFieldValue = fieldValue; } else { - // null values mean that a field might be linked to other entities + // We have a selection set which means that we'll be checking for links const fieldSelect = getSelectionSet(node); const link = store.getLink(fieldKey); - // Cache Incomplete: A missing link for a field means it's not cached - if (link === undefined) { - if (typeof fieldValue === 'object' && fieldValue !== null) { - // The entity on the field was invalid and can still be recovered - data[fieldAlias] = fieldValue; - hasFields = true; - } else if ( - schemaPredicates !== undefined && - schemaPredicates.isFieldNullable(typename, fieldName) - ) { - ctx.result.completeness = 'PARTIAL'; - data[fieldAlias] = null; - } else { - ctx.result.completeness = 'EMPTY'; - data[fieldAlias] = null; - } - } else { + if (link !== undefined) { const prevData = data[fieldAlias] as Data; - data[fieldAlias] = resolveLink(ctx, link, fieldSelect, prevData); - hasFields = true; + dataFieldValue = resolveLink( + ctx, + link, + typename, + fieldName, + fieldSelect, + prevData + ); + } else if (typeof fieldValue === 'object' && fieldValue !== null) { + // The entity on the field was invalid but can still be recovered + dataFieldValue = fieldValue; } } - } - if (isQuery && ctx.result.completeness === 'PARTIAL' && !hasFields) { - ctx.result.completeness = 'EMPTY'; - return null; + // Now that dataFieldValue has been retrieved it'll be set on data + // If it's uncached (undefined) but nullable we can continue assembling + // a partial query result + if ( + dataFieldValue === undefined && + schemaPredicates !== undefined && + schemaPredicates.isFieldNullable(typename, fieldName) + ) { + // The field is uncached but we have a schema that says it's nullable + // Set the field to null and continue + hasPartials = true; + data[fieldAlias] = null; + } else if (dataFieldValue === undefined) { + // The field is uncached and not nullable; return undefined + return undefined; + } else { + // Otherwise continue as usual + hasFields = true; + data[fieldAlias] = dataFieldValue; + } } - return data; + if (hasPartials) ctx.partial = true; + return isQuery && hasPartials && !hasFields ? undefined : data; }; const resolveResolverResult = ( ctx: Context, result: DataField, + typename: string, + fieldName: string, key: string, select: SelectionSet, prevData: void | Data | Data[] -) => { +): DataField | undefined => { // When we are dealing with a list we have to call this method again. if (Array.isArray(result)) { - // @ts-ignore: Link cannot be expressed as a recursive type - return result.map((childResult, index) => { - const data = prevData !== undefined ? prevData[index] : undefined; - const indexKey = joinKeys(key, `${index}`); - return resolveResolverResult(ctx, childResult, indexKey, select, data); - }); + const { schemaPredicates } = ctx; + const isListNullable = + schemaPredicates !== undefined && + schemaPredicates.isListNullable(typename, fieldName); + const newResult = new Array(result.length); + for (let i = 0, l = result.length; i < l; i++) { + const data = prevData !== undefined ? prevData[i] : undefined; + const childKey = joinKeys(key, `${i}`); + const childResult = resolveResolverResult( + ctx, + result[i], + typename, + fieldName, + childKey, + select, + data + ); + if (childResult === undefined && !isListNullable) { + return undefined; + } else { + result[i] = childResult !== undefined ? childResult : null; + } + } + + return newResult; } else if (result === null) { return null; } else if (isDataOrKey(result)) { @@ -324,17 +320,8 @@ const resolveResolverResult = ( const childKey = (typeof result === 'string' ? result : ctx.store.keyOfEntity(result)) || key; - const selectionResult = readSelection(ctx, childKey, select, data); - - if (selectionResult !== null && typeof result === 'object') { - for (key in result) { - if (key !== '__typename' && result.hasOwnProperty(key)) { - selectionResult[key] = result[key]; - } - } - } - - return selectionResult; + // TODO: Copy over fields from result but check against schema whether that's safe + return readSelection(ctx, childKey, select, data); } warning( @@ -345,21 +332,38 @@ const resolveResolverResult = ( key ); - ctx.result.completeness = 'EMPTY'; - return null; + return undefined; }; const resolveLink = ( ctx: Context, link: Link | Link[], + typename: string, + fieldName: string, select: SelectionSet, prevData: void | Data | Data[] -): null | Data | Data[] => { +): DataField | undefined => { if (Array.isArray(link)) { + const { schemaPredicates } = ctx; + const isListNullable = + schemaPredicates !== undefined && + schemaPredicates.isListNullable(typename, fieldName); const newLink = new Array(link.length); for (let i = 0, l = link.length; i < l; i++) { - const data = prevData !== undefined ? prevData[i] : undefined; - newLink[i] = resolveLink(ctx, link[i], select, data); + const innerPrevData = prevData !== undefined ? prevData[i] : undefined; + const childLink = resolveLink( + ctx, + link[i], + typename, + fieldName, + select, + innerPrevData + ); + if (childLink === undefined && !isListNullable) { + return undefined; + } else { + newLink[i] = childLink !== undefined ? childLink : null; + } } return newLink; diff --git a/src/store.test.ts b/src/store.test.ts index 79fd48f..0c6fe09 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -139,7 +139,7 @@ describe('Store with OptimisticMutationConfig', () => { store.invalidateQuery(Todos); clearStoreState(); ({ data } = query(store, { query: Todos })); - expect((data as any).todos).toEqual(null); + expect(data).toBe(null); expect(store.getRecord('Todo:0.text')).toBe(undefined); }); @@ -175,7 +175,7 @@ describe('Store with OptimisticMutationConfig', () => { query: Appointment, variables: { id: '1' }, })); - expect((data as any).appointment).toEqual(null); + expect(data).toBe(null); expect(store.getRecord('Appointment:1.info')).toBe(undefined); }); diff --git a/src/store.ts b/src/store.ts index 6727e1e..74bbb48 100644 --- a/src/store.ts +++ b/src/store.ts @@ -15,7 +15,7 @@ import { } from './types'; import { keyOfEntity, joinKeys, keyOfField } from './helpers'; -import { startQuery } from './operations/query'; +import { read } from './operations/query'; import { writeFragment, startWrite } from './operations/write'; import { invalidate } from './operations/invalidate'; import { SchemaPredicates } from './ast/schemaPredicates'; @@ -211,9 +211,7 @@ export class Store { ctx: { query: DocumentNode; variables?: Variables }, updater: (data: Data | null) => null | Data ): void { - const { data, completeness } = startQuery(this, ctx); - const input = completeness === 'EMPTY' ? null : data; - const output = updater(input); + const output = updater(read(this, ctx).data); if (output !== null) { startWrite(this, ctx, output); } diff --git a/src/test-utils/examples-1.test.ts b/src/test-utils/examples-1.test.ts index 2863361..2c92fda 100644 --- a/src/test-utils/examples-1.test.ts +++ b/src/test-utils/examples-1.test.ts @@ -62,7 +62,7 @@ it('passes the "getting-started" example', () => { expect(queryRes.data).toEqual(todosData); expect(queryRes.dependencies).toEqual(writeRes.dependencies); - expect(queryRes.completeness).toBe('FULL'); + expect(queryRes.partial).toBe(false); const mutatedTodo = { ...todosData.todos[2], @@ -82,7 +82,7 @@ it('passes the "getting-started" example', () => { queryRes = query(store, { query: Todos }); - expect(queryRes.completeness).toBe('FULL'); + expect(queryRes.partial).toBe(false); expect(queryRes.data).toEqual({ ...todosData, todos: [...todosData.todos.slice(0, 2), mutatedTodo], @@ -109,7 +109,7 @@ it('passes the "getting-started" example', () => { queryRes = query(store, { query: Todos }); - expect(queryRes.completeness).toBe('FULL'); + expect(queryRes.partial).toBe(false); expect(queryRes.data).toEqual({ ...todosData, todos: [...todosData.todos.slice(0, 2), newMutatedTodo], @@ -145,7 +145,7 @@ it('respects property-level resolvers when given', () => { ], }); expect(queryRes.dependencies).toEqual(writeRes.dependencies); - expect(queryRes.completeness).toBe('FULL'); + expect(queryRes.partial).toBe(false); const mutatedTodo = { ...todosData.todos[2], @@ -165,7 +165,7 @@ it('respects property-level resolvers when given', () => { queryRes = query(store, { query: Todos }); - expect(queryRes.completeness).toBe('FULL'); + expect(queryRes.partial).toBe(false); expect(queryRes.data).toEqual({ ...todosData, todos: [ @@ -248,7 +248,7 @@ it('Respects property-level resolvers when given', () => { const queryRes = query(store, { query: Todos }); - expect(queryRes.completeness).toBe('FULL'); + expect(queryRes.partial).toBe(false); expect(queryRes.data).toEqual({ ...todosData, todos: [ diff --git a/src/test-utils/suite.test.ts b/src/test-utils/suite.test.ts index 9b62002..bde4845 100644 --- a/src/test-utils/suite.test.ts +++ b/src/test-utils/suite.test.ts @@ -14,8 +14,9 @@ const expectCacheIntegrity = (testcase: TestCase) => { const request = { query: testcase.query, variables: testcase.variables }; const writeRes = write(store, request, testcase.data); const queryRes = query(store, request); + expect(queryRes.data).not.toBe(null); expect(queryRes.data).toEqual(testcase.data); - expect(queryRes.completeness).toBe('FULL'); + expect(queryRes.partial).toBe(false); expect(queryRes.dependencies).toEqual(writeRes.dependencies); }; diff --git a/src/types.ts b/src/types.ts index b868f8c..8443767 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,6 +97,3 @@ export interface OptimisticMutationConfig { export interface KeyingConfig { [typename: string]: KeyGenerator; } - -// Completeness of the query result -export type Completeness = 'EMPTY' | 'PARTIAL' | 'FULL';