Skip to content

Commit

Permalink
[prisma] update model loader to cache queries and batch compatible qu…
Browse files Browse the repository at this point in the history
…eries to reduce likelyhood pf prisma deoptimization
  • Loading branch information
hayes committed Sep 1, 2023
1 parent da2bc98 commit 1bbd3d7
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 48 deletions.
6 changes: 6 additions & 0 deletions .changeset/pink-hairs-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@pothos/plugin-prisma': minor
---

update model loader to cache query mappings and batch compatible queries to reduce likelyhood of
prisma deoptimization
2 changes: 1 addition & 1 deletion packages/plugin-prisma/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ datasource db {
generator client {
provider = "prisma-client-js"
output = "../tests/client"
previewFeatures = ["fullTextSearch", "filteredRelationCount", "extendedWhereUnique"]
previewFeatures = ["fullTextSearch"]
}

generator pothos {
Expand Down
18 changes: 4 additions & 14 deletions packages/plugin-prisma/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { PrismaModelTypes } from './types';
import { formatPrismaCursor, parsePrismaCursor } from './util/cursors';
import { getModel, getRefFromModel } from './util/datamodel';
import { getLoaderMapping, setLoaderMappings } from './util/loader-map';
import { queryFromInfo, selectionStateFromInfo } from './util/map-query';
import { queryFromInfo } from './util/map-query';

export { prismaConnectionHelpers } from './connection-helpers';
export { PrismaInterfaceRef } from './interface-ref';
Expand Down Expand Up @@ -164,19 +164,9 @@ export class PrismaPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
return fallback(queryFromInfo({ context, info }), parent, args, context, info);
}

const selectionState = selectionStateFromInfo(context, info);

return loaderCache(parent)
.loadSelection(selectionState, context)
.then((result) => {
const mappings = selectionState.mappings[info.path.key];

if (mappings) {
setLoaderMappings(context, info, mappings.mappings);
}

return resolver(result, args, context, info);
});
return loaderCache(context)
.loadSelection(info, parent as object)
.then((result) => resolver(result, args, context, info));
};
}
}
Expand Down
134 changes: 102 additions & 32 deletions packages/plugin-prisma/src/model-loader.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,55 @@
import { GraphQLResolveInfo } from 'graphql';
import {
createContextCache,
InterfaceRef,
ObjectRef,
PothosSchemaError,
SchemaTypes,
} from '@pothos/core';
import { PrismaDelegate, SelectionMap } from './types';
import { getDelegateFromModel, getModel } from './util/datamodel';
import { getClient } from './util/get-client';
import { cacheKey, setLoaderMappings } from './util/loader-map';
import { selectionStateFromInfo } from './util/map-query';
import {
mergeSelection,
selectionCompatible,
SelectionState,
selectionToQuery,
} from './util/selections';

