@avytrix/nestjs-drizzle-crud 3.7.0 → 3.7.2
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/AGENT.md +9 -3
- package/CHANGELOG.md +68 -0
- package/README.md +12 -4
- package/dist/core/abstract/sql-base-crud.service.d.ts +12 -0
- package/dist/core/abstract/sql-base-crud.service.js +169 -33
- package/dist/core/abstract/sql-base-crud.service.js.map +1 -1
- package/dist/core/utils/query-parser.js +8 -9
- package/dist/core/utils/query-parser.js.map +1 -1
- package/dist/modules/drizzle-connection.js +1 -0
- package/dist/modules/drizzle-connection.js.map +1 -1
- package/dist/modules/drizzle-crud.module.js +4 -0
- package/dist/modules/drizzle-crud.module.js.map +1 -1
- package/package.json +1 -1
package/AGENT.md
CHANGED
|
@@ -390,8 +390,10 @@ consumer
|
|
|
390
390
|
The scope is ANDed into every read, count and write (bulk, upsert, soft delete,
|
|
391
391
|
restore, force delete, attach included) and written onto every created row, so
|
|
392
392
|
another tenant's id is a 404. A missing context value is a 403, never an
|
|
393
|
-
unscoped query.
|
|
394
|
-
|
|
393
|
+
unscoped query. A belongs-to key a write sets must point at a row of the
|
|
394
|
+
caller's tenant when the related table has the scope columns; another tenant's
|
|
395
|
+
row is the same 400 as a missing one. Do not add `tenant_id` to filters or DTOs
|
|
396
|
+
by hand, and use `skipScope: true` only in trusted server code.
|
|
395
397
|
|
|
396
398
|
# Optimistic locking
|
|
397
399
|
|
|
@@ -434,7 +436,8 @@ never reaches the caller as a 500: a unique violation is a 409, a value that is
|
|
|
434
436
|
too long, fails a check, has the wrong type or is not a valid date is a 400 (on
|
|
435
437
|
reads too), a foreign key pointing at a row that does not exist is a 400, and a
|
|
436
438
|
delete blocked by dependent rows is a 409. The same holds on MySQL (strict mode
|
|
437
|
-
errors included) and SQLite (constraint errors
|
|
439
|
+
errors included) and SQLite (constraint errors, and the `STRICT` and
|
|
440
|
+
`SQLITE_MISMATCH` type errors).
|
|
438
441
|
|
|
439
442
|
---
|
|
440
443
|
|
|
@@ -453,6 +456,9 @@ whole set (plus one `SELECT` that checks the ids), so never loop `create` or
|
|
|
453
456
|
`update` over an array yourself. The 400 from `BulkOperationException` lists
|
|
454
457
|
only the failed items: `errors: [{ index | id, status, message }]`.
|
|
455
458
|
`bulkMode: 'perItem'` runs one single-row call per item when a hook needs it.
|
|
459
|
+
One statement may bind at most 65,535 values on PostgreSQL and MySQL, 32,766 on
|
|
460
|
+
SQLite and 100 on Cloudflare D1; past that the call is a 400 before it runs, so
|
|
461
|
+
split very large batches into chunks.
|
|
456
462
|
|
|
457
463
|
To insert or update by a unique key, use
|
|
458
464
|
`upsert(dto, { conflictTarget: 'email' })` or `massUpsert(rows, ...)` rather
|
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,74 @@ restore, `ER_DUP_ENTRY` mapping, dialect locking) are tracked separately and unc
|
|
|
11
11
|
3.3.1, 3.4.0, 3.5.0 and 3.5.2 were tagged but never published to npm. Their changes reached npm
|
|
12
12
|
in 3.5.1 (3.3.1 to 3.5.0) and 3.6.0 (3.5.2). The unscoped `nestjs-drizzle-crud` stops at 3.3.0.
|
|
13
13
|
|
|
14
|
+
## [3.7.2] - 2026-11-03
|
|
15
|
+
|
|
16
|
+
Fixes from the 3.7.1 audit. The batch limit now counts the statement the database receives.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- The bound parameter limit is checked per statement, against the database's own limit:
|
|
21
|
+
65,535 on PostgreSQL and MySQL, 32,766 on SQLite and libSQL, 100 on Cloudflare D1. The service
|
|
22
|
+
counts the parameters of every statement as Drizzle builds it, so two OR'd `IN` lists, the
|
|
23
|
+
version checks of a `massUpdate` with `expectedVersions`, the children of a nested create and
|
|
24
|
+
the targets of `attach` and `sync` all count, and a statement past the limit is a 400 that
|
|
25
|
+
names its count and the limit before it runs. Before, each input list was checked against
|
|
26
|
+
60,000 on its own: some of those calls still ended in a 500, a `massCreate` that would have fit
|
|
27
|
+
on PostgreSQL was refused (it counted four extra values per row), and on SQLite the cap sat
|
|
28
|
+
above the database's limit. The message changes from "Too many values in one call (n); the
|
|
29
|
+
limit is 60000" to "Too many values in one statement: n bound parameters, and PostgreSQL takes
|
|
30
|
+
at most 65535". `bindLimit(db)` is protected, for a server built with another limit.
|
|
31
|
+
`MAX_BATCH_PARAMETERS`, `assertBatchSize` and `insertParameters` stay for compatibility but are
|
|
32
|
+
deprecated and no longer used.
|
|
33
|
+
- On a scoped entity, `create`, `update`, `upsert` and the mass writes check every belongs-to key
|
|
34
|
+
they set against the caller's tenant when the related table has the scope columns, the way
|
|
35
|
+
`attach` checks its targets: one `SELECT` per relation for the whole call. A key to another
|
|
36
|
+
tenant's row is the same 400 `ForeignKeyViolationException` as a key to a missing row, so the
|
|
37
|
+
answer no longer tells them apart, and a missing row is refused even without a database
|
|
38
|
+
foreign key. Calls with `skipScope: true` and related tables without the scope columns are
|
|
39
|
+
not checked.
|
|
40
|
+
- On SQLite, `contains`, `startsWith` and `endsWith` with `sql.caseSensitive: true` compile to
|
|
41
|
+
`GLOB` and match case exactly. They used `LIKE`, which ignores ASCII case. The default
|
|
42
|
+
(case-insensitive) and the `like` operator are unchanged.
|
|
43
|
+
- On MySQL, `connection.idleTimeout` also sets mysql2's `maxIdle` to one below the pool size.
|
|
44
|
+
mysql2 only reaps idle connections when `maxIdle` is below `connectionLimit`, so the timeout
|
|
45
|
+
had no effect before.
|
|
46
|
+
- `forRootAsync` with more than one of `useFactory`, `useClass` and `useExisting` throws at
|
|
47
|
+
startup and names them. Before, `useFactory` won and the class was ignored.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- On SQLite, a `STRICT` table refusing a value of the wrong type (extended code 3091, which
|
|
52
|
+
libSQL reports as `UNKNOWN_SQLITE_ERROR_3091`) and `SQLITE_MISMATCH` (`"abc"` for an
|
|
53
|
+
`INTEGER PRIMARY KEY`) are a 400 `ValidationFailedException`, not a 500. The dialect contract
|
|
54
|
+
checks the same mapping on every dialect.
|
|
55
|
+
- Filters inside `options.counts` get the same 32 level depth cap as a `findAll` filter. A very
|
|
56
|
+
deep one was a 500 (stack overflow).
|
|
57
|
+
- The filter depth check no longer overflows the call stack on an `IN` list of a few hundred
|
|
58
|
+
thousand items.
|
|
59
|
+
- A batch so large that Drizzle's own SQL builder overflows the call stack is the same 400 as
|
|
60
|
+
any other statement past the limit.
|
|
61
|
+
- Full-text search over several columns no longer drops rows that have `NULL` in one of them.
|
|
62
|
+
The vector was `to_tsvector(cfg, a) || to_tsvector(cfg, b)`, and `x || NULL` is `NULL`, so
|
|
63
|
+
such a row never matched, in `fullTextSearch()` and in `search.mode: "fullText"` alike. Each
|
|
64
|
+
column is now wrapped in `coalesce(column, '')` when there is more than one. A GIN index on the
|
|
65
|
+
old multi-column expression no longer matches the query; recreate it with the `coalesce` (the
|
|
66
|
+
full-text search page has the exact `CREATE INDEX`). A single column is unchanged.
|
|
67
|
+
|
|
68
|
+
### Docs
|
|
69
|
+
|
|
70
|
+
- `AGENTS.md` names the package `@avytrix/nestjs-drizzle-crud`, and says that PGlite, the engine
|
|
71
|
+
of the unit and e2e specs, returns no rows above 32,767 bound parameters.
|
|
72
|
+
|
|
73
|
+
## [3.7.1] - 2026-11-02
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- The error for an upsert on a scoped entity on MySQL now reads "upsert on a scoped entity needs
|
|
78
|
+
PostgreSQL or SQLite". It said PostgreSQL only, though SQLite supports a scoped upsert since 3.7.0.
|
|
79
|
+
- `?fields=` paths named `__proto__`, `constructor` or `prototype` are treated as plain names.
|
|
80
|
+
Before, they reached the select tree's object internals (CodeQL js/remote-property-injection).
|
|
81
|
+
|
|
14
82
|
## [3.7.0] - 2026-11-02
|
|
15
83
|
|
|
16
84
|
SQLite support, MySQL connections built for you, and full-text search that works like `findAll`.
|
package/README.md
CHANGED
|
@@ -287,7 +287,7 @@ export class UsersController {
|
|
|
287
287
|
|---|---|---|
|
|
288
288
|
| `dialect` | `'postgresql' \| 'mysql' \| 'sqlite'` | **Required.** Database dialect. `'sqlite'` covers SQLite, libSQL and Cloudflare D1. |
|
|
289
289
|
| `connectionString` | `string` | Connection string. The module builds the connection (PostgreSQL with `postgres`, MySQL with `mysql2`). |
|
|
290
|
-
| `connection` | `{ poolSize?, idleTimeout?, ssl? }` | Pool size, idle timeout (seconds) and TLS for the connection built from `connectionString`. |
|
|
290
|
+
| `connection` | `{ poolSize?, idleTimeout?, ssl? }` | Pool size, idle timeout (seconds) and TLS for the connection built from `connectionString`. On MySQL an idle timeout also sets mysql2's `maxIdle` one below the pool size, so idle connections really close. |
|
|
291
291
|
| `db` | `Drizzle instance` | Alternatively, pass a Drizzle instance you built yourself (any dialect). |
|
|
292
292
|
| `schema` | `Record<string, unknown>` | Drizzle schema, used when building from `connectionString`. |
|
|
293
293
|
| `defaults.softDelete` | `boolean` | Enable soft delete for all entities (default `true`). |
|
|
@@ -334,7 +334,8 @@ DrizzleCrudModule.forRootAsync({
|
|
|
334
334
|
```
|
|
335
335
|
|
|
336
336
|
`forRootAsync` also takes `useClass` or `useExisting` with a class that
|
|
337
|
-
implements `DrizzleCrudOptionsFactory
|
|
337
|
+
implements `DrizzleCrudOptionsFactory`. Give exactly one of the three; more than
|
|
338
|
+
one is a startup error:
|
|
338
339
|
|
|
339
340
|
```typescript
|
|
340
341
|
@Injectable()
|
|
@@ -542,7 +543,7 @@ await service.findAll({
|
|
|
542
543
|
| `in` / `notIn` | `IN (...)` / `NOT IN (...)` |
|
|
543
544
|
| `between` | `BETWEEN a AND b`, `[from, to]` |
|
|
544
545
|
| `isNull` / `isNotNull` | `true` / `"true"` gives `IS NULL` / `IS NOT NULL`, `false` / `"false"` the opposite |
|
|
545
|
-
| `startsWith` / `endsWith` / `contains` | escaped `LIKE`, case-insensitive unless `sql.caseSensitive` |
|
|
546
|
+
| `startsWith` / `endsWith` / `contains` | escaped `LIKE`, case-insensitive unless `sql.caseSensitive` (then `GLOB` on SQLite, whose `LIKE` ignores case) |
|
|
546
547
|
| `iStartsWith` / `iEndsWith` / `iContains` | escaped `ILIKE` (`LIKE` on MySQL), always case-insensitive |
|
|
547
548
|
| `arrayContains` / `arrayOverlaps` | PostgreSQL arrays, `@>` / `&&` |
|
|
548
549
|
| `jsonContains` | PostgreSQL `jsonb`, `@>` |
|
|
@@ -940,6 +941,11 @@ inside a savepoint that is rolled back, and the response body lists only them:
|
|
|
940
941
|
|
|
941
942
|
`bulkMode: 'perItem'` runs the single-row method per item instead, as in 3.3.
|
|
942
943
|
|
|
944
|
+
One statement may bind at most 65,535 values on PostgreSQL and MySQL, 32,766 on
|
|
945
|
+
SQLite and 100 on Cloudflare D1. The service counts the real statement (every
|
|
946
|
+
column, version check and OR'd list included) and answers one past the limit
|
|
947
|
+
with a 400 that names both numbers, before it runs. Split larger batches.
|
|
948
|
+
|
|
943
949
|
---
|
|
944
950
|
|
|
945
951
|
## Upsert
|
|
@@ -1044,7 +1050,9 @@ await docs.findAll({}, {}, { context: { tenantId: 7 } });
|
|
|
1044
1050
|
|
|
1045
1051
|
A missing context value is a 403 (`MissingScopeException`), never an unscoped
|
|
1046
1052
|
query. A resolver that returns `null`, or `skipScope: true`, runs unscoped on
|
|
1047
|
-
purpose.
|
|
1053
|
+
purpose. A belongs-to key that a write sets must point at a row of the same
|
|
1054
|
+
tenant when the related table has the scope columns: another tenant's row is the
|
|
1055
|
+
same 400 `ForeignKeyViolationException` as a missing one.
|
|
1048
1056
|
|
|
1049
1057
|
---
|
|
1050
1058
|
|
|
@@ -35,6 +35,14 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
|
|
|
35
35
|
protected filterWritable(entity: Record<string, any>, operation: "create" | "update", options?: SqlOperationOptions): Record<string, any>;
|
|
36
36
|
protected keyOf(value: unknown): string;
|
|
37
37
|
constructor(config: SqlCrudConfig);
|
|
38
|
+
private readonly boundDbs;
|
|
39
|
+
protected dbFor(transaction?: any): any;
|
|
40
|
+
protected withBindLimit(db: any): any;
|
|
41
|
+
protected bindLimit(db: any): {
|
|
42
|
+
limit: number;
|
|
43
|
+
name: string;
|
|
44
|
+
};
|
|
45
|
+
protected isD1(db: any): boolean;
|
|
38
46
|
private validateConfiguration;
|
|
39
47
|
private validateColumns;
|
|
40
48
|
private validateRelations;
|
|
@@ -146,6 +154,10 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
|
|
|
146
154
|
private takeNested;
|
|
147
155
|
private insertNested;
|
|
148
156
|
private relatedRow;
|
|
157
|
+
private keysOutsideScope;
|
|
158
|
+
private assertBatchKeysInScope;
|
|
159
|
+
private assertKeysInScope;
|
|
160
|
+
private missingParentException;
|
|
149
161
|
private linkTargets;
|
|
150
162
|
attach(id: any, relation: string, relatedIds: unknown[], options?: SqlOperationOptions): Promise<T>;
|
|
151
163
|
detach(id: any, relation: string, relatedIds?: unknown[], options?: SqlOperationOptions): Promise<T>;
|
|
@@ -11,6 +11,15 @@ const query_parser_1 = require("../utils/query-parser");
|
|
|
11
11
|
const JOIN_KEY = "__crudJoinKey";
|
|
12
12
|
const CURSOR_KEY = "__crudCursor";
|
|
13
13
|
const ROLLBACK = Symbol("rollback");
|
|
14
|
+
const BIND_LIMITS = {
|
|
15
|
+
postgresql: { limit: 65_535, name: "PostgreSQL" },
|
|
16
|
+
mysql: { limit: 65_535, name: "MySQL" },
|
|
17
|
+
sqlite: { limit: 32_766, name: "SQLite" },
|
|
18
|
+
d1: { limit: 100, name: "Cloudflare D1" },
|
|
19
|
+
};
|
|
20
|
+
class BindLimitException extends common_1.BadRequestException {
|
|
21
|
+
}
|
|
22
|
+
const CHECKED_DIALECTS = new WeakSet();
|
|
14
23
|
const GROUP_KEYS = new Set(["and", "or", "not"]);
|
|
15
24
|
const QUANTIFIERS = new Set(["some", "none", "every"]);
|
|
16
25
|
const COUNT_KEY = "__crudCountKey";
|
|
@@ -273,6 +282,53 @@ class SqlBaseCrudService {
|
|
|
273
282
|
this.config = (0, merge_config_1.mergeCrudConfig)(this.defaultConfig, config);
|
|
274
283
|
this.validateConfiguration();
|
|
275
284
|
}
|
|
285
|
+
boundDbs = new WeakMap();
|
|
286
|
+
dbFor(transaction) {
|
|
287
|
+
return this.withBindLimit(transaction || this.config.db);
|
|
288
|
+
}
|
|
289
|
+
withBindLimit(db) {
|
|
290
|
+
const dialect = db?.dialect;
|
|
291
|
+
if (typeof dialect?.sqlToQuery !== "function" ||
|
|
292
|
+
CHECKED_DIALECTS.has(dialect)) {
|
|
293
|
+
return db;
|
|
294
|
+
}
|
|
295
|
+
const cached = this.boundDbs.get(db);
|
|
296
|
+
if (cached)
|
|
297
|
+
return cached;
|
|
298
|
+
const { limit, name } = this.bindLimit(db);
|
|
299
|
+
const checked = Object.create(dialect);
|
|
300
|
+
const tooMany = (count) => new BindLimitException(`Too many values in one statement: ${count} bound parameters, and ${name} takes at most ${limit}. Split the batch.`);
|
|
301
|
+
checked.sqlToQuery = (...args) => {
|
|
302
|
+
let query;
|
|
303
|
+
try {
|
|
304
|
+
query = dialect.sqlToQuery(...args);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (error instanceof RangeError)
|
|
308
|
+
throw tooMany(`more than ${limit}`);
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
const count = query?.params?.length ?? 0;
|
|
312
|
+
if (count > limit)
|
|
313
|
+
throw tooMany(String(count));
|
|
314
|
+
return query;
|
|
315
|
+
};
|
|
316
|
+
CHECKED_DIALECTS.add(checked);
|
|
317
|
+
const view = Object.create(db, {
|
|
318
|
+
dialect: { value: checked, writable: true, configurable: true },
|
|
319
|
+
});
|
|
320
|
+
this.boundDbs.set(db, view);
|
|
321
|
+
return view;
|
|
322
|
+
}
|
|
323
|
+
bindLimit(db) {
|
|
324
|
+
if (this.isD1(db))
|
|
325
|
+
return BIND_LIMITS.d1;
|
|
326
|
+
return BIND_LIMITS[this.config.dialect] ?? BIND_LIMITS.postgresql;
|
|
327
|
+
}
|
|
328
|
+
isD1(db) {
|
|
329
|
+
const kind = db?.constructor?.[drizzle_orm_1.entityKind];
|
|
330
|
+
return kind === "D1Database" || kind === "D1Transaction";
|
|
331
|
+
}
|
|
276
332
|
validateConfiguration() {
|
|
277
333
|
if (!this.config.db)
|
|
278
334
|
throw new Error("Database instance is required");
|
|
@@ -394,15 +450,19 @@ class SqlBaseCrudService {
|
|
|
394
450
|
return roots;
|
|
395
451
|
}
|
|
396
452
|
parseSelect(select) {
|
|
397
|
-
const
|
|
453
|
+
const tree = () => ({
|
|
454
|
+
base: [],
|
|
455
|
+
relations: Object.create(null),
|
|
456
|
+
});
|
|
457
|
+
const root = tree();
|
|
398
458
|
for (const entry of select || []) {
|
|
399
459
|
const segments = (entry || "").split(".").filter(Boolean);
|
|
400
460
|
if (segments.length === 0)
|
|
401
461
|
continue;
|
|
402
462
|
let node = root;
|
|
403
463
|
for (const name of segments.slice(0, -1)) {
|
|
404
|
-
if (!node.relations
|
|
405
|
-
node.relations[name] =
|
|
464
|
+
if (!Object.hasOwn(node.relations, name))
|
|
465
|
+
node.relations[name] = tree();
|
|
406
466
|
node = node.relations[name];
|
|
407
467
|
}
|
|
408
468
|
node.base.push(segments[segments.length - 1]);
|
|
@@ -410,7 +470,9 @@ class SqlBaseCrudService {
|
|
|
410
470
|
return root;
|
|
411
471
|
}
|
|
412
472
|
selectFor(select, name) {
|
|
413
|
-
return select.relations
|
|
473
|
+
return Object.hasOwn(select.relations, name)
|
|
474
|
+
? select.relations[name]
|
|
475
|
+
: { base: [], relations: {} };
|
|
414
476
|
}
|
|
415
477
|
keysForChildren(nodes, hostTable = this.config.table) {
|
|
416
478
|
return nodes
|
|
@@ -825,8 +887,17 @@ class SqlBaseCrudService {
|
|
|
825
887
|
if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT_")) {
|
|
826
888
|
return code.slice("SQLITE_CONSTRAINT_".length);
|
|
827
889
|
}
|
|
890
|
+
if (code === "UNKNOWN_SQLITE_ERROR_3091" || e?.rawCode === 3091) {
|
|
891
|
+
return "DATATYPE";
|
|
892
|
+
}
|
|
893
|
+
if (code === "SQLITE_MISMATCH")
|
|
894
|
+
return "MISMATCH";
|
|
828
895
|
}
|
|
829
896
|
const message = `${error?.cause?.message ?? ""} ${error?.message ?? ""}`;
|
|
897
|
+
if (/cannot store \S+ value in \S+ column/.test(message))
|
|
898
|
+
return "DATATYPE";
|
|
899
|
+
if (/datatype mismatch/.test(message))
|
|
900
|
+
return "MISMATCH";
|
|
830
901
|
const m = message.match(/(UNIQUE|PRIMARY KEY|FOREIGN KEY|NOT NULL|CHECK) constraint failed/);
|
|
831
902
|
return m?.[1]?.replace(" ", "");
|
|
832
903
|
}
|
|
@@ -861,6 +932,8 @@ class SqlBaseCrudService {
|
|
|
861
932
|
static SQLITE_DATA_ERRORS = {
|
|
862
933
|
NOTNULL: "a required value is missing",
|
|
863
934
|
CHECK: "a value violates a column check constraint",
|
|
935
|
+
DATATYPE: "a value does not match its column type",
|
|
936
|
+
MISMATCH: "a value does not match its column type",
|
|
864
937
|
};
|
|
865
938
|
dataErrorMessage(error) {
|
|
866
939
|
const code = error?.code ?? error?.cause?.code;
|
|
@@ -893,7 +966,7 @@ class SqlBaseCrudService {
|
|
|
893
966
|
? text.match(/column '([^']+)'/i)?.[1]
|
|
894
967
|
: undefined) ??
|
|
895
968
|
(this.config.dialect === "sqlite"
|
|
896
|
-
? text.match(/NOT NULL constraint failed
|
|
969
|
+
? text.match(/(?:NOT NULL constraint failed:|value in \S+ column) (?:[^.\s]+\.)?(\S+)/)?.[1]
|
|
897
970
|
: undefined);
|
|
898
971
|
return new crud_exceptions_1.ValidationFailedException(column ? `${message}: ${column}` : message);
|
|
899
972
|
}
|
|
@@ -960,7 +1033,9 @@ class SqlBaseCrudService {
|
|
|
960
1033
|
throw new common_1.BadRequestException(`Invalid full-text query mode: ${String(mode)} (expected 'plain' or 'websearch')`);
|
|
961
1034
|
}
|
|
962
1035
|
const parse = drizzle_orm_1.sql.raw(mode === "websearch" ? "websearch_to_tsquery" : "plainto_tsquery");
|
|
963
|
-
const vector = drizzle_orm_1.sql.join(columns.map((column) =>
|
|
1036
|
+
const vector = drizzle_orm_1.sql.join(columns.map((column) => columns.length > 1
|
|
1037
|
+
? (0, drizzle_orm_1.sql) `to_tsvector(${config}, coalesce(${column}, ''))`
|
|
1038
|
+
: (0, drizzle_orm_1.sql) `to_tsvector(${config}, ${column})`), (0, drizzle_orm_1.sql) ` || `);
|
|
964
1039
|
return { vector, query: (0, drizzle_orm_1.sql) `${parse}(${config}, ${term})` };
|
|
965
1040
|
}
|
|
966
1041
|
buildSearchCondition(search) {
|
|
@@ -1105,7 +1180,7 @@ class SqlBaseCrudService {
|
|
|
1105
1180
|
}
|
|
1106
1181
|
async findFirst(conditions, options) {
|
|
1107
1182
|
const { transaction, relations = [], select = [] } = options || {};
|
|
1108
|
-
const db =
|
|
1183
|
+
const db = this.dbFor(transaction);
|
|
1109
1184
|
const nodes = this.relationTree(relations);
|
|
1110
1185
|
const joined = this.planJoins(nodes);
|
|
1111
1186
|
const selection = this.parseSelect(select);
|
|
@@ -1127,7 +1202,7 @@ class SqlBaseCrudService {
|
|
|
1127
1202
|
planList(filters, sortBy, sortOrder, options) {
|
|
1128
1203
|
this.checkReadOptions(options);
|
|
1129
1204
|
const { transaction, relations = [], select = [] } = options || {};
|
|
1130
|
-
const db =
|
|
1205
|
+
const db = this.dbFor(transaction);
|
|
1131
1206
|
const nodes = this.relationTree(relations);
|
|
1132
1207
|
const joined = this.planJoins(nodes);
|
|
1133
1208
|
const selection = this.parseSelect(select);
|
|
@@ -1395,9 +1470,10 @@ class SqlBaseCrudService {
|
|
|
1395
1470
|
this.checkReadOptions(options);
|
|
1396
1471
|
const entityData = await this.prepareCreate(data, options);
|
|
1397
1472
|
const nested = this.takeNested(entityData, options);
|
|
1398
|
-
const db =
|
|
1473
|
+
const db = this.dbFor(transaction);
|
|
1399
1474
|
await this.emitEvent("before", "create", false, { input: data, transaction }, options);
|
|
1400
1475
|
try {
|
|
1476
|
+
await this.assertKeysInScope(db, entityData, options, nested.map((entry) => entry.name));
|
|
1401
1477
|
const createdEntity = nested.length === 0
|
|
1402
1478
|
? await this.insertOne(db, entityData, options)
|
|
1403
1479
|
: await this.executeSqlTransaction((tx) => this.insertNested(tx, entityData, nested, {
|
|
@@ -1520,6 +1596,55 @@ class SqlBaseCrudService {
|
|
|
1520
1596
|
}
|
|
1521
1597
|
return out;
|
|
1522
1598
|
}
|
|
1599
|
+
async keysOutsideScope(db, rows, options, skip = []) {
|
|
1600
|
+
const failures = new Map();
|
|
1601
|
+
if (!this.resolveScope(options))
|
|
1602
|
+
return failures;
|
|
1603
|
+
for (const [name, relation] of Object.entries(this.config.relations ?? {})) {
|
|
1604
|
+
const local = relation.localKey;
|
|
1605
|
+
if (!local || this.isCollection(relation) || skip.includes(name)) {
|
|
1606
|
+
continue;
|
|
1607
|
+
}
|
|
1608
|
+
const scope = this.relatedScopeConditions(relation.table, options);
|
|
1609
|
+
const referenced = relation.table[relation.references ?? "id"];
|
|
1610
|
+
if (scope.length === 0 || !referenced)
|
|
1611
|
+
continue;
|
|
1612
|
+
const keys = this.uniqueValues(rows.map((row) => row[local]).filter((value) => value != null));
|
|
1613
|
+
if (keys.length === 0)
|
|
1614
|
+
continue;
|
|
1615
|
+
const found = await db
|
|
1616
|
+
.select({ id: referenced })
|
|
1617
|
+
.from(relation.table)
|
|
1618
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(referenced, keys), ...scope));
|
|
1619
|
+
const present = new Set(found.map((row) => this.valueKey(row.id)));
|
|
1620
|
+
rows.forEach((row, index) => {
|
|
1621
|
+
const value = row[local];
|
|
1622
|
+
if (value == null || present.has(this.valueKey(value)))
|
|
1623
|
+
return;
|
|
1624
|
+
if (!failures.has(index)) {
|
|
1625
|
+
failures.set(index, this.missingParentException(local));
|
|
1626
|
+
}
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
return failures;
|
|
1630
|
+
}
|
|
1631
|
+
async assertBatchKeysInScope(db, rows, refs, message, options) {
|
|
1632
|
+
const failures = await this.keysOutsideScope(db, rows, options);
|
|
1633
|
+
if (failures.size === 0)
|
|
1634
|
+
return;
|
|
1635
|
+
throw new crud_exceptions_1.BulkOperationException(message, Array.from(failures, ([index, error]) => ({ ...refs[index], error })));
|
|
1636
|
+
}
|
|
1637
|
+
async assertKeysInScope(db, row, options, skip = []) {
|
|
1638
|
+
const failure = (await this.keysOutsideScope(db, [row], options, skip)).get(0);
|
|
1639
|
+
if (failure)
|
|
1640
|
+
throw failure;
|
|
1641
|
+
}
|
|
1642
|
+
missingParentException(localKey) {
|
|
1643
|
+
const field = this.config.dialect === "postgresql"
|
|
1644
|
+
? (this.columnOf(this.config.table, localKey)?.name ?? localKey)
|
|
1645
|
+
: "reference";
|
|
1646
|
+
return new crud_exceptions_1.ForeignKeyViolationException(this.getEntityName(), field);
|
|
1647
|
+
}
|
|
1523
1648
|
async linkTargets(tx, relation, parentValue, ids, options) {
|
|
1524
1649
|
const wanted = this.uniqueValues(ids);
|
|
1525
1650
|
if (wanted.length === 0)
|
|
@@ -1686,9 +1811,10 @@ class SqlBaseCrudService {
|
|
|
1686
1811
|
if (this.config.timestamps?.updatedAt) {
|
|
1687
1812
|
entityData[this.config.timestamps.updatedAt] = new Date();
|
|
1688
1813
|
}
|
|
1689
|
-
const db =
|
|
1814
|
+
const db = this.dbFor(transaction);
|
|
1690
1815
|
await this.emitEvent("before", "update", false, { ids: [id], input: data, transaction }, options);
|
|
1691
1816
|
try {
|
|
1817
|
+
await this.assertKeysInScope(db, entityData, opts);
|
|
1692
1818
|
const [updatedEntity] = await this.updateWhereIn(db, this.withVersionBump(entityData), [id], opts);
|
|
1693
1819
|
if (!updatedEntity) {
|
|
1694
1820
|
if (expected !== undefined) {
|
|
@@ -1751,7 +1877,7 @@ class SqlBaseCrudService {
|
|
|
1751
1877
|
if (!hooks.skipBefore)
|
|
1752
1878
|
await this.beforeSoftDelete(id);
|
|
1753
1879
|
await this.emitEvent("before", "softDelete", false, { ids: [id], transaction }, options);
|
|
1754
|
-
const db =
|
|
1880
|
+
const db = this.dbFor(transaction);
|
|
1755
1881
|
let updateQuery = db
|
|
1756
1882
|
.update(this.config.table)
|
|
1757
1883
|
.set(this.softDeleteSet(new Date()))
|
|
@@ -1793,7 +1919,7 @@ class SqlBaseCrudService {
|
|
|
1793
1919
|
if (!hooks.skipBefore)
|
|
1794
1920
|
await this.beforeRestore(id);
|
|
1795
1921
|
await this.emitEvent("before", "restore", false, { ids: [id], transaction }, options);
|
|
1796
|
-
const db =
|
|
1922
|
+
const db = this.dbFor(transaction);
|
|
1797
1923
|
const [restoredEntity] = await this.runRead(this.updateWhereIn(db, this.softDeleteSet(null), [id], options));
|
|
1798
1924
|
if (!restoredEntity) {
|
|
1799
1925
|
if (options?.expectedVersion != null && this.config.versionColumn) {
|
|
@@ -1815,7 +1941,7 @@ class SqlBaseCrudService {
|
|
|
1815
1941
|
if (!hooks.skipBefore)
|
|
1816
1942
|
await this.beforeDelete(id);
|
|
1817
1943
|
await this.emitEvent("before", "delete", false, { ids: [id], transaction }, options);
|
|
1818
|
-
const db =
|
|
1944
|
+
const db = this.dbFor(transaction);
|
|
1819
1945
|
const deleteQuery = db
|
|
1820
1946
|
.delete(this.config.table)
|
|
1821
1947
|
.where(this.writeWhere([id], options));
|
|
@@ -1846,10 +1972,11 @@ class SqlBaseCrudService {
|
|
|
1846
1972
|
async upsert(data, upsert = {}, options) {
|
|
1847
1973
|
this.checkReadOptions(options);
|
|
1848
1974
|
const entityData = await this.prepareCreate(data, options);
|
|
1849
|
-
const db = options?.transaction
|
|
1975
|
+
const db = this.dbFor(options?.transaction);
|
|
1850
1976
|
await this.emitEvent("before", "upsert", false, { input: data, transaction: options?.transaction }, options);
|
|
1851
1977
|
let row;
|
|
1852
1978
|
try {
|
|
1979
|
+
await this.assertKeysInScope(db, entityData, options);
|
|
1853
1980
|
[row] = await this.upsertRows(db, [entityData], upsert, options);
|
|
1854
1981
|
}
|
|
1855
1982
|
catch (error) {
|
|
@@ -1869,7 +1996,6 @@ class SqlBaseCrudService {
|
|
|
1869
1996
|
}
|
|
1870
1997
|
async massUpsert(data, upsert = {}, options) {
|
|
1871
1998
|
this.checkReadOptions(options);
|
|
1872
|
-
this.assertBatchSize(this.insertParameters(data));
|
|
1873
1999
|
const message = "Mass upsert errors";
|
|
1874
2000
|
return this.executeSqlTransaction(async (tx) => {
|
|
1875
2001
|
const opts = { ...options, transaction: tx };
|
|
@@ -1880,6 +2006,7 @@ class SqlBaseCrudService {
|
|
|
1880
2006
|
if (rows.length === 0)
|
|
1881
2007
|
return [];
|
|
1882
2008
|
await this.emitEvent("before", "upsert", true, { input: data, transaction: tx }, options);
|
|
2009
|
+
await this.assertBatchKeysInScope(tx, rows, rows.map((_, index) => ({ index })), message, opts);
|
|
1883
2010
|
const written = await this.runSet(tx, message, (sp) => this.upsertRows(sp, rows, upsert, opts), rows.map((row, index) => ({
|
|
1884
2011
|
index,
|
|
1885
2012
|
run: (sp) => this.upsertRows(sp, [row], upsert, opts),
|
|
@@ -1969,7 +2096,7 @@ class SqlBaseCrudService {
|
|
|
1969
2096
|
let statement;
|
|
1970
2097
|
if (this.config.dialect === "mysql") {
|
|
1971
2098
|
if (scope) {
|
|
1972
|
-
throw new common_1.BadRequestException("upsert on a scoped entity needs PostgreSQL");
|
|
2099
|
+
throw new common_1.BadRequestException("upsert on a scoped entity needs PostgreSQL or SQLite");
|
|
1973
2100
|
}
|
|
1974
2101
|
statement = insert.onDuplicateKeyUpdate({ set });
|
|
1975
2102
|
}
|
|
@@ -2004,7 +2131,6 @@ class SqlBaseCrudService {
|
|
|
2004
2131
|
return this.massCreatePerItem(data, options);
|
|
2005
2132
|
}
|
|
2006
2133
|
this.checkReadOptions(options);
|
|
2007
|
-
this.assertBatchSize(this.insertParameters(data));
|
|
2008
2134
|
return this.executeSqlTransaction(async (tx) => {
|
|
2009
2135
|
const opts = { ...options, transaction: tx };
|
|
2010
2136
|
const rows = await this.eachItem(data.map((item, index) => ({
|
|
@@ -2014,6 +2140,7 @@ class SqlBaseCrudService {
|
|
|
2014
2140
|
if (rows.length === 0)
|
|
2015
2141
|
return [];
|
|
2016
2142
|
await this.emitEvent("before", "create", true, { input: data, transaction: tx }, options);
|
|
2143
|
+
await this.assertBatchKeysInScope(tx, rows, rows.map((_, index) => ({ index })), "Mass create errors", opts);
|
|
2017
2144
|
const created = await this.runSet(tx, "Mass create errors", (sp) => this.insertMany(sp, rows, opts), rows.map((row, index) => ({
|
|
2018
2145
|
index,
|
|
2019
2146
|
run: (sp) => this.insertOne(sp, row, opts),
|
|
@@ -2063,7 +2190,6 @@ class SqlBaseCrudService {
|
|
|
2063
2190
|
if (options?.bulkMode === "perItem") {
|
|
2064
2191
|
return this.massUpdatePerItem(ids, data, options);
|
|
2065
2192
|
}
|
|
2066
|
-
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2067
2193
|
this.checkReadOptions(options);
|
|
2068
2194
|
const message = "Mass update errors";
|
|
2069
2195
|
return this.executeSqlTransaction(async (tx) => {
|
|
@@ -2084,6 +2210,7 @@ class SqlBaseCrudService {
|
|
|
2084
2210
|
},
|
|
2085
2211
|
})), message);
|
|
2086
2212
|
await this.emitEvent("before", "update", true, { ids: unique, input: data, transaction: tx }, options);
|
|
2213
|
+
await this.assertBatchKeysInScope(tx, sets, unique.map((id) => ({ id })), message, opts);
|
|
2087
2214
|
const now = new Date();
|
|
2088
2215
|
const stamp = (set) => this.withVersionBump(this.config.timestamps?.updatedAt
|
|
2089
2216
|
? { ...set, [this.config.timestamps.updatedAt]: now }
|
|
@@ -2131,7 +2258,6 @@ class SqlBaseCrudService {
|
|
|
2131
2258
|
if (options?.bulkMode === "perItem") {
|
|
2132
2259
|
return this.massSoftDeletePerItem(ids, options);
|
|
2133
2260
|
}
|
|
2134
|
-
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2135
2261
|
const message = "Mass soft delete errors";
|
|
2136
2262
|
return this.executeSqlTransaction(async (tx) => {
|
|
2137
2263
|
const opts = {
|
|
@@ -2175,7 +2301,6 @@ class SqlBaseCrudService {
|
|
|
2175
2301
|
if (options?.bulkMode === "perItem") {
|
|
2176
2302
|
return this.massRestorePerItem(ids, options);
|
|
2177
2303
|
}
|
|
2178
|
-
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2179
2304
|
this.checkReadOptions(options);
|
|
2180
2305
|
const message = "Mass restore errors";
|
|
2181
2306
|
return this.executeSqlTransaction(async (tx) => {
|
|
@@ -2213,7 +2338,6 @@ class SqlBaseCrudService {
|
|
|
2213
2338
|
if (options?.bulkMode === "perItem") {
|
|
2214
2339
|
return this.massDeletePerItem(ids, options);
|
|
2215
2340
|
}
|
|
2216
|
-
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2217
2341
|
const message = "Mass delete errors";
|
|
2218
2342
|
return this.executeSqlTransaction(async (tx) => {
|
|
2219
2343
|
const opts = { ...options, transaction: tx };
|
|
@@ -2326,6 +2450,8 @@ class SqlBaseCrudService {
|
|
|
2326
2450
|
return await this.savepoint(tx, statement);
|
|
2327
2451
|
}
|
|
2328
2452
|
catch (error) {
|
|
2453
|
+
if (error instanceof BindLimitException)
|
|
2454
|
+
throw error;
|
|
2329
2455
|
const failures = await this.diagnose(tx, items, operation);
|
|
2330
2456
|
if (failures.length > 0) {
|
|
2331
2457
|
throw new crud_exceptions_1.BulkOperationException(message, failures);
|
|
@@ -2391,7 +2517,7 @@ class SqlBaseCrudService {
|
|
|
2391
2517
|
}
|
|
2392
2518
|
savepoint(tx, run) {
|
|
2393
2519
|
return typeof tx?.transaction === "function"
|
|
2394
|
-
? tx.transaction(run)
|
|
2520
|
+
? tx.transaction((sp) => run(this.withBindLimit(sp)))
|
|
2395
2521
|
: run(tx);
|
|
2396
2522
|
}
|
|
2397
2523
|
async exists(id, options) {
|
|
@@ -2403,14 +2529,14 @@ class SqlBaseCrudService {
|
|
|
2403
2529
|
}
|
|
2404
2530
|
async count(filters, options) {
|
|
2405
2531
|
this.checkReadOptions(options);
|
|
2406
|
-
const db = options?.transaction
|
|
2532
|
+
const db = this.dbFor(options?.transaction);
|
|
2407
2533
|
const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
|
|
2408
2534
|
const result = await this.runRead(this.buildCountQuery(db, conditions, filterRelations, options));
|
|
2409
2535
|
return parseInt(result[0]?.count?.toString() || "0", 10);
|
|
2410
2536
|
}
|
|
2411
2537
|
async countBy(column, filters, options) {
|
|
2412
2538
|
this.checkReadOptions(options);
|
|
2413
|
-
const db = options?.transaction
|
|
2539
|
+
const db = this.dbFor(options?.transaction);
|
|
2414
2540
|
const spec = this.resolveGroupColumn(column, options);
|
|
2415
2541
|
const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
|
|
2416
2542
|
const joins = Array.from(new Set([...filterRelations, ...(spec.relation ? [spec.relation] : [])]));
|
|
@@ -2494,6 +2620,8 @@ class SqlBaseCrudService {
|
|
|
2494
2620
|
const filter = !Array.isArray(counts) && (0, merge_config_1.isPlainObject)(counts?.[name])
|
|
2495
2621
|
? counts?.[name]
|
|
2496
2622
|
: undefined;
|
|
2623
|
+
if (filter)
|
|
2624
|
+
(0, query_parser_1.assertFilterDepth)(filter);
|
|
2497
2625
|
return { name, relation, filter };
|
|
2498
2626
|
});
|
|
2499
2627
|
const hosts = rows.filter((row) => row && typeof row === "object");
|
|
@@ -2550,7 +2678,6 @@ class SqlBaseCrudService {
|
|
|
2550
2678
|
}
|
|
2551
2679
|
pushColumnCondition(conditions, column, value, caseInsensitive = this.config.sql?.caseSensitive === false) {
|
|
2552
2680
|
if (Array.isArray(value)) {
|
|
2553
|
-
this.assertBatchSize(value.length);
|
|
2554
2681
|
conditions.push((0, drizzle_orm_1.inArray)(column, value.map((item) => this.coerceFilterValue(column, item))));
|
|
2555
2682
|
}
|
|
2556
2683
|
else if ((0, merge_config_1.isPlainObject)(value)) {
|
|
@@ -2799,7 +2926,6 @@ class SqlBaseCrudService {
|
|
|
2799
2926
|
if (!Array.isArray(raw)) {
|
|
2800
2927
|
throw new common_1.BadRequestException(`Filter operator "${op}" expects an array`);
|
|
2801
2928
|
}
|
|
2802
|
-
this.assertBatchSize(raw.length);
|
|
2803
2929
|
return raw;
|
|
2804
2930
|
}
|
|
2805
2931
|
static MAX_BATCH_PARAMETERS = 60_000;
|
|
@@ -2837,9 +2963,19 @@ class SqlBaseCrudService {
|
|
|
2837
2963
|
: kind === "endsWith"
|
|
2838
2964
|
? `%${escaped}`
|
|
2839
2965
|
: `%${escaped}%`;
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2966
|
+
if (insensitive || this.config.sql?.caseSensitive === false) {
|
|
2967
|
+
return this.caseInsensitiveLike(column, pattern);
|
|
2968
|
+
}
|
|
2969
|
+
if (this.config.dialect === "sqlite") {
|
|
2970
|
+
const literal = raw.replace(/[*?[]/g, "[$&]");
|
|
2971
|
+
const glob = kind === "startsWith"
|
|
2972
|
+
? `${literal}*`
|
|
2973
|
+
: kind === "endsWith"
|
|
2974
|
+
? `*${literal}`
|
|
2975
|
+
: `*${literal}*`;
|
|
2976
|
+
return (0, drizzle_orm_1.sql) `${column} glob ${glob}`;
|
|
2977
|
+
}
|
|
2978
|
+
return this.likePattern(column, pattern);
|
|
2843
2979
|
}
|
|
2844
2980
|
applyComplexFilter(conditions, column, filterObj) {
|
|
2845
2981
|
const coerce = (value) => this.coerceFilterValue(column, value);
|
|
@@ -2930,15 +3066,15 @@ class SqlBaseCrudService {
|
|
|
2930
3066
|
}
|
|
2931
3067
|
async executeSqlTransaction(operation, existingTransaction) {
|
|
2932
3068
|
this.assertTransactions(existingTransaction ?? this.config.db);
|
|
3069
|
+
const run = (tx) => operation(this.withBindLimit(tx));
|
|
2933
3070
|
if (existingTransaction)
|
|
2934
|
-
return
|
|
2935
|
-
return this.config.db.transaction(
|
|
3071
|
+
return run(existingTransaction);
|
|
3072
|
+
return this.config.db.transaction(run);
|
|
2936
3073
|
}
|
|
2937
3074
|
assertTransactions(db) {
|
|
2938
3075
|
if (this.config.dialect !== "sqlite")
|
|
2939
3076
|
return;
|
|
2940
|
-
|
|
2941
|
-
if (kind === "D1Database" || kind === "D1Transaction") {
|
|
3077
|
+
if (this.isD1(db)) {
|
|
2942
3078
|
throw new crud_exceptions_1.TransactionException("Cloudflare D1 has no interactive transactions, so mass writes, nested writes and attach/detach/sync are not available on it. Use the single-row methods, or db.batch() yourself.");
|
|
2943
3079
|
}
|
|
2944
3080
|
if (db?.resultKind === "sync") {
|