Skip to content

Commit

Permalink
feat!: bac-329 update to prisma v5
Browse files Browse the repository at this point in the history
With this update, Yates now requires prisma v5. Under the hood not much
has changed, except we now load data models from the private property
`_runtimeDataModel`. I've imported the typings used for this internal
data structure and reference the prisma test case that looks for it to
try and prevent any problems that arise from using this private
property.
The only major change I see is that previously, updates to a row
protected by RLS would silently fail (the default PG behaviour), but now
Prisma will throw a "Record to update not found" error. This is actually
desirable, as it avoids some difficult to debug errors in ability logic.

BREAKING CHANGE: Yates now requires Prisma @ v5

Signed-off-by: Lucian Buzzo <[email protected]>
  • Loading branch information
LucianBuzzo committed Aug 2, 2023
1 parent cffafeb commit e7dc385
Show file tree
Hide file tree
Showing 8 changed files with 91 additions and 81 deletions.
99 changes: 49 additions & 50 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
"author": "Cerebrum <[email protected]> (https://cerebrum.com)",
"license": "MIT",
"devDependencies": {
"@prisma/client": "^4.0.0",
"@prisma/client": "^5.0.0",
"@types/cls-hooked": "^4.3.3",
"@types/jest": "^29.2.6",
"@types/lodash": "^4.14.191",
"@types/uuid": "^9.0.0",
"cls-hooked": "^4.2.2",
"jest": "^29.3.1",
"prisma": "^4.9.0",
"prisma": "^5.0.0",
"rome": "^11.0.0",
"ts-jest": "^29.0.5",
"typescript": "^4.9.4",
Expand All @@ -33,7 +33,7 @@
"lodash": "^4.17.21"
},
"peerDependencies": {
"@prisma/client": "^4.0.0",
"prisma": "^4.9.0"
"@prisma/client": "^5.0.0",
"prisma": "^5.0.0"
}
}
3 changes: 1 addition & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ datasource db {

// Note that any user of Yates will also need to use the clientExtensions preview feature
generator client {
provider = "prisma-client-js"
previewFeatures = ["clientExtensions"]
provider = "prisma-client-js"
}

model User {
Expand Down
11 changes: 9 additions & 2 deletions src/expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import random from "lodash/random";
import matches from "lodash/matches";
import { Parser } from "@lucianbuzzo/node-sql-parser";
import { escapeIdentifier, escapeLiteral } from "./escape";
import { RuntimeDataModel } from "@prisma/client/runtime/library";

const PRISMA_NUMERIC_TYPES = ["Int", "BigInt", "Float", "Decimal"];

Expand Down Expand Up @@ -41,12 +42,17 @@ const expressionContext = (context: string) => `___yates_context_${context}`;
const getLargeRandomInt = () => random(1000000000, 2147483647);

const getDmmfMetaData = (client: PrismaClient, model: string, field: string) => {
const modelData = (client as any)._baseDmmf.datamodel.models.find((m: any) => m.name === model);
const runtimeDataModel = (client as any)._runtimeDataModel as RuntimeDataModel;
const modelData = runtimeDataModel.models[model];
if (!modelData) {
throw new Error(`Could not retrieve model data from Prisma Client for model '${model}'`);
}
const fieldData = modelData.fields.find((f: any) => f.name === field);

if (!fieldData) {
throw new Error(`Could not retrieve field data from Prisma Client for field '${model}.${field}'`);
}

return fieldData;
};

Expand Down Expand Up @@ -179,6 +185,7 @@ export const expressionToSQL = async (getExpression: Expression, table: string):
return getExpression;
}

// Create an ephemeral client to capture the SQL query
const baseClient = new PrismaClient({
log: [{ level: "query", emit: "event" }],
});
Expand Down Expand Up @@ -223,7 +230,7 @@ export const expressionToSQL = async (getExpression: Expression, table: string):
// as opoosed to a plain SQL expression or "where" object
const isSubselect = typeof rawExpression === "object" && typeof rawExpression.then === "function";

expressionClient.$on("query", (e) => {
baseClient.$on("query", (e: any) => {
try {
const parser = new Parser();
// Parse the query into an AST
Expand Down
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Prisma, PrismaClient } from "@prisma/client";
import { RuntimeDataModel } from "@prisma/client/runtime/library";
import difference from "lodash/difference";
import flatMap from "lodash/flatMap";
import map from "lodash/map";
Expand Down Expand Up @@ -90,7 +91,7 @@ export const createClient = (prisma: PrismaClient, getContext: GetContextFn, opt
async $allOperations(params) {
const { model, args, query, operation } = params;
if (!model) {
return query(args);
return (query as any)(args);
}

const ctx = getContext();
Expand Down Expand Up @@ -227,7 +228,12 @@ export const createRoles = async <K extends CustomAbilities = CustomAbilities, T
}) => {
const abilities: Partial<DefaultAbilities> = {};
// See https://github.com/prisma/prisma/discussions/14777
const models = (prisma as any)._baseDmmf.datamodel.models.map((m: any) => m.name) as Models[];
// We are reaching into the prisma internals to get the data model.
// This is a bit sketchy, but we can get the internal type definition from the runtime library
// and there is even a test case in prisma that checks that this value is exported
// See https://github.com/prisma/prisma/blob/5.1.0/packages/client/tests/functional/extensions/pdp.ts#L51
const runtimeDataModel = (prisma as any)._runtimeDataModel as RuntimeDataModel;
const models = Object.keys(runtimeDataModel.models).map((m) => runtimeDataModel.models[m].dbName || m) as Models[];
if (customAbilities) {
const diff = difference(Object.keys(customAbilities), models);
if (diff.length) {
Expand Down
2 changes: 1 addition & 1 deletion test/integration/expressions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ describe("expressions", () => {
role,
}),
}),
).rejects.toThrow("Invalid field name");
).rejects.toThrow("Could not retrieve field data");
});
});

Expand Down
20 changes: 10 additions & 10 deletions test/integration/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,16 +247,16 @@ describe("setup", () => {
},
});

const post2 = await client.post.update({
where: {
id: postId2,
},
data: {
published: true,
},
});

expect(post2.published).toBe(false);
await expect(() =>
client.post.update({
where: {
id: postId2,
},
data: {
published: true,
},
}),
).rejects.toThrow("Record to update not found");
});

it("should be able to allow a role to delete a resource using a custom ability", async () => {
Expand Down
19 changes: 9 additions & 10 deletions test/integration/rbac.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,17 +298,16 @@ describe("rbac", () => {
},
});

// Because the role can read the post, the update will silently fail and the post will not be updated.
// The Postgres docs indicate that an error should be thrown if the policy "WITH CHECK" expression fails, but this is not the case
// https://www.postgresql.org/docs/11/sql-createpolicy.html#SQL-CREATEPOLICY-UPDATE
await client.post.update({
where: { id: postId },
data: {
title: {
set: "lorem ipsum",
await expect(() =>
client.post.update({
where: { id: postId },
data: {
title: {
set: "lorem ipsum",
},
},
},
});
}),
).rejects.toThrow("Record to update not found");

const post = await client.post.findUnique({
where: { id: postId },
Expand Down

0 comments on commit e7dc385

Please sign in to comment.