Skip to content

Commit

Permalink
Implement db lists API in terms of GraphQLSchema (#5569)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmatown committed Apr 29, 2021
1 parent 05d4883 commit d216fd0
Show file tree
Hide file tree
Showing 5 changed files with 298 additions and 202 deletions.
5 changes: 5 additions & 0 deletions .changeset/real-apricots-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/keystone': patch
---

Refactored implementation of db lists API
29 changes: 17 additions & 12 deletions packages-next/keystone/src/lib/context/createContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
ImagesConfig,
} from '@keystone-next/types';

import { itemDbAPIForList, itemAPIForList, getArgsFactory } from './itemAPI';
import { getDbAPIFactory, itemAPIForList } from './itemAPI';
import { accessControlContext, skipAccessControlContext } from './createAccessControlContext';
import { createImagesContext } from './createImagesContext';

Expand All @@ -25,15 +25,20 @@ export function makeCreateContext({
}) {
const images = createImagesContext(imagesConfig);
// We precompute these helpers here rather than every time createContext is called
// because they require parsing the entire schema, which is potentially expensive.
const publicGetArgsByList: Record<string, ReturnType<typeof getArgsFactory>> = {};
// because they involve creating a new GraphQLSchema, creating a GraphQL document AST(programmatically, not by parsing) and validating the
// note this isn't as big of an optimisation as you would imagine(at least in comparison with the rest of the system),
// the regular non-db lists api does more expensive things on every call
// like parsing the generated GraphQL document, and validating it against the schema on _every_ call
// is that really that bad? no not really. this has just been more optimised because the cost of what it's
// doing is more obvious(even though in reality it's much smaller than the alternative)
const publicDbApiFactories: Record<string, ReturnType<typeof getDbAPIFactory>> = {};
for (const [listKey, list] of Object.entries(keystone.lists)) {
publicGetArgsByList[listKey] = getArgsFactory(list, graphQLSchema);
publicDbApiFactories[listKey] = getDbAPIFactory(list.gqlNames, graphQLSchema);
}

const internalGetArgsByList: Record<string, ReturnType<typeof getArgsFactory>> = {};
const internalDbApiFactories: Record<string, ReturnType<typeof getDbAPIFactory>> = {};
for (const [listKey, list] of Object.entries(keystone.lists)) {
internalGetArgsByList[listKey] = getArgsFactory(list, internalSchema);
internalDbApiFactories[listKey] = getDbAPIFactory(list.gqlNames, internalSchema);
}

const createContext = ({
Expand Down Expand Up @@ -62,8 +67,8 @@ export function makeCreateContext({
}
return result.data as Record<string, any>;
};
const dbAPI: Record<string, ReturnType<typeof itemDbAPIForList>> = {};
const itemAPI: Record<string, ReturnType<typeof itemAPIForList>> = {};
const dbAPI: KeystoneContext['db']['lists'] = {};
const itemAPI: KeystoneContext['lists'] = {};
const contextToReturn: KeystoneContext = {
schemaName,
...(skipAccessControl ? skipAccessControlContext : accessControlContext),
Expand All @@ -90,10 +95,10 @@ export function makeCreateContext({
gqlNames: (listKey: string) => keystone.lists[listKey].gqlNames,
images,
};
const getArgsByList = schemaName === 'public' ? publicGetArgsByList : internalGetArgsByList;
for (const [listKey, list] of Object.entries(keystone.lists)) {
dbAPI[listKey] = itemDbAPIForList(list, contextToReturn, getArgsByList[listKey]);
itemAPI[listKey] = itemAPIForList(list, contextToReturn, getArgsByList[listKey]);
const dbAPIFactories = schemaName === 'public' ? publicDbApiFactories : internalDbApiFactories;
for (const listKey of Object.keys(keystone.lists)) {
dbAPI[listKey] = dbAPIFactories[listKey](contextToReturn);
itemAPI[listKey] = itemAPIForList(listKey, contextToReturn, dbAPI[listKey]);
}
return contextToReturn;
};
Expand Down
209 changes: 209 additions & 0 deletions packages-next/keystone/src/lib/context/executeGraphQLFieldToRootVal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { KeystoneContext } from '@keystone-next/types';
import {
GraphQLScalarType,
GraphQLObjectType,
GraphQLArgument,
GraphQLArgumentConfig,
GraphQLSchema,
DocumentNode,
validate,
execute,
GraphQLField,
VariableDefinitionNode,
TypeNode,
GraphQLType,
GraphQLNonNull,
GraphQLList,
GraphQLEnumType,
GraphQLInputObjectType,
GraphQLInterfaceType,
GraphQLUnionType,
ListTypeNode,
NamedTypeNode,
ArgumentNode,
GraphQLFieldConfig,
GraphQLOutputType,
} from 'graphql';

function getNamedOrListTypeNodeForType(
type:
| GraphQLScalarType
| GraphQLObjectType<any, any>
| GraphQLInterfaceType
| GraphQLUnionType
| GraphQLEnumType
| GraphQLInputObjectType
| GraphQLList<any>
): NamedTypeNode | ListTypeNode {
if (type instanceof GraphQLList) {
return { kind: 'ListType', type: getTypeNodeForType(type.ofType) };
}
return { kind: 'NamedType', name: { kind: 'Name', value: type.name } };
}

function getTypeNodeForType(type: GraphQLType): TypeNode {
if (type instanceof GraphQLNonNull) {
return { kind: 'NonNullType', type: getNamedOrListTypeNodeForType(type.ofType) };
}
return getNamedOrListTypeNodeForType(type);
}

function getVariablesForGraphQLField(field: GraphQLField<any, any>) {
const variableDefinitions: VariableDefinitionNode[] = field.args.map(arg => ({
kind: 'VariableDefinition',
type: getTypeNodeForType(arg.type),
variable: { kind: 'Variable', name: { kind: 'Name', value: arg.name } },
}));

const argumentNodes: ArgumentNode[] = field.args.map(arg => ({
kind: 'Argument',
name: { kind: 'Name', value: arg.name },
value: { kind: 'Variable', name: { kind: 'Name', value: arg.name } },
}));

return { variableDefinitions, argumentNodes };
}

const rawField = 'raw';

const RawScalar = new GraphQLScalarType({ name: 'RawThingPlsDontRelyOnThisAnywhere' });

const ReturnRawValueObjectType = new GraphQLObjectType({
name: 'ReturnRawValue',
fields: {
[rawField]: {
type: RawScalar,
resolve(rootVal) {
return rootVal;
},
},
},
});

type RequiredButStillAllowUndefined<
T extends Record<string, any>,
// this being a param is important and is what makes this work,
// please do not move it inside the mapped type.
// i can't find a place that explains this but the tldr is that
// having the keyof T _inside_ the mapped type means TS will keep modifiers
// like readonly and optionality and we want to remove those here
Key extends keyof T = keyof T
> = {
[K in Key]: T[K];
};

function argsToArgsConfig(args: GraphQLArgument[]) {
return Object.fromEntries(
args.map(arg => {
const argConfig: RequiredButStillAllowUndefined<GraphQLArgumentConfig> = {
astNode: arg.astNode,
defaultValue: arg.defaultValue,
deprecationReason: arg.deprecationReason,
description: arg.description,
extensions: arg.extensions,
type: arg.type,
};
return [arg.name, argConfig];
})
);
}

type OutputTypeWithoutNonNull = GraphQLObjectType | GraphQLList<OutputType>;

type OutputType = OutputTypeWithoutNonNull | GraphQLNonNull<OutputTypeWithoutNonNull>;

// note the GraphQLNonNull and GraphQLList constructors are incorrectly
// not generic over their inner type which is why we have to use as
// (the classes are generic but not the constructors)
function getTypeForField(originalType: GraphQLOutputType): OutputType {
if (originalType instanceof GraphQLNonNull) {
return new GraphQLNonNull(getTypeForField(originalType.ofType)) as OutputType;
}
if (originalType instanceof GraphQLList) {
return new GraphQLList(getTypeForField(originalType.ofType)) as OutputType;
}
return ReturnRawValueObjectType;
}

function getRootValGivenOutputType(originalType: OutputType, value: any): any {
if (originalType instanceof GraphQLNonNull) {
return getRootValGivenOutputType(originalType.ofType, value);
}
if (originalType instanceof GraphQLList) {
if (value === null) return null;
return value.map((x: any) => getRootValGivenOutputType(originalType.ofType, x));
}
return value[rawField];
}

export function executeGraphQLFieldToRootVal(field: GraphQLField<any, any>) {
const { argumentNodes, variableDefinitions } = getVariablesForGraphQLField(field);
const document: DocumentNode = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: field.name },
arguments: argumentNodes,
selectionSet: {
kind: 'SelectionSet',
selections: [{ kind: 'Field', name: { kind: 'Name', value: rawField } }],
},
},
],
},
variableDefinitions,
},
],
};

const type = getTypeForField(field.type);

const fieldConfig: RequiredButStillAllowUndefined<GraphQLFieldConfig<any, any>> = {
args: argsToArgsConfig(field.args),
astNode: undefined,
deprecationReason: field.deprecationReason,
description: field.description,
extensions: field.extensions,
resolve: field.resolve,
subscribe: field.subscribe,
type,
};
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
[field.name]: fieldConfig,
},
}),
});

const validationErrors = validate(schema, document);

if (validationErrors.length > 0) {
throw validationErrors[0];
}
return async (
args: Record<string, any>,
context: KeystoneContext,
rootValue: Record<string, string> = {}
) => {
const result = await execute({
schema,
document,
contextValue: context,
variableValues: args,
rootValue,
});
if (result.errors?.length) {
throw result.errors[0];
}
return getRootValGivenOutputType(type, result.data![field.name]);
};
}

This file was deleted.

Loading

1 comment on commit d216fd0

@vercel
Copy link

@vercel vercel bot commented on d216fd0 Apr 29, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.