interface ResolvablePromise<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (err: unknown) => void;
}
export class ModelLoader {
model: object;
context: object;
builder: PothosSchemaTypes.SchemaBuilder<never>;
findUnique: (model: Record<string, unknown>, ctx: {}) => unknown;
modelName: string;

queryCache = new Map<string, { selection: SelectionState; query: SelectionMap }>();
staged = new Set<{
promise: Promise<Record<string, unknown>>;
state: SelectionState;
models: Map<object, ResolvablePromise<Record<string, unknown> | null>>;
}>();
delegate: PrismaDelegate;
tick = Promise.resolve();

constructor(
model: object,
context: object,
builder: PothosSchemaTypes.SchemaBuilder<never>,
modelName: string,
findUnique: (model: Record<string, unknown>, ctx: {}) => unknown,
) {
this.model = model;
this.context = context;
this.builder = builder;
this.findUnique = findUnique;
this.modelName = modelName;
this.delegate = getDelegateFromModel(
getClient(this.builder, this.context as never),
this.modelName,
);
}

static forRef<Types extends SchemaTypes>(
Expand Down Expand Up @@ -217,48 +232,103 @@ export class ModelLoader {
return this.getFindUnique(findBy);
}

async loadSelection(selection: SelectionState, context: object) {
const query = selectionToQuery(selection);
getSelection(info: GraphQLResolveInfo) {
const key = cacheKey(info.parentType.name, info.path);
if (!this.queryCache.has(key)) {
const selection = selectionStateFromInfo(this.context, info);
this.queryCache.set(key, {
selection,
query: selectionToQuery(selection),
});
}

return this.queryCache.get(key)!;
}

async loadSelection(info: GraphQLResolveInfo, model: object) {
const { selection, query } = this.getSelection(info);

const result = await this.stageQuery(selection, query, model);

if (result) {
const mappings = selection.mappings[info.path.key];

if (mappings) {
setLoaderMappings(this.context, info, mappings.mappings);
}
}

return result;
}

async stageQuery(selection: SelectionState, query: SelectionMap, model: object) {
for (const entry of this.staged) {
if (selectionCompatible(entry.state, query)) {
mergeSelection(entry.state, query);

return entry.promise;
if (!entry.models.has(model)) {
entry.models.set(model, createResolvablePromise<Record<string, unknown> | null>());
}

return entry.models.get(model)!.promise;
}
}

return this.initLoad(selection, context);
return this.initLoad(selection, model);
}

async initLoad(state: SelectionState, context: {}) {
const entry = {
promise: Promise.resolve().then(() => {
this.staged.delete(entry);

const delegate = getDelegateFromModel(
getClient(this.builder, context as never),
this.modelName,
);

if (delegate.findUniqueOrThrow) {
return delegate.findUniqueOrThrow({
...selectionToQuery(state),
where: { ...(this.findUnique(this.model as Record<string, unknown>, context) as {}) },
} as never) as Promise<Record<string, unknown>>;
}
async initLoad(state: SelectionState, initialModel: {}) {
const models = new Map<object, ResolvablePromise<Record<string, unknown> | null>>();

const promise = createResolvablePromise<Record<string, unknown> | null>();
models.set(initialModel, promise);

return delegate.findUnique({
rejectOnNotFound: true,
...selectionToQuery(state),
where: { ...(this.findUnique(this.model as Record<string, unknown>, context) as {}) },
} as never) as Promise<Record<string, unknown>>;
}),
const entry = {
models,
state,
};

this.staged.add(entry);

return entry.promise;
const nextTick = createResolvablePromise<void>();
void this.tick.then(() => {
this.staged.delete(entry);

for (const [model, { resolve, reject }] of entry.models) {
if (this.delegate.findUniqueOrThrow) {
void this.delegate
.findUniqueOrThrow({
...selectionToQuery(state),
where: { ...(this.findUnique(model as Record<string, unknown>, this.context) as {}) },
} as never)
// eslint-disable-next-line promise/no-nesting
.then(resolve as () => {}, reject);
} else {
void this.delegate
.findUnique({
rejectOnNotFound: true,
...selectionToQuery(state),
where: { ...(this.findUnique(model as Record<string, unknown>, this.context) as {}) },
} as never)
// eslint-disable-next-line promise/no-nesting
.then(resolve as () => {}, reject);
}
}
});
setTimeout(() => void nextTick.resolve(), 0);
this.tick = nextTick.promise;

return promise.promise;
}
}

function createResolvablePromise<T = unknown>(): ResolvablePromise<T> {
let resolveFn!: (value: T) => void;
let rejectFn!: (reason?: unknown) => void;
const promise = new Promise<T>((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
});

return { promise, resolve: resolveFn, reject: rejectFn };
}
2 changes: 1 addition & 1 deletion packages/plugin-prisma/src/util/loader-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { LoaderMappings } from '../types';

const cache = createContextCache((ctx) => new Map<string, LoaderMappings>());

export function cacheKey(type: string, path: GraphQLResolveInfo['path'], subPath: string[]) {
export function cacheKey(type: string, path: GraphQLResolveInfo['path'], subPath: string[] = []) {
let key = '';
let current: GraphQLResolveInfo['path'] | undefined = path;

Expand Down
3 changes: 3 additions & 0 deletions packages/test-utils/src/create-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export function createTestServer(options: TestServerOptions) {
schema: options.schema,
context: options.contextFactory ?? (() => ({})),
plugins: executePlugin ? [executePlugin] : [],
maskedErrors: {
isDev: true,
},
});

// eslint-disable-next-line @typescript-eslint/no-misused-promises
Expand Down

0 comments on commit 1bbd3d7

Please sign in to comment.