Skip to content

Commit

Permalink
feat: Prisma support
Browse files Browse the repository at this point in the history
  • Loading branch information
zermelo-wisen committed Feb 14, 2024
1 parent d8200c8 commit 13a2735
Show file tree
Hide file tree
Showing 22 changed files with 1,027 additions and 6 deletions.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
},
"devDependencies": {
"@jest/types": "^29.6.3",
"@prisma/client": "^5.9.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@swc/core": "^1.3.78",
Expand Down Expand Up @@ -85,6 +86,7 @@
"test/postgres",
"test/sqlite",
"test/next",
"test/typescript-esm"
"test/typescript-esm",
"test/prisma"
]
}
3 changes: 3 additions & 0 deletions src/PackageMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default class PackageMatcher extends Array<Package> {
export interface Package {
path: string;
exclude?: string[];
prisma?: string; // custom prisma client module id
}

export function parsePackages(packages: unknown): Package[] | undefined {
Expand All @@ -50,6 +51,8 @@ export function parsePackages(packages: unknown): Package[] | undefined {
else if (typeof pkg === "object" && pkg !== null && "path" in pkg) {
const entry: Package = { path: String(pkg.path) };
if ("exclude" in pkg) entry.exclude = Array.isArray(pkg.exclude) ? pkg.exclude : [];
if ("prisma" in pkg && pkg.prisma != null && typeof pkg.prisma === "string")
entry.prisma = pkg.prisma;
result.push(entry);
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export class Config {
return (this.absoluteAppmapDir ||= resolve(this.root, this.relativeAppmapDir));
}

get prismaClientModuleIds(): string[] {
const result = ["@prisma/client"];
this.packages.forEach((p) => {
if (p.prisma) result.push(p.prisma);
});
return result;
}

export() {
process.env.APPMAP_ROOT = this.root;
}
Expand Down
151 changes: 151 additions & 0 deletions src/hooks/prisma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import assert from "node:assert";

import { ESTree } from "meriyah";
import type prisma from "@prisma/client";

import type AppMap from "../AppMap";
import { getTime } from "../util/getTime";
import { fixReturnEventIfPromiseResult, recording } from "../recorder";
import { FunctionInfo } from "../registry";
import config from "../config";

export default function prismaHook(mod: typeof prisma, id?: string) {
// Imported PrismaClient type does not have _request method in type definition.
// But we have it in runtime.
console.log("MOD", mod);
assert(mod.PrismaClient != null);
const PC = mod.PrismaClient as { prototype: unknown };
const proto = PC.prototype;
assert(proto != null && typeof proto === "object");
assert("_request" in proto);
proto._request = createProxy(
proto._request as (...args: unknown[]) => unknown,
id ?? "@prisma/client",
);
return mod;
}

prismaHook.applicable = function (id: string) {
return config.prismaClientModuleIds.includes(id);
};

// https://github.com/prisma/prisma/blob/095cba1a1b79d0d950246b07c9fb48d22fd7f229/packages/client/src/runtime/getPrismaClient.ts#L181
interface QueryEvent {
timestamp: Date;
query: string;
params: string;
duration: number;
target: string;
}

interface PrismaRequestParamsArgs {
data?: unknown;
include?: unknown;
where?: unknown;
}

interface PrismaRequestParams {
action?: string;
model?: string;
args?: PrismaRequestParamsArgs;
}

let hookAttached = false;

const functionInfos = new Map<string, FunctionInfo>();

let functionCount = 0;
function getFunctionInfo(
model: string,
action: string,
params: ESTree.Parameter[],
moduleId: string,
) {
const key = model + "." + action;
if (!functionInfos.has(key)) {
const info: FunctionInfo = {
async: true,
generator: false,
id: action,
params: params,
location: { path: `${moduleId}/${model}`, lineno: ++functionCount },
klassOrFile: model,
static: true,
};
functionInfos.set(key, info);
}

return functionInfos.get(key)!;
}

function createProxy<T extends (...args: unknown[]) => unknown>(
prismaClientMethod: T,
moduleId: string,
) {
return new Proxy(prismaClientMethod, {
apply(target, thisArg: unknown, argArray: Parameters<T>) {
if (!hookAttached) {
hookAttached = true;
assert(
thisArg != null &&
typeof thisArg === "object" &&
"_engine" in thisArg &&
thisArg._engine != null &&
typeof thisArg._engine === "object" &&
"config" in thisArg._engine &&
thisArg._engine.config != null &&
typeof thisArg._engine.config === "object" &&
"logLevel" in thisArg._engine.config &&
"logQueries" in thisArg._engine.config &&
"activeProvider" in thisArg._engine.config &&
typeof thisArg._engine.config.activeProvider == "string",
);

const dbType = thisArg._engine.config.activeProvider;
thisArg._engine.config.logLevel = "query";
thisArg._engine.config.logQueries = true;
assert("$on" in thisArg && typeof thisArg.$on === "function");
thisArg.$on("query", (queryEvent: QueryEvent) => {
const call = recording.sqlQuery(dbType, queryEvent.query);
const elapsedSec = queryEvent.duration / 1000.0;
recording.functionReturn(call.id, undefined, elapsedSec);
});
}

// Report Prisma query as a function call, if suitable
let prismaCall: AppMap.FunctionCallEvent | undefined;
if (argArray?.length > 0) {
const requestParams = argArray[0] as PrismaRequestParams;

if (requestParams.action && requestParams.model) {
prismaCall = recording.functionCall(
getFunctionInfo(
requestParams.model,
requestParams.action,
["data", "include", "where"].map((k) => {
return { type: "Identifier", name: k } as ESTree.Identifier;
}),
moduleId,
),
requestParams.model,
[requestParams.args?.data, requestParams.args?.include, requestParams.args?.where],
);
}
}

if (prismaCall) {
const start = getTime();
try {
const result = target.apply(thisArg, argArray);
const ret = recording.functionReturn(prismaCall.id, result, getTime() - start);
return fixReturnEventIfPromiseResult(result, ret, prismaCall, start);
} catch (exn: unknown) {
recording.functionException(prismaCall.id, exn, getTime() - start);
throw exn;
}
}

return target.apply(thisArg, argArray);
},
});
}
6 changes: 4 additions & 2 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { URL, fileURLToPath } from "node:url";

import type { NodeLoaderHooksAPI2 } from "ts-node";

import config from "./config";
import { warn } from "./message";
import transform, { findHook } from "./transform";
import { readPkgUp } from "./util/readPkgUp";
Expand All @@ -12,7 +13,7 @@ export const load: NodeLoaderHooksAPI2["load"] = async function load(url, contex
const urlObj = new URL(url);

// With a custom loader, in some node versions, extensionless files (typically used
// as main entry points inside bin folders) are trated as ESM rather than
// as main entry points inside bin folders) are treated as ESM rather than
// CommonJS modules. This leads to TypeError [ERR_UNKNOWN_FILE_EXTENSION].
// To fix this, we give the context.format hint to defaultLoad if we decide
// that the file is a CommonJS module.
Expand Down Expand Up @@ -44,7 +45,8 @@ export const load: NodeLoaderHooksAPI2["load"] = async function load(url, contex
// For these modules, we preempt import with CommonJS require
// to allow our hooks to modify the loaded module in cache
// (which is shared between ESM and CJS for builtins at least).
if (["node:http", "node:https", "http", "https"].includes(url)) forceRequire(url);
if (["node:http", "node:https", "http", "https", config.prismaClientModuleIds].includes(url))
forceRequire(url);

return defaultLoad(url, context, defaultLoad);
};
7 changes: 4 additions & 3 deletions src/requireHook.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import httpHook from "./hooks/http";
import mysqlHook from "./hooks/mysql";
import pgHook from "./hooks/pg";
import prismaHook from "./hooks/prisma";
import sqliteHook from "./hooks/sqlite";

interface Hook {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mod: any): any;
(mod: any, id?: string): any;
applicable(id: string): boolean;
}

const hooks: Hook[] = [httpHook, mysqlHook, pgHook, sqliteHook];
const hooks: Hook[] = [httpHook, mysqlHook, pgHook, sqliteHook, prismaHook];

export default function requireHook(
original: NodeJS.Require,
Expand All @@ -18,6 +19,6 @@ export default function requireHook(
): unknown {
const mod: unknown = original.apply(thisArg, [id]);
const hook = hooks.find((h) => h.applicable(id));
if (hook) return hook(mod);
if (hook) return hook(mod, id);
else return mod;
}
Loading

0 comments on commit 13a2735

Please sign in to comment.