@avytrix/nestjs-drizzle-crud 3.7.1 → 3.8.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.
@@ -10,7 +10,23 @@ const merge_config_1 = require("../utils/merge-config");
10
10
  const query_parser_1 = require("../utils/query-parser");
11
11
  const JOIN_KEY = "__crudJoinKey";
12
12
  const CURSOR_KEY = "__crudCursor";
13
+ const SCOPE_MEMO = Symbol("scopeMemo");
13
14
  const ROLLBACK = Symbol("rollback");
15
+ class DeferredDiagnosis {
16
+ settle;
17
+ constructor(settle) {
18
+ this.settle = settle;
19
+ }
20
+ }
21
+ const BIND_LIMITS = {
22
+ postgresql: { limit: 65_535, name: "PostgreSQL" },
23
+ mysql: { limit: 65_535, name: "MySQL" },
24
+ sqlite: { limit: 32_766, name: "SQLite" },
25
+ d1: { limit: 100, name: "Cloudflare D1" },
26
+ };
27
+ class BindLimitException extends common_1.BadRequestException {
28
+ }
29
+ const CHECKED_DIALECTS = new WeakSet();
14
30
  const GROUP_KEYS = new Set(["and", "or", "not"]);
15
31
  const QUANTIFIERS = new Set(["some", "none", "every"]);
16
32
  const COUNT_KEY = "__crudCountKey";
@@ -176,9 +192,25 @@ class SqlBaseCrudService {
176
192
  const scope = this.config.scope;
177
193
  if (!scope || options?.skipScope)
178
194
  return null;
195
+ const memo = options?.[SCOPE_MEMO];
196
+ if (memo && Object.hasOwn(memo, "values"))
197
+ return memo.values;
179
198
  const values = typeof scope === "function"
180
199
  ? scope(options?.context ?? crud_context_1.CrudContext.current() ?? {})
181
200
  : scope;
201
+ const checked = this.checkScope(values);
202
+ if (memo)
203
+ memo.values = checked;
204
+ return checked;
205
+ }
206
+ withScopeMemo(options) {
207
+ if (typeof this.config.scope !== "function" ||
208
+ options?.[SCOPE_MEMO]) {
209
+ return options;
210
+ }
211
+ return { ...options, [SCOPE_MEMO]: {} };
212
+ }
213
+ checkScope(values) {
182
214
  if (values === null)
183
215
  return null;
184
216
  if (!(0, merge_config_1.isPlainObject)(values)) {
@@ -273,6 +305,53 @@ class SqlBaseCrudService {
273
305
  this.config = (0, merge_config_1.mergeCrudConfig)(this.defaultConfig, config);
274
306
  this.validateConfiguration();
275
307
  }
308
+ boundDbs = new WeakMap();
309
+ dbFor(transaction) {
310
+ return this.withBindLimit(transaction || this.config.db);
311
+ }
312
+ withBindLimit(db) {
313
+ const dialect = db?.dialect;
314
+ if (typeof dialect?.sqlToQuery !== "function" ||
315
+ CHECKED_DIALECTS.has(dialect)) {
316
+ return db;
317
+ }
318
+ const cached = this.boundDbs.get(db);
319
+ if (cached)
320
+ return cached;
321
+ const { limit, name } = this.bindLimit(db);
322
+ const checked = Object.create(dialect);
323
+ const tooMany = (count) => new BindLimitException(`Too many values in one statement: ${count} bound parameters, and ${name} takes at most ${limit}. Split the batch.`);
324
+ checked.sqlToQuery = (...args) => {
325
+ let query;
326
+ try {
327
+ query = dialect.sqlToQuery(...args);
328
+ }
329
+ catch (error) {
330
+ if (error instanceof RangeError)
331
+ throw tooMany(`more than ${limit}`);
332
+ throw error;
333
+ }
334
+ const count = query?.params?.length ?? 0;
335
+ if (count > limit)
336
+ throw tooMany(String(count));
337
+ return query;
338
+ };
339
+ CHECKED_DIALECTS.add(checked);
340
+ const view = Object.create(db, {
341
+ dialect: { value: checked, writable: true, configurable: true },
342
+ });
343
+ this.boundDbs.set(db, view);
344
+ return view;
345
+ }
346
+ bindLimit(db) {
347
+ if (this.isD1(db))
348
+ return BIND_LIMITS.d1;
349
+ return BIND_LIMITS[this.config.dialect] ?? BIND_LIMITS.postgresql;
350
+ }
351
+ isD1(db) {
352
+ const kind = db?.constructor?.[drizzle_orm_1.entityKind];
353
+ return kind === "D1Database" || kind === "D1Transaction";
354
+ }
276
355
  validateConfiguration() {
277
356
  if (!this.config.db)
278
357
  throw new Error("Database instance is required");
@@ -366,6 +445,10 @@ class SqlBaseCrudService {
366
445
  return relation.type;
367
446
  return "belongsTo";
368
447
  }
448
+ relationNamed(name) {
449
+ const relations = this.config.relations || {};
450
+ return Object.hasOwn(relations, name) ? relations[name] : undefined;
451
+ }
369
452
  isCollection(relation) {
370
453
  return this.relationType(relation) !== "belongsTo";
371
454
  }
@@ -556,23 +639,19 @@ class SqlBaseCrudService {
556
639
  const referenced = target[referencedName];
557
640
  if (!referenced)
558
641
  return;
559
- const ids = Array.from(new Set(hosts.map((host) => host[localKey]).filter((v) => v != null)));
560
- if (ids.length === 0) {
561
- for (const host of hosts)
562
- host[node.name] = null;
642
+ const ids = this.loaderKeys(hosts, localKey, node);
643
+ if (ids.length === 0)
563
644
  return;
564
- }
565
645
  const fields = this.relationProjection(target, select.base, [
566
646
  referencedName,
567
647
  ...this.keysForChildren(node.children, target),
568
648
  ]);
569
- const conditions = [(0, drizzle_orm_1.inArray)(referenced, ids)];
570
- conditions.push(...this.relatedRowScope(target, options));
571
- const related = await (fields ? db.select(fields) : db.select())
649
+ const scope = this.relatedRowScope(target, options);
650
+ const related = await this.inChunks(db, ids, (chunk) => (fields ? db.select(fields) : db.select())
572
651
  .from(target)
573
- .where((0, drizzle_orm_1.and)(...conditions));
652
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(referenced, chunk), ...scope)));
574
653
  const byKey = new Map();
575
- for (const row of related ?? [])
654
+ for (const row of related)
576
655
  byKey.set(row?.[referencedName], row);
577
656
  for (const host of hosts) {
578
657
  host[node.name] = byKey.get(host[localKey]) ?? null;
@@ -587,27 +666,43 @@ class SqlBaseCrudService {
587
666
  if (!foreign)
588
667
  return;
589
668
  const parentKey = this.parentKeyOf(node.config, hostTable);
590
- const ids = Array.from(new Set(hosts.map((host) => host[parentKey]).filter((v) => v != null)));
591
- if (ids.length === 0) {
592
- for (const host of hosts)
593
- host[node.name] = [];
669
+ const ids = this.loaderKeys(hosts, parentKey, node);
670
+ if (ids.length === 0)
594
671
  return;
595
- }
596
672
  const fields = this.relationProjection(target, select.base, [
597
673
  foreignKey,
598
674
  ...this.keysForChildren(node.children, target),
599
675
  ]);
600
- const conditions = [(0, drizzle_orm_1.inArray)(foreign, ids)];
601
- conditions.push(...this.relatedRowScope(target, options));
602
- let query = (fields ? db.select(fields) : db.select())
603
- .from(target)
604
- .where((0, drizzle_orm_1.and)(...conditions));
605
- const orderBy = this.collectionOrderBy(node.config);
606
- if (orderBy.length > 0)
607
- query = query.orderBy(...orderBy);
608
- const children = await query;
676
+ const children = await this.loadCollection(db, node, ids, options, () => fields ? db.select(fields).from(target) : db.select().from(target));
609
677
  this.groupCollection(hosts, node.name, parentKey, children, foreignKey);
610
678
  }
679
+ loaderKeys(hosts, key, node) {
680
+ const ids = Array.from(new Set(hosts.map((host) => host[key]).filter((v) => v != null)));
681
+ if (ids.length === 0) {
682
+ const collection = this.isCollection(node.config);
683
+ for (const host of hosts)
684
+ host[node.name] = collection ? [] : null;
685
+ }
686
+ return ids;
687
+ }
688
+ loadCollection(db, node, ids, options, source) {
689
+ const relation = node.config;
690
+ const key = this.relationType(relation) === "manyToMany"
691
+ ? relation.through[relation.throughLocalKey]
692
+ : relation.table[relation.foreignKey];
693
+ const scope = this.relatedRowScope(relation.table, options);
694
+ const orderBy = this.collectionOrderBy(relation);
695
+ return this.inChunks(db, ids, (chunk) => {
696
+ const query = source().where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(key, chunk), ...scope));
697
+ return orderBy.length > 0 ? query.orderBy(...orderBy) : query;
698
+ });
699
+ }
700
+ selectThrough(db, fields, relation, target = relation.table, through = relation.through) {
701
+ return db
702
+ .select(fields)
703
+ .from(through)
704
+ .innerJoin(target, (0, drizzle_orm_1.eq)(through[relation.throughForeignKey], target[relation.references ?? "id"]));
705
+ }
611
706
  async loadManyToMany(db, hosts, node, select, hostTable = this.config.table, options) {
612
707
  const { through, throughLocalKey, throughForeignKey, references } = node.config;
613
708
  if (!through || !throughLocalKey || !throughForeignKey)
@@ -620,12 +715,9 @@ class SqlBaseCrudService {
620
715
  if (!referenced || !joinLocal || !joinForeign)
621
716
  return;
622
717
  const parentKey = this.parentKeyOf(node.config, hostTable);
623
- const ids = Array.from(new Set(hosts.map((host) => host[parentKey]).filter((v) => v != null)));
624
- if (ids.length === 0) {
625
- for (const host of hosts)
626
- host[node.name] = [];
718
+ const ids = this.loaderKeys(hosts, parentKey, node);
719
+ if (ids.length === 0)
627
720
  return;
628
- }
629
721
  const columns = (0, drizzle_orm_1.getTableColumns)(target);
630
722
  const wanted = select.base.length > 0
631
723
  ? [...select.base, ...this.keysForChildren(node.children, target)]
@@ -635,19 +727,25 @@ class SqlBaseCrudService {
635
727
  if (columns[name])
636
728
  fields[name] = columns[name];
637
729
  }
638
- const conditions = [(0, drizzle_orm_1.inArray)(joinLocal, ids)];
639
- conditions.push(...this.relatedRowScope(target, options));
640
- let query = db
641
- .select(fields)
642
- .from(through)
643
- .innerJoin(target, (0, drizzle_orm_1.eq)(joinForeign, referenced))
644
- .where((0, drizzle_orm_1.and)(...conditions));
645
- const orderBy = this.collectionOrderBy(node.config);
646
- if (orderBy.length > 0)
647
- query = query.orderBy(...orderBy);
648
- const rows = await query;
730
+ const rows = await this.loadCollection(db, node, ids, options, () => this.selectThrough(db, fields, node.config));
649
731
  this.groupCollection(hosts, node.name, parentKey, rows, JOIN_KEY, true);
650
732
  }
733
+ relationChunkSize(db) {
734
+ const { limit } = this.bindLimit(db);
735
+ return Math.max(1, limit - Math.min(1_000, Math.floor(limit / 10)));
736
+ }
737
+ async inChunks(db, ids, query) {
738
+ const size = this.relationChunkSize(db);
739
+ if (ids.length <= size)
740
+ return (await query(ids)) ?? [];
741
+ const out = [];
742
+ for (let start = 0; start < ids.length; start += size) {
743
+ for (const row of (await query(ids.slice(start, start + size))) ?? []) {
744
+ out.push(row);
745
+ }
746
+ }
747
+ return out;
748
+ }
651
749
  groupCollection(hosts, name, parentKey, rows, groupBy, stripGroupKey = false) {
652
750
  const grouped = new Map();
653
751
  for (const row of rows ?? []) {
@@ -783,10 +881,7 @@ class SqlBaseCrudService {
783
881
  return { key, column, order };
784
882
  }
785
883
  const name = key.slice(0, dot);
786
- const relations = this.config.relations || {};
787
- const relation = Object.hasOwn(relations, name)
788
- ? relations[name]
789
- : undefined;
884
+ const relation = this.relationNamed(name);
790
885
  const column = relation
791
886
  ? this.columnOf(relation.table, key.slice(dot + 1))
792
887
  : undefined;
@@ -831,8 +926,17 @@ class SqlBaseCrudService {
831
926
  if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT_")) {
832
927
  return code.slice("SQLITE_CONSTRAINT_".length);
833
928
  }
929
+ if (code === "UNKNOWN_SQLITE_ERROR_3091" || e?.rawCode === 3091) {
930
+ return "DATATYPE";
931
+ }
932
+ if (code === "SQLITE_MISMATCH")
933
+ return "MISMATCH";
834
934
  }
835
935
  const message = `${error?.cause?.message ?? ""} ${error?.message ?? ""}`;
936
+ if (/cannot store \S+ value in \S+ column/.test(message))
937
+ return "DATATYPE";
938
+ if (/datatype mismatch/.test(message))
939
+ return "MISMATCH";
836
940
  const m = message.match(/(UNIQUE|PRIMARY KEY|FOREIGN KEY|NOT NULL|CHECK) constraint failed/);
837
941
  return m?.[1]?.replace(" ", "");
838
942
  }
@@ -867,6 +971,8 @@ class SqlBaseCrudService {
867
971
  static SQLITE_DATA_ERRORS = {
868
972
  NOTNULL: "a required value is missing",
869
973
  CHECK: "a value violates a column check constraint",
974
+ DATATYPE: "a value does not match its column type",
975
+ MISMATCH: "a value does not match its column type",
870
976
  };
871
977
  dataErrorMessage(error) {
872
978
  const code = error?.code ?? error?.cause?.code;
@@ -899,7 +1005,7 @@ class SqlBaseCrudService {
899
1005
  ? text.match(/column '([^']+)'/i)?.[1]
900
1006
  : undefined) ??
901
1007
  (this.config.dialect === "sqlite"
902
- ? text.match(/NOT NULL constraint failed: (?:[^.\s]+\.)?(\S+)/)?.[1]
1008
+ ? text.match(/(?:NOT NULL constraint failed:|value in \S+ column) (?:[^.\s]+\.)?(\S+)/)?.[1]
903
1009
  : undefined);
904
1010
  return new crud_exceptions_1.ValidationFailedException(column ? `${message}: ${column}` : message);
905
1011
  }
@@ -966,9 +1072,22 @@ class SqlBaseCrudService {
966
1072
  throw new common_1.BadRequestException(`Invalid full-text query mode: ${String(mode)} (expected 'plain' or 'websearch')`);
967
1073
  }
968
1074
  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) => (0, drizzle_orm_1.sql) `to_tsvector(${config}, ${column})`), (0, drizzle_orm_1.sql) ` || `);
