diff --git a/packages/core/src/build-cache.ts b/packages/core/src/build-cache.ts index a514da520..bc5a1dbbd 100644 --- a/packages/core/src/build-cache.ts +++ b/packages/core/src/build-cache.ts @@ -51,6 +51,7 @@ import { InputType, OutputType, SchemaTypes, + typeBrandKey, } from '.'; export default class BuildCache { @@ -563,6 +564,16 @@ export default class BuildCache { private buildInterface(config: GiraphQLInterfaceTypeConfig) { const resolveType: GraphQLTypeResolver = (parent, context, info) => { + if (typeof parent === 'object' && parent !== null && typeBrandKey in parent) { + const typeBrand = (parent as { [typeBrandKey]: OutputType })[typeBrandKey]; + + if (typeof typeBrand === 'string') { + return typeBrand; + } + + return this.getTypeConfig(typeBrand).name; + } + // eslint-disable-next-line @typescript-eslint/no-use-before-define const implementers = this.getImplementers(type); diff --git a/packages/core/src/builder.ts b/packages/core/src/builder.ts index 03d86158c..931994d5f 100644 --- a/packages/core/src/builder.ts +++ b/packages/core/src/builder.ts @@ -43,6 +43,7 @@ import { } from './types'; import { normalizeEnumValues, valuesFromEnum, verifyRef } from './utils'; import { + AbstractReturnShape, BaseEnum, EnumParam, EnumTypeOptions, @@ -143,6 +144,7 @@ export default class SchemaBuilder { name, interfaces: (options.interfaces ?? []) as ObjectParam[], description: options.description, + extensions: options.extensions, isTypeOf: options.isTypeOf as GraphQLIsTypeOfFn, giraphqlOptions: options as GiraphQLSchemaTypes.ObjectTypeOptions, }; @@ -203,6 +205,7 @@ export default class SchemaBuilder { name: 'Query', description: options.description, giraphqlOptions: options as unknown as GiraphQLSchemaTypes.QueryTypeOptions, + extensions: options.extensions, }; this.configStore.addTypeConfig(config); @@ -236,6 +239,7 @@ export default class SchemaBuilder { name: 'Mutation', description: options.description, giraphqlOptions: options as unknown as GiraphQLSchemaTypes.MutationTypeOptions, + extensions: options.extensions, }; this.configStore.addTypeConfig(config); @@ -269,6 +273,7 @@ export default class SchemaBuilder { name: 'Subscription', description: options.description, giraphqlOptions: options as unknown as GiraphQLSchemaTypes.SubscriptionTypeOptions, + extensions: options.extensions, }; this.configStore.addTypeConfig(config); @@ -313,8 +318,8 @@ export default class SchemaBuilder { const ref = param instanceof InterfaceRef - ? (param as InterfaceRef, ParentShape>) - : new InterfaceRef, ParentShape>(name); + ? (param as InterfaceRef, ParentShape>) + : new InterfaceRef, ParentShape>(name); const typename = ref.name; @@ -325,6 +330,7 @@ export default class SchemaBuilder { interfaces: (options.interfaces ?? []) as ObjectParam[], description: options.description, giraphqlOptions: options as unknown as GiraphQLSchemaTypes.InterfaceTypeOptions, + extensions: options.extensions, }; this.configStore.addTypeConfig(config, ref); @@ -373,7 +379,7 @@ export default class SchemaBuilder { name: string, options: GiraphQLSchemaTypes.UnionTypeOptions, ) { - const ref = new UnionRef, ParentShape>(name); + const ref = new UnionRef, ParentShape>(name); options.types.forEach((type) => { verifyRef(type); @@ -387,6 +393,7 @@ export default class SchemaBuilder { description: options.description, resolveType: options.resolveType as GraphQLTypeResolver, giraphqlOptions: options as unknown as GiraphQLSchemaTypes.UnionTypeOptions, + extensions: options.extensions, }; this.configStore.addTypeConfig(config, ref); @@ -417,6 +424,7 @@ export default class SchemaBuilder { values, description: options.description, giraphqlOptions: options as unknown as GiraphQLSchemaTypes.EnumTypeOptions, + extensions: options.extensions, }; this.configStore.addTypeConfig(config, ref); @@ -448,6 +456,7 @@ export default class SchemaBuilder { parseValue: options.parseValue, serialize: options.serialize, giraphqlOptions: options as unknown as GiraphQLSchemaTypes.ScalarTypeOptions, + extensions: options.extensions, }; this.configStore.addTypeConfig(config, ref); @@ -499,6 +508,7 @@ export default class SchemaBuilder { name, description: options.description, giraphqlOptions: options as unknown as GiraphQLSchemaTypes.InputObjectTypeOptions, + extensions: options.extensions, }; this.configStore.addTypeConfig(config, ref); diff --git a/packages/core/src/types/type-params.ts b/packages/core/src/types/type-params.ts index 8963123f0..59569c631 100644 --- a/packages/core/src/types/type-params.ts +++ b/packages/core/src/types/type-params.ts @@ -2,9 +2,11 @@ import { InterfaceRef, ObjectRef, RootName, SchemaTypes } from '..'; export const outputShapeKey = Symbol.for('GiraphQL.outputShapeKey'); export const parentShapeKey = Symbol.for('GiraphQL.parentShapeKey'); +export const abstractReturnShapeKey = Symbol.for('GiraphQL.abstractReturnShapeKey'); export const inputShapeKey = Symbol.for('GiraphQL.inputShapeKey'); export const inputFieldShapeKey = Symbol.for('GiraphQL.inputFieldShapeKey'); export const outputFieldShapeKey = Symbol.for('GiraphQL.outputFieldShapeKey'); +export const typeBrandKey = Symbol.for('GiraphQL.typeBrandKey'); export type OutputShape = T extends { [outputShapeKey]: infer U; @@ -28,6 +30,12 @@ export type ParentShape = T extends { ? U : OutputShape; +export type AbstractReturnShape = T extends { + [abstractReturnShapeKey]: infer U; +} + ? U + : OutputShape; + export type InputShape = T extends { [inputShapeKey]: infer U; } diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 83b79ae56..f45f5302a 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,3 +1,5 @@ +import { OutputType, SchemaTypes, typeBrandKey } from '..'; + export * from './context-cache'; export * from './enums'; export * from './input'; @@ -34,3 +36,14 @@ you may be able to resolve this by importing it directly fron the file that defi `); } } + +export function brandWithType(val: unknown, type: OutputType) { + if (typeof val !== 'object' || val === null) { + return; + } + + Object.defineProperty(val, typeBrandKey, { + enumerable: false, + value: type, + }); +} diff --git a/packages/plugin-prisma/package.json b/packages/plugin-prisma/package.json index 90b7f06dd..454e657c0 100644 --- a/packages/plugin-prisma/package.json +++ b/packages/plugin-prisma/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@giraphql/core": "^2.11.0", + "@giraphql/plugin-relay": "^2.11.0", "@prisma/client": "^2.27.0", "apollo-server": "^2.25.2", "graphql": ">=15.5.1", diff --git a/packages/plugin-prisma/src/cursors.ts b/packages/plugin-prisma/src/cursors.ts new file mode 100644 index 000000000..82f2c7077 --- /dev/null +++ b/packages/plugin-prisma/src/cursors.ts @@ -0,0 +1,134 @@ +import { MaybePromise } from '@giraphql/core'; + +const DEFAULT_MAX_SIZE = 100; +const DEFAULT_SIZE = 20; + +function formatCursor(value: unknown): string { + switch (typeof value) { + case 'number': + return Buffer.from(`GPC:N:${value}`).toString('base64'); + case 'string': + return Buffer.from(`GPC:S:${value}`).toString('base64'); + default: + throw new TypeError(`Unsupported cursor type ${typeof value}`); + } +} + +function parseCursor(cursor: unknown) { + if (typeof cursor !== 'string') { + throw new TypeError('Cursor must be a string'); + } + + try { + const decoded = Buffer.from(cursor, 'base64').toString(); + const [, type, value] = decoded.match(/^GPC:(\w):(.*)/) as [string, string, string]; + + switch (type) { + case 'S': + return value; + case 'N': + return Number.parseInt(value, 10); + default: + throw new TypeError(`Invalid cursor type ${type}`); + } + } catch { + throw new Error(`Invalid cursor: ${cursor}`); + } +} + +interface PrismaCursorConnectionQueryOptions { + args: GiraphQLSchemaTypes.DefaultConnectionArguments; + defaultSize?: number; + maxSize?: number; + column: string; +} + +interface ResolvePrismaCursorConnectionOptions extends PrismaCursorConnectionQueryOptions { + query: {}; +} + +export function prismaCursorConnectionQuery({ + args: { before, after, first, last }, + maxSize = DEFAULT_MAX_SIZE, + defaultSize = DEFAULT_SIZE, + column, +}: PrismaCursorConnectionQueryOptions) { + if (first != null && first < 0) { + throw new TypeError('Argument "first" must be a non-negative integer'); + } + + if (last != null && last < 0) { + throw new Error('Argument "last" must be a non-negative integer'); + } + + if (before && after) { + throw new Error('Arguments "before" and "after" are not supported at the same time'); + } + + if (before != null && last == null) { + throw new Error('Argument "last" must be provided when using "before"'); + } + + if (before != null && first != null) { + throw new Error('Arguments "before" and "first" are not supported at the same time'); + } + + if (after != null && last != null) { + throw new Error('Arguments "after" and "last" are not supported at the same time'); + } + + const cursor = before ?? after; + + let take = Math.min(first ?? last ?? defaultSize, maxSize) + 1; + + if (before) { + take = -take; + } + + return cursor == null + ? { take, skip: 0 } + : { + cursor: { + [column]: parseCursor(cursor), + }, + take, + skip: 1, + }; +} + +export async function resolvePrismaCursorConnection( + options: ResolvePrismaCursorConnectionOptions, + resolve: (query: { include?: {}; cursor?: {}; take: number; skip: number }) => MaybePromise, +) { + const query = prismaCursorConnectionQuery(options); + const results = await resolve({ + ...options.query, + ...query, + }); + + const gotFullResults = results.length === Math.abs(query.take); + const hasNextPage = options.args.before ? true : gotFullResults; + const hasPreviousPage = options.args.after ? true : gotFullResults; + const nodes = gotFullResults + ? results.slice(query.take < 0 ? 1 : 0, query.take < 0 ? results.length : -1) + : results; + + const edges = nodes.map((value, index) => + value == null + ? null + : { + cursor: formatCursor((value as Record)[options.column]), + node: value, + }, + ); + + return { + edges, + pageInfo: { + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + hasPreviousPage, + hasNextPage, + }, + }; +} diff --git a/packages/plugin-prisma/src/field-builder.ts b/packages/plugin-prisma/src/field-builder.ts index a538039e5..bcdbc2dea 100644 --- a/packages/plugin-prisma/src/field-builder.ts +++ b/packages/plugin-prisma/src/field-builder.ts @@ -2,25 +2,36 @@ import { GraphQLResolveInfo } from 'graphql'; import { FieldKind, FieldNullability, + FieldOptionsFromKind, FieldRef, InputFieldMap, + InputFieldsFromShape, + MaybePromise, NormalizeArgs, ObjectFieldBuilder, + ObjectRef, + PluginName, RootFieldBuilder, SchemaTypes, TypeParam, } from '@giraphql/core'; -import { getLoaderMapping, setLoaderMapping } from './loader-map'; +import { resolvePrismaCursorConnection } from './cursors'; +import { getLoaderMapping, setLoaderMappings } from './loader-map'; import { ModelLoader } from './model-loader'; import { getFindUniqueForRef, getRefFromModel, getRelation } from './refs'; import { + DelegateFromName, IncludeFromPrismaDelegate, + ListRelationField, + ModelName, + PrismaConnectionFieldOptions, PrismaDelegate, + RelatedConnectionOptions, RelatedFieldOptions, RelationShape, ShapeFromPrismaDelegate, } from './types'; -import { includesFromInfo } from './util'; +import { queryFromInfo } from './util'; const fieldBuilderProto = RootFieldBuilder.prototype as GiraphQLSchemaTypes.RootFieldBuilder< SchemaTypes, @@ -37,17 +48,101 @@ fieldBuilderProto.prismaField = function prismaField({ type, resolve, ...options ...options, type: typeParam, resolve: (parent: unknown, args: unknown, ctx: {}, info: GraphQLResolveInfo) => { - const { includes, mappings } = includesFromInfo(info); + const query = queryFromInfo(ctx, info); - if (mappings) { - setLoaderMapping(ctx, info.path, mappings); - } - - return resolve({ include: includes as never }, parent, args as never, ctx, info) as never; + return resolve(query, parent, args as never, ctx, info) as never; }, }) as never; }; +fieldBuilderProto.prismaConnection = function prismaConnection< + Name extends ModelName, + Type extends DelegateFromName, + Nullable extends boolean, + ResolveReturnShape, + Args extends InputFieldMap, +>( + this: typeof fieldBuilderProto, + { + type, + cursor, + maxSize, + defaultSize, + resolve, + ...options + }: Omit< + FieldOptionsFromKind< + SchemaTypes, + unknown, + Type, + Nullable, + Args & InputFieldsFromShape, + FieldKind, + unknown, + ResolveReturnShape + >, + 'args' | 'resolve' | 'type' + > & + PrismaConnectionFieldOptions< + SchemaTypes, + unknown, + Name, + DelegateFromName, + ObjectRef>, + Nullable, + Args, + ResolveReturnShape, + FieldKind + >, + connectionOptions: GiraphQLSchemaTypes.ConnectionObjectOptions< + SchemaTypes, + Type, + ResolveReturnShape + >, + edgeOptions: GiraphQLSchemaTypes.ConnectionEdgeObjectOptions< + SchemaTypes, + Type, + ResolveReturnShape + >, +) { + const ref = getRefFromModel(type, this.builder); + + const fieldRef = ( + this as typeof fieldBuilderProto & { connection: (...args: unknown[]) => FieldRef } + ).connection( + { + ...options, + type: ref, + resolve: ( + parent: unknown, + args: GiraphQLSchemaTypes.DefaultConnectionArguments, + ctx: {}, + info: GraphQLResolveInfo, + ) => + resolvePrismaCursorConnection( + { + query: queryFromInfo(ctx, info), + column: cursor, + maxSize, + defaultSize, + args, + }, + (query) => resolve(query as never, parent, args as never, ctx, info), + ), + }, + { + ...connectionOptions, + extensions: { + ...(connectionOptions as Record | undefined)?.extensions, + giraphQLPrismaIndirectInclude: ['edges', 'node'], + }, + }, + edgeOptions, + ); + + return fieldRef; +} as never; + export class PrismaObjectFieldBuilder< Types extends SchemaTypes, Type extends PrismaDelegate, @@ -55,6 +150,148 @@ export class PrismaObjectFieldBuilder< > extends ObjectFieldBuilder> { model: string; + relatedConnection: 'relay' extends PluginName + ? < + Field extends ListRelationField, + Nullable extends boolean, + Args extends InputFieldMap, + ResolveReturnShape, + >( + ...args: NormalizeArgs< + [ + field: Field, + options: RelatedConnectionOptions< + Types, + ShapeFromPrismaDelegate, + Type, + Field, + Nullable, + Args, + NeedsResolve + >, + connectionOptions?: GiraphQLSchemaTypes.ConnectionObjectOptions< + Types, + ObjectRef>, + ResolveReturnShape + >, + edgeOptions?: GiraphQLSchemaTypes.ConnectionEdgeObjectOptions< + Types, + ObjectRef>, + ResolveReturnShape + >, + ] + > + ) => FieldRef< + GiraphQLSchemaTypes.ConnectionShapeHelper< + Types, + ShapeFromPrismaDelegate, + Nullable + >['shape'] + > + : '@giraphql/plugin-relay is required to use this method' = function relatedConnection( + this: PrismaObjectFieldBuilder, + name: string, + { + maxSize, + defaultSize, + cursor, + query, + resolve, + extensions, + ...options + }: { + maxSize?: number; + defaultSize?: number; + cursor: string; + extensions: {}; + query: ((args: {}) => {}) | {}; + resolve: (query: {}, parent: unknown, args: {}, ctx: {}, info: {}) => MaybePromise<{}[]>; + }, + connectionOptions = {}, + edgeOptions = {}, + ) { + const { client } = this.builder.options.prisma; + const relationField = getRelation(client, this.model, name); + const parentRef = getRefFromModel(this.model, this.builder); + const ref = getRefFromModel(relationField.type, this.builder); + const findUnique = getFindUniqueForRef(parentRef, this.builder); + const loaderCache = ModelLoader.forModel(this.model, this.builder); + + const fieldRef = ( + this as unknown as typeof fieldBuilderProto & { + connection: (...args: unknown[]) => FieldRef; + } + ).connection( + { + ...options, + extensions: { + ...extensions, + giraphQLPrismaQuery: query, + giraphQLPrismaRelation: name, + }, + type: ref, + resolve: ( + parent: object, + args: GiraphQLSchemaTypes.DefaultConnectionArguments, + context: {}, + info: GraphQLResolveInfo, + ) => + resolvePrismaCursorConnection( + { + query: queryFromInfo(context, info), + column: cursor, + maxSize, + defaultSize, + args, + }, + (mergedQuery) => { + const mapping = getLoaderMapping(context, info.path); + + const loadedValue = (parent as Record)[name]; + + if ( + // if we attempted to load the relation, and its missing it will be null + // undefined means that the query was not constructed in a way that requested the relation + loadedValue !== undefined && + mapping + ) { + if (loadedValue !== null && loadedValue !== undefined) { + setLoaderMappings(context, info.path, mapping); + } + + return loadedValue as never; + } + + const queryOptions = { + ...((typeof query === 'function' ? query(args) : query) as {}), + ...queryFromInfo(context, info), + }; + + if (!resolve && !findUnique) { + throw new Error(`Missing findUnique for Prisma type ${this.model}`); + } + + if (resolve) { + return resolve(mergedQuery, parent, args, context, info); + } + + return loaderCache(parent).loadRelation(name, queryOptions, context) as Promise<{}[]>; + }, + ), + }, + { + ...connectionOptions, + extensions: { + ...(connectionOptions as Record | undefined)?.extensions, + giraphQLPrismaIndirectInclude: ['edges', 'node'], + }, + }, + edgeOptions, + ); + + return fieldRef; + } as never; + constructor(name: string, builder: GiraphQLSchemaTypes.SchemaBuilder, model: string) { super(name, builder); @@ -100,10 +337,7 @@ export class PrismaObjectFieldBuilder< giraphQLPrismaRelation: name, }, resolve: (parent, args, context, info) => { - const parentPath = - typeof info.path.prev?.key === 'number' ? info.path.prev?.prev : info.path.prev; - - const mapping = getLoaderMapping(context, parentPath!)?.[name]; + const mapping = getLoaderMapping(context, info.path); const loadedValue = (parent as Record)[name]; @@ -111,40 +345,29 @@ export class PrismaObjectFieldBuilder< // if we attempted to load the relation, and its missing it will be null // undefined means that the query was not constructed in a way that requested the relation loadedValue !== undefined && - mapping && - info.fieldNodes[0].alias?.value === mapping.alias && - info.fieldNodes[0].name.value === mapping.field + mapping ) { if (loadedValue !== null && loadedValue !== undefined) { - setLoaderMapping(context, info.path, mapping.mappings); + setLoaderMappings(context, info.path, mapping); } return loadedValue as never; } - const queryOptions = (typeof query === 'function' ? query(args) : query) as { - include?: Record; + const queryOptions = { + ...((typeof query === 'function' ? query(args) : query) as {}), + ...queryFromInfo(context, info), }; - const { includes, mappings } = includesFromInfo(info); - - if (includes) { - queryOptions.include = includes; - } - - if (mappings) { - setLoaderMapping(context, info.path, mappings); - } - if (resolve) { - return resolve(queryOptions as never, parent, args as never, context, info); + return resolve(queryOptions, parent, args as never, context, info); } if (!findUnique) { throw new Error(`Missing findUnique for Prisma type ${this.model}`); } - return loaderCache(parent).loadRelation(name, queryOptions) as never; + return loaderCache(parent).loadRelation(name, queryOptions, context) as never; }, }) as FieldRef, 'Object'>; } diff --git a/packages/plugin-prisma/src/global-types.ts b/packages/plugin-prisma/src/global-types.ts index 27a9970c9..ed96171bc 100644 --- a/packages/plugin-prisma/src/global-types.ts +++ b/packages/plugin-prisma/src/global-types.ts @@ -1,16 +1,23 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-empty-interface */ import { FieldKind, FieldNullability, FieldRef, InputFieldMap, InterfaceParam, + NormalizeArgs, ObjectRef, + OutputType, + PluginName, SchemaTypes, } from '@giraphql/core'; import { DelegateFromName, ModelName, + PrismaConnectionFieldOptions, PrismaFieldOptions, + PrismaNodeOptions, PrismaObjectTypeOptions, ShapeFromPrismaDelegate, } from './types'; @@ -46,7 +53,14 @@ declare global { >( type: Name, options: PrismaObjectTypeOptions, - ) => ObjectRef<{}>; + ) => ObjectRef>>; + + prismaNode: 'relay' extends PluginName + ? , Interfaces extends InterfaceParam[]>( + name: Name, + options: PrismaNodeOptions, + ) => ObjectRef>> + : '@giraphql/plugin-relay is required to use this method'; } export interface RootFieldBuilder< @@ -83,6 +97,76 @@ declare global { Kind >, ) => FieldRef>; + + prismaConnection: 'relay' extends PluginName + ? < + Name extends ModelName, + Type extends DelegateFromName, + Nullable extends boolean, + ResolveReturnShape, + Args extends InputFieldMap = {}, + >( + ...args: NormalizeArgs< + [ + options: PrismaConnectionFieldOptions< + Types, + ParentShape, + Name, + DelegateFromName, + ObjectRef>, + Nullable, + Args, + ResolveReturnShape, + Kind + >, + connectionOptions?: ConnectionObjectOptions< + Types, + ObjectRef>, + ResolveReturnShape + >, + edgeOptions?: ConnectionEdgeObjectOptions< + Types, + ObjectRef>, + ResolveReturnShape + >, + ] + > + ) => FieldRef< + ConnectionShapeHelper, Nullable>['shape'] + > + : '@giraphql/plugin-relay is required to use this method'; + } + + export interface ConnectionFieldOptions< + Types extends SchemaTypes, + ParentShape, + Type extends OutputType, + Nullable extends boolean, + Args extends InputFieldMap, + ResolveReturnShape, + > {} + + export interface ConnectionObjectOptions< + Types extends SchemaTypes, + Type extends OutputType, + Resolved, + > {} + + export interface ConnectionEdgeObjectOptions< + Types extends SchemaTypes, + Type extends OutputType, + Resolved, + > {} + + export interface DefaultConnectionArguments { + first?: number | null | undefined; + last?: number | null | undefined; + before?: string | null | undefined; + after?: string | null | undefined; + } + + export interface ConnectionShapeHelper { + shape: unknown; } } } diff --git a/packages/plugin-prisma/src/loader-map.ts b/packages/plugin-prisma/src/loader-map.ts index 8cd9258fb..0e6910479 100644 --- a/packages/plugin-prisma/src/loader-map.ts +++ b/packages/plugin-prisma/src/loader-map.ts @@ -4,32 +4,43 @@ import { LoaderMappings } from './util'; const cache = createContextCache((ctx) => new Map()); -export function cacheKey(path: GraphQLResolveInfo['path']) { - let key = String(path.key); - let current = path.prev; +export function cacheKey(path: GraphQLResolveInfo['path'], subPath: string[]) { + let key = ''; + let current: GraphQLResolveInfo['path'] | undefined = path; while (current) { - key = `${current.key}.${key}`; + if (typeof current.key === 'string') { + key = key ? `${current.key}.${key}` : current.key; + } current = current.prev; } + for (const entry of subPath) { + key = `${key}.${entry}`; + } + return key; } -export function setLoaderMapping( +export function setLoaderMappings( ctx: object, path: GraphQLResolveInfo['path'], value: LoaderMappings, ) { - const map = cache(ctx); - const key = cacheKey(path); - - map.set(key, value); + Object.keys(value).forEach((field) => { + const mapping = value[field]; + const map = cache(ctx); + const selectionName = mapping.alias ?? mapping.field; + const subPath = [...mapping.indirectPath, selectionName]; + const key = cacheKey(path, subPath); + + map.set(key, mapping.mappings); + }); } export function getLoaderMapping(ctx: object, path: GraphQLResolveInfo['path']) { const map = cache(ctx); - const key = cacheKey(path); + const key = cacheKey(path, []); return map.get(key) ?? null; } diff --git a/packages/plugin-prisma/src/model-loader.ts b/packages/plugin-prisma/src/model-loader.ts index 7ff4e9712..5ba71ef34 100644 --- a/packages/plugin-prisma/src/model-loader.ts +++ b/packages/plugin-prisma/src/model-loader.ts @@ -7,14 +7,18 @@ const loaderCache = new WeakMap ModelLoader>(); export class ModelLoader { model: object; delegate: PrismaDelegate<{}, never>; - findUnique: (args: unknown) => unknown; + findUnique: (args: unknown, ctx: {}) => unknown; staged = new Set<{ promise: Promise>; include: Record; }>(); - constructor(model: object, delegate: PrismaDelegate, findUnique: (args: unknown) => unknown) { + constructor( + model: object, + delegate: PrismaDelegate, + findUnique: (args: unknown, ctx: {}) => unknown, + ) { this.model = model; this.delegate = delegate; this.findUnique = findUnique; @@ -39,7 +43,7 @@ export class ModelLoader { return loaderCache.get(delegate)!; } - async loadRelation(relation: string, include: unknown) { + async loadRelation(relation: string, include: unknown, context: {}) { let promise; for (const entry of this.staged) { if (entry.include[relation] === undefined) { @@ -52,7 +56,7 @@ export class ModelLoader { } if (!promise) { - promise = this.initLoad(relation, include); + promise = this.initLoad(relation, include, context); } const result = await promise; @@ -60,7 +64,7 @@ export class ModelLoader { return result[relation]; } - async initLoad(relation: string, includeArg: unknown) { + async initLoad(relation: string, includeArg: unknown, context: {}) { const include: Record = { [relation]: includeArg, }; @@ -73,7 +77,7 @@ export class ModelLoader { resolve( this.delegate.findUnique({ rejectOnNotFound: true, - where: { ...(this.findUnique(this.model) as {}) }, + where: { ...(this.findUnique(this.model, context) as {}) }, include, } as never) as Promise>, ); diff --git a/packages/plugin-prisma/src/refs.ts b/packages/plugin-prisma/src/refs.ts index 6154c6332..94ef7802a 100644 --- a/packages/plugin-prisma/src/refs.ts +++ b/packages/plugin-prisma/src/refs.ts @@ -5,7 +5,7 @@ import { PrismaDelegate } from './types'; export const refMap = new WeakMap>>(); export const findUniqueMap = new WeakMap< object, - Map, ((args: unknown) => unknown) | null> + Map, ((args: unknown, ctx: {}) => unknown) | null> >(); export function getRefFromModel( @@ -37,14 +37,14 @@ export function getFindUniqueForRef( return null; } - return cache.get(ref)!; + return cache.get(ref)! as (args: unknown, context: Types['Context']) => unknown; } export function setFindUniqueForRef( ref: ObjectRef, builder: GiraphQLSchemaTypes.SchemaBuilder, // eslint-disable-next-line @typescript-eslint/no-explicit-any - findUnique: ((args: any) => unknown) | null, + findUnique: ((args: any, context: Types['Context']) => unknown) | null, ) { if (!findUniqueMap.has(builder)) { findUniqueMap.set(builder, new Map()); diff --git a/packages/plugin-prisma/src/schema-builder.ts b/packages/plugin-prisma/src/schema-builder.ts index 246266971..726acdca9 100644 --- a/packages/plugin-prisma/src/schema-builder.ts +++ b/packages/plugin-prisma/src/schema-builder.ts @@ -1,14 +1,25 @@ import './global-types'; -import SchemaBuilder, { FieldNullability, SchemaTypes, TypeParam } from '@giraphql/core'; +import { GraphQLResolveInfo } from 'graphql'; +import SchemaBuilder, { + brandWithType, + FieldNullability, + FieldRef, + InterfaceRef, + OutputType, + SchemaTypes, + TypeParam, +} from '@giraphql/core'; import { PrismaObjectFieldBuilder } from './field-builder'; -import { getRefFromModel, setFindUniqueForRef } from './refs'; +import { getDelegateFromModel, getRefFromModel, setFindUniqueForRef } from './refs'; +import { ModelName, PrismaNodeOptions } from './types'; +import { queryFromInfo } from './util'; const schemaBuilderProto = SchemaBuilder.prototype as GiraphQLSchemaTypes.SchemaBuilder; schemaBuilderProto.prismaObject = function prismaObject(type, { fields, findUnique, ...options }) { const ref = getRefFromModel(type, this); - const name = (options.name ?? type) as string; + const name = options.name ?? type; ref.name = name; @@ -29,3 +40,69 @@ schemaBuilderProto.prismaObject = function prismaObject(type, { fields, findUniq return ref as never; }; + +schemaBuilderProto.prismaNode = function prismaNode( + this: GiraphQLSchemaTypes.SchemaBuilder & { + nodeInterfaceRef?: () => InterfaceRef; + }, + model: ModelName, + { findUnique, name, ...options }: PrismaNodeOptions, []>, +) { + const interfaceRef = this.nodeInterfaceRef?.(); + + if (!interfaceRef) { + throw new TypeError('builder.prismaNode requires @giraphql/plugin-relay to be installed'); + } + + const typeName = name ?? model; + const delegate = getDelegateFromModel(this.options.prisma.client, model); + const extendedOptions = { + ...options, + interfaces: [interfaceRef, ...(options.interfaces ?? [])], + findUnique: (parent: unknown, context: {}) => + findUnique(options.id.resolve(parent as never, context) as string, context), + loadWithoutCache: async ( + id: string, + context: SchemaTypes['Context'], + info: GraphQLResolveInfo, + ) => { + const query = queryFromInfo(context, info, typeName); + const record = await delegate.findUnique({ + ...query, + rejectOnNotFound: true, + where: findUnique(id, context) as {}, + } as never); + + brandWithType(record, typeName as OutputType); + + return record; + }, + }; + + const ref = this.prismaObject(model, extendedOptions as never); + + this.configStore.onTypeConfig(ref, (nodeConfig) => { + this.objectField(ref, 'id', (t) => + ( + t as unknown as { + globalID: (options: Record) => FieldRef; + } + ).globalID({ + ...options.id, + nullable: false, + args: {}, + resolve: async ( + parent: never, + args: object, + context: object, + info: GraphQLResolveInfo, + ) => ({ + type: nodeConfig.name, + id: await options.id.resolve(parent, context), + }), + }), + ); + }); + + return ref; +} as never; diff --git a/packages/plugin-prisma/src/types.ts b/packages/plugin-prisma/src/types.ts index 0bdd544b9..29294684d 100644 --- a/packages/plugin-prisma/src/types.ts +++ b/packages/plugin-prisma/src/types.ts @@ -5,19 +5,27 @@ import { FieldNullability, FieldOptionsFromKind, InputFieldMap, + InputFieldsFromShape, InputShapeFromFields, InterfaceParam, ListResolveValue, MaybePromise, ObjectRef, + OutputShape, + OutputType, SchemaTypes, ShapeFromTypeParam, TypeParam, } from '@giraphql/core'; import { PrismaObjectFieldBuilder } from './field-builder'; -export interface PrismaDelegate { +export interface PrismaDelegate< + Shape extends {} = {}, + Options extends FindUniqueArgs = never, + ManyOptions extends FindManyArgs = never, +> { findUnique: (args: Options) => UniqueReturn; + findMany: (args?: ManyOptions) => unknown; } export interface UniqueReturn { @@ -30,6 +38,10 @@ export interface FindUniqueArgs { where?: unknown; } +export interface FindManyArgs { + cursor?: {}; +} + export type ShapeFromPrismaDelegate = T extends PrismaDelegate ? Shape : never; @@ -38,6 +50,10 @@ export type IncludeFromPrismaDelegate = T extends PrismaDelegate<{}, infer Op ? NonNullable : never; +export type CursorFromPrismaDelegate = T extends PrismaDelegate<{}, never, infer Options> + ? string & keyof NonNullable + : never; + export type SelectFromPrismaDelegate = T extends PrismaDelegate<{}, infer Options> ? NonNullable : never; @@ -46,6 +62,18 @@ export type WhereFromPrismaDelegate = T extends PrismaDelegate<{}, infer Opti ? NonNullable : never; +export type ListRelationField = IncludeFromPrismaDelegate extends infer Include + ? NonNullable< + { + [K in keyof Include]: Include[K] extends infer Option + ? Option extends { orderBy?: unknown } + ? K + : never + : never; + }[keyof Include] + > + : never; + export type RelationShape< T extends PrismaDelegate, Relation extends keyof IncludeFromPrismaDelegate, @@ -82,17 +110,58 @@ export type PrismaObjectTypeOptions< >, 'fields' > & { - name?: String; + name?: string; fields?: PrismaObjectFieldsShape; findUnique: FindUnique & ( | (( parent: ShapeFromPrismaDelegate>, + context: Types['Context'], ) => WhereFromPrismaDelegate>) | null ); }; +export type PrismaNodeOptions< + Types extends SchemaTypes, + Name extends ModelName, + Interfaces extends InterfaceParam[], + Type extends DelegateFromName & PrismaDelegate = DelegateFromName, +> = Omit< + | GiraphQLSchemaTypes.ObjectTypeOptions>> + | GiraphQLSchemaTypes.ObjectTypeWithInterfaceOptions< + Types, + ObjectRef>, + Interfaces + >, + 'fields' | 'isTypeOf' +> & { + name?: string; + id: Omit< + FieldOptionsFromKind< + Types, + ShapeFromPrismaDelegate, + 'ID', + false, + {}, + 'Object', + OutputShape, + MaybePromise> + >, + 'args' | 'nullable' | 'resolve' | 'type' + > & { + resolve: ( + parent: ShapeFromPrismaDelegate, + context: Types['Context'], + ) => MaybePromise>; + }; + fields?: PrismaObjectFieldsShape; + findUnique: ( + id: string, + context: Types['Context'], + ) => WhereFromPrismaDelegate>; +}; + export type ModelName = { [K in keyof Types['PrismaClient']]: Types['PrismaClient'][K] extends PrismaDelegate ? K extends string @@ -108,8 +177,10 @@ export type DelegateFromName< ? Extract], PrismaDelegate> : never; -export type QueryForField = Include extends object - ? Include | ((args: InputShapeFromFields) => Include) +export type QueryForField = Include extends { where?: unknown } + ? + | Omit + | ((args: InputShapeFromFields) => Omit) : never; export type InlcudeFromRelation< @@ -161,7 +232,7 @@ export type RelatedFieldOptions< info: GraphQLResolveInfo, ) => MaybePromise>; }) & { - query?: QueryForField[Field], 'include' | 'select'>>; + query?: QueryForField[Field]>; }; export type PrismaFieldOptions< @@ -201,3 +272,121 @@ export type PrismaFieldOptions< : MaybePromise : never; }; + +export type PrismaConnectionFieldOptions< + Types extends SchemaTypes, + ParentShape, + Name extends ModelName, + Type extends PrismaDelegate, + Param extends OutputType, + Nullable extends boolean, + Args extends InputFieldMap, + ResolveReturnShape, + Kind extends FieldKind, + // eslint-disable-next-line @typescript-eslint/sort-type-union-intersection-members +> = Omit< + GiraphQLSchemaTypes.ConnectionFieldOptions< + Types, + ParentShape, + Param, + Nullable, + Args, + ResolveReturnShape + >, + 'resolve' | 'type' +> & + Omit< + FieldOptionsFromKind< + Types, + ParentShape, + Param, + Nullable, + Args & InputFieldsFromShape, + Kind, + ParentShape, + ResolveReturnShape + >, + 'args' | 'resolve' | 'type' + > & { + type: Name; + cursor: CursorFromPrismaDelegate; + defaultSize?: number; + maxSize?: number; + resolve: ( + query: { + include?: IncludeFromPrismaDelegate; + cursor?: {}; + take: number; + skip: number; + }, + parent: ParentShape, + args: GiraphQLSchemaTypes.DefaultConnectionArguments & InputShapeFromFields, + context: Types['Context'], + info: GraphQLResolveInfo, + ) => MaybePromise[]>; + }; + +export type RelatedConnectionOptions< + Types extends SchemaTypes, + ParentShape, + Type extends PrismaDelegate, + Field extends keyof SelectFromPrismaDelegate, + Nullable extends boolean, + Args extends InputFieldMap, + NeedsResolve extends boolean, + // eslint-disable-next-line @typescript-eslint/sort-type-union-intersection-members +> = Omit< + GiraphQLSchemaTypes.ObjectFieldOptions< + Types, + ParentShape, + ObjectRef, + Nullable, + Args, + unknown + >, + 'resolve' | 'type' +> & + Omit< + GiraphQLSchemaTypes.ConnectionFieldOptions< + Types, + ParentShape, + ObjectRef, + Nullable, + Args, + unknown + >, + 'resolve' | 'type' + > & { + query?: QueryForField[Field]>; + cursor: CursorFromPrismaDelegate; + defaultSize?: number; + maxSize?: number; + } & (NeedsResolve extends false + ? { + resolve?: ( + query: { + include?: IncludeFromPrismaDelegate; + cursor?: {}; + take: number; + skip: number; + }, + parent: ParentShape, + args: InputShapeFromFields, + context: Types['Context'], + info: GraphQLResolveInfo, + ) => MaybePromise>; + } + : { + resolve: ( + query: { + include?: IncludeFromPrismaDelegate; + cursor?: {}; + take: number; + skip: number; + }, + parent: ParentShape, + args: InputShapeFromFields, + context: Types['Context'], + info: GraphQLResolveInfo, + ) => MaybePromise>; + }); diff --git a/packages/plugin-prisma/src/util.ts b/packages/plugin-prisma/src/util.ts index f22d56970..808f72d68 100644 --- a/packages/plugin-prisma/src/util.ts +++ b/packages/plugin-prisma/src/util.ts @@ -12,6 +12,7 @@ import { SelectionSetNode, } from 'graphql'; import { getArgumentValues } from 'graphql/execution/values'; +import { setLoaderMappings } from './loader-map'; export type IncludeMap = Record; export type LoaderMappings = Record< @@ -20,15 +21,22 @@ export type LoaderMappings = Record< field: string; alias?: string; mappings: LoaderMappings; + indirectPath: string[]; } >; +interface IndirectLoadMap { + subFields: string[]; + path: string[]; +} + function handleField( info: GraphQLResolveInfo, fields: GraphQLFieldMap, selection: FieldNode, includes: IncludeMap, mappings: LoaderMappings, + indirectMap?: IndirectLoadMap, ) { const field = fields[selection.name.value]; @@ -36,17 +44,40 @@ function handleField( throw new Error(`Unknown field ${selection.name.value}`); } - const relationName = field.extensions?.giraphQLPrismaRelation as string | undefined; + if (indirectMap?.subFields.length) { + if (field.name === indirectMap.subFields[0] && selection.selectionSet) { + const type = getNamedType(field.type); + + if (!(type instanceof GraphQLObjectType)) { + throw new TypeError(`Expected ${type.name} to be a an object type`); + } + + includesFromSelectionSet( + type, + info, + includes, + mappings, + selection.selectionSet, + indirectMap.subFields.length > 0 + ? { + subFields: indirectMap.subFields.slice(1), + path: [...indirectMap.path, selection.alias?.value ?? selection.name.value], + } + : undefined, + ); + } - if (!relationName || includes[relationName]) { return; } - let query = field.extensions?.giraphQLPrismaQuery as unknown; - if (!query) { + const relationName = field.extensions?.giraphQLPrismaRelation as string | undefined; + + if (!relationName || includes[relationName]) { return; } + let query = (field.extensions?.giraphQLPrismaQuery as unknown) ?? {}; + if (typeof query === 'function') { const args = getArgumentValues(field, selection, info.variableValues) as Record< string, @@ -62,6 +93,7 @@ function handleField( field: selection.name.value, alias: selection.alias?.value, mappings: nestedMappings, + indirectPath: indirectMap?.path ?? [], }; if (selection.selectionSet) { @@ -88,6 +120,7 @@ export function includesFromFragment( includes: Record, mappings: LoaderMappings, fragment: FragmentDefinitionNode | InlineFragmentNode, + indirectMap?: IndirectLoadMap, ) { // Includes currently don't make sense for Interfaces and Unions // so we only need to handle fragments for the parent type @@ -95,7 +128,7 @@ export function includesFromFragment( return; } - includesFromSelectionSet(type, info, includes, mappings, fragment.selectionSet); + includesFromSelectionSet(type, info, includes, mappings, fragment.selectionSet, indirectMap); } export function includesFromSelectionSet( @@ -104,22 +137,33 @@ export function includesFromSelectionSet( includes: Record, mappings: LoaderMappings, selectionSet: SelectionSetNode, + prevIndirectMap?: IndirectLoadMap, ) { const fields = type.getFields(); + const indirectInclude = type.extensions?.giraphQLPrismaIndirectInclude as string[] | undefined; + const indirectMap = + prevIndirectMap ?? (indirectInclude ? { subFields: indirectInclude, path: [] } : undefined); for (const selection of selectionSet.selections) { switch (selection.kind) { case 'Field': - handleField(info, fields, selection, includes, mappings); + handleField(info, fields, selection, includes, mappings, indirectMap); break; case 'FragmentSpread': if (!info.fragments[selection.name.value]) { throw new Error(`Missing fragment ${selection.name.value}`); } - includesFromFragment(type, info, includes, mappings, info.fragments[selection.name.value]); + includesFromFragment( + type, + info, + includes, + mappings, + info.fragments[selection.name.value], + indirectMap, + ); break; case 'InlineFragment': - includesFromFragment(type, info, includes, mappings, selection); + includesFromFragment(type, info, includes, mappings, selection, indirectMap); break; default: throw new Error(`Unexpected selection kind ${(selection as { kind: string }).kind}`); @@ -127,7 +171,7 @@ export function includesFromSelectionSet( } } -export function includesFromInfo(info: GraphQLResolveInfo) { +export function queryFromInfo(ctx: object, info: GraphQLResolveInfo, typeName?: string): {} { const { fieldNodes } = info; const includes: IncludeMap = {}; @@ -137,7 +181,7 @@ export function includesFromInfo(info: GraphQLResolveInfo) { continue; } - const type = getNamedType(info.returnType); + const type = typeName ? info.schema.getTypeMap()[typeName] : getNamedType(info.returnType); if (!(type instanceof GraphQLObjectType)) { throw new TypeError('Expected returnType to be an object type'); @@ -147,7 +191,11 @@ export function includesFromInfo(info: GraphQLResolveInfo) { } if (Object.keys(includes).length > 0) { - return { includes, mappings }; + if (mappings) { + setLoaderMappings(ctx, info.path, mappings); + } + + return { include: includes }; } return {}; diff --git a/packages/plugin-prisma/tests/example/builder.ts b/packages/plugin-prisma/tests/example/builder.ts index 960b2c194..113492383 100644 --- a/packages/plugin-prisma/tests/example/builder.ts +++ b/packages/plugin-prisma/tests/example/builder.ts @@ -1,4 +1,5 @@ import SchemaBuilder from '@giraphql/core'; +import RelayPlugin from '@giraphql/plugin-relay'; import { PrismaClient } from '@prisma/client'; // eslint-disable-next-line import/no-named-as-default import PrismaPlugin from '../../src'; @@ -30,7 +31,8 @@ export default new SchemaBuilder<{ }; PrismaClient: typeof prisma; }>({ - plugins: [PrismaPlugin], + plugins: [PrismaPlugin, RelayPlugin], + relayOptions: {}, prisma: { client: prisma, }, diff --git a/packages/plugin-prisma/tests/example/schema/index.ts b/packages/plugin-prisma/tests/example/schema/index.ts index 92ae02be9..3dd05332d 100644 --- a/packages/plugin-prisma/tests/example/schema/index.ts +++ b/packages/plugin-prisma/tests/example/schema/index.ts @@ -1,10 +1,11 @@ -import { Post, PrismaClient, User } from '@prisma/client'; import builder, { prisma } from '../builder'; -builder.prismaObject('User', { - findUnique: (user) => ({ id: user.id }), +builder.prismaNode('User', { + id: { + resolve: (user) => user.id, + }, + findUnique: (id) => ({ id: Number.parseInt(id, 10) }), fields: (t) => ({ - id: t.exposeID('id'), email: t.exposeString('email'), name: t.exposeString('name', { nullable: true }), profile: t.relation('profile'), @@ -23,9 +24,13 @@ builder.prismaObject('User', { where: { authorId: user.id }, }), }), + postsConnection: t.relatedConnection('posts', { + cursor: 'id', + }), profileThroughManualLookup: t.field({ // eslint-disable-next-line @typescript-eslint/no-use-before-define type: Profile, + nullable: true, resolve: (user) => prisma.user.findUnique({ where: { id: user.id } }).profile(), }), }), @@ -86,6 +91,13 @@ builder.queryType({ ...query, }), }), + userConnection: t.prismaConnection({ + type: 'User', + cursor: 'id', + defaultSize: 10, + maxSize: 15, + resolve: async (query, parent, args) => prisma.user.findMany({ ...query }), + }), }), }); diff --git a/packages/plugin-prisma/tests/index.test.ts b/packages/plugin-prisma/tests/index.test.ts index e35ce47ab..b5b91f136 100644 --- a/packages/plugin-prisma/tests/index.test.ts +++ b/packages/plugin-prisma/tests/index.test.ts @@ -38,18 +38,17 @@ describe('prisma', () => { Object { "data": Object { "me": Object { - "id": "1", + "id": "VXNlcjox", }, }, } - `); + `); expect(queries).toMatchInlineSnapshot(` Array [ Object { "action": "findUnique", "args": Object { - "include": undefined, "where": Object { "id": 1, }, @@ -82,10 +81,10 @@ describe('prisma', () => { "data": Object { "users": Array [ Object { - "id": "1", + "id": "VXNlcjox", }, Object { - "id": "2", + "id": "VXNlcjoy", }, ], }, @@ -96,9 +95,7 @@ describe('prisma', () => { Array [ Object { "action": "findMany", - "args": Object { - "include": undefined, - }, + "args": Object {}, "dataPath": Array [], "model": "User", "runInTransaction": false, @@ -334,19 +331,19 @@ describe('prisma', () => { "oldestPosts": Array [ Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "1", }, Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "2", }, Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "3", }, @@ -354,19 +351,19 @@ describe('prisma', () => { "posts": Array [ Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "3", }, Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "2", }, Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "1", }, @@ -377,55 +374,55 @@ describe('prisma', () => { `); expect(queries).toMatchInlineSnapshot(` -Array [ - Object { - "action": "findUnique", - "args": Object { - "include": Object { - "posts": Object { - "include": Object { - "author": Object { - "include": Object { - "profile": true, + Array [ + Object { + "action": "findUnique", + "args": Object { + "include": Object { + "posts": Object { + "include": Object { + "author": Object { + "include": Object { + "profile": true, + }, + }, + }, + "orderBy": Object { + "createdAt": "desc", + }, }, }, + "where": Object { + "id": 1, + }, }, - "orderBy": Object { - "createdAt": "desc", - }, + "dataPath": Array [], + "model": "User", + "runInTransaction": false, }, - }, - "where": Object { - "id": 1, - }, - }, - "dataPath": Array [], - "model": "User", - "runInTransaction": false, - }, - Object { - "action": "findMany", - "args": Object { - "include": Object { - "author": Object { - "include": Object { - "profile": true, + Object { + "action": "findMany", + "args": Object { + "include": Object { + "author": Object { + "include": Object { + "profile": true, + }, + }, + }, + "orderBy": Object { + "createdAt": "asc", + }, + "where": Object { + "authorId": 1, + }, }, + "dataPath": Array [], + "model": "Post", + "runInTransaction": false, }, - }, - "orderBy": Object { - "createdAt": "asc", - }, - "where": Object { - "authorId": 1, - }, - }, - "dataPath": Array [], - "model": "Post", - "runInTransaction": false, - }, -] -`); + ] + `); }); it('queries with variables and alieases', async () => { @@ -467,19 +464,19 @@ Array [ "oldestPosts": Array [ Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "1", }, Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "2", }, Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "3", }, @@ -498,19 +495,19 @@ Array [ "posts": Array [ Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "3", }, Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "2", }, Object { "author": Object { - "id": "1", + "id": "VXNlcjox", }, "id": "1", }, @@ -521,69 +518,69 @@ Array [ `); expect(queries).toMatchInlineSnapshot(` -Array [ - Object { - "action": "findUnique", - "args": Object { - "include": Object { - "posts": Object { - "include": Object { - "author": Object { - "include": Object { - "profile": true, + Array [ + Object { + "action": "findUnique", + "args": Object { + "include": Object { + "posts": Object { + "include": Object { + "author": Object { + "include": Object { + "profile": true, + }, + }, + }, + "orderBy": Object { + "createdAt": "desc", + }, }, }, + "where": Object { + "id": 1, + }, }, - "orderBy": Object { - "createdAt": "desc", + "dataPath": Array [], + "model": "User", + "runInTransaction": false, + }, + Object { + "action": "findMany", + "args": Object { + "orderBy": Object { + "createdAt": "desc", + }, + "where": Object { + "authorId": 1, + }, }, + "dataPath": Array [], + "model": "Post", + "runInTransaction": false, }, - }, - "where": Object { - "id": 1, - }, - }, - "dataPath": Array [], - "model": "User", - "runInTransaction": false, - }, - Object { - "action": "findMany", - "args": Object { - "orderBy": Object { - "createdAt": "desc", - }, - "where": Object { - "authorId": 1, - }, - }, - "dataPath": Array [], - "model": "Post", - "runInTransaction": false, - }, - Object { - "action": "findMany", - "args": Object { - "include": Object { - "author": Object { - "include": Object { - "profile": true, + Object { + "action": "findMany", + "args": Object { + "include": Object { + "author": Object { + "include": Object { + "profile": true, + }, + }, + }, + "orderBy": Object { + "createdAt": "asc", + }, + "where": Object { + "authorId": 1, + }, }, + "dataPath": Array [], + "model": "Post", + "runInTransaction": false, }, - }, - "orderBy": Object { - "createdAt": "asc", - }, - "where": Object { - "authorId": 1, - }, - }, - "dataPath": Array [], - "model": "Post", - "runInTransaction": false, - }, -] -`); + ] + `); }); it('queries through non-prisma fields', async () => { @@ -631,7 +628,6 @@ Array [ Object { "action": "findUnique", "args": Object { - "include": undefined, "where": Object { "id": 1, }, diff --git a/packages/plugin-relay/src/field-builder.ts b/packages/plugin-relay/src/field-builder.ts index 4af424875..8152b2795 100644 --- a/packages/plugin-relay/src/field-builder.ts +++ b/packages/plugin-relay/src/field-builder.ts @@ -130,7 +130,7 @@ fieldBuilderProto.node = function node({ id, ...options }) { String(rawID.id), ); - return (await resolveNodes(this.builder, context, [globalID]))[0]; + return (await resolveNodes(this.builder, context, info, [globalID]))[0]; }, }); }; @@ -169,7 +169,7 @@ fieldBuilderProto.nodeList = function nodeList({ ids, ...options }) { ), ); - return resolveNodes(this.builder, context, globalIds); + return resolveNodes(this.builder, context, info, globalIds); }, }); }; diff --git a/packages/plugin-relay/src/global-types.ts b/packages/plugin-relay/src/global-types.ts index 1084c1f8a..1c7d85467 100644 --- a/packages/plugin-relay/src/global-types.ts +++ b/packages/plugin-relay/src/global-types.ts @@ -8,6 +8,7 @@ import { InputFieldRef, InputFieldsFromShape, InputObjectRef, + InputShapeFromFields, InputShapeFromTypeParam, inputShapeKey, InterfaceParam, @@ -20,17 +21,14 @@ import { OutputShape, OutputType, ParentShape, + Resolver, SchemaTypes, ShapeFromTypeParam, } from '@giraphql/core'; import { - ConnectionEdgeObjectOptions, - ConnectionFieldOptions, - ConnectionObjectOptions, ConnectionShape, ConnectionShapeForType, ConnectionShapeFromResolve, - DefaultConnectionArguments, GlobalIDFieldOptions, GlobalIDInputFieldOptions, GlobalIDInputShape, @@ -114,17 +112,11 @@ declare global { connectionObject: , ResolveReturnShape>( ...args: NormalizeArgs< [ - connectionOptions: ConnectionObjectOptions< - Types, - ConnectionShapeFromResolve - > & { + connectionOptions: ConnectionObjectOptions & { name: string; type: Type; }, - edgeOptions?: ConnectionEdgeObjectOptions< - Types, - ConnectionShapeForType['edges'][number] - > & { + edgeOptions?: ConnectionEdgeObjectOptions & { name?: string; }, ] @@ -226,17 +218,60 @@ declare global { >, 'args' | 'resolve' | 'type' >, - connectionOptions?: ConnectionObjectOptions< - Types, - ConnectionShapeFromResolve - >, - edgeOptions?: ConnectionEdgeObjectOptions< - Types, - ConnectionShapeFromResolve['edges'][number] - >, + connectionOptions?: ConnectionObjectOptions, + edgeOptions?: ConnectionEdgeObjectOptions, ] > ) => FieldRef>; } + + export interface ConnectionFieldOptions< + Types extends SchemaTypes, + ParentShape, + Type extends OutputType, + Nullable extends boolean, + Args extends InputFieldMap, + ResolveReturnShape, + > { + type: Type; + args?: Args; + resolve: Resolver< + ParentShape, + InputShapeFromFields>, + Types['Context'], + ConnectionShapeForType, + ResolveReturnShape + >; + } + + export interface ConnectionObjectOptions< + Types extends SchemaTypes, + Type extends OutputType, + Resolved, + > extends ObjectTypeOptions> { + name?: string; + } + + export interface ConnectionEdgeObjectOptions< + Types extends SchemaTypes, + Type extends OutputType, + Resolved, + > extends ObjectTypeOptions< + Types, + ConnectionShapeFromResolve['edges'][number] + > { + name?: string; + } + + export interface DefaultConnectionArguments { + first?: number | null | undefined; + last?: number | null | undefined; + before?: string | null | undefined; + after?: string | null | undefined; + } + + export interface ConnectionShapeHelper { + shape: ConnectionShape; + } } } diff --git a/packages/plugin-relay/src/schema-builder.ts b/packages/plugin-relay/src/schema-builder.ts index 80a0da4b9..a2b0b1599 100644 --- a/packages/plugin-relay/src/schema-builder.ts +++ b/packages/plugin-relay/src/schema-builder.ts @@ -111,8 +111,8 @@ schemaBuilderProto.nodeInterfaceRef = function nodeInterfaceRef() { id: t.arg.id({ required: true }), }, nullable: true, - resolve: async (root, args, context) => - (await resolveNodes(this, context, [String(args.id)]))[0], + resolve: async (root, args, context, info) => + (await resolveNodes(this, context, info, [String(args.id)]))[0], }) as FieldRef, ); @@ -127,10 +127,11 @@ schemaBuilderProto.nodeInterfaceRef = function nodeInterfaceRef() { list: false, items: true, }, - resolve: async (root, args, context) => + resolve: async (root, args, context, info) => (await resolveNodes( this, context, + info, args.ids as string[], )) as Promise | null>[], }), diff --git a/packages/plugin-relay/src/types.ts b/packages/plugin-relay/src/types.ts index 9abba7357..9394a9a0a 100644 --- a/packages/plugin-relay/src/types.ts +++ b/packages/plugin-relay/src/types.ts @@ -7,7 +7,6 @@ import { FieldRequiredness, InputFieldMap, InputFieldRef, - InputFieldsFromShape, InputRef, InputShape, InputShapeFromFields, @@ -193,11 +192,17 @@ export type ConnectionShape = )[]; }); +export type ConnectionShapeFromBaseShape< + Types extends SchemaTypes, + Shape, + Nullable extends boolean, +> = ConnectionShape; + export type ConnectionShapeForType< Types extends SchemaTypes, Type extends OutputType, Nullable extends boolean, -> = ConnectionShape, Nullable>; +> = ConnectionShape, Nullable>; export type ConnectionShapeFromResolve< Types extends SchemaTypes, @@ -207,46 +212,13 @@ export type ConnectionShapeFromResolve< > = Resolved extends Promise ? T extends ConnectionShapeForType ? T - : never + : ConnectionShapeForType : Resolved extends ConnectionShapeForType ? Resolved - : never; + : ConnectionShapeForType; -export interface DefaultConnectionArguments { - first?: number | null | undefined; - last?: number | null | undefined; - before?: string | null | undefined; - after?: string | null | undefined; -} - -export interface ConnectionFieldOptions< - Types extends SchemaTypes, - ParentShape, - Type extends OutputType, - Nullable extends boolean, - Args extends InputFieldMap, - ResolveReturnShape, -> { - args?: Args; - type: Type; - resolve: Resolver< - ParentShape, - InputShapeFromFields>, - Types['Context'], - ConnectionShapeForType, - ResolveReturnShape - >; -} - -export interface ConnectionObjectOptions - extends GiraphQLSchemaTypes.ObjectTypeOptions { - name?: string; -} - -export interface ConnectionEdgeObjectOptions - extends GiraphQLSchemaTypes.ObjectTypeOptions { - name?: string; -} +export interface DefaultConnectionArguments + extends GiraphQLSchemaTypes.DefaultConnectionArguments {} export type NodeBaseObjectOptionsForParam< Types extends SchemaTypes, @@ -304,6 +276,11 @@ export type NodeObjectOptions< ids: string[], context: Types['Context'], ) => MaybePromise | null | undefined>[]>; + loadWithoutCache?: ( + id: string, + context: Types['Context'], + info: GraphQLResolveInfo, + ) => MaybePromise | null | undefined>; }; export type GlobalIDFieldOptions< diff --git a/packages/plugin-relay/src/utils/connections.ts b/packages/plugin-relay/src/utils/connections.ts index 161bb4f93..d9b3f0a47 100644 --- a/packages/plugin-relay/src/utils/connections.ts +++ b/packages/plugin-relay/src/utils/connections.ts @@ -75,7 +75,7 @@ function offsetForArgs(options: ResolveOffsetConnectionOptions) { export async function resolveOffsetConnection( options: ResolveOffsetConnectionOptions, resolve: (params: { offset: number; limit: number }) => Promise | T[], -): Promise> { +): Promise, boolean>> { const { limit, offset, expectedSize, hasPreviousPage, hasNextPage } = offsetForArgs(options); const nodes = await resolve({ offset, limit }); @@ -85,7 +85,7 @@ export async function resolveOffsetConnection( ? null : { cursor: offsetToCursor(offset + index), - node: value, + node: value as NonNullable, }, ); diff --git a/packages/plugin-relay/src/utils/resolve-nodes.ts b/packages/plugin-relay/src/utils/resolve-nodes.ts index aaf6b4ea6..2357320e4 100644 --- a/packages/plugin-relay/src/utils/resolve-nodes.ts +++ b/packages/plugin-relay/src/utils/resolve-nodes.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ +import { GraphQLResolveInfo } from 'graphql'; import { MaybePromise, ObjectParam, OutputType, SchemaTypes } from '@giraphql/core'; import { NodeObjectOptions } from '../types'; import { internalDecodeGlobalID, internalEncodeGlobalID } from './internal'; @@ -16,36 +17,55 @@ function getRequestCache(context: object) { export async function resolveNodes( builder: GiraphQLSchemaTypes.SchemaBuilder, context: object, + info: GraphQLResolveInfo, globalIDs: (string | null | undefined)[], ): Promise[]> { const requestCache = getRequestCache(context); - const idsByType: Record> = {}; + const idsByType: Record> = {}; + const results: Record = {}; - globalIDs.forEach((globalID) => { - if (globalID == null || requestCache.has(globalID)) { + globalIDs.forEach((globalID, i) => { + if (globalID == null) { + return; + } + + if (requestCache.has(globalID)) { + results[globalID] = requestCache.get(globalID)!; return; } const { id, typename } = internalDecodeGlobalID(builder, globalID); - idsByType[typename] = idsByType[typename] || new Set(); - idsByType[typename].add(id); + idsByType[typename] = idsByType[typename] || new Map(); + idsByType[typename].set(id, globalID); }); - await Promise.all([ - Object.keys(idsByType).map((typename) => - resolveUncachedNodesForType(builder, context, [...idsByType[typename]], typename), - ), - ]); - - return globalIDs.map((globalID) => - globalID == null ? null : requestCache.get(globalID) ?? null, + await Promise.all( + Object.keys(idsByType).map(async (typename) => { + const ids = [...idsByType[typename].keys()]; + const globalIds = [...idsByType[typename].values()]; + + const resultsForType = await resolveUncachedNodesForType( + builder, + context, + info, + ids, + typename, + ); + + resultsForType.forEach((val, i) => { + results[globalIds[i]] = val; + }); + }), ); + + return globalIDs.map((globalID) => (globalID == null ? null : results[globalID] ?? null)); } export async function resolveUncachedNodesForType( builder: GiraphQLSchemaTypes.SchemaBuilder, context: object, + info: GraphQLResolveInfo, ids: string[], type: OutputType | string, ): Promise { @@ -76,7 +96,7 @@ export async function resolveUncachedNodesForType( if (options.loadOne) { return Promise.all( - ids.map((id, i) => { + ids.map((id) => { const globalID = internalEncodeGlobalID(builder, config.name, id); const entryPromise = Promise.resolve(options.loadOne!(id, context)).then( (result: unknown) => { @@ -93,5 +113,11 @@ export async function resolveUncachedNodesForType( ); } + if (options.loadWithoutCache) { + return Promise.all( + ids.map((id) => Promise.resolve(options.loadWithoutCache!(id, context, info))), + ); + } + throw new Error(`${config.name} does not support loading by id`); }