@avytrix/nestjs-drizzle-crud 3.7.1 → 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 +59 -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 +158 -28
- 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,65 @@ 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
|
+
|
|
14
73
|
## [3.7.1] - 2026-11-02
|
|
15
74
|
|
|
16
75
|
### Fixed
|
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");
|
|
@@ -831,8 +887,17 @@ class SqlBaseCrudService {
|
|
|
831
887
|
if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT_")) {
|
|
832
888
|
return code.slice("SQLITE_CONSTRAINT_".length);
|
|
833
889
|
}
|
|
890
|
+
if (code === "UNKNOWN_SQLITE_ERROR_3091" || e?.rawCode === 3091) {
|
|
891
|
+
return "DATATYPE";
|
|
892
|
+
}
|
|
893
|
+
if (code === "SQLITE_MISMATCH")
|
|
894
|
+
return "MISMATCH";
|
|
834
895
|
}
|
|
835
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";
|
|
836
901
|
const m = message.match(/(UNIQUE|PRIMARY KEY|FOREIGN KEY|NOT NULL|CHECK) constraint failed/);
|
|
837
902
|
return m?.[1]?.replace(" ", "");
|
|
838
903
|
}
|
|
@@ -867,6 +932,8 @@ class SqlBaseCrudService {
|
|
|
867
932
|
static SQLITE_DATA_ERRORS = {
|
|
868
933
|
NOTNULL: "a required value is missing",
|
|
869
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",
|
|
870
937
|
};
|
|
871
938
|
dataErrorMessage(error) {
|
|
872
939
|
const code = error?.code ?? error?.cause?.code;
|
|
@@ -899,7 +966,7 @@ class SqlBaseCrudService {
|
|
|
899
966
|
? text.match(/column '([^']+)'/i)?.[1]
|
|
900
967
|
: undefined) ??
|
|
901
968
|
(this.config.dialect === "sqlite"
|
|
902
|
-
? text.match(/NOT NULL constraint failed
|
|
969
|
+
? text.match(/(?:NOT NULL constraint failed:|value in \S+ column) (?:[^.\s]+\.)?(\S+)/)?.[1]
|
|
903
970
|
: undefined);
|
|
904
971
|
return new crud_exceptions_1.ValidationFailedException(column ? `${message}: ${column}` : message);
|
|
905
972
|
}
|
|
@@ -966,7 +1033,9 @@ class SqlBaseCrudService {
|
|
|
966
1033
|
throw new common_1.BadRequestException(`Invalid full-text query mode: ${String(mode)} (expected 'plain' or 'websearch')`);
|
|
967
1034
|
}
|
|
968
1035
|
const parse = drizzle_orm_1.sql.raw(mode === "websearch" ? "websearch_to_tsquery" : "plainto_tsquery");
|
|
969
|
-
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) ` || `);
|
|
970
1039
|
return { vector, query: (0, drizzle_orm_1.sql) `${parse}(${config}, ${term})` };
|
|
971
1040
|
}
|
|
972
1041
|
buildSearchCondition(search) {
|
|
@@ -1111,7 +1180,7 @@ class SqlBaseCrudService {
|
|
|
1111
1180
|
}
|
|
1112
1181
|
async findFirst(conditions, options) {
|
|
1113
1182
|
const { transaction, relations = [], select = [] } = options || {};
|
|
1114
|
-
const db =
|
|
1183
|
+
const db = this.dbFor(transaction);
|
|
1115
1184
|
const nodes = this.relationTree(relations);
|
|
1116
1185
|
const joined = this.planJoins(nodes);
|
|
1117
1186
|
const selection = this.parseSelect(select);
|
|
@@ -1133,7 +1202,7 @@ class SqlBaseCrudService {
|
|
|
1133
1202
|
planList(filters, sortBy, sortOrder, options) {
|
|
1134
1203
|
this.checkReadOptions(options);
|
|
1135
1204
|
const { transaction, relations = [], select = [] } = options || {};
|
|
1136
|
-
const db =
|
|
1205
|
+
const db = this.dbFor(transaction);
|
|
1137
1206
|
const nodes = this.relationTree(relations);
|
|
1138
1207
|
const joined = this.planJoins(nodes);
|
|
1139
1208
|
const selection = this.parseSelect(select);
|
|
@@ -1401,9 +1470,10 @@ class SqlBaseCrudService {
|
|
|
1401
1470
|
this.checkReadOptions(options);
|
|
1402
1471
|
const entityData = await this.prepareCreate(data, options);
|
|
1403
1472
|
const nested = this.takeNested(entityData, options);
|
|
1404
|
-
const db =
|
|
1473
|
+
const db = this.dbFor(transaction);
|
|
1405
1474
|
await this.emitEvent("before", "create", false, { input: data, transaction }, options);
|
|
1406
1475
|
try {
|
|
1476
|
+
await this.assertKeysInScope(db, entityData, options, nested.map((entry) => entry.name));
|
|
1407
1477
|
const createdEntity = nested.length === 0
|
|
1408
1478
|
? await this.insertOne(db, entityData, options)
|
|
1409
1479
|
: await this.executeSqlTransaction((tx) => this.insertNested(tx, entityData, nested, {
|
|
@@ -1526,6 +1596,55 @@ class SqlBaseCrudService {
|
|
|
1526
1596
|
}
|
|
1527
1597
|
return out;
|
|
1528
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
|
+
}
|
|
1529
1648
|
async linkTargets(tx, relation, parentValue, ids, options) {
|
|
1530
1649
|
const wanted = this.uniqueValues(ids);
|
|
1531
1650
|
if (wanted.length === 0)
|
|
@@ -1692,9 +1811,10 @@ class SqlBaseCrudService {
|
|
|
1692
1811
|
if (this.config.timestamps?.updatedAt) {
|
|
1693
1812
|
entityData[this.config.timestamps.updatedAt] = new Date();
|
|
1694
1813
|
}
|
|
1695
|
-
const db =
|
|
1814
|
+
const db = this.dbFor(transaction);
|
|
1696
1815
|
await this.emitEvent("before", "update", false, { ids: [id], input: data, transaction }, options);
|
|
1697
1816
|
try {
|
|
1817
|
+
await this.assertKeysInScope(db, entityData, opts);
|
|
1698
1818
|
const [updatedEntity] = await this.updateWhereIn(db, this.withVersionBump(entityData), [id], opts);
|
|
1699
1819
|
if (!updatedEntity) {
|
|
1700
1820
|
if (expected !== undefined) {
|
|
@@ -1757,7 +1877,7 @@ class SqlBaseCrudService {
|
|
|
1757
1877
|
if (!hooks.skipBefore)
|
|
1758
1878
|
await this.beforeSoftDelete(id);
|
|
1759
1879
|
await this.emitEvent("before", "softDelete", false, { ids: [id], transaction }, options);
|
|
1760
|
-
const db =
|
|
1880
|
+
const db = this.dbFor(transaction);
|
|
1761
1881
|
let updateQuery = db
|
|
1762
1882
|
.update(this.config.table)
|
|
1763
1883
|
.set(this.softDeleteSet(new Date()))
|
|
@@ -1799,7 +1919,7 @@ class SqlBaseCrudService {
|
|
|
1799
1919
|
if (!hooks.skipBefore)
|
|
1800
1920
|
await this.beforeRestore(id);
|
|
1801
1921
|
await this.emitEvent("before", "restore", false, { ids: [id], transaction }, options);
|
|
1802
|
-
const db =
|
|
1922
|
+
const db = this.dbFor(transaction);
|
|
1803
1923
|
const [restoredEntity] = await this.runRead(this.updateWhereIn(db, this.softDeleteSet(null), [id], options));
|
|
1804
1924
|
if (!restoredEntity) {
|
|
1805
1925
|
if (options?.expectedVersion != null && this.config.versionColumn) {
|
|
@@ -1821,7 +1941,7 @@ class SqlBaseCrudService {
|
|
|
1821
1941
|
if (!hooks.skipBefore)
|
|
1822
1942
|
await this.beforeDelete(id);
|
|
1823
1943
|
await this.emitEvent("before", "delete", false, { ids: [id], transaction }, options);
|
|
1824
|
-
const db =
|
|
1944
|
+
const db = this.dbFor(transaction);
|
|
1825
1945
|
const deleteQuery = db
|
|
1826
1946
|
.delete(this.config.table)
|
|
1827
1947
|
.where(this.writeWhere([id], options));
|
|
@@ -1852,10 +1972,11 @@ class SqlBaseCrudService {
|
|
|
1852
1972
|
async upsert(data, upsert = {}, options) {
|
|
1853
1973
|
this.checkReadOptions(options);
|
|
1854
1974
|
const entityData = await this.prepareCreate(data, options);
|
|
1855
|
-
const db = options?.transaction
|
|
1975
|
+
const db = this.dbFor(options?.transaction);
|
|
1856
1976
|
await this.emitEvent("before", "upsert", false, { input: data, transaction: options?.transaction }, options);
|
|
1857
1977
|
let row;
|
|
1858
1978
|
try {
|
|
1979
|
+
await this.assertKeysInScope(db, entityData, options);
|
|
1859
1980
|
[row] = await this.upsertRows(db, [entityData], upsert, options);
|
|
1860
1981
|
}
|
|
1861
1982
|
catch (error) {
|
|
@@ -1875,7 +1996,6 @@ class SqlBaseCrudService {
|
|
|
1875
1996
|
}
|
|
1876
1997
|
async massUpsert(data, upsert = {}, options) {
|
|
1877
1998
|
this.checkReadOptions(options);
|
|
1878
|
-
this.assertBatchSize(this.insertParameters(data));
|
|
1879
1999
|
const message = "Mass upsert errors";
|
|
1880
2000
|
return this.executeSqlTransaction(async (tx) => {
|
|
1881
2001
|
const opts = { ...options, transaction: tx };
|
|
@@ -1886,6 +2006,7 @@ class SqlBaseCrudService {
|
|
|
1886
2006
|
if (rows.length === 0)
|
|
1887
2007
|
return [];
|
|
1888
2008
|
await this.emitEvent("before", "upsert", true, { input: data, transaction: tx }, options);
|
|
2009
|
+
await this.assertBatchKeysInScope(tx, rows, rows.map((_, index) => ({ index })), message, opts);
|
|
1889
2010
|
const written = await this.runSet(tx, message, (sp) => this.upsertRows(sp, rows, upsert, opts), rows.map((row, index) => ({
|
|
1890
2011
|
index,
|
|
1891
2012
|
run: (sp) => this.upsertRows(sp, [row], upsert, opts),
|
|
@@ -2010,7 +2131,6 @@ class SqlBaseCrudService {
|
|
|
2010
2131
|
return this.massCreatePerItem(data, options);
|
|
2011
2132
|
}
|
|
2012
2133
|
this.checkReadOptions(options);
|
|
2013
|
-
this.assertBatchSize(this.insertParameters(data));
|
|
2014
2134
|
return this.executeSqlTransaction(async (tx) => {
|
|
2015
2135
|
const opts = { ...options, transaction: tx };
|
|
2016
2136
|
const rows = await this.eachItem(data.map((item, index) => ({
|
|
@@ -2020,6 +2140,7 @@ class SqlBaseCrudService {
|
|
|
2020
2140
|
if (rows.length === 0)
|
|
2021
2141
|
return [];
|
|
2022
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);
|
|
2023
2144
|
const created = await this.runSet(tx, "Mass create errors", (sp) => this.insertMany(sp, rows, opts), rows.map((row, index) => ({
|
|
2024
2145
|
index,
|
|
2025
2146
|
run: (sp) => this.insertOne(sp, row, opts),
|
|
@@ -2069,7 +2190,6 @@ class SqlBaseCrudService {
|
|
|
2069
2190
|
if (options?.bulkMode === "perItem") {
|
|
2070
2191
|
return this.massUpdatePerItem(ids, data, options);
|
|
2071
2192
|
}
|
|
2072
|
-
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2073
2193
|
this.checkReadOptions(options);
|
|
2074
2194
|
const message = "Mass update errors";
|
|
2075
2195
|
return this.executeSqlTransaction(async (tx) => {
|
|
@@ -2090,6 +2210,7 @@ class SqlBaseCrudService {
|
|
|
2090
2210
|
},
|
|
2091
2211
|
})), message);
|
|
2092
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);
|
|
2093
2214
|
const now = new Date();
|
|
2094
2215
|
const stamp = (set) => this.withVersionBump(this.config.timestamps?.updatedAt
|
|
2095
2216
|
? { ...set, [this.config.timestamps.updatedAt]: now }
|
|
@@ -2137,7 +2258,6 @@ class SqlBaseCrudService {
|
|
|
2137
2258
|
if (options?.bulkMode === "perItem") {
|
|
2138
2259
|
return this.massSoftDeletePerItem(ids, options);
|
|
2139
2260
|
}
|
|
2140
|
-
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2141
2261
|
const message = "Mass soft delete errors";
|
|
2142
2262
|
return this.executeSqlTransaction(async (tx) => {
|
|
2143
2263
|
const opts = {
|
|
@@ -2181,7 +2301,6 @@ class SqlBaseCrudService {
|
|
|
2181
2301
|
if (options?.bulkMode === "perItem") {
|
|
2182
2302
|
return this.massRestorePerItem(ids, options);
|
|
2183
2303
|
}
|
|
2184
|
-
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2185
2304
|
this.checkReadOptions(options);
|
|
2186
2305
|
const message = "Mass restore errors";
|
|
2187
2306
|
return this.executeSqlTransaction(async (tx) => {
|
|
@@ -2219,7 +2338,6 @@ class SqlBaseCrudService {
|
|
|
2219
2338
|
if (options?.bulkMode === "perItem") {
|
|
2220
2339
|
return this.massDeletePerItem(ids, options);
|
|
2221
2340
|
}
|
|
2222
|
-
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2223
2341
|
const message = "Mass delete errors";
|
|
2224
2342
|
return this.executeSqlTransaction(async (tx) => {
|
|
2225
2343
|
const opts = { ...options, transaction: tx };
|
|
@@ -2332,6 +2450,8 @@ class SqlBaseCrudService {
|
|
|
2332
2450
|
return await this.savepoint(tx, statement);
|
|
2333
2451
|
}
|
|
2334
2452
|
catch (error) {
|
|
2453
|
+
if (error instanceof BindLimitException)
|
|
2454
|
+
throw error;
|
|
2335
2455
|
const failures = await this.diagnose(tx, items, operation);
|
|
2336
2456
|
if (failures.length > 0) {
|
|
2337
2457
|
throw new crud_exceptions_1.BulkOperationException(message, failures);
|
|
@@ -2397,7 +2517,7 @@ class SqlBaseCrudService {
|
|
|
2397
2517
|
}
|
|
2398
2518
|
savepoint(tx, run) {
|
|
2399
2519
|
return typeof tx?.transaction === "function"
|
|
2400
|
-
? tx.transaction(run)
|
|
2520
|
+
? tx.transaction((sp) => run(this.withBindLimit(sp)))
|
|
2401
2521
|
: run(tx);
|
|
2402
2522
|
}
|
|
2403
2523
|
async exists(id, options) {
|
|
@@ -2409,14 +2529,14 @@ class SqlBaseCrudService {
|
|
|
2409
2529
|
}
|
|
2410
2530
|
async count(filters, options) {
|
|
2411
2531
|
this.checkReadOptions(options);
|
|
2412
|
-
const db = options?.transaction
|
|
2532
|
+
const db = this.dbFor(options?.transaction);
|
|
2413
2533
|
const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
|
|
2414
2534
|
const result = await this.runRead(this.buildCountQuery(db, conditions, filterRelations, options));
|
|
2415
2535
|
return parseInt(result[0]?.count?.toString() || "0", 10);
|
|
2416
2536
|
}
|
|
2417
2537
|
async countBy(column, filters, options) {
|
|
2418
2538
|
this.checkReadOptions(options);
|
|
2419
|
-
const db = options?.transaction
|
|
2539
|
+
const db = this.dbFor(options?.transaction);
|
|
2420
2540
|
const spec = this.resolveGroupColumn(column, options);
|
|
2421
2541
|
const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
|
|
2422
2542
|
const joins = Array.from(new Set([...filterRelations, ...(spec.relation ? [spec.relation] : [])]));
|
|
@@ -2500,6 +2620,8 @@ class SqlBaseCrudService {
|
|
|
2500
2620
|
const filter = !Array.isArray(counts) && (0, merge_config_1.isPlainObject)(counts?.[name])
|
|
2501
2621
|
? counts?.[name]
|
|
2502
2622
|
: undefined;
|
|
2623
|
+
if (filter)
|
|
2624
|
+
(0, query_parser_1.assertFilterDepth)(filter);
|
|
2503
2625
|
return { name, relation, filter };
|
|
2504
2626
|
});
|
|
2505
2627
|
const hosts = rows.filter((row) => row && typeof row === "object");
|
|
@@ -2556,7 +2678,6 @@ class SqlBaseCrudService {
|
|
|
2556
2678
|
}
|
|
2557
2679
|
pushColumnCondition(conditions, column, value, caseInsensitive = this.config.sql?.caseSensitive === false) {
|
|
2558
2680
|
if (Array.isArray(value)) {
|
|
2559
|
-
this.assertBatchSize(value.length);
|
|
2560
2681
|
conditions.push((0, drizzle_orm_1.inArray)(column, value.map((item) => this.coerceFilterValue(column, item))));
|
|
2561
2682
|
}
|
|
2562
2683
|
else if ((0, merge_config_1.isPlainObject)(value)) {
|
|
@@ -2805,7 +2926,6 @@ class SqlBaseCrudService {
|
|
|
2805
2926
|
if (!Array.isArray(raw)) {
|
|
2806
2927
|
throw new common_1.BadRequestException(`Filter operator "${op}" expects an array`);
|
|
2807
2928
|
}
|
|
2808
|
-
this.assertBatchSize(raw.length);
|
|
2809
2929
|
return raw;
|
|
2810
2930
|
}
|
|
2811
2931
|
static MAX_BATCH_PARAMETERS = 60_000;
|
|
@@ -2843,9 +2963,19 @@ class SqlBaseCrudService {
|
|
|
2843
2963
|
: kind === "endsWith"
|
|
2844
2964
|
? `%${escaped}`
|
|
2845
2965
|
: `%${escaped}%`;
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
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);
|
|
2849
2979
|
}
|
|
2850
2980
|
applyComplexFilter(conditions, column, filterObj) {
|
|
2851
2981
|
const coerce = (value) => this.coerceFilterValue(column, value);
|
|
@@ -2936,15 +3066,15 @@ class SqlBaseCrudService {
|
|
|
2936
3066
|
}
|
|
2937
3067
|
async executeSqlTransaction(operation, existingTransaction) {
|
|
2938
3068
|
this.assertTransactions(existingTransaction ?? this.config.db);
|
|
3069
|
+
const run = (tx) => operation(this.withBindLimit(tx));
|
|
2939
3070
|
if (existingTransaction)
|
|
2940
|
-
return
|
|
2941
|
-
return this.config.db.transaction(
|
|
3071
|
+
return run(existingTransaction);
|
|
3072
|
+
return this.config.db.transaction(run);
|
|
2942
3073
|
}
|
|
2943
3074
|
assertTransactions(db) {
|
|
2944
3075
|
if (this.config.dialect !== "sqlite")
|
|
2945
3076
|
return;
|
|
2946
|
-
|
|
2947
|
-
if (kind === "D1Database" || kind === "D1Transaction") {
|
|
3077
|
+
if (this.isD1(db)) {
|
|
2948
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.");
|
|
2949
3079
|
}
|
|
2950
3080
|
if (db?.resultKind === "sync") {
|