1075
+ const vector = drizzle_orm_1.sql.join(columns.map((column) => {
1076
+ if (this.isTsVector(column)) {
1077
+ return columns.length > 1
1078
+ ? (0, drizzle_orm_1.sql) `coalesce(${column}, ''::tsvector)`
1079
+ : (0, drizzle_orm_1.sql) `${column}`;
1080
+ }
1081
+ return columns.length > 1
1082
+ ? (0, drizzle_orm_1.sql) `to_tsvector(${config}, coalesce(${column}, ''))`
1083
+ : (0, drizzle_orm_1.sql) `to_tsvector(${config}, ${column})`;
1084
+ }), (0, drizzle_orm_1.sql) ` || `);
970
1085
  return { vector, query: (0, drizzle_orm_1.sql) `${parse}(${config}, ${term})` };
971
1086
  }
1087
+ isTsVector(column) {
1088
+ const type = typeof column?.getSQLType === "function" ? column.getSQLType() : "";
1089
+ return String(type).trim().toLowerCase() === "tsvector";
1090
+ }
972
1091
  buildSearchCondition(search) {
973
1092
  if (!search?.term || !search.columns?.length)
974
1093
  return undefined;
@@ -1060,6 +1179,29 @@ class SqlBaseCrudService {
1060
1179
  }
1061
1180
  return row;
1062
1181
  }
1182
+ async guardRow(id, options) {
1183
+ const row = await this.findForGuard(id, options);
1184
+ if (!row)
1185
+ throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
1186
+ }
1187
+ skipsGuard(options, ...methods) {
1188
+ if (!this.config.sql?.useReturning)
1189
+ return false;
1190
+ if (this.config.events && !options?.skipEvents)
1191
+ return false;
1192
+ const base = SqlBaseCrudService.prototype;
1193
+ return methods.every((name) => this[name] === base[name]);
1194
+ }
1195
+ async guardOnError(guarded, id, options, run) {
1196
+ try {
1197
+ return await run();
1198
+ }
1199
+ catch (error) {
1200
+ if (!guarded)
1201
+ await this.guardRow(id, options);
1202
+ throw error;
1203
+ }
1204
+ }
1063
1205
  assertVersion(id, actual, expected) {
1064
1206
  if (expected === undefined || expected === null)
1065
1207
  return;
@@ -1102,16 +1244,23 @@ class SqlBaseCrudService {
1102
1244
  };
1103
1245
  }
1104
1246
  async find(id, options) {
1247
+ options = this.withScopeMemo(options);
1105
1248
  this.checkReadOptions(options);
1106
1249
  return this.findFirst([(0, drizzle_orm_1.eq)(this.config.table[this.config.primaryKey], id)], options);
1107
1250
  }
1108
1251
  async findOne(where, options) {
1252
+ options = this.withScopeMemo(options);
1109
1253
  this.checkReadOptions(options);
1110
1254
  return this.findFirst(this.buildWhereConditionsFromPartial(where), options);
1111
1255
  }
