prisma-pg-toolkit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.MD +88 -0
- package/dist/index.cjs +159 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +69 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/index.ts +28 -0
- package/src/joins/JoinBuilder.ts +52 -0
- package/src/locking/optimisticLocking.ts +33 -0
- package/src/locking/passimisticLocking.ts +29 -0
- package/src/security/security.ts +40 -0
- package/src/testing/seed-joins.ts +21 -0
- package/src/testing/test-joins.ts +21 -0
- package/src/testing/test-optimistic.ts +33 -0
- package/src/testing/test-pessimistic.ts +47 -0
package/README.MD
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# prisma-pg-toolkit
|
|
2
|
+
|
|
3
|
+
Joins (`INNER` / `LEFT` / `RIGHT` / `FULL OUTER` / `CROSS`) and locking (pessimistic / optimistic) helpers for **Prisma + PostgreSQL**.
|
|
4
|
+
|
|
5
|
+
Prisma's client only performs `LEFT JOIN`-style relation loading internally, and has no built-in row locking. This library fills both gaps with safe, config-driven APIs — no raw SQL required from consumers.
|
|
6
|
+
|
|
7
|
+
> **Postgres only.** Join syntax (`FULL OUTER JOIN`, `RIGHT JOIN`) and locking (`FOR UPDATE`) differ across databases — this library targets Postgres specifically.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install prisma-pg-toolkit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires `@prisma/client` v5+ as a peer dependency.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { PrismaClient } from '@prisma/client';
|
|
21
|
+
import { withToolKit } from 'prisma-pg-toolkit';
|
|
22
|
+
|
|
23
|
+
const base = new PrismaClient();
|
|
24
|
+
const prisma = withToolKit(base);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Joins
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const result = await prisma.$join({
|
|
31
|
+
from: 'User',
|
|
32
|
+
select: ['User.email', 'Post.title'],
|
|
33
|
+
join: {
|
|
34
|
+
type: 'LEFT', // 'INNER' | 'LEFT' | 'RIGHT' | 'FULL OUTER' | 'CROSS'
|
|
35
|
+
table: 'Post',
|
|
36
|
+
on: { fromColumn: 'id', toColumn: 'userId' },
|
|
37
|
+
},
|
|
38
|
+
where: { column: 'published', op: '=', value: true }, // optional
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`CROSS` joins don't need an `on` clause.
|
|
43
|
+
|
|
44
|
+
## Pessimistic locking
|
|
45
|
+
|
|
46
|
+
Locks a row (`SELECT ... FOR UPDATE`) inside a transaction. Other callers requesting the same row **wait** until the transaction commits or rolls back.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
await prisma.$lock.pessimistic(
|
|
50
|
+
{ table: 'User', id: 1, mode: 'FOR UPDATE' }, // or 'FOR SHARE'
|
|
51
|
+
async (tx, row) => {
|
|
52
|
+
return tx.user.update({ where: { id: row.id }, data: { name: 'Updated' } });
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Optimistic locking
|
|
58
|
+
|
|
59
|
+
Requires a `version: Int` column on the model. Update succeeds only if `expectedVersion` still matches the current row; otherwise throws `OptimisticLockError`, and the caller should re-fetch and retry.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { OptimisticLockError } from 'prisma-pg-toolkit';
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await prisma.$lock.optimistic({
|
|
66
|
+
model: 'user', // matches your Prisma model accessor, lowercase
|
|
67
|
+
id: 1,
|
|
68
|
+
expectedVersion: 3,
|
|
69
|
+
data: { name: 'New Name' },
|
|
70
|
+
});
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err instanceof OptimisticLockError) {
|
|
73
|
+
// row changed since you read it — re-fetch and retry
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Security
|
|
79
|
+
|
|
80
|
+
Table and column names are validated against a strict identifier pattern before being spliced into SQL — only letters, digits, and underscores, matching Postgres' identifier rules. Values are always passed as parameterized bindings via Prisma's tagged-template `$queryRaw`, never string-concatenated.
|
|
81
|
+
|
|
82
|
+
## Testing
|
|
83
|
+
|
|
84
|
+
Manual test scripts demonstrating all join types and both locking strategies against seeded data are in [`src/testing`](./src/testing) on GitHub — useful as runnable examples.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
OptimisticLockError: () => OptimisticLockError,
|
|
24
|
+
buildJoinQuery: () => buildJoinQuery,
|
|
25
|
+
optimisticLockUpdate: () => optimisticLockUpdate,
|
|
26
|
+
pessimisticLock: () => pessimisticLock,
|
|
27
|
+
withToolKit: () => withToolKit
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/joins/JoinBuilder.ts
|
|
32
|
+
var import_client = require("@prisma/client");
|
|
33
|
+
|
|
34
|
+
// src/security/security.ts
|
|
35
|
+
var IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
36
|
+
var MAX_IDENTIFIER_LENGTH = 63;
|
|
37
|
+
function validateSegment(segment) {
|
|
38
|
+
if (segment.length === 0) {
|
|
39
|
+
throw new Error("Identifier segment cannot be empty");
|
|
40
|
+
}
|
|
41
|
+
if (segment.length > MAX_IDENTIFIER_LENGTH) {
|
|
42
|
+
throw new Error(`Identifier "${segment}" exceeds ${MAX_IDENTIFIER_LENGTH} character limit(Postgres will silently truncate it)`);
|
|
43
|
+
}
|
|
44
|
+
if (!IDENT_RE.test(segment)) {
|
|
45
|
+
throw new Error(`Unsafe or invalid identifier: "${segment}"`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function assertSafeIdentifier(name) {
|
|
49
|
+
validateSegment(name);
|
|
50
|
+
return name;
|
|
51
|
+
}
|
|
52
|
+
function assertSafeQualifiedIdentifier(name) {
|
|
53
|
+
const [core, ...rest] = name.split(/\s+as\s+/i);
|
|
54
|
+
if (rest.length > 1) {
|
|
55
|
+
throw new Error(`Invalid identifier expression: "${name}"`);
|
|
56
|
+
}
|
|
57
|
+
const segment = core.trim().split(".");
|
|
58
|
+
if (segment.length > 2) {
|
|
59
|
+
throw new Error(`Invalid identifier expression: "${name}"`);
|
|
60
|
+
}
|
|
61
|
+
segment.forEach(validateSegment);
|
|
62
|
+
if (rest.length === 1) {
|
|
63
|
+
validateSegment(rest[0].trim());
|
|
64
|
+
}
|
|
65
|
+
return name;
|
|
66
|
+
}
|
|
67
|
+
function quoteIdentifier(name) {
|
|
68
|
+
return `"${name}"`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/joins/JoinBuilder.ts
|
|
72
|
+
function buildJoinQuery(config) {
|
|
73
|
+
const fromTable = assertSafeIdentifier(config.from);
|
|
74
|
+
const selectCols = (config.select ?? ["*"]).map((c) => c === "*" ? c : assertSafeQualifiedIdentifier(c)).join(", ");
|
|
75
|
+
let query = import_client.Prisma.sql`SELECT ${import_client.Prisma.raw(selectCols)} FROM ${import_client.Prisma.raw(`"${quoteIdentifier(fromTable)}"`)}`;
|
|
76
|
+
if (config.join) {
|
|
77
|
+
const { type, table, on } = config.join;
|
|
78
|
+
const joinTable = assertSafeIdentifier(table);
|
|
79
|
+
if (type === "CROSS") {
|
|
80
|
+
query = import_client.Prisma.sql`${query} CROSS JOIN ${import_client.Prisma.raw(`"${quoteIdentifier(joinTable)}"`)}`;
|
|
81
|
+
} else {
|
|
82
|
+
if (!on) throw new Error(`on Condition is required for ${type} JOIN`);
|
|
83
|
+
const fromCol = assertSafeIdentifier(on.fromColumn);
|
|
84
|
+
const toCol = assertSafeIdentifier(on.toColumn);
|
|
85
|
+
query = import_client.Prisma.sql`${query} ${import_client.Prisma.raw(type)} JOIN ${import_client.Prisma.raw(`"${quoteIdentifier(joinTable)}"`)} ON ${import_client.Prisma.raw(`"${quoteIdentifier(fromTable)}"."${quoteIdentifier(fromCol)}"`)} = ${import_client.Prisma.raw(`"${quoteIdentifier(joinTable)}"."${quoteIdentifier(toCol)}"`)}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (config.where) {
|
|
89
|
+
const col = assertSafeIdentifier(config.where.column);
|
|
90
|
+
const op = config.where.op;
|
|
91
|
+
query = import_client.Prisma.sql`${query} WHERE ${import_client.Prisma.raw(`"${quoteIdentifier(col)}"`)} ${import_client.Prisma.raw(op)} ${config.where.value}`;
|
|
92
|
+
}
|
|
93
|
+
return query;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/locking/passimisticLocking.ts
|
|
97
|
+
var import_client2 = require("@prisma/client");
|
|
98
|
+
async function pessimisticLock(prisma, config, fn) {
|
|
99
|
+
const table = assertSafeIdentifier(config.table);
|
|
100
|
+
const mode = config.mode ?? "FOR UPDATE";
|
|
101
|
+
return prisma.$transaction(
|
|
102
|
+
async (tx) => {
|
|
103
|
+
const rows = await tx.$queryRaw(
|
|
104
|
+
import_client2.Prisma.sql`SELECT * FROM ${import_client2.Prisma.raw(`${quoteIdentifier(table)}`)} WHERE id = ${config.id} ${import_client2.Prisma.raw(mode)}`
|
|
105
|
+
);
|
|
106
|
+
if (!rows.length) throw new Error(`Row not found in ${table} with id ${config.id}`);
|
|
107
|
+
return fn(tx, rows[0]);
|
|
108
|
+
},
|
|
109
|
+
{ maxWait: config.maxWait ?? 5e3, timeout: config.timeout ?? 1e4 }
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/locking/optimisticLocking.ts
|
|
114
|
+
var OptimisticLockError = class extends Error {
|
|
115
|
+
constructor(message = "Row was modified by another transaction") {
|
|
116
|
+
super(message);
|
|
117
|
+
this.name = "OptimisticLockError";
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
async function optimisticLockUpdate(prisma, config) {
|
|
121
|
+
const client = prisma;
|
|
122
|
+
if (!client[config.model]) {
|
|
123
|
+
throw new Error(`Model "${config.model}" not found on Prisma client`);
|
|
124
|
+
}
|
|
125
|
+
const result = await client[config.model].updateMany({
|
|
126
|
+
where: { id: config.id, version: config.expectedVersion },
|
|
127
|
+
data: { ...config.data, version: { increment: 1 } }
|
|
128
|
+
});
|
|
129
|
+
if (result.count === 0) {
|
|
130
|
+
throw new OptimisticLockError();
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/index.ts
|
|
136
|
+
function withToolKit(prisma) {
|
|
137
|
+
return prisma.$extends({
|
|
138
|
+
name: "prisma-toolkit",
|
|
139
|
+
client: {
|
|
140
|
+
async $join(config) {
|
|
141
|
+
const sql = buildJoinQuery(config);
|
|
142
|
+
return prisma.$queryRaw(sql);
|
|
143
|
+
},
|
|
144
|
+
$lock: {
|
|
145
|
+
pessimistic: (config, fn) => pessimisticLock(prisma, config, fn),
|
|
146
|
+
optimistic: (config) => optimisticLockUpdate(prisma, config)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
152
|
+
0 && (module.exports = {
|
|
153
|
+
OptimisticLockError,
|
|
154
|
+
buildJoinQuery,
|
|
155
|
+
optimisticLockUpdate,
|
|
156
|
+
pessimisticLock,
|
|
157
|
+
withToolKit
|
|
158
|
+
});
|
|
159
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/joins/JoinBuilder.ts","../src/security/security.ts","../src/locking/passimisticLocking.ts","../src/locking/optimisticLocking.ts"],"sourcesContent":["import { PrismaClient, Prisma } from \"@prisma/client\"\r\nimport { buildJoinQuery } from \"./joins/JoinBuilder\"\r\nimport { pessimisticLock } from \"./locking/passimisticLocking\"\r\nimport { optimisticLockUpdate } from \"./locking/optimisticLocking\"\r\n\r\n\r\nexport function withToolKit(\r\n prisma: PrismaClient\r\n) {\r\n return prisma.$extends({\r\n name: \"prisma-toolkit\",\r\n client: {\r\n async $join(config: Parameters<typeof buildJoinQuery>[0]) {\r\n const sql = buildJoinQuery(config)\r\n return prisma.$queryRaw(sql)\r\n },\r\n $lock: {\r\n pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => pessimisticLock(prisma, config, fn),\r\n optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => optimisticLockUpdate(prisma, config)\r\n }\r\n }\r\n })\r\n}\r\n\r\nexport { buildJoinQuery } from \"./joins/JoinBuilder\"\r\nexport { pessimisticLock } from \"./locking/passimisticLocking\"\r\nexport { optimisticLockUpdate, OptimisticLockError } from \"./locking/optimisticLocking\"\r\nexport type { JoinType } from \"./joins/JoinBuilder\"","import { Prisma } from \"@prisma/client\"\r\nimport { assertSafeIdentifier, assertSafeQualifiedIdentifier, quoteIdentifier } from \"../security/security\"\r\n\r\nexport type JoinType = \"INNER\" | \"LEFT\" | \"RIGHT\" | \"FULL OUTER\" | \"CROSS\"\r\n\r\ninterface JoinCondition {\r\n fromColumn: string\r\n toColumn: string\r\n}\r\n\r\ninterface JoinConfig {\r\n from: string\r\n select?: string[]\r\n join?: {\r\n type: JoinType,\r\n table: string,\r\n on?: JoinCondition\r\n }\r\n where?: {\r\n column: string\r\n op: '=' | '!=' | '>' | '<' | '>=' | '<=';\r\n value: any\r\n }\r\n}\r\n\r\n\r\nexport function buildJoinQuery(config: JoinConfig): Prisma.Sql {\r\n const fromTable = assertSafeIdentifier(config.from)\r\n const selectCols = (config.select ?? ['*']).map((c) => (c === '*' ? c : assertSafeQualifiedIdentifier(c))).join(', ')\r\n let query = Prisma.sql`SELECT ${Prisma.raw(selectCols)} FROM ${Prisma.raw(`\"${quoteIdentifier(fromTable)}\"`)}`;\r\n if (config.join) {\r\n const { type, table, on } = config.join\r\n const joinTable = assertSafeIdentifier(table)\r\n if (type === \"CROSS\") {\r\n query = Prisma.sql`${query} CROSS JOIN ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\"`)}`;\r\n }\r\n else {\r\n if (!on) throw new Error(`on Condition is required for ${type} JOIN`)\r\n const fromCol = assertSafeIdentifier(on.fromColumn)\r\n const toCol = assertSafeIdentifier(on.toColumn)\r\n query = Prisma.sql`${query} ${Prisma.raw(type)} JOIN ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\"`)} ON ${Prisma.raw(`\"${quoteIdentifier(fromTable)}\".\"${quoteIdentifier(fromCol)}\"`)} = ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\".\"${quoteIdentifier(toCol)}\"`)}`;\r\n }\r\n }\r\n if (config.where) {\r\n const col = assertSafeIdentifier(config.where.column)\r\n const op = config.where.op\r\n query = Prisma.sql`${query} WHERE ${Prisma.raw(`\"${quoteIdentifier(col)}\"`)} ${Prisma.raw(op)} ${config.where.value}`\r\n }\r\n return query\r\n}\r\n\r\n\r\n","const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\r\nconst MAX_IDENTIFIER_LENGTH = 63\r\n\r\nfunction validateSegment(segment: string): void {\r\n if (segment.length === 0) {\r\n throw new Error(\"Identifier segment cannot be empty\")\r\n }\r\n if (segment.length > MAX_IDENTIFIER_LENGTH) {\r\n throw new Error(`Identifier \"${segment}\" exceeds ${MAX_IDENTIFIER_LENGTH} character limit(Postgres will silently truncate it)`)\r\n }\r\n if (!IDENT_RE.test(segment)) {\r\n throw new Error(`Unsafe or invalid identifier: \"${segment}\"`)\r\n }\r\n}\r\n\r\nexport function assertSafeIdentifier(name: string): string {\r\n validateSegment(name)\r\n return name\r\n}\r\n\r\nexport function assertSafeQualifiedIdentifier(name: string): string {\r\n const [core, ...rest] = name.split(/\\s+as\\s+/i)\r\n if (rest.length > 1) {\r\n throw new Error(`Invalid identifier expression: \"${name}\"`)\r\n }\r\n const segment = core.trim().split(\".\")\r\n if (segment.length > 2) {\r\n throw new Error(`Invalid identifier expression: \"${name}\"`)\r\n }\r\n segment.forEach(validateSegment)\r\n\r\n if (rest.length === 1) {\r\n validateSegment(rest[0].trim())\r\n }\r\n return name\r\n}\r\n\r\nexport function quoteIdentifier(name: string): string {\r\n return `\"${name}\"`\r\n}","import { Prisma, PrismaClient } from \"@prisma/client\"\r\nimport { assertSafeIdentifier, quoteIdentifier } from \"../security/security\"\r\n\r\ninterface PessimisticLockConfig {\r\n table: string\r\n id: string | number\r\n mode?: \"FOR UPDATE\" | \"FOR SHARE\"\r\n maxWait?: number\r\n timeout?: number\r\n\r\n}\r\n\r\nexport async function pessimisticLock<T>(\r\n prisma: PrismaClient,\r\n config: PessimisticLockConfig,\r\n fn: (tx: Prisma.TransactionClient, row: any) => Promise<T>\r\n): Promise<T> {\r\n const table = assertSafeIdentifier(config.table)\r\n const mode = config.mode ?? \"FOR UPDATE\"\r\n return prisma.$transaction(async (tx) => {\r\n const rows: any[] = await tx.$queryRaw(\r\n Prisma.sql`SELECT * FROM ${Prisma.raw(`${quoteIdentifier(table)}`)} WHERE id = ${config.id} ${Prisma.raw(mode)}`\r\n )\r\n if (!rows.length) throw new Error(`Row not found in ${table} with id ${config.id}`)\r\n return fn(tx, rows[0])\r\n },\r\n { maxWait: config.maxWait ?? 5000, timeout: config.timeout ?? 10000 }\r\n )\r\n}","import { PrismaClient } from \"@prisma/client\";\r\n\r\nexport class OptimisticLockError extends Error {\r\n constructor(message = \"Row was modified by another transaction\") {\r\n super(message)\r\n this.name = \"OptimisticLockError\"\r\n }\r\n}\r\n\r\ninterface OptimisticLockConfig {\r\n model: string\r\n id: number\r\n expectedVersion: number\r\n data: Record<string, any>\r\n}\r\n\r\nexport async function optimisticLockUpdate(\r\n prisma: PrismaClient,\r\n config: OptimisticLockConfig\r\n) {\r\n const client = prisma as any\r\n if (!client[config.model]) {\r\n throw new Error(`Model \"${config.model}\" not found on Prisma client`)\r\n }\r\n const result = await client[config.model].updateMany({\r\n where: { id: config.id, version: config.expectedVersion },\r\n data: { ...config.data, version: { increment: 1 } }\r\n })\r\n if (result.count === 0) {\r\n throw new OptimisticLockError()\r\n }\r\n return result\r\n}"],"mappings":"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","names":["import_client"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as _prisma_client_runtime_library from '@prisma/client/runtime/library';
|
|
2
|
+
import { Prisma, PrismaClient } from '@prisma/client';
|
|
3
|
+
|
|
4
|
+
type JoinType = "INNER" | "LEFT" | "RIGHT" | "FULL OUTER" | "CROSS";
|
|
5
|
+
interface JoinCondition {
|
|
6
|
+
fromColumn: string;
|
|
7
|
+
toColumn: string;
|
|
8
|
+
}
|
|
9
|
+
interface JoinConfig {
|
|
10
|
+
from: string;
|
|
11
|
+
select?: string[];
|
|
12
|
+
join?: {
|
|
13
|
+
type: JoinType;
|
|
14
|
+
table: string;
|
|
15
|
+
on?: JoinCondition;
|
|
16
|
+
};
|
|
17
|
+
where?: {
|
|
18
|
+
column: string;
|
|
19
|
+
op: '=' | '!=' | '>' | '<' | '>=' | '<=';
|
|
20
|
+
value: any;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
declare function buildJoinQuery(config: JoinConfig): Prisma.Sql;
|
|
24
|
+
|
|
25
|
+
interface PessimisticLockConfig {
|
|
26
|
+
table: string;
|
|
27
|
+
id: string | number;
|
|
28
|
+
mode?: "FOR UPDATE" | "FOR SHARE";
|
|
29
|
+
maxWait?: number;
|
|
30
|
+
timeout?: number;
|
|
31
|
+
}
|
|
32
|
+
declare function pessimisticLock<T>(prisma: PrismaClient, config: PessimisticLockConfig, fn: (tx: Prisma.TransactionClient, row: any) => Promise<T>): Promise<T>;
|
|
33
|
+
|
|
34
|
+
declare class OptimisticLockError extends Error {
|
|
35
|
+
constructor(message?: string);
|
|
36
|
+
}
|
|
37
|
+
interface OptimisticLockConfig {
|
|
38
|
+
model: string;
|
|
39
|
+
id: number;
|
|
40
|
+
expectedVersion: number;
|
|
41
|
+
data: Record<string, any>;
|
|
42
|
+
}
|
|
43
|
+
declare function optimisticLockUpdate(prisma: PrismaClient, config: OptimisticLockConfig): Promise<any>;
|
|
44
|
+
|
|
45
|
+
declare function withToolKit(prisma: PrismaClient): _prisma_client_runtime_library.DynamicClientExtensionThis<Prisma.TypeMap<_prisma_client_runtime_library.InternalArgs & {
|
|
46
|
+
result: {};
|
|
47
|
+
model: {};
|
|
48
|
+
query: {};
|
|
49
|
+
client: {
|
|
50
|
+
$join: () => (config: Parameters<typeof buildJoinQuery>[0]) => Promise<unknown>;
|
|
51
|
+
$lock: () => {
|
|
52
|
+
pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => Promise<unknown>;
|
|
53
|
+
optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => Promise<any>;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
}, {}>, Prisma.TypeMapCb<Prisma.PrismaClientOptions>, {
|
|
57
|
+
result: {};
|
|
58
|
+
model: {};
|
|
59
|
+
query: {};
|
|
60
|
+
client: {
|
|
61
|
+
$join: () => (config: Parameters<typeof buildJoinQuery>[0]) => Promise<unknown>;
|
|
62
|
+
$lock: () => {
|
|
63
|
+
pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => Promise<unknown>;
|
|
64
|
+
optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => Promise<any>;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
export { type JoinType, OptimisticLockError, buildJoinQuery, optimisticLockUpdate, pessimisticLock, withToolKit };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as _prisma_client_runtime_library from '@prisma/client/runtime/library';
|
|
2
|
+
import { Prisma, PrismaClient } from '@prisma/client';
|
|
3
|
+
|
|
4
|
+
type JoinType = "INNER" | "LEFT" | "RIGHT" | "FULL OUTER" | "CROSS";
|
|
5
|
+
interface JoinCondition {
|
|
6
|
+
fromColumn: string;
|
|
7
|
+
toColumn: string;
|
|
8
|
+
}
|
|
9
|
+
interface JoinConfig {
|
|
10
|
+
from: string;
|
|
11
|
+
select?: string[];
|
|
12
|
+
join?: {
|
|
13
|
+
type: JoinType;
|
|
14
|
+
table: string;
|
|
15
|
+
on?: JoinCondition;
|
|
16
|
+
};
|
|
17
|
+
where?: {
|
|
18
|
+
column: string;
|
|
19
|
+
op: '=' | '!=' | '>' | '<' | '>=' | '<=';
|
|
20
|
+
value: any;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
declare function buildJoinQuery(config: JoinConfig): Prisma.Sql;
|
|
24
|
+
|
|
25
|
+
interface PessimisticLockConfig {
|
|
26
|
+
table: string;
|
|
27
|
+
id: string | number;
|
|
28
|
+
mode?: "FOR UPDATE" | "FOR SHARE";
|
|
29
|
+
maxWait?: number;
|
|
30
|
+
timeout?: number;
|
|
31
|
+
}
|
|
32
|
+
declare function pessimisticLock<T>(prisma: PrismaClient, config: PessimisticLockConfig, fn: (tx: Prisma.TransactionClient, row: any) => Promise<T>): Promise<T>;
|
|
33
|
+
|
|
34
|
+
declare class OptimisticLockError extends Error {
|
|
35
|
+
constructor(message?: string);
|
|
36
|
+
}
|
|
37
|
+
interface OptimisticLockConfig {
|
|
38
|
+
model: string;
|
|
39
|
+
id: number;
|
|
40
|
+
expectedVersion: number;
|
|
41
|
+
data: Record<string, any>;
|
|
42
|
+
}
|
|
43
|
+
declare function optimisticLockUpdate(prisma: PrismaClient, config: OptimisticLockConfig): Promise<any>;
|
|
44
|
+
|
|
45
|
+
declare function withToolKit(prisma: PrismaClient): _prisma_client_runtime_library.DynamicClientExtensionThis<Prisma.TypeMap<_prisma_client_runtime_library.InternalArgs & {
|
|
46
|
+
result: {};
|
|
47
|
+
model: {};
|
|
48
|
+
query: {};
|
|
49
|
+
client: {
|
|
50
|
+
$join: () => (config: Parameters<typeof buildJoinQuery>[0]) => Promise<unknown>;
|
|
51
|
+
$lock: () => {
|
|
52
|
+
pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => Promise<unknown>;
|
|
53
|
+
optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => Promise<any>;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
}, {}>, Prisma.TypeMapCb<Prisma.PrismaClientOptions>, {
|
|
57
|
+
result: {};
|
|
58
|
+
model: {};
|
|
59
|
+
query: {};
|
|
60
|
+
client: {
|
|
61
|
+
$join: () => (config: Parameters<typeof buildJoinQuery>[0]) => Promise<unknown>;
|
|
62
|
+
$lock: () => {
|
|
63
|
+
pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => Promise<unknown>;
|
|
64
|
+
optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => Promise<any>;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
export { type JoinType, OptimisticLockError, buildJoinQuery, optimisticLockUpdate, pessimisticLock, withToolKit };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/joins/JoinBuilder.ts
|
|
2
|
+
import { Prisma } from "@prisma/client";
|
|
3
|
+
|
|
4
|
+
// src/security/security.ts
|
|
5
|
+
var IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
6
|
+
var MAX_IDENTIFIER_LENGTH = 63;
|
|
7
|
+
function validateSegment(segment) {
|
|
8
|
+
if (segment.length === 0) {
|
|
9
|
+
throw new Error("Identifier segment cannot be empty");
|
|
10
|
+
}
|
|
11
|
+
if (segment.length > MAX_IDENTIFIER_LENGTH) {
|
|
12
|
+
throw new Error(`Identifier "${segment}" exceeds ${MAX_IDENTIFIER_LENGTH} character limit(Postgres will silently truncate it)`);
|
|
13
|
+
}
|
|
14
|
+
if (!IDENT_RE.test(segment)) {
|
|
15
|
+
throw new Error(`Unsafe or invalid identifier: "${segment}"`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function assertSafeIdentifier(name) {
|
|
19
|
+
validateSegment(name);
|
|
20
|
+
return name;
|
|
21
|
+
}
|
|
22
|
+
function assertSafeQualifiedIdentifier(name) {
|
|
23
|
+
const [core, ...rest] = name.split(/\s+as\s+/i);
|
|
24
|
+
if (rest.length > 1) {
|
|
25
|
+
throw new Error(`Invalid identifier expression: "${name}"`);
|
|
26
|
+
}
|
|
27
|
+
const segment = core.trim().split(".");
|
|
28
|
+
if (segment.length > 2) {
|
|
29
|
+
throw new Error(`Invalid identifier expression: "${name}"`);
|
|
30
|
+
}
|
|
31
|
+
segment.forEach(validateSegment);
|
|
32
|
+
if (rest.length === 1) {
|
|
33
|
+
validateSegment(rest[0].trim());
|
|
34
|
+
}
|
|
35
|
+
return name;
|
|
36
|
+
}
|
|
37
|
+
function quoteIdentifier(name) {
|
|
38
|
+
return `"${name}"`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/joins/JoinBuilder.ts
|
|
42
|
+
function buildJoinQuery(config) {
|
|
43
|
+
const fromTable = assertSafeIdentifier(config.from);
|
|
44
|
+
const selectCols = (config.select ?? ["*"]).map((c) => c === "*" ? c : assertSafeQualifiedIdentifier(c)).join(", ");
|
|
45
|
+
let query = Prisma.sql`SELECT ${Prisma.raw(selectCols)} FROM ${Prisma.raw(`"${quoteIdentifier(fromTable)}"`)}`;
|
|
46
|
+
if (config.join) {
|
|
47
|
+
const { type, table, on } = config.join;
|
|
48
|
+
const joinTable = assertSafeIdentifier(table);
|
|
49
|
+
if (type === "CROSS") {
|
|
50
|
+
query = Prisma.sql`${query} CROSS JOIN ${Prisma.raw(`"${quoteIdentifier(joinTable)}"`)}`;
|
|
51
|
+
} else {
|
|
52
|
+
if (!on) throw new Error(`on Condition is required for ${type} JOIN`);
|
|
53
|
+
const fromCol = assertSafeIdentifier(on.fromColumn);
|
|
54
|
+
const toCol = assertSafeIdentifier(on.toColumn);
|
|
55
|
+
query = Prisma.sql`${query} ${Prisma.raw(type)} JOIN ${Prisma.raw(`"${quoteIdentifier(joinTable)}"`)} ON ${Prisma.raw(`"${quoteIdentifier(fromTable)}"."${quoteIdentifier(fromCol)}"`)} = ${Prisma.raw(`"${quoteIdentifier(joinTable)}"."${quoteIdentifier(toCol)}"`)}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (config.where) {
|
|
59
|
+
const col = assertSafeIdentifier(config.where.column);
|
|
60
|
+
const op = config.where.op;
|
|
61
|
+
query = Prisma.sql`${query} WHERE ${Prisma.raw(`"${quoteIdentifier(col)}"`)} ${Prisma.raw(op)} ${config.where.value}`;
|
|
62
|
+
}
|
|
63
|
+
return query;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/locking/passimisticLocking.ts
|
|
67
|
+
import { Prisma as Prisma2 } from "@prisma/client";
|
|
68
|
+
async function pessimisticLock(prisma, config, fn) {
|
|
69
|
+
const table = assertSafeIdentifier(config.table);
|
|
70
|
+
const mode = config.mode ?? "FOR UPDATE";
|
|
71
|
+
return prisma.$transaction(
|
|
72
|
+
async (tx) => {
|
|
73
|
+
const rows = await tx.$queryRaw(
|
|
74
|
+
Prisma2.sql`SELECT * FROM ${Prisma2.raw(`${quoteIdentifier(table)}`)} WHERE id = ${config.id} ${Prisma2.raw(mode)}`
|
|
75
|
+
);
|
|
76
|
+
if (!rows.length) throw new Error(`Row not found in ${table} with id ${config.id}`);
|
|
77
|
+
return fn(tx, rows[0]);
|
|
78
|
+
},
|
|
79
|
+
{ maxWait: config.maxWait ?? 5e3, timeout: config.timeout ?? 1e4 }
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/locking/optimisticLocking.ts
|
|
84
|
+
var OptimisticLockError = class extends Error {
|
|
85
|
+
constructor(message = "Row was modified by another transaction") {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = "OptimisticLockError";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
async function optimisticLockUpdate(prisma, config) {
|
|
91
|
+
const client = prisma;
|
|
92
|
+
if (!client[config.model]) {
|
|
93
|
+
throw new Error(`Model "${config.model}" not found on Prisma client`);
|
|
94
|
+
}
|
|
95
|
+
const result = await client[config.model].updateMany({
|
|
96
|
+
where: { id: config.id, version: config.expectedVersion },
|
|
97
|
+
data: { ...config.data, version: { increment: 1 } }
|
|
98
|
+
});
|
|
99
|
+
if (result.count === 0) {
|
|
100
|
+
throw new OptimisticLockError();
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/index.ts
|
|
106
|
+
function withToolKit(prisma) {
|
|
107
|
+
return prisma.$extends({
|
|
108
|
+
name: "prisma-toolkit",
|
|
109
|
+
client: {
|
|
110
|
+
async $join(config) {
|
|
111
|
+
const sql = buildJoinQuery(config);
|
|
112
|
+
return prisma.$queryRaw(sql);
|
|
113
|
+
},
|
|
114
|
+
$lock: {
|
|
115
|
+
pessimistic: (config, fn) => pessimisticLock(prisma, config, fn),
|
|
116
|
+
optimistic: (config) => optimisticLockUpdate(prisma, config)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
export {
|
|
122
|
+
OptimisticLockError,
|
|
123
|
+
buildJoinQuery,
|
|
124
|
+
optimisticLockUpdate,
|
|
125
|
+
pessimisticLock,
|
|
126
|
+
withToolKit
|
|
127
|
+
};
|
|
128
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/joins/JoinBuilder.ts","../src/security/security.ts","../src/locking/passimisticLocking.ts","../src/locking/optimisticLocking.ts","../src/index.ts"],"sourcesContent":["import { Prisma } from \"@prisma/client\"\r\nimport { assertSafeIdentifier, assertSafeQualifiedIdentifier, quoteIdentifier } from \"../security/security\"\r\n\r\nexport type JoinType = \"INNER\" | \"LEFT\" | \"RIGHT\" | \"FULL OUTER\" | \"CROSS\"\r\n\r\ninterface JoinCondition {\r\n fromColumn: string\r\n toColumn: string\r\n}\r\n\r\ninterface JoinConfig {\r\n from: string\r\n select?: string[]\r\n join?: {\r\n type: JoinType,\r\n table: string,\r\n on?: JoinCondition\r\n }\r\n where?: {\r\n column: string\r\n op: '=' | '!=' | '>' | '<' | '>=' | '<=';\r\n value: any\r\n }\r\n}\r\n\r\n\r\nexport function buildJoinQuery(config: JoinConfig): Prisma.Sql {\r\n const fromTable = assertSafeIdentifier(config.from)\r\n const selectCols = (config.select ?? ['*']).map((c) => (c === '*' ? c : assertSafeQualifiedIdentifier(c))).join(', ')\r\n let query = Prisma.sql`SELECT ${Prisma.raw(selectCols)} FROM ${Prisma.raw(`\"${quoteIdentifier(fromTable)}\"`)}`;\r\n if (config.join) {\r\n const { type, table, on } = config.join\r\n const joinTable = assertSafeIdentifier(table)\r\n if (type === \"CROSS\") {\r\n query = Prisma.sql`${query} CROSS JOIN ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\"`)}`;\r\n }\r\n else {\r\n if (!on) throw new Error(`on Condition is required for ${type} JOIN`)\r\n const fromCol = assertSafeIdentifier(on.fromColumn)\r\n const toCol = assertSafeIdentifier(on.toColumn)\r\n query = Prisma.sql`${query} ${Prisma.raw(type)} JOIN ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\"`)} ON ${Prisma.raw(`\"${quoteIdentifier(fromTable)}\".\"${quoteIdentifier(fromCol)}\"`)} = ${Prisma.raw(`\"${quoteIdentifier(joinTable)}\".\"${quoteIdentifier(toCol)}\"`)}`;\r\n }\r\n }\r\n if (config.where) {\r\n const col = assertSafeIdentifier(config.where.column)\r\n const op = config.where.op\r\n query = Prisma.sql`${query} WHERE ${Prisma.raw(`\"${quoteIdentifier(col)}\"`)} ${Prisma.raw(op)} ${config.where.value}`\r\n }\r\n return query\r\n}\r\n\r\n\r\n","const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\r\nconst MAX_IDENTIFIER_LENGTH = 63\r\n\r\nfunction validateSegment(segment: string): void {\r\n if (segment.length === 0) {\r\n throw new Error(\"Identifier segment cannot be empty\")\r\n }\r\n if (segment.length > MAX_IDENTIFIER_LENGTH) {\r\n throw new Error(`Identifier \"${segment}\" exceeds ${MAX_IDENTIFIER_LENGTH} character limit(Postgres will silently truncate it)`)\r\n }\r\n if (!IDENT_RE.test(segment)) {\r\n throw new Error(`Unsafe or invalid identifier: \"${segment}\"`)\r\n }\r\n}\r\n\r\nexport function assertSafeIdentifier(name: string): string {\r\n validateSegment(name)\r\n return name\r\n}\r\n\r\nexport function assertSafeQualifiedIdentifier(name: string): string {\r\n const [core, ...rest] = name.split(/\\s+as\\s+/i)\r\n if (rest.length > 1) {\r\n throw new Error(`Invalid identifier expression: \"${name}\"`)\r\n }\r\n const segment = core.trim().split(\".\")\r\n if (segment.length > 2) {\r\n throw new Error(`Invalid identifier expression: \"${name}\"`)\r\n }\r\n segment.forEach(validateSegment)\r\n\r\n if (rest.length === 1) {\r\n validateSegment(rest[0].trim())\r\n }\r\n return name\r\n}\r\n\r\nexport function quoteIdentifier(name: string): string {\r\n return `\"${name}\"`\r\n}","import { Prisma, PrismaClient } from \"@prisma/client\"\r\nimport { assertSafeIdentifier, quoteIdentifier } from \"../security/security\"\r\n\r\ninterface PessimisticLockConfig {\r\n table: string\r\n id: string | number\r\n mode?: \"FOR UPDATE\" | \"FOR SHARE\"\r\n maxWait?: number\r\n timeout?: number\r\n\r\n}\r\n\r\nexport async function pessimisticLock<T>(\r\n prisma: PrismaClient,\r\n config: PessimisticLockConfig,\r\n fn: (tx: Prisma.TransactionClient, row: any) => Promise<T>\r\n): Promise<T> {\r\n const table = assertSafeIdentifier(config.table)\r\n const mode = config.mode ?? \"FOR UPDATE\"\r\n return prisma.$transaction(async (tx) => {\r\n const rows: any[] = await tx.$queryRaw(\r\n Prisma.sql`SELECT * FROM ${Prisma.raw(`${quoteIdentifier(table)}`)} WHERE id = ${config.id} ${Prisma.raw(mode)}`\r\n )\r\n if (!rows.length) throw new Error(`Row not found in ${table} with id ${config.id}`)\r\n return fn(tx, rows[0])\r\n },\r\n { maxWait: config.maxWait ?? 5000, timeout: config.timeout ?? 10000 }\r\n )\r\n}","import { PrismaClient } from \"@prisma/client\";\r\n\r\nexport class OptimisticLockError extends Error {\r\n constructor(message = \"Row was modified by another transaction\") {\r\n super(message)\r\n this.name = \"OptimisticLockError\"\r\n }\r\n}\r\n\r\ninterface OptimisticLockConfig {\r\n model: string\r\n id: number\r\n expectedVersion: number\r\n data: Record<string, any>\r\n}\r\n\r\nexport async function optimisticLockUpdate(\r\n prisma: PrismaClient,\r\n config: OptimisticLockConfig\r\n) {\r\n const client = prisma as any\r\n if (!client[config.model]) {\r\n throw new Error(`Model \"${config.model}\" not found on Prisma client`)\r\n }\r\n const result = await client[config.model].updateMany({\r\n where: { id: config.id, version: config.expectedVersion },\r\n data: { ...config.data, version: { increment: 1 } }\r\n })\r\n if (result.count === 0) {\r\n throw new OptimisticLockError()\r\n }\r\n return result\r\n}","import { PrismaClient, Prisma } from \"@prisma/client\"\r\nimport { buildJoinQuery } from \"./joins/JoinBuilder\"\r\nimport { pessimisticLock } from \"./locking/passimisticLocking\"\r\nimport { optimisticLockUpdate } from \"./locking/optimisticLocking\"\r\n\r\n\r\nexport function withToolKit(\r\n prisma: PrismaClient\r\n) {\r\n return prisma.$extends({\r\n name: \"prisma-toolkit\",\r\n client: {\r\n async $join(config: Parameters<typeof buildJoinQuery>[0]) {\r\n const sql = buildJoinQuery(config)\r\n return prisma.$queryRaw(sql)\r\n },\r\n $lock: {\r\n pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => pessimisticLock(prisma, config, fn),\r\n optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => optimisticLockUpdate(prisma, config)\r\n }\r\n }\r\n })\r\n}\r\n\r\nexport { buildJoinQuery } from \"./joins/JoinBuilder\"\r\nexport { pessimisticLock } from \"./locking/passimisticLocking\"\r\nexport { optimisticLockUpdate, OptimisticLockError } from \"./locking/optimisticLocking\"\r\nexport type { JoinType } from \"./joins/JoinBuilder\""],"mappings":"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","names":["Prisma","Prisma"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prisma-pg-toolkit",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Joins (INNER/LEFT/RIGHT/FULL OUTER/CROSS) and locking (pessimistic/optimistic) helpers for Prisma",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "tsx src/testing/test-joins.ts",
|
|
22
|
+
"prisma:migrate": "npx prisma migrate dev --name init",
|
|
23
|
+
"prisma:generate": "npx prisma generate",
|
|
24
|
+
"seed": "tsx prisma/seed.ts",
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"prepublishOnly": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"prisma",
|
|
30
|
+
"joins",
|
|
31
|
+
"locking",
|
|
32
|
+
"optimistic-locking",
|
|
33
|
+
"pessimistic-locking"
|
|
34
|
+
],
|
|
35
|
+
"author": "",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@prisma/client": ">=5.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.0.0",
|
|
42
|
+
"@prisma/client": "^6.19.3",
|
|
43
|
+
"prisma": "^6.19.3",
|
|
44
|
+
"ts-node": "^10.9.2",
|
|
45
|
+
"ts-node-dev": "^2.0.0",
|
|
46
|
+
"tsup": "^8.5.1",
|
|
47
|
+
"tsx": "^4.23.0",
|
|
48
|
+
"typescript": "^5.7.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { PrismaClient, Prisma } from "@prisma/client"
|
|
2
|
+
import { buildJoinQuery } from "./joins/JoinBuilder"
|
|
3
|
+
import { pessimisticLock } from "./locking/passimisticLocking"
|
|
4
|
+
import { optimisticLockUpdate } from "./locking/optimisticLocking"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export function withToolKit(
|
|
8
|
+
prisma: PrismaClient
|
|
9
|
+
) {
|
|
10
|
+
return prisma.$extends({
|
|
11
|
+
name: "prisma-toolkit",
|
|
12
|
+
client: {
|
|
13
|
+
async $join(config: Parameters<typeof buildJoinQuery>[0]) {
|
|
14
|
+
const sql = buildJoinQuery(config)
|
|
15
|
+
return prisma.$queryRaw(sql)
|
|
16
|
+
},
|
|
17
|
+
$lock: {
|
|
18
|
+
pessimistic: (config: Parameters<typeof pessimisticLock>[1], fn: Parameters<typeof pessimisticLock>[2]) => pessimisticLock(prisma, config, fn),
|
|
19
|
+
optimistic: (config: Parameters<typeof optimisticLockUpdate>[1]) => optimisticLockUpdate(prisma, config)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { buildJoinQuery } from "./joins/JoinBuilder"
|
|
26
|
+
export { pessimisticLock } from "./locking/passimisticLocking"
|
|
27
|
+
export { optimisticLockUpdate, OptimisticLockError } from "./locking/optimisticLocking"
|
|
28
|
+
export type { JoinType } from "./joins/JoinBuilder"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Prisma } from "@prisma/client"
|
|
2
|
+
import { assertSafeIdentifier, assertSafeQualifiedIdentifier, quoteIdentifier } from "../security/security"
|
|
3
|
+
|
|
4
|
+
export type JoinType = "INNER" | "LEFT" | "RIGHT" | "FULL OUTER" | "CROSS"
|
|
5
|
+
|
|
6
|
+
interface JoinCondition {
|
|
7
|
+
fromColumn: string
|
|
8
|
+
toColumn: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface JoinConfig {
|
|
12
|
+
from: string
|
|
13
|
+
select?: string[]
|
|
14
|
+
join?: {
|
|
15
|
+
type: JoinType,
|
|
16
|
+
table: string,
|
|
17
|
+
on?: JoinCondition
|
|
18
|
+
}
|
|
19
|
+
where?: {
|
|
20
|
+
column: string
|
|
21
|
+
op: '=' | '!=' | '>' | '<' | '>=' | '<=';
|
|
22
|
+
value: any
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
export function buildJoinQuery(config: JoinConfig): Prisma.Sql {
|
|
28
|
+
const fromTable = assertSafeIdentifier(config.from)
|
|
29
|
+
const selectCols = (config.select ?? ['*']).map((c) => (c === '*' ? c : assertSafeQualifiedIdentifier(c))).join(', ')
|
|
30
|
+
let query = Prisma.sql`SELECT ${Prisma.raw(selectCols)} FROM ${Prisma.raw(`"${quoteIdentifier(fromTable)}"`)}`;
|
|
31
|
+
if (config.join) {
|
|
32
|
+
const { type, table, on } = config.join
|
|
33
|
+
const joinTable = assertSafeIdentifier(table)
|
|
34
|
+
if (type === "CROSS") {
|
|
35
|
+
query = Prisma.sql`${query} CROSS JOIN ${Prisma.raw(`"${quoteIdentifier(joinTable)}"`)}`;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
if (!on) throw new Error(`on Condition is required for ${type} JOIN`)
|
|
39
|
+
const fromCol = assertSafeIdentifier(on.fromColumn)
|
|
40
|
+
const toCol = assertSafeIdentifier(on.toColumn)
|
|
41
|
+
query = Prisma.sql`${query} ${Prisma.raw(type)} JOIN ${Prisma.raw(`"${quoteIdentifier(joinTable)}"`)} ON ${Prisma.raw(`"${quoteIdentifier(fromTable)}"."${quoteIdentifier(fromCol)}"`)} = ${Prisma.raw(`"${quoteIdentifier(joinTable)}"."${quoteIdentifier(toCol)}"`)}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (config.where) {
|
|
45
|
+
const col = assertSafeIdentifier(config.where.column)
|
|
46
|
+
const op = config.where.op
|
|
47
|
+
query = Prisma.sql`${query} WHERE ${Prisma.raw(`"${quoteIdentifier(col)}"`)} ${Prisma.raw(op)} ${config.where.value}`
|
|
48
|
+
}
|
|
49
|
+
return query
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { PrismaClient } from "@prisma/client";
|
|
2
|
+
|
|
3
|
+
export class OptimisticLockError extends Error {
|
|
4
|
+
constructor(message = "Row was modified by another transaction") {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = "OptimisticLockError"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface OptimisticLockConfig {
|
|
11
|
+
model: string
|
|
12
|
+
id: number
|
|
13
|
+
expectedVersion: number
|
|
14
|
+
data: Record<string, any>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function optimisticLockUpdate(
|
|
18
|
+
prisma: PrismaClient,
|
|
19
|
+
config: OptimisticLockConfig
|
|
20
|
+
) {
|
|
21
|
+
const client = prisma as any
|
|
22
|
+
if (!client[config.model]) {
|
|
23
|
+
throw new Error(`Model "${config.model}" not found on Prisma client`)
|
|
24
|
+
}
|
|
25
|
+
const result = await client[config.model].updateMany({
|
|
26
|
+
where: { id: config.id, version: config.expectedVersion },
|
|
27
|
+
data: { ...config.data, version: { increment: 1 } }
|
|
28
|
+
})
|
|
29
|
+
if (result.count === 0) {
|
|
30
|
+
throw new OptimisticLockError()
|
|
31
|
+
}
|
|
32
|
+
return result
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Prisma, PrismaClient } from "@prisma/client"
|
|
2
|
+
import { assertSafeIdentifier, quoteIdentifier } from "../security/security"
|
|
3
|
+
|
|
4
|
+
interface PessimisticLockConfig {
|
|
5
|
+
table: string
|
|
6
|
+
id: string | number
|
|
7
|
+
mode?: "FOR UPDATE" | "FOR SHARE"
|
|
8
|
+
maxWait?: number
|
|
9
|
+
timeout?: number
|
|
10
|
+
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function pessimisticLock<T>(
|
|
14
|
+
prisma: PrismaClient,
|
|
15
|
+
config: PessimisticLockConfig,
|
|
16
|
+
fn: (tx: Prisma.TransactionClient, row: any) => Promise<T>
|
|
17
|
+
): Promise<T> {
|
|
18
|
+
const table = assertSafeIdentifier(config.table)
|
|
19
|
+
const mode = config.mode ?? "FOR UPDATE"
|
|
20
|
+
return prisma.$transaction(async (tx) => {
|
|
21
|
+
const rows: any[] = await tx.$queryRaw(
|
|
22
|
+
Prisma.sql`SELECT * FROM ${Prisma.raw(`${quoteIdentifier(table)}`)} WHERE id = ${config.id} ${Prisma.raw(mode)}`
|
|
23
|
+
)
|
|
24
|
+
if (!rows.length) throw new Error(`Row not found in ${table} with id ${config.id}`)
|
|
25
|
+
return fn(tx, rows[0])
|
|
26
|
+
},
|
|
27
|
+
{ maxWait: config.maxWait ?? 5000, timeout: config.timeout ?? 10000 }
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
2
|
+
const MAX_IDENTIFIER_LENGTH = 63
|
|
3
|
+
|
|
4
|
+
function validateSegment(segment: string): void {
|
|
5
|
+
if (segment.length === 0) {
|
|
6
|
+
throw new Error("Identifier segment cannot be empty")
|
|
7
|
+
}
|
|
8
|
+
if (segment.length > MAX_IDENTIFIER_LENGTH) {
|
|
9
|
+
throw new Error(`Identifier "${segment}" exceeds ${MAX_IDENTIFIER_LENGTH} character limit(Postgres will silently truncate it)`)
|
|
10
|
+
}
|
|
11
|
+
if (!IDENT_RE.test(segment)) {
|
|
12
|
+
throw new Error(`Unsafe or invalid identifier: "${segment}"`)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertSafeIdentifier(name: string): string {
|
|
17
|
+
validateSegment(name)
|
|
18
|
+
return name
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertSafeQualifiedIdentifier(name: string): string {
|
|
22
|
+
const [core, ...rest] = name.split(/\s+as\s+/i)
|
|
23
|
+
if (rest.length > 1) {
|
|
24
|
+
throw new Error(`Invalid identifier expression: "${name}"`)
|
|
25
|
+
}
|
|
26
|
+
const segment = core.trim().split(".")
|
|
27
|
+
if (segment.length > 2) {
|
|
28
|
+
throw new Error(`Invalid identifier expression: "${name}"`)
|
|
29
|
+
}
|
|
30
|
+
segment.forEach(validateSegment)
|
|
31
|
+
|
|
32
|
+
if (rest.length === 1) {
|
|
33
|
+
validateSegment(rest[0].trim())
|
|
34
|
+
}
|
|
35
|
+
return name
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function quoteIdentifier(name: string): string {
|
|
39
|
+
return `"${name}"`
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Prisma, PrismaClient } from "@prisma/client"
|
|
2
|
+
|
|
3
|
+
const prisma = new PrismaClient()
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async function runJoin(label: string, sql: Prisma.Sql) {
|
|
7
|
+
const rows = await prisma.$queryRaw(sql)
|
|
8
|
+
console.log(`\n--- ${label} (${(rows as any[]).length} rows) ---`)
|
|
9
|
+
console.table(rows)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function main() {
|
|
13
|
+
await runJoin("INNER JOIN (expect 3)", Prisma.sql`SELECT u.email, p.title FROM "User" u INNER JOIN "Post" p ON p."userId" = u.id`)
|
|
14
|
+
await runJoin("LEFT JOIN (expect 4, + TOM)", Prisma.sql`SELECT u.email, p.title FROM "User" u LEFT JOIN "Post" p ON p."userId" = u.id`)
|
|
15
|
+
await runJoin("RIGHT JOIN (expect 4 + Abandoned Post)", Prisma.sql`SELECT u.email,p.title FROM "User" u RIGHT JOIN "Post" p ON p."userId" = u.id`)
|
|
16
|
+
await runJoin("FULL OUTER JOIN (expect 5", Prisma.sql`SELECT u.email, p.title FROM "User" u FULL OUTER JOIN "Post" p ON p."userId" = u.id`);
|
|
17
|
+
await runJoin("CROS JOIN (expect 4 users * 4 posts = 16", Prisma.sql`SELECT u.email, p.title FROM "User" u CROSS JOIN "Post" p`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
main().then(() => console.log("Main function exexuted")).catch((err) => console.log(err)).finally(() => prisma.$disconnect())
|
|
21
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { PrismaClient } from "@prisma/client";
|
|
2
|
+
import { withToolKit } from "../index.js";
|
|
3
|
+
|
|
4
|
+
const base = new PrismaClient()
|
|
5
|
+
const prisma = withToolKit(base)
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
const data: any = {
|
|
9
|
+
from: "User",
|
|
10
|
+
select: ["title", "content"],
|
|
11
|
+
join: {
|
|
12
|
+
type: "CROSS",
|
|
13
|
+
table: "Post",
|
|
14
|
+
on: { fromColumn: "id", toColumn: "userId" }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const result = await prisma.$join(data)
|
|
18
|
+
return result
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
main().then((res) => console.table(res)).catch((err) => console.error(err)).finally(() => base.$disconnect())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { PrismaClient } from "@prisma/client"
|
|
2
|
+
import { withToolKit } from ".."
|
|
3
|
+
|
|
4
|
+
const base = new PrismaClient()
|
|
5
|
+
const prisma = withToolKit(base)
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
const user = await prisma.user.findFirst({ where: { email: "tom1@gmail.com" } })
|
|
9
|
+
if (!user) throw new Error("emailId not found")
|
|
10
|
+
console.log("current version ", user.version)
|
|
11
|
+
|
|
12
|
+
const success = await prisma.$lock.optimistic({
|
|
13
|
+
model: "user",
|
|
14
|
+
id: user.id,
|
|
15
|
+
expectedVersion: user.version,
|
|
16
|
+
data: { name: "Updated Tom" }
|
|
17
|
+
})
|
|
18
|
+
console.log("Updated with new version successded ", success)
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const test2 = await prisma.$lock.optimistic({
|
|
22
|
+
model: "user",
|
|
23
|
+
id: user.id,
|
|
24
|
+
expectedVersion: user.version,
|
|
25
|
+
data: { name: "This should not apply" }
|
|
26
|
+
})
|
|
27
|
+
if (!test2) console.log('ERROR: stale update should NOT have succeeded');
|
|
28
|
+
console.log("this is new version", test2)
|
|
29
|
+
} catch (error: any) {
|
|
30
|
+
console.log('Correctly rejected stale update:', error.message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
main().then((res) => console.table(res)).catch((err) => console.error(err)).finally(() => base.$disconnect())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { PrismaClient } from "@prisma/client"
|
|
2
|
+
import { withToolKit } from ".."
|
|
3
|
+
|
|
4
|
+
const urlLink = process.env.DIRECT_URL! as string
|
|
5
|
+
if (!urlLink) throw new Error("DIRECT_URL not exist")
|
|
6
|
+
|
|
7
|
+
const base = new PrismaClient({
|
|
8
|
+
datasources: { db: { url: urlLink } }
|
|
9
|
+
})
|
|
10
|
+
const prisma = withToolKit(base)
|
|
11
|
+
|
|
12
|
+
function sleep(ms: number) {
|
|
13
|
+
return new Promise((res) => setTimeout(res, ms))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
const user = await prisma.user.findFirst({ where: { email: "tom1@gmail.com" } })
|
|
18
|
+
if (!user) throw new Error("Email Id not found for user")
|
|
19
|
+
const log = (label: string) => console.log(`[${new Date().toISOString()}] ${label}`)
|
|
20
|
+
const txa = prisma.$lock.pessimistic({ table: "User", id: user.id, mode: "FOR UPDATE" }, async (tx, row) => {
|
|
21
|
+
log("TX A: acquired lock")
|
|
22
|
+
await sleep(3000)
|
|
23
|
+
log("TX A: releasing lock (transaction committing)")
|
|
24
|
+
return row
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const txb = (async () => {
|
|
28
|
+
await sleep(200)
|
|
29
|
+
log('TX B: attempting to acquire lock (should block here)');
|
|
30
|
+
return prisma.$lock.pessimistic({
|
|
31
|
+
table: "User",
|
|
32
|
+
id: user.id,
|
|
33
|
+
mode: "FOR UPDATE"
|
|
34
|
+
},
|
|
35
|
+
async function (tx, row) {
|
|
36
|
+
log("TX B: acquired lock (A must have released it)")
|
|
37
|
+
return row
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
})()
|
|
41
|
+
await txa
|
|
42
|
+
await txb
|
|
43
|
+
log("Both transactions complete")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
}
|
|
47
|
+
main().then((res) => console.log(res)).catch((err) => console.error(err)).finally(() => prisma.$disconnect())
|