-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Refactor query reading to use graphql-anywhere #747
Changes from 6 commits
09d58f0
80a0ff2
69727a7
a52f443
23afba0
8dda7fe
0aa563f
1c59ca0
d8a4879
82dc4b4
cce085b
fe7f013
f6ac722
b6f10c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,5 @@ | ||
import isArray = require('lodash.isarray'); | ||
import isNull = require('lodash.isnull'); | ||
import isObject = require('lodash.isobject'); | ||
import has = require('lodash.has'); | ||
import merge = require('lodash.merge'); | ||
|
||
import { | ||
storeKeyNameFromField, | ||
resultKeyNameFromField, | ||
isField, | ||
isInlineFragment, | ||
storeKeyNameFromFieldNameAndArgs, | ||
} from './storeUtils'; | ||
|
||
import { | ||
|
@@ -19,8 +10,9 @@ import { | |
|
||
import { | ||
SelectionSet, | ||
Field, | ||
Document, | ||
OperationDefinition, | ||
FragmentDefinition, | ||
} from 'graphql'; | ||
|
||
import { | ||
|
@@ -29,14 +21,15 @@ import { | |
FragmentMap, | ||
} from '../queries/getFromAST'; | ||
|
||
import { | ||
shouldInclude, | ||
} from '../queries/directives'; | ||
|
||
import { | ||
ApolloError, | ||
} from '../errors/ApolloError'; | ||
|
||
import graphql, { | ||
Resolver, | ||
ResultMapper, | ||
} from 'graphql-anywhere'; | ||
|
||
export interface DiffResult { | ||
result?: any; | ||
isMissing?: boolean; | ||
|
@@ -104,6 +97,52 @@ export function handleFragmentErrors(fragmentErrors: { [typename: string]: Error | |
} | ||
} | ||
|
||
type ReadStoreContext = { | ||
store: NormalizedCache; | ||
throwOnMissingField: boolean; | ||
hasMissingField: boolean; | ||
} | ||
|
||
const readStoreResolver: Resolver = ( | ||
fieldName: string, | ||
objId: string, | ||
args: any, | ||
context: ReadStoreContext | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. are these the standard arguments to graphql-anywhere resolvers? Can you walk me through graphql-anywhere execution some time, so I understand why fieldName and objectId are there? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, I would have expected to somehow get the parent somehow, but apparently that's not how it works? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, I see. I would have expected the parent to be the first argument. Maybe fieldName could be the last, since it's kind of like the info field in graphql? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess it's the first argument because in regular GraphQL it's the first thing you write: Query: {
myFieldName: (root, args, context) => { ... }
} In this case it's: |
||
) => { | ||
const obj = context.store[objId]; | ||
const storeKeyName = storeKeyNameFromFieldNameAndArgs(fieldName, args); | ||
const fieldValue = (obj || {})[storeKeyName]; | ||
|
||
if (typeof fieldValue === 'undefined') { | ||
if (context.throwOnMissingField) { | ||
throw new ApolloError({ | ||
errorMessage: `Can't find field ${storeKeyName} on object (${objId}) ${JSON.stringify(obj, null, 2)}. | ||
Perhaps you want to use the \`returnPartialData\` option?`, | ||
extraInfo: { | ||
isFieldError: true, | ||
}, | ||
}); | ||
} | ||
|
||
context.hasMissingField = true; | ||
|
||
return fieldValue; | ||
} | ||
|
||
if (isJsonValue(fieldValue)) { | ||
// if this is an object scalar, it must be a json blob and we have to unescape it | ||
return fieldValue.json; | ||
} | ||
|
||
if (isIdValue(fieldValue)) { | ||
return fieldValue.id; | ||
} | ||
|
||
return fieldValue; | ||
}; | ||
|
||
const mapper: ResultMapper = (childValues, rootValue) => childValues; | ||
|
||
/** | ||
* Given a store, a root ID, and a selection set, return as much of the result as possible and | ||
* identify which selection sets and root IDs need to be fetched to get the rest of the requested | ||
|
@@ -130,238 +169,48 @@ export function diffSelectionSetAgainstStore({ | |
variables: Object, | ||
fragmentMap?: FragmentMap, | ||
}): DiffResult { | ||
if (selectionSet.kind !== 'SelectionSet') { | ||
throw new Error('Must be a selection set.'); | ||
} | ||
const doc = makeDocument(selectionSet, rootId, fragmentMap); | ||
|
||
if (!fragmentMap) { | ||
fragmentMap = {}; | ||
} | ||
const context: ReadStoreContext = { | ||
store, | ||
throwOnMissingField, | ||
|
||
const result = {}; | ||
let hasMissingFields = false; | ||
|
||
// A map going from a typename to missing field errors thrown on that | ||
// typename. This data structure is needed to support union types. For example, if we have | ||
// a union type (Apple | Orange) and we only receive fields for fragments on | ||
// "Apple", that should not result in an error. But, if at least one of the fragments | ||
// for each of "Apple" and "Orange" is missing a field, that should return an error. | ||
// (i.e. with this approach, we manage to handle missing fields correctly even for | ||
// union types without any knowledge of the GraphQL schema). | ||
let fragmentErrors: { [typename: string]: Error } = {}; | ||
|
||
selectionSet.selections.forEach((selection) => { | ||
// Don't push more than one missing field per field in the query | ||
let fieldResult: any; | ||
|
||
const included = shouldInclude(selection, variables); | ||
|
||
if (isField(selection)) { | ||
const diffResult = diffFieldAgainstStore({ | ||
field: selection, | ||
throwOnMissingField, | ||
variables, | ||
rootId, | ||
store, | ||
fragmentMap, | ||
included, | ||
}); | ||
hasMissingFields = hasMissingFields || diffResult.isMissing; | ||
fieldResult = diffResult.result; | ||
|
||
const resultFieldKey = resultKeyNameFromField(selection); | ||
if (included && fieldResult !== undefined) { | ||
(result as any)[resultFieldKey] = fieldResult; | ||
} | ||
} else if (isInlineFragment(selection)) { | ||
const typename = selection.typeCondition.name.value; | ||
|
||
if (included) { | ||
try { | ||
const diffResult = diffSelectionSetAgainstStore({ | ||
selectionSet: selection.selectionSet, | ||
throwOnMissingField, | ||
variables, | ||
rootId, | ||
store, | ||
fragmentMap, | ||
}); | ||
|
||
hasMissingFields = hasMissingFields || diffResult.isMissing; | ||
fieldResult = diffResult.result; | ||
|
||
if (isObject(fieldResult)) { | ||
merge(result, fieldResult); | ||
} | ||
|
||
if (!fragmentErrors[typename]) { | ||
fragmentErrors[typename] = null; | ||
} | ||
} catch (e) { | ||
if (e.extraInfo && e.extraInfo.isFieldError) { | ||
fragmentErrors[typename] = e; | ||
} else { | ||
throw e; | ||
} | ||
} | ||
} | ||
} else { | ||
const fragment = fragmentMap[selection.name.value]; | ||
|
||
if (!fragment) { | ||
throw new Error(`No fragment named ${selection.name.value}`); | ||
} | ||
|
||
const typename = fragment.typeCondition.name.value; | ||
|
||
if (included) { | ||
try { | ||
const diffResult = diffSelectionSetAgainstStore({ | ||
selectionSet: fragment.selectionSet, | ||
throwOnMissingField, | ||
variables, | ||
rootId, | ||
store, | ||
fragmentMap, | ||
}); | ||
hasMissingFields = hasMissingFields || diffResult.isMissing; | ||
fieldResult = diffResult.result; | ||
|
||
if (isObject(fieldResult)) { | ||
merge(result, fieldResult); | ||
} | ||
|
||
if (!fragmentErrors[typename]) { | ||
fragmentErrors[typename] = null; | ||
} | ||
} catch (e) { | ||
if (e.extraInfo && e.extraInfo.isFieldError) { | ||
fragmentErrors[typename] = e; | ||
} else { | ||
throw e; | ||
} | ||
} | ||
} | ||
} | ||
}); | ||
// Filled in during execution | ||
hasMissingField: false, | ||
}; | ||
|
||
if (throwOnMissingField) { | ||
handleFragmentErrors(fragmentErrors); | ||
} | ||
const result = graphql(readStoreResolver, doc, 'ROOT_QUERY', context, variables, mapper); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we could one-up graphql here and make the arguments named. (i know that's technically in graphql-anywhere, but I'm just putting all my thoughts here). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the role of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation has the resolvers return either the ID of the related object or a scalar leaf field. So the initial root value is the ID of the root object, which is |
||
|
||
return { | ||
result, | ||
isMissing: hasMissingFields, | ||
isMissing: context.hasMissingField, | ||
}; | ||
} | ||
|
||
function diffFieldAgainstStore({ | ||
field, | ||
throwOnMissingField, | ||
variables, | ||
rootId, | ||
store, | ||
fragmentMap, | ||
included = true, | ||
}: { | ||
field: Field, | ||
throwOnMissingField: boolean, | ||
variables: Object, | ||
// Shim to use graphql-anywhere, to be removed | ||
function makeDocument( | ||
selectionSet: SelectionSet, | ||
rootId: string, | ||
store: NormalizedCache, | ||
fragmentMap?: FragmentMap, | ||
included?: boolean, | ||
}): DiffResult { | ||
const storeObj = store[rootId] || {}; | ||
const storeFieldKey = storeKeyNameFromField(field, variables); | ||
|
||
if (! has(storeObj, storeFieldKey)) { | ||
if (throwOnMissingField && included) { | ||
throw new ApolloError({ | ||
errorMessage: `Can't find field ${storeFieldKey} on object (${rootId}) ${JSON.stringify(storeObj, null, 2)}. | ||
Perhaps you want to use the \`returnPartialData\` option?`, | ||
extraInfo: { | ||
isFieldError: true, | ||
}, | ||
}); | ||
} | ||
|
||
return { | ||
isMissing: true, | ||
}; | ||
} | ||
|
||
const storeValue = storeObj[storeFieldKey]; | ||
|
||
// Handle all scalar types here | ||
if (! field.selectionSet) { | ||
if (isJsonValue(storeValue)) { | ||
// if this is an object scalar, it must be a json blob and we have to unescape it | ||
return { | ||
result: storeValue.json, | ||
}; | ||
} else { | ||
// if this is a non-object scalar, we can return it immediately | ||
return { | ||
result: storeValue, | ||
}; | ||
} | ||
} | ||
|
||
// From here down, the field has a selection set, which means it's trying to | ||
// query a GraphQLObjectType | ||
if (isNull(storeValue)) { | ||
// Basically any field in a GraphQL response can be null | ||
return { | ||
result: null, | ||
}; | ||
fragmentMap: FragmentMap | ||
): Document { | ||
if (rootId !== 'ROOT_QUERY') { | ||
throw new Error('only supports query'); | ||
} | ||
|
||
if (isArray(storeValue)) { | ||
let isMissing: any; | ||
|
||
const result = (storeValue as string[]).map((id) => { | ||
// null value in array | ||
if (isNull(id)) { | ||
return null; | ||
} | ||
|
||
const itemDiffResult = diffSelectionSetAgainstStore({ | ||
store, | ||
throwOnMissingField, | ||
rootId: id, | ||
selectionSet: field.selectionSet, | ||
variables, | ||
fragmentMap, | ||
}); | ||
|
||
if (itemDiffResult.isMissing) { | ||
// XXX merge all of the missing selections from the children to get a more minimal result | ||
isMissing = 'true'; | ||
} | ||
const op: OperationDefinition = { | ||
kind: 'OperationDefinition', | ||
operation: 'query', | ||
selectionSet, | ||
}; | ||
|
||
return itemDiffResult.result; | ||
}); | ||
const frags: FragmentDefinition[] = fragmentMap ? | ||
Object.keys(fragmentMap).map((name) => fragmentMap[name]) : | ||
[]; | ||
|
||
return { | ||
result, | ||
isMissing, | ||
}; | ||
} | ||
|
||
// If the store value is an object and it has a selection set, it must be | ||
// an escaped id. | ||
if (isIdValue(storeValue)) { | ||
const unescapedId = storeValue.id; | ||
return diffSelectionSetAgainstStore({ | ||
store, | ||
throwOnMissingField, | ||
rootId: unescapedId, | ||
selectionSet: field.selectionSet, | ||
variables, | ||
fragmentMap, | ||
}); | ||
} | ||
const doc: Document = { | ||
kind: 'Document', | ||
definitions: [op, ...frags], | ||
}; | ||
|
||
throw new Error('Unexpected value in the store where the query had a subselection.'); | ||
return doc; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -88,9 +88,13 @@ export function storeKeyNameFromField(field: Field, variables?: Object): string | |
} | ||
|
||
export function storeKeyNameFromFieldNameAndArgs(fieldName: string, args?: Object): string { | ||
const stringifiedArgs: string = JSON.stringify(args); | ||
if (args) { | ||
const stringifiedArgs: string = JSON.stringify(args); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. wait, did we never do anything about alphabetical ordering etc? Because we'll definitely get a cache miss if the order isn't the same, even if the values are semantically equivalent. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, we might want to do that. Perhaps something to open a new issue about, since it doesn't necessarily need to be part of the refactor. |
||
|
||
return `${fieldName}(${stringifiedArgs})`; | ||
return `${fieldName}(${stringifiedArgs})`; | ||
} | ||
|
||
return fieldName; | ||
} | ||
|
||
export function resultKeyNameFromField(field: Field): string { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So many things called graphql now... I guess that's life.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll change to
graphql as graphqlAnywhere
just to keep it classy.