1112
1256
  async findFirst(conditions, options) {
1257
+ const first = await this.readFirst(conditions, options);
1258
+ await first.hydrate();
1259
+ return first.row;
1260
+ }
1261
+ async readFirst(conditions, options) {
1113
1262
  const { transaction, relations = [], select = [] } = options || {};
1114
- const db = transaction || this.config.db;
1263
+ const db = this.dbFor(transaction);
1115
1264
  const nodes = this.relationTree(relations);
1116
1265
  const joined = this.planJoins(nodes);
1117
1266
  const selection = this.parseSelect(select);
@@ -1124,16 +1273,21 @@ class SqlBaseCrudService {
1124
1273
  query = this.applyLock(query, options);
1125
1274
  const result = await this.runRead(query);
1126
1275
  const rows = this.normalizeRelations(result, joined.map((node) => node.name));
1276
+ return {
1277
+ row: rows[0] || null,
1278
+ hydrate: () => this.hydrate(db, rows, nodes, selection, options),
1279
+ };
1280
+ }
1281
+ async hydrate(db, rows, nodes, selection, options) {
1127
1282
  await Promise.all([
1128
1283
  this.loadRelations(db, rows, nodes, selection, undefined, options),
1129
1284
  this.loadCounts(db, rows, options?.counts, options),
1130
1285
  ]);
1131
- return rows[0] || null;
1132
1286
  }
1133
1287
  planList(filters, sortBy, sortOrder, options) {
1134
1288
  this.checkReadOptions(options);
1135
1289
  const { transaction, relations = [], select = [] } = options || {};
1136
- const db = transaction || this.config.db;
1290
+ const db = this.dbFor(transaction);
1137
1291
  const nodes = this.relationTree(relations);
1138
1292
  const joined = this.planJoins(nodes);
1139
1293
  const selection = this.parseSelect(select);
@@ -1165,6 +1319,9 @@ class SqlBaseCrudService {
1165
1319
  built.conditions.push(...this.rowScope(options));
1166
1320
  return built;
1167
1321
  }
1322
+ parseCount(result) {
1323
+ return parseInt(result?.[0]?.count?.toString() || "0", 10);
1324
+ }
1168
1325
  buildCountQuery(db, conditions, filterRelations, options) {
1169
1326
  let query = db
1170
1327
  .select({ count: (0, drizzle_orm_1.sql) `count(*)` })
@@ -1175,6 +1332,7 @@ class SqlBaseCrudService {
1175
1332
  return query;
1176
1333
  }
1177
1334
  async findAll(filters, pagination, options) {
1335
+ options = this.withScopeMemo(options);
1178
1336
  const { sortBy, sortOrder = "asc" } = pagination || {};
1179
1337
  const withCount = pagination?.withCount !== false;
1180
1338
  const { page, limit: safeLimit, offset, } = this.resolvePagination(pagination?.page, pagination?.limit);
@@ -1197,10 +1355,7 @@ class SqlBaseCrudService {
1197
1355
  const [fetched, totalResult] = await this.runRead(Promise.all([dataQuery, countQuery]));
1198
1356
  const data = fetched.slice(0, safeLimit);
1199
1357
  const rows = this.normalizeRelations(data, plan.joined.map((node) => node.name));
1200
- await Promise.all([
1201
- this.loadRelations(plan.db, rows, plan.nodes, plan.selection, undefined, options),
1202
- this.loadCounts(plan.db, rows, options?.counts, options),
1203
- ]);
1358
+ await this.hydrate(plan.db, rows, plan.nodes, plan.selection, options);
1204
1359
  if (!withCount) {
1205
1360
  return {
1206
1361
  data: rows,
@@ -1212,12 +1367,13 @@ class SqlBaseCrudService {
1212
1367
  }
1213
1368
  return {
1214
1369
  data: rows,
1215
- total: parseInt(totalResult[0]?.count?.toString() || "0", 10),
1370
+ total: this.parseCount(totalResult),
1216
1371
  page,
1217
1372
  limit: safeLimit,
1218
1373
  };
1219
1374
  }
1220
1375
  async findByCursor(filters, pagination, options) {
1376
+ options = this.withScopeMemo(options);
1221
1377
  const { sortBy, sortOrder = "asc", count = false } = pagination || {};
1222
1378
  const { limit } = this.resolvePagination(1, pagination?.limit);
1223
1379
  const plan = this.planList(filters, sortBy, sortOrder, options);
@@ -1234,9 +1390,17 @@ class SqlBaseCrudService {
1234
1390
  conditions.push(this.keysetCondition(plan.sort, values));
1235
1391
  }
1236
1392
  const fields = this.buildSelection(plan.baseSelect, plan.joined, plan.selection);
1237
- plan.sort.forEach((spec, i) => {
1238
- fields[`${CURSOR_KEY}${i}`] = this.cursorField(spec.column);
1393
+ const cursorKeys = plan.sort.map((spec, i) => {
1394
+ const field = this.cursorField(spec.column);
1395
+ const projected = field === spec.column && !spec.relation
1396
+ ? Object.keys(fields).find((key) => fields[key] === spec.column)
1397
+ : undefined;
1398
+ if (projected)
1399
+ return projected;
1400
+ fields[`${CURSOR_KEY}${i}`] = field;
1401
+ return `${CURSOR_KEY}${i}`;
1239
1402
  });
1403
+ const hidden = cursorKeys.filter((key) => key.startsWith(CURSOR_KEY));
1240
1404
  let dataQuery = plan.db.select(fields).from(this.config.table);
1241
1405
  dataQuery = this.applyJoins(dataQuery, plan.joinRelations, options);
1242
1406
  if (conditions.length > 0)
@@ -1254,21 +1418,17 @@ class SqlBaseCrudService {
1254
1418
  const page = data.slice(0, limit);
1255
1419
  const last = page[page.length - 1];
1256
1420
  const nextCursor = data.length > limit && last
1257
- ? (0, cursor_1.encodeCursor)(plan.sort.map((_, i) => last[`${CURSOR_KEY}${i}`]), signature)
1421
+ ? (0, cursor_1.encodeCursor)(cursorKeys.map((key) => last[key]), signature)
1258
1422
  : null;
1259
1423
  for (const row of page) {
1260
- plan.sort.forEach((_, i) => {
1261
- delete row[`${CURSOR_KEY}${i}`];
1262
- });
1424
+ for (const key of hidden)
1425
+ delete row[key];
1263
1426
  }
1264
1427
  const rows = this.normalizeRelations(page, plan.joined.map((node) => node.name));
1265
- await Promise.all([
1266
- this.loadRelations(plan.db, rows, plan.nodes, plan.selection, undefined, options),
1267
- this.loadCounts(plan.db, rows, options?.counts, options),
1268
- ]);
1428
+ await this.hydrate(plan.db, rows, plan.nodes, plan.selection, options);
1269
1429
  const out = { data: rows, limit, nextCursor };
1270
1430
  if (count) {
1271
- out.total = parseInt(totalResult?.[0]?.count?.toString() || "0", 10);
1431
+ out.total = this.parseCount(totalResult);
1272
1432
  }
1273
1433
  return out;
1274
1434
  }
@@ -1287,9 +1447,16 @@ class SqlBaseCrudService {
1287
1447
  return (0, drizzle_orm_1.gt)(column, value);
1288
1448
  if (op === "<")
1289
1449
  return (0, drizzle_orm_1.lt)(column, value);
1450
+ if (op === ">=")
1451
+ return (0, drizzle_orm_1.gte)(column, value);
1452
+ if (op === "<=")
1453
+ return (0, drizzle_orm_1.lte)(column, value);
1290
1454
  return (0, drizzle_orm_1.eq)(column, value);
1291
1455
  }
1292
1456
  keysetCondition(specs, values) {
1457
+ const row = this.keysetRowComparison(specs, values);
1458
+ if (row)
1459
+ return row;
1293
1460
  const branches = [];
1294
1461
  specs.forEach((spec, i) => {
1295
1462
  const after = this.afterCursor(spec, values[i]);
@@ -1302,21 +1469,64 @@ class SqlBaseCrudService {
1302
1469
  : this.compareCursor(previous.column, "=", values[j]));
1303
1470
  branches.push(ties.length > 0 ? (0, drizzle_orm_1.and)(...ties, after) : after);
1304
1471
  });
1305
- return branches.length > 0 ? (0, drizzle_orm_1.or)(...branches) : (0, drizzle_orm_1.sql) `false`;
1472
+ if (branches.length === 0)
1473
+ return (0, drizzle_orm_1.sql) `false`;
1474
+ const after = (0, drizzle_orm_1.or)(...branches);
1475
+ const bound = this.keysetBound(specs, values);
1476
+ return bound ? (0, drizzle_orm_1.and)(bound, after) : after;
1477
+ }
1478
+ keysetBound(specs, values) {
1479
+ const [first] = specs;
1480
+ if (!first || specs.length < 2 || values[0] === null)
1481
+ return undefined;
1482
+ if (this.keysetNullable(first) && !this.nullsFirst(first)) {
1483
+ return undefined;
1484
+ }
1485
+ return this.compareCursor(first.column, first.order === "asc" ? ">=" : "<=", values[0]);
1486
+ }
1487
+ keysetRowComparison(specs, values) {
1488
+ if (this.config.dialect !== "postgresql" || specs.length < 2) {
1489
+ return undefined;
1490
+ }
1491
+ const order = specs[0].order;
1492
+ const fits = specs.every((spec, i) => spec.order === order &&
1493
+ values[i] !== null &&
1494
+ !this.keysetNullable(spec));
1495
+ if (!fits)
1496
+ return undefined;
1497
+ const columns = drizzle_orm_1.sql.join(specs.map((spec) => spec.column), (0, drizzle_orm_1.sql) `, `);
1498
+ const params = drizzle_orm_1.sql.join(specs.map((spec, i) => spec.column?.dataType === "date"
1499
+ ? (0, drizzle_orm_1.sql) `${values[i]}`
1500
+ : drizzle_orm_1.sql.param(values[i], spec.column)), (0, drizzle_orm_1.sql) `, `);
1501
+ return (0, drizzle_orm_1.sql) `(${columns}) ${drizzle_orm_1.sql.raw(order === "asc" ? ">" : "<")} (${params})`;
1502
+ }
1503
+ keysetNullable(spec) {
1504
+ if (spec.relation)
1505
+ return true;
1506
+ return !(spec.column?.notNull || spec.column?.primary);
1507
+ }
1508
+ nullsFirst(spec) {
1509
+ return this.config.dialect === "postgresql"
1510
+ ? spec.order === "desc"
1511
+ : spec.order === "asc";
1306
1512
  }
1307
1513
  afterCursor(spec, value) {
1308
- const postgres = this.config.dialect === "postgresql";
1309
- const nullsFirst = postgres ? spec.order === "desc" : spec.order === "asc";
1514
+ const nullsFirst = this.nullsFirst(spec);
1310
1515
  if (value === null) {
1311
1516
  return nullsFirst ? (0, drizzle_orm_1.isNotNull)(spec.column) : undefined;
1312
1517
  }
1313
1518
  const past = this.compareCursor(spec.column, spec.order === "asc" ? ">" : "<", value);
1314
- return nullsFirst ? past : (0, drizzle_orm_1.or)(past, (0, drizzle_orm_1.isNull)(spec.column));
1519
+ return nullsFirst || !this.keysetNullable(spec)
1520
+ ? past
1521
+ : (0, drizzle_orm_1.or)(past, (0, drizzle_orm_1.isNull)(spec.column));
1315
1522
  }
1316
1523
  async emitEvent(phase, operation, bulk, payload, options) {
1317
1524
  const bus = this.config.events;
1318
1525
  if (!bus || options?.skipEvents)
1319
1526
  return;
1527
+ if (payload.transaction && typeof payload.transaction === "object") {
1528
+ this.ownedTransactions.delete(payload.transaction);
1529
+ }
1320
1530
  await bus.emit({
1321
1531
  entity: this.getEntityName(),
1322
1532
  operation,
@@ -1338,10 +1548,26 @@ class SqlBaseCrudService {
1338
1548
  return;
1339
1549
  }
1340
1550
  const hosts = rows.filter(Boolean);
1341
- await Promise.all([
1342
- this.loadRelations(db, hosts, nodes, undefined, undefined, options),
1343
- this.loadCounts(db, hosts, options?.counts, options),
1344
- ]);
1551
+ await this.hydrate(db, hosts, nodes, undefined, options);
1552
+ }
1553
+ loadsAfterCommit(options) {
1554
+ if (options?.transaction)
1555
+ return false;
1556
+ try {
1557
+ return this.countPlan(options?.counts).every((count) => !count.filter);
1558
+ }
1559
+ catch {
1560
+ return false;
1561
+ }
1562
+ }
1563
+ async writeThenLoad(options, write) {
1564
+ const late = this.loadsAfterCommit(options);
1565
+ const rows = await this.executeSqlTransaction((tx) => write(tx, (inTx, written) => late
1566
+ ? Promise.resolve()
1567
+ : this.loadWriteRelations(inTx, written, options)), options?.transaction);
1568
+ if (late)
1569
+ await this.loadWriteRelations(this.dbFor(), rows, options);
1570
+ return rows;
1345
1571
  }
1346
1572
  expectedVersionOf(data, options) {
1347
1573
  const column = this.config.versionColumn;
@@ -1397,13 +1623,15 @@ class SqlBaseCrudService {
1397
1623
  return createdEntity;
1398
1624
  }
1399
1625
  async create(data, options) {
1626
+ options = this.withScopeMemo(options);
1400
1627
  const { transaction, hooks = {} } = options || {};
1401
1628
  this.checkReadOptions(options);
1402
1629
  const entityData = await this.prepareCreate(data, options);
1403
1630
  const nested = this.takeNested(entityData, options);
1404
- const db = transaction || this.config.db;
1631
+ const db = this.dbFor(transaction);
1405
1632
  await this.emitEvent("before", "create", false, { input: data, transaction }, options);
1406
1633
  try {
1634
+ await this.assertKeysInScope(db, entityData, options, nested.map((entry) => entry.name));
1407
1635
  const createdEntity = nested.length === 0
1408
1636
  ? await this.insertOne(db, entityData, options)
1409
1637
  : await this.executeSqlTransaction((tx) => this.insertNested(tx, entityData, nested, {
@@ -1494,7 +1722,7 @@ class SqlBaseCrudService {
1494
1722
  .returning();
1495
1723
  ids.push(...inserted.map((r) => r[relation.references ?? "id"]));
1496
1724
  }
1497
- await this.linkTargets(tx, relation, parentValue, ids, options);
1725
+ await this.linkTargets(tx, relation, parentValue, ids, options, true);
1498
1726
  }
1499
1727
  return created;
1500
1728
  }
@@ -1526,7 +1754,56 @@ class SqlBaseCrudService {
1526
1754
  }
1527
1755
  return out;
1528
1756
  }
1529
- async linkTargets(tx, relation, parentValue, ids, options) {
1757
+ async keysOutsideScope(db, rows, options, skip = []) {
1758
+ const failures = new Map();
1759
+ if (!this.resolveScope(options))
1760
+ return failures;
1761
+ for (const [name, relation] of Object.entries(this.config.relations ?? {})) {
1762
+ const local = relation.localKey;
1763
+ if (!local || this.isCollection(relation) || skip.includes(name)) {
1764
+ continue;
1765
+ }
1766
+ const scope = this.relatedScopeConditions(relation.table, options);
1767
+ const referenced = relation.table[relation.references ?? "id"];
1768
+ if (scope.length === 0 || !referenced)
1769
+ continue;
1770
+ const keys = this.uniqueValues(rows.map((row) => row[local]).filter((value) => value != null));
1771
+ if (keys.length === 0)
1772
+ continue;
1773
+ const found = await db
1774
+ .select({ id: referenced })
1775
+ .from(relation.table)
1776
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(referenced, keys), ...scope));
1777
+ const present = new Set(found.map((row) => this.valueKey(row.id)));
1778
+ rows.forEach((row, index) => {
1779
+ const value = row[local];
1780
+ if (value == null || present.has(this.valueKey(value)))
1781
+ return;
1782
+ if (!failures.has(index)) {
1783
+ failures.set(index, this.missingParentException(local));
1784
+ }
1785
+ });
1786
+ }
1787
+ return failures;
1788
+ }
1789
+ async assertBatchKeysInScope(db, rows, refs, message, options) {
1790
+ const failures = await this.keysOutsideScope(db, rows, options);
1791
+ if (failures.size === 0)
1792
+ return;
1793
+ throw new crud_exceptions_1.BulkOperationException(message, Array.from(failures, ([index, error]) => ({ ...refs[index], error })));
1794
+ }
1795
+ async assertKeysInScope(db, row, options, skip = []) {
1796
+ const failure = (await this.keysOutsideScope(db, [row], options, skip)).get(0);
1797
+ if (failure)
1798
+ throw failure;
1799
+ }
1800
+ missingParentException(localKey) {
1801
+ const field = this.config.dialect === "postgresql"
1802
+ ? (this.columnOf(this.config.table, localKey)?.name ?? localKey)
1803
+ : "reference";
1804
+ return new crud_exceptions_1.ForeignKeyViolationException(this.getEntityName(), field);
1805
+ }
1806
+ async linkTargets(tx, relation, parentValue, ids, options, fresh = false) {
1530
1807
  const wanted = this.uniqueValues(ids);
1531
1808
  if (wanted.length === 0)
1532
1809
  return;
@@ -1550,10 +1827,12 @@ class SqlBaseCrudService {
1550
1827
  error: new crud_exceptions_1.EntityNotFoundException(name, id),
1551
1828
  })));
1552
1829
  }
1553
- const linked = await tx
1554
- .select({ id: foreign })
1555
- .from(through)
1556
- .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(local, parentValue), (0, drizzle_orm_1.inArray)(foreign, wanted)));
1830
+ const linked = fresh
1831
+ ? []
1832
+ : await tx
1833
+ .select({ id: foreign })
1834
+ .from(through)
1835
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(local, parentValue), (0, drizzle_orm_1.inArray)(foreign, wanted)));
1557
1836
  const already = new Set(linked.map((row) => this.valueKey(row.id)));
1558
1837
  const add = wanted.filter((id) => !already.has(this.valueKey(id)));
1559
1838
  if (add.length > 0) {
@@ -1564,19 +1843,19 @@ class SqlBaseCrudService {
1564
1843
  }
1565
1844
  }
1566
1845
  async attach(id, relation, relatedIds, options) {
1846
+ options = this.withScopeMemo(options);
1567
1847
  return this.changeLinks("attach", id, relation, relatedIds, options);
1568
1848
  }
1569
1849
  async detach(id, relation, relatedIds, options) {
1850
+ options = this.withScopeMemo(options);
1570
1851
  return this.changeLinks("detach", id, relation, relatedIds, options);
1571
1852
  }
1572
1853
  async sync(id, relation, relatedIds, options) {
1854
+ options = this.withScopeMemo(options);
1573
1855
  return this.changeLinks("sync", id, relation, relatedIds, options);
1574
1856
  }
1575
1857
  async changeLinks(operation, id, name, relatedIds, options) {
1576
- const relations = this.config.relations || {};
1577
- const relation = Object.hasOwn(relations, name)
1578
- ? relations[name]
1579
- : undefined;
1858
+ const relation = this.relationNamed(name);
1580
1859
  if (!relation || this.relationType(relation) !== "manyToMany") {
1581
1860
  throw new common_1.BadRequestException(`"${name}" is not a many-to-many relation of ${this.getEntityName()}`);
1582
1861
  }
@@ -1597,13 +1876,13 @@ class SqlBaseCrudService {
1597
1876
  const parentKey = this.parentKeyOf(relation);
1598
1877
  return this.executeSqlTransaction(async (tx) => {
1599
1878
  const opts = { ...options, transaction: tx };
1600
- const parent = await this.find(id, {
1879
+ const read = {
1601
1880
  ...opts,
1602
- relations: [],
1603
- counts: undefined,
1604
- select: [this.config.primaryKey, parentKey],
1881
+ relations: Array.from(new Set([name, ...(options?.relations ?? [])])),
1605
1882
  skipAllowlists: true,
1606
- });
1883
+ };
1884
+ this.checkReadOptions(read);
1885
+ const { row: parent, hydrate } = await this.readFirst([(0, drizzle_orm_1.eq)(this.config.table[this.config.primaryKey], id)], read);
1607
1886
  if (!parent)
1608
1887
  throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
1609
1888
  await this.emitEvent("before", operation, false, { ids: [id], input: relatedIds, transaction: tx }, options);
@@ -1631,11 +1910,8 @@ class SqlBaseCrudService {
1631
1910
  : (0, drizzle_orm_1.eq)(local, parentValue));
1632
1911
  }
1633
1912
  }
1634
- const row = (await this.find(id, {
1635
- ...opts,
1636
- relations: Array.from(new Set([name, ...(options?.relations ?? [])])),
1637
- skipAllowlists: true,
1638
- }));
1913
+ await hydrate();
1914
+ const row = parent;
1639
1915
  await this.emitEvent("after", operation, false, { ids: [id], input: relatedIds, rows: [row], transaction: tx }, options);
1640
1916
  return row;
1641
1917
  }, options?.transaction);
@@ -1677,26 +1953,31 @@ class SqlBaseCrudService {
1677
1953
  return entityData;
1678
1954
  }
1679
1955
  async update(id, data, options) {
1956
+ options = this.withScopeMemo(options);
1680
1957
  const { transaction, hooks = {} } = options || {};
1681
1958
  this.checkReadOptions(options);
1682
1959
  const expected = this.expectedVersionOf(data, options);
1683
1960
  const opts = { ...options, expectedVersion: expected };
1684
- const existing = await this.findForGuard(id, opts);
1685
- if (!existing)
1686
- throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
1687
- await this.validateUpdate(id, data);
1688
- const processedData = hooks.skipBefore
1689
- ? data
1690
- : await this.beforeUpdate(id, data);
1691
- const entityData = this.prepareUpdate(processedData, opts);
1961
+ const guarded = !this.skipsGuard(options, "validateUpdate", "mapUpdateDtoToEntity", ...(hooks.skipBefore ? [] : ["beforeUpdate"]));
1962
+ if (guarded)
1963
+ await this.guardRow(id, opts);
1964
+ const entityData = await this.guardOnError(guarded, id, opts, async () => {
1965
+ await this.validateUpdate(id, data);
1966
+ const processedData = hooks.skipBefore
1967
+ ? data
1968
+ : await this.beforeUpdate(id, data);
1969
+ return this.prepareUpdate(processedData, opts);
1970
+ });
1692
1971
  if (this.config.timestamps?.updatedAt) {
1693
1972
  entityData[this.config.timestamps.updatedAt] = new Date();
1694
1973
  }
1695
- const db = transaction || this.config.db;
1974
+ const db = this.dbFor(transaction);
1696
1975
  await this.emitEvent("before", "update", false, { ids: [id], input: data, transaction }, options);
1697
1976
  try {
1698
- const [updatedEntity] = await this.updateWhereIn(db, this.withVersionBump(entityData), [id], opts);
1977
+ await this.guardOnError(guarded, id, opts, () => this.assertKeysInScope(db, entityData, opts));
1978
+ const [updatedEntity] = await this.updateWhereIn(db, this.withVersionBump(entityData), [id], opts, this.softDeleteScope(opts));
1699
1979
  if (!updatedEntity) {
1980
+ await this.guardRow(id, opts);
1700
1981
  if (expected !== undefined) {
1701
1982
  throw new crud_exceptions_1.StaleVersionException(this.getEntityName(), id);
1702
1983
  }
@@ -1712,11 +1993,12 @@ class SqlBaseCrudService {
1712
1993
  throw this.toClientError(error, "write");
1713
1994
  }
1714
1995
  }
1715
- async updateWhereIn(db, set, ids, options) {
1996
+ async updateWhereIn(db, set, ids, options, extra) {
1997
+ const where = this.writeWhere(ids, options);
1716
1998
  const query = db
1717
1999
  .update(this.config.table)
1718
2000
  .set(set)
1719
- .where(this.writeWhere(ids, options));
2001
+ .where(extra ? (0, drizzle_orm_1.and)(where, extra) : where);
1720
2002
  if (this.config.sql?.useReturning) {
1721
2003
  return this.inIdOrder(await query.returning(), ids);
1722
2004
  }
@@ -1742,39 +2024,65 @@ class SqlBaseCrudService {
1742
2024
  .map((id) => byKey.get(this.keyOf(id)))
1743
2025
  .filter((row) => !!row);
1744
2026
  }
1745
- async softDelete(id, options) {
1746
- if (!this.config.softDelete?.enabled) {
1747
- throw new common_1.BadRequestException("Soft delete is not enabled for this entity");
1748
- }
2027
+ async guardedWrite(id, options, step) {
1749
2028
  const { transaction, hooks = {} } = options || {};
1750
- const existing = await this.findForGuard(id, {
1751
- ...options,
1752
- withDeleted: false,
1753
- onlyDeleted: false,
1754
- });
1755
- if (!existing)
1756
- throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
2029
+ if (!this.skipsGuard(options, ...(hooks.skipBefore ? [] : [step.hook]))) {
2030
+ await this.guardRow(id, step.state);
2031
+ }
1757
2032
  if (!hooks.skipBefore)
1758
- await this.beforeSoftDelete(id);
1759
- await this.emitEvent("before", "softDelete", false, { ids: [id], transaction }, options);
1760
- const db = transaction || this.config.db;
1761
- let updateQuery = db
1762
- .update(this.config.table)
1763
- .set(this.softDeleteSet(new Date()))
1764
- .where(this.writeWhere([id], options));
1765
- if (this.config.sql?.useReturning)
1766
- updateQuery = updateQuery.returning();
1767
- const result = await this.runRead(updateQuery);
1768
- const success = this.wasAffected(result);
1769
- if (!success && options?.expectedVersion != null) {
2033
+ await step.before();
2034
+ const event = { ids: [id], transaction };
2035
+ await this.emitEvent("before", step.operation, false, event, options);
2036
+ const finish = async () => {
2037
+ const result = await step.write(this.dbFor(transaction));
2038
+ if (result === undefined) {
2039
+ await this.guardRow(id, step.state);
2040
+ return step.miss();
2041
+ }
2042
+ if (!hooks.skipAfter)
2043
+ await step.after(result);
2044
+ const rows = step.rows?.(result);
2045
+ await this.emitEvent("after", step.operation, false, { ...event, rows }, options);
2046
+ return result;
2047
+ };
2048
+ if (!step.errors)
2049
+ return finish();
2050
+ try {
2051
+ return await finish();
2052
+ }
2053
+ catch (error) {
2054
+ throw this.toClientError(error, step.errors);
2055
+ }
2056
+ }
2057
+ staleOrFalse(id, options) {
2058
+ if (options?.expectedVersion != null) {
1770
2059
  throw new crud_exceptions_1.StaleVersionException(this.getEntityName(), id);
1771
2060
  }
1772
- if (success && !hooks.skipAfter)
1773
- await this.afterSoftDelete(id);
1774
- if (success) {
1775
- await this.emitEvent("after", "softDelete", false, { ids: [id], transaction }, options);
2061
+ return false;
2062
+ }
2063
+ async softDelete(id, options) {
2064
+ options = this.withScopeMemo(options);
2065
+ if (!this.config.softDelete?.enabled) {
2066
+ throw new common_1.BadRequestException("Soft delete is not enabled for this entity");
1776
2067
  }
1777
- return success;
2068
+ const live = { ...options, withDeleted: false, onlyDeleted: false };
2069
+ return this.guardedWrite(id, options, {
2070
+ operation: "softDelete",
2071
+ state: live,
2072
+ hook: "beforeSoftDelete",
2073
+ before: () => this.beforeSoftDelete(id),
2074
+ write: async (db) => {
2075
+ let query = db
2076
+ .update(this.config.table)
2077
+ .set(this.softDeleteSet(new Date()))
2078
+ .where((0, drizzle_orm_1.and)(this.writeWhere([id], options), this.softDeleteScope(live)));
2079
+ if (this.config.sql?.useReturning)
2080
+ query = query.returning();
2081
+ return this.wasAffected(await this.runRead(query)) || undefined;
2082
+ },
2083
+ miss: () => this.staleOrFalse(id, options),
2084
+ after: () => this.afterSoftDelete(id),
2085
+ });
1778
2086
  }
1779
2087
  softDeleteSet(deletedAt) {
1780
2088
  return this.withVersionBump({
@@ -1785,62 +2093,43 @@ class SqlBaseCrudService {
1785
2093
  });
1786
2094
  }
1787
2095
  async restore(id, options) {
2096
+ options = this.withScopeMemo(options);
1788
2097
  if (!this.config.softDelete?.enabled) {
1789
2098
  throw new common_1.BadRequestException("Soft delete is not enabled for this entity");
1790
2099
  }
1791
- const { transaction, hooks = {} } = options || {};
1792
- const existing = await this.findForGuard(id, {
1793
- ...options,
1794
- withDeleted: false,
1795
- onlyDeleted: true,
2100
+ const trashed = { ...options, withDeleted: false, onlyDeleted: true };
2101
+ const restored = await this.guardedWrite(id, options, {
2102
+ operation: "restore",
2103
+ state: trashed,
2104
+ hook: "beforeRestore",
2105
+ before: () => this.beforeRestore(id),
2106
+ write: async (db) => (await this.runRead(this.updateWhereIn(db, this.softDeleteSet(null), [id], options, this.softDeleteScope(trashed))))[0],
2107
+ miss: () => {
2108
+ if (options?.expectedVersion != null && this.config.versionColumn) {
2109
+ throw new crud_exceptions_1.StaleVersionException(this.getEntityName(), id);
2110
+ }
2111
+ throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
2112
+ },
2113
+ after: (entity) => this.afterRestore(entity),
2114
+ rows: (entity) => [entity],
1796
2115
  });
1797
- if (!existing)
1798
- throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
1799
- if (!hooks.skipBefore)
1800
- await this.beforeRestore(id);
1801
- await this.emitEvent("before", "restore", false, { ids: [id], transaction }, options);
1802
- const db = transaction || this.config.db;
1803
- const [restoredEntity] = await this.runRead(this.updateWhereIn(db, this.softDeleteSet(null), [id], options));
1804
- if (!restoredEntity) {
1805
- if (options?.expectedVersion != null && this.config.versionColumn) {
1806
- throw new crud_exceptions_1.StaleVersionException(this.getEntityName(), id);
1807
- }
1808
- throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
1809
- }
1810
- if (!hooks.skipAfter)
1811
- await this.afterRestore(restoredEntity);
1812
- await this.emitEvent("after", "restore", false, { ids: [id], rows: [restoredEntity], transaction }, options);
1813
- await this.loadWriteRelations(db, [restoredEntity], options);
1814
- return restoredEntity;
2116
+ await this.loadWriteRelations(this.dbFor(options?.transaction), [restored], options);
2117
+ return restored;
1815
2118
  }
1816
2119
  async delete(id, options) {
1817
- const { transaction, hooks = {} } = options || {};
1818
- const existing = await this.findForGuard(id, options);
1819
- if (!existing)
1820
- throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
1821
- if (!hooks.skipBefore)
1822
- await this.beforeDelete(id);
1823
- await this.emitEvent("before", "delete", false, { ids: [id], transaction }, options);
1824
- const db = transaction || this.config.db;
1825
- const deleteQuery = db
1826
- .delete(this.config.table)
1827
- .where(this.writeWhere([id], options));
1828
- try {
1829
- const result = await deleteQuery;
1830
- const success = this.wasAffected(result);
1831
- if (!success && options?.expectedVersion != null) {
1832
- throw new crud_exceptions_1.StaleVersionException(this.getEntityName(), id);
1833
- }
1834
- if (success && !hooks.skipAfter)
1835
- await this.afterDelete(id);
1836
- if (success) {
1837
- await this.emitEvent("after", "delete", false, { ids: [id], transaction }, options);
1838
- }
1839
- return success;
1840
- }
1841
- catch (error) {
1842
- throw this.toClientError(error, "delete");
1843
- }
2120
+ options = this.withScopeMemo(options);
2121
+ return this.guardedWrite(id, options, {
2122
+ operation: "delete",
2123
+ state: options,
2124
+ hook: "beforeDelete",
2125
+ before: () => this.beforeDelete(id),
2126
+ write: async (db) => this.wasAffected(await db
2127
+ .delete(this.config.table)
2128
+ .where((0, drizzle_orm_1.and)(this.writeWhere([id], options), this.softDeleteScope(options)))) || undefined,
2129
+ miss: () => this.staleOrFalse(id, options),
2130
+ after: () => this.afterDelete(id),
2131
+ errors: "delete",
2132
+ });
1844
2133
  }
1845
2134
  async forceDelete(id, options) {
1846
2135
  return this.delete(id, {
@@ -1850,12 +2139,14 @@ class SqlBaseCrudService {
1850
2139
  });
1851
2140
  }
1852
2141
  async upsert(data, upsert = {}, options) {
2142
+ options = this.withScopeMemo(options);
1853
2143
  this.checkReadOptions(options);
1854
2144
  const entityData = await this.prepareCreate(data, options);
1855
- const db = options?.transaction || this.config.db;
2145
+ const db = this.dbFor(options?.transaction);
1856
2146
  await this.emitEvent("before", "upsert", false, { input: data, transaction: options?.transaction }, options);
1857
2147
  let row;
1858
2148
  try {
2149
+ await this.assertKeysInScope(db, entityData, options);
1859
2150
  [row] = await this.upsertRows(db, [entityData], upsert, options);
1860
2151
  }
1861
2152
  catch (error) {
@@ -1874,43 +2165,40 @@ class SqlBaseCrudService {
1874
2165
  return row;
1875
2166
  }
1876
2167
  async massUpsert(data, upsert = {}, options) {
1877
- this.checkReadOptions(options);
1878
- this.assertBatchSize(this.insertParameters(data));
2168
+ options = this.withScopeMemo(options);
1879
2169
  const message = "Mass upsert errors";
1880
- return this.executeSqlTransaction(async (tx) => {
1881
- const opts = { ...options, transaction: tx };
1882
- const rows = await this.eachItem(data.map((item, index) => ({
2170
+ return this.massWrite({
2171
+ operation: "upsert",
2172
+ message,
2173
+ options,
2174
+ input: data,
2175
+ returnsRows: true,
2176
+ prepare: (opts) => data.map((item, index) => ({
1883
2177
  index,
1884
2178
  run: () => this.prepareCreate(item, opts),
1885
- })), message);
1886
- if (rows.length === 0)
1887
- return [];
1888
- await this.emitEvent("before", "upsert", true, { input: data, transaction: tx }, options);
1889
- const written = await this.runSet(tx, message, (sp) => this.upsertRows(sp, rows, upsert, opts), rows.map((row, index) => ({
1890
- index,
1891
- run: (sp) => this.upsertRows(sp, [row], upsert, opts),
1892
- })), "write");
1893
- if (written.length < rows.length) {
1894
- const keys = this.conflictKeys(upsert);
1895
- const seen = new Set(written.map((row) => keys.map((k) => this.valueKey(row[k])).join("|")));
1896
- throw new crud_exceptions_1.BulkOperationException(message, rows.flatMap((row, index) => seen.has(keys.map((k) => this.valueKey(row[k])).join("|"))
1897
- ? []
1898
- : [
1899
- {
1900
- index,
1901
- error: new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), this.conflictValue(row, upsert)),
1902
- },
1903
- ]));
1904
- }
1905
- await this.emitEvent("after", "upsert", true, {
1906
- ids: this.idsOf(written),
1907
- input: data,
1908
- rows: written,
1909
- transaction: tx,
1910
- }, options);
1911
- await this.loadWriteRelations(tx, written, options);
2179
+ })),
2180
+ keys: true,
2181
+ write: (tx, rows, opts) => this.upsertSet(tx, rows, upsert, opts, message),
2182
+ });
2183
+ }
2184
+ async upsertSet(tx, rows, upsert, opts, message) {
2185
+ const written = await this.runSet(tx, message, (sp) => this.upsertRows(sp, rows, upsert, opts), rows.map((row, index) => ({
2186
+ index,
2187
+ run: (sp) => this.upsertRows(sp, [row], upsert, opts),
2188
+ })), "write");
2189
+ if (written.length >= rows.length)
1912
2190
  return written;
1913
- }, options?.transaction);
2191
+ const keys = this.conflictKeys(upsert);
2192
+ const keyOf = (row) => keys.map((k) => this.valueKey(row[k])).join("|");
2193
+ const seen = new Set(written.map(keyOf));
2194
+ throw new crud_exceptions_1.BulkOperationException(message, rows.flatMap((row, index) => seen.has(keyOf(row))
2195
+ ? []
2196
+ : [
2197
+ {
2198
+ index,
2199
+ error: new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), this.conflictValue(row, upsert)),
2200
+ },
2201
+ ]));
1914
2202
  }
1915
2203
  conflictKeys(upsert) {
1916
2204
  return upsert.conflictTarget === undefined
@@ -2005,38 +2293,78 @@ class SqlBaseCrudService {
2005
2293
  ? (0, drizzle_orm_1.sql) `values(${drizzle_orm_1.sql.identifier(column.name)})`
2006
2294
  : (0, drizzle_orm_1.sql) `excluded.${drizzle_orm_1.sql.identifier(column.name)}`;
2007
2295
  }
2296
+ async massWrite(spec) {
2297
+ const { options, message, ids } = spec;
2298
+ if (spec.returnsRows)
2299
+ this.checkReadOptions(options);
2300
+ const body = async (tx, load) => {
2301
+ const opts = { ...options, transaction: tx, ...spec.state };
2302
+ const unique = ids ? this.uniqueIds(ids) : [];
2303
+ if (ids) {
2304
+ if (unique.length === 0)
2305
+ return [];
2306
+ await this.assertAllExist(tx, unique, opts, message);
2307
+ }
2308
+ const items = spec.prepare?.(opts, unique);
2309
+ const prepared = items ? await this.eachItem(items, message) : [];
2310
+ if (!ids && prepared.length === 0)
2311
+ return [];
2312
+ const event = {
2313
+ ids: ids ? unique : undefined,
2314
+ input: spec.input,
2315
+ transaction: tx,
2316
+ };
2317
+ await this.emitEvent("before", spec.operation, true, event, options);
2318
+ if (spec.keys) {
2319
+ await this.assertBatchKeysInScope(tx, prepared, ids
2320
+ ? unique.map((id) => ({ id }))
2321
+ : prepared.map((_, index) => ({ index })), message, opts);
2322
+ }
2323
+ const written = await spec.write(tx, prepared, opts, unique);
2324
+ if (ids)
2325
+ this.assertAllWritten(unique, written, message, options);
2326
+ if (spec.after && !options?.hooks?.skipAfter) {
2327
+ await spec.after(written, unique);
2328
+ }
2329
+ await this.emitEvent("after", spec.operation, true, {
2330
+ ...event,
2331
+ ids: ids ? unique : this.idsOf(written),
2332
+ rows: spec.returnsRows ? written : undefined,
2333
+ }, options);
2334
+ if (spec.returnsRows)
2335
+ await load(tx, written);
2336
+ return ids ? this.inIdOrder(written, ids) : written;
2337
+ };
2338
+ return spec.returnsRows
2339
+ ? this.writeThenLoad(options, body)
2340
+ : this.executeSqlTransaction((tx) => body(tx, async () => { }), options?.transaction);
2341
+ }
2008
2342
  async massCreate(data, options) {
2343
+ options = this.withScopeMemo(options);
2009
2344
  if (options?.bulkMode === "perItem") {
2010
- return this.massCreatePerItem(data, options);
2345
+ return this.eachInSavepoint(data.map((item, index) => ({ index, item })), "Mass create errors", options, ({ item }, opts) => this.create(item, opts));
2011
2346
  }
2012
- this.checkReadOptions(options);
2013
- this.assertBatchSize(this.insertParameters(data));
2014
- return this.executeSqlTransaction(async (tx) => {
2015
- const opts = { ...options, transaction: tx };
2016
- const rows = await this.eachItem(data.map((item, index) => ({
2347
+ const message = "Mass create errors";
2348
+ return this.massWrite({
2349
+ operation: "create",
2350
+ message,
2351
+ options,
2352
+ input: data,
2353
+ returnsRows: true,
2354
+ prepare: (opts) => data.map((item, index) => ({
2017
2355
  index,
2018
2356
  run: () => this.prepareCreate(item, opts),
2019
- })), "Mass create errors");
2020
- if (rows.length === 0)
2021
- return [];
2022
- await this.emitEvent("before", "create", true, { input: data, transaction: tx }, options);
2023
- const created = await this.runSet(tx, "Mass create errors", (sp) => this.insertMany(sp, rows, opts), rows.map((row, index) => ({
2357
+ })),
2358
+ keys: true,
2359
+ write: (tx, rows, opts) => this.runSet(tx, message, (sp) => this.insertMany(sp, rows, opts), rows.map((row, index) => ({
2024
2360
  index,
2025
2361
  run: (sp) => this.insertOne(sp, row, opts),
2026
- })), "write");
2027
- if (!options?.hooks?.skipAfter) {
2362
+ })), "write"),
2363
+ after: async (created) => {
2028
2364
  for (const entity of created)
2029
2365
  await this.afterCreate(entity);
2030
- }
2031
- await this.emitEvent("after", "create", true, {
2032
- ids: this.idsOf(created),
2033
- input: data,
2034
- rows: created,
2035
- transaction: tx,
2036
- }, options);
2037
- await this.loadWriteRelations(tx, created, options);
2038
- return created;
2039
- }, options?.transaction);
2366
+ },
2367
+ });
2040
2368
  }
2041
2369
  async insertMany(db, rows, options) {
2042
2370
  if (this.config.sql?.useReturning) {
@@ -2047,9 +2375,28 @@ class SqlBaseCrudService {
2047
2375
  await db.insert(this.config.table).values(rows);
2048
2376
  return this.fetchByIds(db, rows.map((row) => row[pk]), options);
2049
2377
  }
2050
- const created = [];
2051
- for (const row of rows)
2052
- created.push(await this.insertOne(db, row, options));
2378
+ const ids = [];
2379
+ for (const row of rows) {
2380
+ const result = await db.insert(this.config.table).values(row);
2381
+ ids.push(row[pk] ?? result?.[0]?.insertId);
2382
+ }
2383
+ return this.readBackCreated(db, ids, options);
2384
+ }
2385
+ async readBackCreated(db, ids, options) {
2386
+ const pk = this.config.primaryKey;
2387
+ const base = this.parseSelect(options?.select).base;
2388
+ const fields = this.buildSelectFields(base.length > 0 ? Array.from(new Set([...base, pk])) : base);
2389
+ const column = this.config.table[pk];
2390
+ const rows = await (fields ? db.select(fields) : db.select())
2391
+ .from(this.config.table)
2392
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(column, ids), ...this.rowScope(options)));
2393
+ const created = this.inIdOrder(rows, ids);
2394
+ if (created.length < ids.length)
2395
+ throw new Error("Failed to create entity");
2396
+ if (fields && !base.includes(pk)) {
2397
+ for (const row of created)
2398
+ delete row[pk];
2399
+ }
2053
2400
  return created;
2054
2401
  }
2055
2402
  assertAllWritten(ids, written, message, options) {
@@ -2066,59 +2413,62 @@ class SqlBaseCrudService {
2066
2413
  })));
2067
2414
  }
2068
2415
  async massUpdate(ids, data, options) {
2416
+ options = this.withScopeMemo(options);
2069
2417
  if (options?.bulkMode === "perItem") {
2070
- return this.massUpdatePerItem(ids, data, options);
2418
+ return this.eachInSavepoint(ids.map((id) => ({ id })), "Mass update errors", options, ({ id }, opts) => this.update(id, data, opts));
2071
2419
  }
2072
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2073
- this.checkReadOptions(options);
2074
2420
  const message = "Mass update errors";
2075
- return this.executeSqlTransaction(async (tx) => {
2076
- const opts = { ...options, transaction: tx };
2077
- const hooks = options?.hooks ?? {};
2078
- const unique = this.uniqueIds(ids);
2079
- if (unique.length === 0)
2080
- return [];
2081
- await this.assertAllExist(tx, unique, opts, message);
2082
- const sets = await this.eachItem(unique.map((id) => ({
2421
+ const skipBefore = options?.hooks?.skipBefore;
2422
+ return this.massWrite({
2423
+ operation: "update",
2424
+ message,
2425
+ options,
2426
+ ids,
2427
+ input: data,
2428
+ returnsRows: true,
2429
+ prepare: (opts, unique) => unique.map((id) => ({
2083
2430
  id,
2084
2431
  run: async () => {
2085
2432
  await this.validateUpdate(id, data);
2086
- const processed = hooks.skipBefore
2433
+ const processed = skipBefore
2087
2434
  ? data
2088
2435
  : await this.beforeUpdate(id, data);
2089
2436
  return this.prepareUpdate(processed, opts);
2090
2437
  },
2091
- })), message);
2092
- await this.emitEvent("before", "update", true, { ids: unique, input: data, transaction: tx }, options);
2093
- const now = new Date();
2094
- const stamp = (set) => this.withVersionBump(this.config.timestamps?.updatedAt
2095
- ? { ...set, [this.config.timestamps.updatedAt]: now }
2096
- : set);
2097
- let updated;
2098
- const first = this.fingerprint(sets[0]);
2099
- if (sets.every((set) => this.fingerprint(set) === first)) {
2100
- const set = stamp(sets[0]);
2101
- updated = await this.runSet(tx, message, (sp) => this.updateWhereIn(sp, set, unique, opts), unique.map((id) => ({
2102
- id,
2103
- run: (sp) => this.updateWhereIn(sp, set, [id], opts),
2104
- })), "write");
2105
- }
2106
- else {
2107
- const rows = await this.eachItem(unique.map((id, i) => ({
2108
- id,
2109
- run: () => this.savepoint(tx, (sp) => this.updateWhereIn(sp, stamp(sets[i]), [id], opts)),
2110
- })), message, "write");
2111
- updated = rows.flat();
2112
- }
2113
- this.assertAllWritten(unique, updated, message, options);
2114
- if (!hooks.skipAfter) {
2438
+ })),
2439
+ keys: true,
2440
+ write: (tx, sets, opts, unique) => this.updateSets(tx, sets, unique, opts, message),
2441
+ after: async (updated) => {
2115
2442
  for (const entity of updated)
2116
2443
  await this.afterUpdate(entity);
2444
+ },
2445
+ });
2446
+ }
2447
+ async updateSets(tx, sets, unique, opts, message) {
2448
+ const now = new Date();
2449
+ const stamp = (set) => this.withVersionBump(this.config.timestamps?.updatedAt
2450
+ ? { ...set, [this.config.timestamps.updatedAt]: now }
2451
+ : set);
2452
+ const groups = new Map();
2453
+ unique.forEach((id, i) => {
2454
+ const key = this.fingerprint(sets[i]);
2455
+ const group = groups.get(key);
2456
+ if (group)
2457
+ group.ids.push(id);
2458
+ else
2459
+ groups.set(key, { set: stamp(sets[i]), ids: [id] });
2460
+ });
2461
+ const written = await this.runSet(tx, message, async (sp) => {
2462
+ const rows = [];
2463
+ for (const group of groups.values()) {
2464
+ rows.push(...(await this.updateWhereIn(sp, group.set, group.ids, opts)));
2117
2465
  }
2118
- await this.emitEvent("after", "update", true, { ids: unique, input: data, rows: updated, transaction: tx }, options);
2119
- await this.loadWriteRelations(tx, updated, options);
2120
- return this.inIdOrder(updated, ids);
2121
- }, options?.transaction);
2466
+ return rows;
2467
+ }, unique.map((id, i) => ({
2468
+ id,
2469
+ run: (sp) => this.updateWhereIn(sp, stamp(sets[i]), [id], opts),
2470
+ })), "write");
2471
+ return groups.size > 1 ? this.inIdOrder(written, unique) : written;
2122
2472
  }
2123
2473
  async runKeyed(statement, ids) {
2124
2474
  if (this.config.sql?.useReturning) {
@@ -2132,118 +2482,96 @@ class SqlBaseCrudService {
2132
2482
  : [];
2133
2483
  }
2134
2484
  async massSoftDelete(ids, options) {
2485
+ options = this.withScopeMemo(options);
2135
2486
  if (!this.config.softDelete?.enabled)
2136
2487
  throw new common_1.BadRequestException("Soft delete not enabled");
2137
2488
  if (options?.bulkMode === "perItem") {
2138
- return this.massSoftDeletePerItem(ids, options);
2489
+ await this.eachInSavepoint(ids.map((id) => ({ id })), "Mass soft delete errors", options, ({ id }, opts) => this.softDelete(id, opts));
2490
+ return true;
2139
2491
  }
2140
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2141
2492
  const message = "Mass soft delete errors";
2142
- return this.executeSqlTransaction(async (tx) => {
2143
- const opts = {
2144
- ...options,
2145
- transaction: tx,
2146
- withDeleted: false,
2147
- onlyDeleted: false,
2148
- };
2149
- const hooks = options?.hooks ?? {};
2150
- const unique = this.uniqueIds(ids);
2151
- if (unique.length === 0)
2152
- return true;
2153
- await this.assertAllExist(tx, unique, opts, message);
2154
- if (!hooks.skipBefore) {
2155
- await this.eachItem(unique.map((id) => ({ id, run: () => this.beforeSoftDelete(id) })), message);
2156
- }
2157
- await this.emitEvent("before", "softDelete", true, { ids: unique, transaction: tx }, options);
2158
- const set = this.softDeleteSet(new Date());
2159
- const written = await this.runSet(tx, message, (sp) => this.runKeyed(sp
2160
- .update(this.config.table)
2161
- .set(set)
2162
- .where(this.writeWhere(unique, opts)), unique), unique.map((id) => ({
2163
- id,
2164
- run: (sp) => sp
2493
+ await this.massWrite({
2494
+ operation: "softDelete",
2495
+ message,
2496
+ options,
2497
+ ids,
2498
+ state: { withDeleted: false, onlyDeleted: false },
2499
+ returnsRows: false,
2500
+ prepare: (_opts, unique) => options?.hooks?.skipBefore
2501
+ ? undefined
2502
+ : unique.map((id) => ({ id, run: () => this.beforeSoftDelete(id) })),
2503
+ write: (tx, _prepared, opts, unique) => {
2504
+ const set = this.softDeleteSet(new Date());
2505
+ const update = (db, keys) => db
2165
2506
  .update(this.config.table)
2166
2507
  .set(set)
2167
- .where(this.writeWhere([id], opts)),
2168
- })), "write");
2169
- this.assertAllWritten(unique, written, message, options);
2170
- if (!hooks.skipAfter) {
2508
+ .where(this.writeWhere(keys, opts));
2509
+ return this.runSet(tx, message, (sp) => this.runKeyed(update(sp, unique), unique), unique.map((id) => ({ id, run: (sp) => update(sp, [id]) })), "write");
2510
+ },
2511
+ after: async (_written, unique) => {
2171
2512
  for (const id of unique)
2172
2513
  await this.afterSoftDelete(id);
2173
- }
2174
- await this.emitEvent("after", "softDelete", true, { ids: unique, transaction: tx }, options);
2175
- return true;
2176
- }, options?.transaction);
2514
+ },
2515
+ });
2516
+ return true;
2177
2517
  }
2178
2518
  async massRestore(ids, options) {
2519
+ options = this.withScopeMemo(options);
2179
2520
  if (!this.config.softDelete?.enabled)
2180
2521
  throw new common_1.BadRequestException("Soft delete not enabled");
2181
2522
  if (options?.bulkMode === "perItem") {
2182
- return this.massRestorePerItem(ids, options);
2523
+ return this.eachInSavepoint(ids.map((id) => ({ id })), "Mass restore errors", options, ({ id }, opts) => this.restore(id, opts));
2183
2524
  }
2184
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2185
- this.checkReadOptions(options);
2186
2525
  const message = "Mass restore errors";
2187
- return this.executeSqlTransaction(async (tx) => {
2188
- const opts = {
2189
- ...options,
2190
- transaction: tx,
2191
- withDeleted: true,
2192
- onlyDeleted: false,
2193
- };
2194
- const hooks = options?.hooks ?? {};
2195
- const unique = this.uniqueIds(ids);
2196
- if (unique.length === 0)
2197
- return [];
2198
- await this.assertAllExist(tx, unique, opts, message);
2199
- if (!hooks.skipBefore) {
2200
- await this.eachItem(unique.map((id) => ({ id, run: () => this.beforeRestore(id) })), message);
2201
- }
2202
- await this.emitEvent("before", "restore", true, { ids: unique, transaction: tx }, options);
2203
- const set = this.softDeleteSet(null);
2204
- const restored = await this.runSet(tx, message, (sp) => this.updateWhereIn(sp, set, unique, opts), unique.map((id) => ({
2205
- id,
2206
- run: (sp) => this.updateWhereIn(sp, set, [id], opts),
2207
- })), "write");
2208
- this.assertAllWritten(unique, restored, message, options);
2209
- if (!hooks.skipAfter) {
2526
+ return this.massWrite({
2527
+ operation: "restore",
2528
+ message,
2529
+ options,
2530
+ ids,
2531
+ state: { withDeleted: true, onlyDeleted: false },
2532
+ returnsRows: true,
2533
+ prepare: (_opts, unique) => options?.hooks?.skipBefore
2534
+ ? undefined
2535
+ : unique.map((id) => ({ id, run: () => this.beforeRestore(id) })),
2536
+ write: (tx, _prepared, opts, unique) => {
2537
+ const set = this.softDeleteSet(null);
2538
+ return this.runSet(tx, message, (sp) => this.updateWhereIn(sp, set, unique, opts), unique.map((id) => ({
2539
+ id,
2540
+ run: (sp) => this.updateWhereIn(sp, set, [id], opts),
2541
+ })), "write");
2542
+ },
2543
+ after: async (restored) => {
2210
2544
  for (const entity of restored)
2211
2545
  await this.afterRestore(entity);
2212
- }
2213
- await this.emitEvent("after", "restore", true, { ids: unique, rows: restored, transaction: tx }, options);
2214
- await this.loadWriteRelations(tx, restored, options);
2215
- return this.inIdOrder(restored, ids);
2216
- }, options?.transaction);
2546
+ },
2547
+ });
2217
2548
  }
2218
2549
  async massDelete(ids, options) {
2550
+ options = this.withScopeMemo(options);
2219
2551
  if (options?.bulkMode === "perItem") {
2220
- return this.massDeletePerItem(ids, options);
2552
+ await this.eachInSavepoint(ids.map((id) => ({ id })), "Mass delete errors", options, ({ id }, opts) => this.delete(id, opts), "delete");
2553
+ return true;
2221
2554
  }
2222
- this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2223
2555
  const message = "Mass delete errors";
2224
- return this.executeSqlTransaction(async (tx) => {
2225
- const opts = { ...options, transaction: tx };
2226
- const hooks = options?.hooks ?? {};
2227
- const unique = this.uniqueIds(ids);
2228
- if (unique.length === 0)
2229
- return true;
2230
- await this.assertAllExist(tx, unique, opts, message);
2231
- if (!hooks.skipBefore) {
2232
- await this.eachItem(unique.map((id) => ({ id, run: () => this.beforeDelete(id) })), message);
2233
- }
2234
- await this.emitEvent("before", "delete", true, { ids: unique, transaction: tx }, options);
2235
- const written = await this.runSet(tx, message, (sp) => this.runKeyed(sp.delete(this.config.table).where(this.writeWhere(unique, opts)), unique), unique.map((id) => ({
2236
- id,
2237
- run: (sp) => sp.delete(this.config.table).where(this.writeWhere([id], opts)),
2238
- })), "delete");
2239
- this.assertAllWritten(unique, written, message, options);
2240
- if (!hooks.skipAfter) {
2556
+ await this.massWrite({
2557
+ operation: "delete",
2558
+ message,
2559
+ options,
2560
+ ids,
2561
+ returnsRows: false,
2562
+ prepare: (_opts, unique) => options?.hooks?.skipBefore
2563
+ ? undefined
2564
+ : unique.map((id) => ({ id, run: () => this.beforeDelete(id) })),
2565
+ write: (tx, _prepared, opts, unique) => {
2566
+ const remove = (db, keys) => db.delete(this.config.table).where(this.writeWhere(keys, opts));
2567
+ return this.runSet(tx, message, (sp) => this.runKeyed(remove(sp, unique), unique), unique.map((id) => ({ id, run: (sp) => remove(sp, [id]) })), "delete");
2568
+ },
2569
+ after: async (_written, unique) => {
2241
2570
  for (const id of unique)
2242
2571
  await this.afterDelete(id);
2243
- }
2244
- await this.emitEvent("after", "delete", true, { ids: unique, transaction: tx }, options);
2245
- return true;
2246
- }, options?.transaction);
2572
+ },
2573
+ });
2574
+ return true;
2247
2575
  }
2248
2576
  async massForceDelete(ids, options) {
2249
2577
  return this.massDelete(ids, {
@@ -2328,10 +2656,16 @@ class SqlBaseCrudService {
2328
2656
  return item.index !== undefined ? { index: item.index } : { id: item.id };
2329
2657
  }
2330
2658
  async runSet(tx, message, statement, items, operation) {
2659
+ const owned = this.ownedTransactions.has(tx);
2331
2660
  try {
2332
- return await this.savepoint(tx, statement);
2661
+ return await (owned ? statement(tx) : this.savepoint(tx, statement));
2333
2662
  }
2334
2663
  catch (error) {
2664
+ if (error instanceof BindLimitException)
2665
+ throw error;
2666
+ if (owned) {
2667
+ throw new DeferredDiagnosis(() => this.settleSet(message, error, items, operation));
2668
+ }
2335
2669
  const failures = await this.diagnose(tx, items, operation);
2336
2670
  if (failures.length > 0) {
2337
2671
  throw new crud_exceptions_1.BulkOperationException(message, failures);
@@ -2339,6 +2673,23 @@ class SqlBaseCrudService {
2339
2673
  throw this.toClientError(error, operation);
2340
2674
  }
2341
2675
  }
2676
+ async settleSet(message, error, items, operation) {
2677
+ let failures = [];
2678
+ try {
2679
+ await this.config.db.transaction(async (fresh) => {
2680
+ failures = await this.diagnose(this.withBindLimit(fresh), items, operation);
2681
+ throw ROLLBACK;
2682
+ });
2683
+ }
2684
+ catch (thrown) {
2685
+ if (thrown !== ROLLBACK)
2686
+ throw thrown;
2687
+ }
2688
+ if (failures.length > 0) {
2689
+ throw new crud_exceptions_1.BulkOperationException(message, failures);
2690
+ }
2691
+ throw this.toClientError(error, operation);
2692
+ }
2342
2693
  async diagnose(tx, items, operation) {
2343
2694
  const failures = [];
2344
2695
  try {
@@ -2363,60 +2714,40 @@ class SqlBaseCrudService {
2363
2714
  }
2364
2715
  return failures;
2365
2716
  }
2366
- async massCreatePerItem(data, options) {
2367
- return this.executeSqlTransaction((tx) => this.eachItem(data.map((item, index) => ({
2368
- index,
2369
- run: () => this.savepoint(tx, (sp) => this.create(item, { ...options, transaction: sp })),
2370
- })), "Mass create errors"), options?.transaction);
2371
- }
2372
- async massUpdatePerItem(ids, data, options) {
2373
- return this.executeSqlTransaction((tx) => this.eachItem(ids.map((id) => ({
2374
- id,
2375
- run: () => this.savepoint(tx, (sp) => this.update(id, data, { ...options, transaction: sp })),
2376
- })), "Mass update errors"), options?.transaction);
2377
- }
2378
- async massSoftDeletePerItem(ids, options) {
2379
- await this.executeSqlTransaction((tx) => this.eachItem(ids.map((id) => ({
2380
- id,
2381
- run: () => this.savepoint(tx, (sp) => this.softDelete(id, { ...options, transaction: sp })),
2382
- })), "Mass soft delete errors"), options?.transaction);
2383
- return true;
2384
- }
2385
- async massRestorePerItem(ids, options) {
2386
- return this.executeSqlTransaction((tx) => this.eachItem(ids.map((id) => ({
2387
- id,
2388
- run: () => this.savepoint(tx, (sp) => this.restore(id, { ...options, transaction: sp })),
2389
- })), "Mass restore errors"), options?.transaction);
2390
- }
2391
- async massDeletePerItem(ids, options) {
2392
- await this.executeSqlTransaction((tx) => this.eachItem(ids.map((id) => ({
2393
- id,
2394
- run: () => this.savepoint(tx, (sp) => this.delete(id, { ...options, transaction: sp })),
2395
- })), "Mass delete errors", "delete"), options?.transaction);
2396
- return true;
2717
+ eachInSavepoint(entries, message, options, call, operation = "write") {
2718
+ return this.executeSqlTransaction((tx) => this.eachItem(entries.map((entry) => ({
2719
+ ...entry,
2720
+ run: () => this.savepoint(tx, (sp) => call(entry, { ...options, transaction: sp })),
2721
+ })), message, operation), options?.transaction);
2397
2722
  }
2398
2723
  savepoint(tx, run) {
2399
2724
  return typeof tx?.transaction === "function"
2400
- ? tx.transaction(run)
2725
+ ? tx.transaction((sp) => run(this.withBindLimit(sp)))
2401
2726
  : run(tx);
2402
2727
  }
2403
2728
  async exists(id, options) {
2729
+ options = this.withScopeMemo(options);
2730
+ this.checkReadOptions({ ...options, select: undefined });
2404
2731
  const entity = await this.find(id, {
2405
2732
  ...options,
2733
+ relations: [],
2734
+ counts: undefined,
2406
2735
  select: [this.config.primaryKey],
2407
2736
  });
2408
2737
  return !!entity;
2409
2738
  }
2410
2739
  async count(filters, options) {
2740
+ options = this.withScopeMemo(options);
2411
2741
  this.checkReadOptions(options);
2412
- const db = options?.transaction || this.config.db;
2742
+ const db = this.dbFor(options?.transaction);
2413
2743
  const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
2414
2744
  const result = await this.runRead(this.buildCountQuery(db, conditions, filterRelations, options));
2415
- return parseInt(result[0]?.count?.toString() || "0", 10);
2745
+ return this.parseCount(result);
2416
2746
  }
2417
2747
  async countBy(column, filters, options) {
2748
+ options = this.withScopeMemo(options);
2418
2749
  this.checkReadOptions(options);
2419
- const db = options?.transaction || this.config.db;
2750
+ const db = this.dbFor(options?.transaction);
2420
2751
  const spec = this.resolveGroupColumn(column, options);
2421
2752
  const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
2422
2753
  const joins = Array.from(new Set([...filterRelations, ...(spec.relation ? [spec.relation] : [])]));
@@ -2447,10 +2778,7 @@ class SqlBaseCrudService {
2447
2778
  }
2448
2779
  else {
2449
2780
  const name = key.slice(0, dot);
2450
- const relations = this.config.relations || {};
2451
- const relation = Object.hasOwn(relations, name)
2452
- ? relations[name]
2453
- : undefined;
2781
+ const relation = this.relationNamed(name);
2454
2782
  const column = relation && !this.isCollection(relation) && relation.localKey
2455
2783
  ? this.columnOf(relation.table, key.slice(dot + 1))
2456
2784
  : undefined;
@@ -2483,25 +2811,9 @@ class SqlBaseCrudService {
2483
2811
  return keys.length > 0 ? Array.from(new Set([...base, ...keys])) : base;
2484
2812
  }
2485
2813
  async loadCounts(db, rows, counts, options) {
2486
- const names = this.countNames(counts);
2487
- if (names.length === 0 || !Array.isArray(rows))
2814
+ if (this.countNames(counts).length === 0 || !Array.isArray(rows))
2488
2815
  return;
2489
- const relations = this.config.relations || {};
2490
- const plan = names.map((name) => {
2491
- const relation = Object.hasOwn(relations, name)
2492
- ? relations[name]
2493
- : undefined;
2494
- if (!relation) {
2495
- throw new common_1.BadRequestException(`Unknown relation to count: ${name}`);
2496
- }
2497
- if (!this.isCollection(relation)) {
2498
- throw new common_1.BadRequestException(`Cannot count "${name}": only a has-many or many-to-many relation has a count`);
2499
- }
2500
- const filter = !Array.isArray(counts) && (0, merge_config_1.isPlainObject)(counts?.[name])
2501
- ? counts?.[name]
2502
- : undefined;
2503
- return { name, relation, filter };
2504
- });
2816
+ const plan = this.countPlan(counts);
2505
2817
  const hosts = rows.filter((row) => row && typeof row === "object");
2506
2818
  for (const host of hosts)
2507
2819
  host._count = {};
@@ -2512,7 +2824,7 @@ class SqlBaseCrudService {
2512
2824
  const ids = Array.from(new Set(hosts.map((host) => host[parentKey]).filter((v) => v != null)));
2513
2825
  const totals = new Map();
2514
2826
  if (ids.length > 0) {
2515
- const result = await this.runRead(this.countQuery(db, relation, ids, name, filter, options));
2827
+ const result = await this.inChunks(db, ids, (chunk) => this.runRead(this.countQuery(db, relation, chunk, name, filter, options)));
2516
2828
  for (const row of result) {
2517
2829
  totals.set(String(row[COUNT_KEY]), Number(row.count));
2518
2830
  }
@@ -2522,6 +2834,23 @@ class SqlBaseCrudService {
2522
2834
  }
2523
2835
  }));
2524
2836
  }
2837
+ countPlan(counts) {
2838
+ return this.countNames(counts).map((name) => {
2839
+ const relation = this.relationNamed(name);
2840
+ if (!relation) {
2841
+ throw new common_1.BadRequestException(`Unknown relation to count: ${name}`);
2842
+ }
2843
+ if (!this.isCollection(relation)) {
2844
+ throw new common_1.BadRequestException(`Cannot count "${name}": only a has-many or many-to-many relation has a count`);
2845
+ }
2846
+ const filter = !Array.isArray(counts) && (0, merge_config_1.isPlainObject)(counts?.[name])
2847
+ ? counts?.[name]
2848
+ : undefined;
2849
+ if (filter)
2850
+ (0, query_parser_1.assertFilterDepth)(filter);
2851
+ return { name, relation, filter };
2852
+ });
2853
+ }
2525
2854
  countQuery(db, relation, ids, name, filter, options) {
2526
2855
  const target = relation.table;
2527
2856
  const conditions = [];
@@ -2538,12 +2867,7 @@ class SqlBaseCrudService {
2538
2867
  const total = (0, drizzle_orm_1.sql) `count(*)`;
2539
2868
  if (this.relationType(relation) === "manyToMany") {
2540
2869
  const joinLocal = relation.through[relation.throughLocalKey];
2541
- const joinForeign = relation.through[relation.throughForeignKey];
2542
- const referenced = target[relation.references ?? "id"];
2543
- return db
2544
- .select({ [COUNT_KEY]: joinLocal, count: total })
2545
- .from(relation.through)
2546
- .innerJoin(target, (0, drizzle_orm_1.eq)(joinForeign, referenced))
2870
+ return this.selectThrough(db, { [COUNT_KEY]: joinLocal, count: total }, relation)
2547
2871
  .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(joinLocal, ids), ...conditions))
2548
2872
  .groupBy(joinLocal);
2549
2873
  }
@@ -2556,7 +2880,6 @@ class SqlBaseCrudService {
2556
2880
  }
2557
2881
  pushColumnCondition(conditions, column, value, caseInsensitive = this.config.sql?.caseSensitive === false) {
2558
2882
  if (Array.isArray(value)) {
2559
- this.assertBatchSize(value.length);
2560
2883
  conditions.push((0, drizzle_orm_1.inArray)(column, value.map((item) => this.coerceFilterValue(column, item))));
2561
2884
  }
2562
2885
  else if ((0, merge_config_1.isPlainObject)(value)) {
@@ -2597,11 +2920,7 @@ class SqlBaseCrudService {
2597
2920
  const joinForeign = relation.through?.[relation.throughForeignKey ?? ""];
2598
2921
  if (!referenced || !joinForeign)
2599
2922
  return undefined;
2600
- subquery = db
2601
- .select({ value: key })
2602
- .from(relation.through)
2603
- .innerJoin(target, (0, drizzle_orm_1.eq)(joinForeign, referenced))
2604
- .where((0, drizzle_orm_1.and)(...conditions));
2923
+ subquery = this.selectThrough(db, { value: key }, relation).where((0, drizzle_orm_1.and)(...conditions));
2605
2924
  }
2606
2925
  else {
2607
2926
  subquery = db
@@ -2629,11 +2948,7 @@ class SqlBaseCrudService {
2629
2948
  let subquery;
2630
2949
  if (this.relationType(relation) === "manyToMany") {
2631
2950
  const through = subqueryAlias(relation.through);
2632
- subquery = db
2633
- .select({ one: (0, drizzle_orm_1.sql) `1` })
2634
- .from(through)
2635
- .innerJoin(target, (0, drizzle_orm_1.eq)(through[relation.throughForeignKey], target[relation.references ?? "id"]))
2636
- .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(through[relation.throughLocalKey], host.table[parentKey]), ...conditions));
2951
+ subquery = this.selectThrough(db, { one: (0, drizzle_orm_1.sql) `1` }, relation, target, through).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(through[relation.throughLocalKey], host.table[parentKey]), ...conditions));
2637
2952
  }
2638
2953
  else {
2639
2954
  subquery = db
@@ -2805,7 +3120,6 @@ class SqlBaseCrudService {
2805
3120
  if (!Array.isArray(raw)) {
2806
3121
  throw new common_1.BadRequestException(`Filter operator "${op}" expects an array`);
2807
3122
  }
2808
- this.assertBatchSize(raw.length);
2809
3123
  return raw;
2810
3124
  }
2811
3125
  static MAX_BATCH_PARAMETERS = 60_000;
@@ -2843,9 +3157,19 @@ class SqlBaseCrudService {
2843
3157
  : kind === "endsWith"
2844
3158
  ? `%${escaped}`
2845
3159
  : `%${escaped}%`;
2846
- return insensitive || this.config.sql?.caseSensitive === false
2847
- ? this.caseInsensitiveLike(column, pattern)
2848
- : this.likePattern(column, pattern);
3160
+ if (insensitive || this.config.sql?.caseSensitive === false) {
3161
+ return this.caseInsensitiveLike(column, pattern);
3162
+ }
3163
+ if (this.config.dialect === "sqlite") {
3164
+ const literal = raw.replace(/[*?[]/g, "[$&]");
3165
+ const glob = kind === "startsWith"
3166
+ ? `${literal}*`
3167
+ : kind === "endsWith"
3168
+ ? `*${literal}`
3169
+ : `*${literal}*`;
3170
+ return (0, drizzle_orm_1.sql) `${column} glob ${glob}`;
3171
+ }
3172
+ return this.likePattern(column, pattern);
2849
3173
  }
2850
3174
  applyComplexFilter(conditions, column, filterObj) {
2851
3175
  const coerce = (value) => this.coerceFilterValue(column, value);
@@ -2936,15 +3260,28 @@ class SqlBaseCrudService {
2936
3260
  }
2937
3261
  async executeSqlTransaction(operation, existingTransaction) {
2938
3262
  this.assertTransactions(existingTransaction ?? this.config.db);
3263
+ const run = (tx) => operation(this.withBindLimit(tx));
2939
3264
  if (existingTransaction)
2940
- return operation(existingTransaction);
2941
- return this.config.db.transaction(operation);
3265
+ return run(existingTransaction);
3266
+ try {
3267
+ return await this.config.db.transaction((tx) => {
3268
+ const view = this.withBindLimit(tx);
3269
+ if (view && typeof view === "object")
3270
+ this.ownedTransactions.add(view);
3271
+ return operation(view);
3272
+ });
3273
+ }
3274
+ catch (error) {
3275
+ if (error instanceof DeferredDiagnosis)
3276
+ return error.settle();
3277
+ throw error;
3278
+ }
2942
3279
  }
3280
+ ownedTransactions = new WeakSet();
2943
3281
  assertTransactions(db) {
2944
3282
  if (this.config.dialect !== "sqlite")
2945
3283
  return;
2946
- const kind = db?.constructor?.[drizzle_orm_1.entityKind];
2947
- if (kind === "D1Database" || kind === "D1Transaction") {
3284
+ if (this.isD1(db)) {
2948
3285
  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
3286
  }
2950
3287
  if (db?.resultKind === "sync") {
@@ -2960,6 +3297,7 @@ class SqlBaseCrudService {
2960
3297
  }
2961
3298
  }
2962
3299
  async fullTextSearch(searchTerm, searchColumns, pagination, options) {
3300
+ options = this.withScopeMemo(options);
2963
3301
  if (this.config.dialect !== "postgresql") {
2964
3302
  throw new common_1.BadRequestException("Full-text search only supported in PostgreSQL");
2965
3303
  }
@@ -2998,12 +3336,9 @@ class SqlBaseCrudService {
2998
3336
  .offset(offset);
2999
3337
  const countQuery = this.buildCountQuery(plan.db, conditions, plan.filterRelations, readOptions);
3000
3338
  const [fetched, totalResult] = await this.runRead(Promise.all([dataQuery, countQuery]));
3001
- const total = parseInt(totalResult[0]?.count?.toString() || "0", 10);
3339
+ const total = this.parseCount(totalResult);
3002
3340
  const rows = this.normalizeRelations(fetched, plan.joined.map((node) => node.name));
3003
- await Promise.all([
3004
- this.loadRelations(plan.db, rows, plan.nodes, plan.selection, undefined, readOptions),
3005
- this.loadCounts(plan.db, rows, readOptions.counts, readOptions),
3006
- ]);
3341
+ await this.hydrate(plan.db, rows, plan.nodes, plan.selection, readOptions);
3007
3342
  return { data: rows, total, page, limit };
3008
3343
  }
3009
3344
  }