@avytrix/nestjs-drizzle-crud 3.5.1 → 3.7.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.
@@ -66,6 +66,10 @@ class SqlBaseCrudService {
66
66
  return result.rowsAffected > 0;
67
67
  if (typeof result.affectedRows === "number")
68
68
  return result.affectedRows > 0;
69
+ if (typeof result.changes === "number")
70
+ return result.changes > 0;
71
+ if (typeof result.meta?.changes === "number")
72
+ return result.meta.changes > 0;
69
73
  return false;
70
74
  }
71
75
  buildWhereConditionsFromPartial(where) {
@@ -196,6 +200,18 @@ class SqlBaseCrudService {
196
200
  return [];
197
201
  return Object.entries(values).map(([key, value]) => value === null ? (0, drizzle_orm_1.isNull)(table[key]) : (0, drizzle_orm_1.eq)(table[key], value));
198
202
  }
203
+ relatedRowScope(target, options) {
204
+ const out = this.relatedScopeConditions(target, options);
205
+ const softDeleted = this.softDeleteCondition(target);
206
+ if (softDeleted)
207
+ out.push(softDeleted);
208
+ return out;
209
+ }
210
+ relatedScopeConditions(target, options) {
211
+ return Object.entries(this.resolveScope(options) ?? {})
212
+ .filter(([key]) => this.columnOf(target, key))
213
+ .map(([key, value]) => value === null ? (0, drizzle_orm_1.isNull)(target[key]) : (0, drizzle_orm_1.eq)(target[key], value));
214
+ }
199
215
  applyScopeToRow(row, options) {
200
216
  const values = this.resolveScope(options);
201
217
  if (values)
@@ -497,7 +513,7 @@ class SqlBaseCrudService {
497
513
  }
498
514
  return Object.keys(fields).length > 0 ? fields : undefined;
499
515
  }
500
- async loadRelations(db, hosts, nodes, select = { base: [], relations: {} }, hostTable = this.config.table) {
516
+ async loadRelations(db, hosts, nodes, select = { base: [], relations: {} }, hostTable = this.config.table, options) {
501
517
  if (!nodes.length || !Array.isArray(hosts))
502
518
  return;
503
519
  const rows = hosts.filter((row) => row && typeof row === "object");
@@ -508,13 +524,13 @@ class SqlBaseCrudService {
508
524
  if (!node.joined) {
509
525
  switch (this.relationType(node.config)) {
510
526
  case "hasMany":
511
- await this.loadHasMany(db, rows, node, childSelect, hostTable);
527
+ await this.loadHasMany(db, rows, node, childSelect, hostTable, options);
512
528
  break;
513
529
  case "manyToMany":
514
- await this.loadManyToMany(db, rows, node, childSelect, hostTable);
530
+ await this.loadManyToMany(db, rows, node, childSelect, hostTable, options);
515
531
  break;
516
532
  default:
517
- await this.loadBelongsTo(db, rows, node, childSelect);
533
+ await this.loadBelongsTo(db, rows, node, childSelect, options);
518
534
  }
519
535
  }
520
536
  if (node.children.length === 0)
@@ -522,10 +538,10 @@ class SqlBaseCrudService {
522
538
  const nextHosts = this.isCollection(node.config)
523
539
  ? rows.flatMap((row) => row[node.name] ?? [])
524
540
  : rows.map((row) => row[node.name]);
525
- await this.loadRelations(db, nextHosts, node.children, childSelect, node.config.table);
541
+ await this.loadRelations(db, nextHosts, node.children, childSelect, node.config.table, options);
526
542
  }));
527
543
  }
528
- async loadBelongsTo(db, hosts, node, select) {
544
+ async loadBelongsTo(db, hosts, node, select, options) {
529
545
  const { localKey, references } = node.config;
530
546
  if (!localKey)
531
547
  return;
@@ -545,9 +561,7 @@ class SqlBaseCrudService {
545
561
  ...this.keysForChildren(node.children, target),
546
562
  ]);
547
563
  const conditions = [(0, drizzle_orm_1.inArray)(referenced, ids)];
548
- const softDeleted = this.softDeleteCondition(target);
549
- if (softDeleted)
550
- conditions.push(softDeleted);
564
+ conditions.push(...this.relatedRowScope(target, options));
551
565
  const related = await (fields ? db.select(fields) : db.select())
552
566
  .from(target)
553
567
  .where((0, drizzle_orm_1.and)(...conditions));
@@ -558,7 +572,7 @@ class SqlBaseCrudService {
558
572
  host[node.name] = byKey.get(host[localKey]) ?? null;
559
573
  }
560
574
  }
561
- async loadHasMany(db, hosts, node, select, hostTable = this.config.table) {
575
+ async loadHasMany(db, hosts, node, select, hostTable = this.config.table, options) {
562
576
  const { foreignKey } = node.config;
563
577
  if (!foreignKey)
564
578
  return;
@@ -578,9 +592,7 @@ class SqlBaseCrudService {
578
592
  ...this.keysForChildren(node.children, target),
579
593
  ]);
580
594
  const conditions = [(0, drizzle_orm_1.inArray)(foreign, ids)];
581
- const softDeleted = this.softDeleteCondition(target);
582
- if (softDeleted)
583
- conditions.push(softDeleted);
595
+ conditions.push(...this.relatedRowScope(target, options));
584
596
  let query = (fields ? db.select(fields) : db.select())
585
597
  .from(target)
586
598
  .where((0, drizzle_orm_1.and)(...conditions));
@@ -590,7 +602,7 @@ class SqlBaseCrudService {
590
602
  const children = await query;
591
603
  this.groupCollection(hosts, node.name, parentKey, children, foreignKey);
592
604
  }
593
- async loadManyToMany(db, hosts, node, select, hostTable = this.config.table) {
605
+ async loadManyToMany(db, hosts, node, select, hostTable = this.config.table, options) {
594
606
  const { through, throughLocalKey, throughForeignKey, references } = node.config;
595
607
  if (!through || !throughLocalKey || !throughForeignKey)
596
608
  return;
@@ -618,9 +630,7 @@ class SqlBaseCrudService {
618
630
  fields[name] = columns[name];
619
631
  }
620
632
  const conditions = [(0, drizzle_orm_1.inArray)(joinLocal, ids)];
621
- const softDeleted = this.softDeleteCondition(target);
622
- if (softDeleted)
623
- conditions.push(softDeleted);
633
+ conditions.push(...this.relatedRowScope(target, options));
624
634
  let query = db
625
635
  .select(fields)
626
636
  .from(through)
@@ -650,7 +660,7 @@ class SqlBaseCrudService {
650
660
  host[name] = grouped.get(host[parentKey]) ?? [];
651
661
  }
652
662
  }
653
- applyJoins(query, relationNames) {
663
+ applyJoins(query, relationNames, options) {
654
664
  const configured = this.config.relations || {};
655
665
  let q = query;
656
666
  for (const name of relationNames) {
@@ -658,8 +668,7 @@ class SqlBaseCrudService {
658
668
  if (!rel?.localKey || this.isCollection(rel))
659
669
  continue;
660
670
  const on = (0, drizzle_orm_1.eq)(this.config.table[rel.localKey], rel.table[rel.references ?? "id"]);
661
- const softDeleted = this.softDeleteCondition(rel.table);
662
- q = q.leftJoin(rel.table, softDeleted ? (0, drizzle_orm_1.and)(on, softDeleted) : on);
671
+ q = q.leftJoin(rel.table, (0, drizzle_orm_1.and)(on, ...this.relatedRowScope(rel.table, options)));
663
672
  }
664
673
  return q;
665
674
  }
@@ -681,13 +690,13 @@ class SqlBaseCrudService {
681
690
  return out;
682
691
  });
683
692
  }
684
- buildOrderBy(sortBy, sortOrder = "desc") {
693
+ buildOrderBy(sortBy, sortOrder = "asc") {
685
694
  return this.resolveSort(sortBy, sortOrder).map((spec) => this.orderOf(spec));
686
695
  }
687
696
  orderOf(spec) {
688
697
  return spec.order === "desc" ? (0, drizzle_orm_1.desc)(spec.column) : (0, drizzle_orm_1.asc)(spec.column);
689
698
  }
690
- resolveSort(sortBy, sortOrder = "desc", options) {
699
+ resolveSort(sortBy, sortOrder = "asc", options) {
691
700
  if (sortOrder !== "asc" && sortOrder !== "desc") {
692
701
  throw new common_1.BadRequestException(`Invalid sortOrder: ${sortOrder} (expected 'asc' or 'desc')`);
693
702
  }
@@ -800,14 +809,37 @@ class SqlBaseCrudService {
800
809
  }
801
810
  isUniqueViolation(error) {
802
811
  const code = error?.code ?? error?.cause?.code;
803
- return code === "23505";
812
+ const errno = error?.errno ?? error?.cause?.errno;
813
+ const sqlite = this.sqliteConstraint(error);
814
+ return (code === "23505" ||
815
+ code === "ER_DUP_ENTRY" ||
816
+ errno === 1062 ||
817
+ sqlite === "UNIQUE" ||
818
+ sqlite === "PRIMARYKEY");
819
+ }
820
+ sqliteConstraint(error) {
821
+ if (this.config.dialect !== "sqlite")
822
+ return undefined;
823
+ for (const e of [error, error?.cause]) {
824
+ const code = e?.extendedCode ?? e?.code;
825
+ if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT_")) {
826
+ return code.slice("SQLITE_CONSTRAINT_".length);
827
+ }
828
+ }
829
+ const message = `${error?.cause?.message ?? ""} ${error?.message ?? ""}`;
830
+ const m = message.match(/(UNIQUE|PRIMARY KEY|FOREIGN KEY|NOT NULL|CHECK) constraint failed/);
831
+ return m?.[1]?.replace(" ", "");
804
832
  }
805
833
  toDuplicateException(error) {
806
834
  const detail = error?.detail ?? error?.cause?.detail ?? "";
807
- const m = detail.match(/Key \((?<field>.+?)\)=\((?<value>.+?)\)/);
835
+ const message = error?.cause?.message ?? error?.message ?? "";
836
+ const m = detail.match(/Key \((?<field>.+?)\)=\((?<value>.+?)\)/) ??
837
+ message.match(/Duplicate entry '(?<value>.*)' for key '(?:[^']*\.)?(?<field>[^']+)'/) ??
838
+ message.match(/UNIQUE constraint failed: (?:[^.,\s]+\.)?(?<field>[^,\s]+)/);
808
839
  return new crud_exceptions_1.DuplicateEntityException(this.getEntityName(), m?.groups?.field ?? "unique field", m?.groups?.value ?? "");
809
840
  }
810
841
  static DATA_ERRORS = {
842
+ "23502": "a required value is missing",
811
843
  "22001": "a value is too long for its column",
812
844
  "23514": "a value violates a column check constraint",
813
845
  "22003": "a numeric value is out of range",
@@ -816,13 +848,54 @@ class SqlBaseCrudService {
816
848
  "22008": "a date or time value is out of range",
817
849
  "21000": "the same key appears more than once in one upsert",
818
850
  };
819
- isDataException(error) {
851
+ static MYSQL_DATA_ERRORS = {
852
+ 1048: "a required value is missing",
853
+ 1364: "a required value is missing",
854
+ 1406: "a value is too long for its column",
855
+ 3819: "a value violates a column check constraint",
856
+ 1264: "a numeric value is out of range",
857
+ 1265: "a value does not match its column type",
858
+ 1366: "a value does not match its column type",
859
+ 1292: "a date or time value has an invalid format",
860
+ };
861
+ static SQLITE_DATA_ERRORS = {
862
+ NOTNULL: "a required value is missing",
863
+ CHECK: "a value violates a column check constraint",
864
+ };
865
+ dataErrorMessage(error) {
820
866
  const code = error?.code ?? error?.cause?.code;
821
- return Object.hasOwn(SqlBaseCrudService.DATA_ERRORS, code ?? "");
867
+ if (Object.hasOwn(SqlBaseCrudService.DATA_ERRORS, code ?? "")) {
868
+ return SqlBaseCrudService.DATA_ERRORS[code];
869
+ }
870
+ if (this.config.dialect === "mysql") {
871
+ const errno = error?.errno ?? error?.cause?.errno;
872
+ return Object.hasOwn(SqlBaseCrudService.MYSQL_DATA_ERRORS, errno ?? "")
873
+ ? SqlBaseCrudService.MYSQL_DATA_ERRORS[errno]
874
+ : undefined;
875
+ }
876
+ const sqlite = this.sqliteConstraint(error) ?? "";
877
+ return Object.hasOwn(SqlBaseCrudService.SQLITE_DATA_ERRORS, sqlite)
878
+ ? SqlBaseCrudService.SQLITE_DATA_ERRORS[sqlite]
879
+ : undefined;
880
+ }
881
+ isDataException(error) {
882
+ return this.dataErrorMessage(error) !== undefined;
822
883
  }
823
884
  toDataException(error) {
824
- const code = error?.code ?? error?.cause?.code;
825
- return new crud_exceptions_1.ValidationFailedException(SqlBaseCrudService.DATA_ERRORS[code] ?? "invalid value");
885
+ const message = this.dataErrorMessage(error) ?? "invalid value";
886
+ const cause = error?.cause ?? error;
887
+ const text = String(cause?.message ?? "");
888
+ const column = error?.column ??
889
+ cause?.column ??
890
+ error?.column_name ??
891
+ cause?.column_name ??
892
+ (this.config.dialect === "mysql"
893
+ ? text.match(/column '([^']+)'/i)?.[1]
894
+ : undefined) ??
895
+ (this.config.dialect === "sqlite"
896
+ ? text.match(/NOT NULL constraint failed: (?:[^.\s]+\.)?(\S+)/)?.[1]
897
+ : undefined);
898
+ return new crud_exceptions_1.ValidationFailedException(column ? `${message}: ${column}` : message);
826
899
  }
827
900
  toClientError(error, operation = "write") {
828
901
  if (this.isUniqueViolation(error))
@@ -846,7 +919,11 @@ class SqlBaseCrudService {
846
919
  }
847
920
  isForeignKeyViolation(error) {
848
921
  const code = error?.code ?? error?.cause?.code;
849
- return code === "23503";
922
+ const errno = error?.errno ?? error?.cause?.errno;
923
+ return (code === "23503" ||
924
+ errno === 1451 ||
925
+ errno === 1452 ||
926
+ this.sqliteConstraint(error) === "FOREIGNKEY");
850
927
  }
851
928
  toForeignKeyException(error) {
852
929
  const detail = error?.detail ?? error?.cause?.detail ?? "";
@@ -854,14 +931,37 @@ class SqlBaseCrudService {
854
931
  return new crud_exceptions_1.ForeignKeyViolationException(this.getEntityName(), m?.groups?.field ?? "reference");
855
932
  }
856
933
  applyLock(query, options) {
857
- if (this.config.dialect !== "postgresql")
934
+ const strength = options?.forNoKeyUpdate
935
+ ? "no key update"
936
+ : options?.lock && options.lock !== "none"
937
+ ? options.lock
938
+ : undefined;
939
+ if (!strength)
858
940
  return query;
859
- const of = { of: this.config.table };
860
- if (options?.forNoKeyUpdate)
861
- return query.for("no key update", of);
862
- if (options?.lock && options.lock !== "none")
863
- return query.for(options.lock, of);
864
- return query;
941
+ if (this.config.dialect === "postgresql") {
942
+ return query.for(strength, { of: this.config.table });
943
+ }
944
+ if (this.config.dialect === "mysql") {
945
+ return query.for(strength === "share" ? "share" : "update");
946
+ }
947
+ throw new common_1.BadRequestException(`Row locks (lock, forNoKeyUpdate) are not supported on ${this.config.dialect}`);
948
+ }
949
+ textSearchConfig(language = "english") {
950
+ if (typeof language !== "string" ||
951
+ !/^[A-Za-z_][A-Za-z0-9_]{0,62}(\.[A-Za-z_][A-Za-z0-9_]{0,62})?$/.test(language)) {
952
+ throw new common_1.BadRequestException(`Invalid full-text search language: ${String(language)}`);
953
+ }
954
+ return drizzle_orm_1.sql.raw(`'${language}'`);
955
+ }
956
+ fullTextParts(columns, term, options = {}) {
957
+ const config = this.textSearchConfig(options.language);
958
+ const mode = options.queryMode ?? "plain";
959
+ if (mode !== "plain" && mode !== "websearch") {
960
+ throw new common_1.BadRequestException(`Invalid full-text query mode: ${String(mode)} (expected 'plain' or 'websearch')`);
961
+ }
962
+ const parse = drizzle_orm_1.sql.raw(mode === "websearch" ? "websearch_to_tsquery" : "plainto_tsquery");
963
+ const vector = drizzle_orm_1.sql.join(columns.map((column) => (0, drizzle_orm_1.sql) `to_tsvector(${config}, ${column})`), (0, drizzle_orm_1.sql) ` || `);
964
+ return { vector, query: (0, drizzle_orm_1.sql) `${parse}(${config}, ${term})` };
865
965
  }
866
966
  buildSearchCondition(search) {
867
967
  if (!search?.term || !search.columns?.length)
@@ -876,20 +976,24 @@ class SqlBaseCrudService {
876
976
  return undefined;
877
977
  if (search.mode === "fullText") {
878
978
  if (this.config.dialect !== "postgresql") {
879
- throw new Error("Full-text search only supported in PostgreSQL");
979
+ throw new common_1.BadRequestException("Full-text search only supported in PostgreSQL");
880
980
  }
881
- const tsVector = drizzle_orm_1.sql.join(columns.map((column) => (0, drizzle_orm_1.sql) `to_tsvector('english', ${column})`), (0, drizzle_orm_1.sql) ` || `);
882
- const tsQuery = (0, drizzle_orm_1.sql) `plainto_tsquery('english', ${term})`;
883
- return (0, drizzle_orm_1.sql) `${tsVector} @@ ${tsQuery}`;
981
+ const { vector, query } = this.fullTextParts(columns, term, search);
982
+ return (0, drizzle_orm_1.sql) `${vector} @@ ${query}`;
884
983
  }
885
984
  const pattern = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
886
985
  const conditions = columns.map((column) => this.caseInsensitiveLike(column, pattern));
887
986
  return conditions.length === 1 ? conditions[0] : (0, drizzle_orm_1.or)(...conditions);
888
987
  }
889
988
  caseInsensitiveLike(column, pattern) {
890
- return this.config.dialect === "mysql"
891
- ? (0, drizzle_orm_1.like)(column, pattern)
892
- : (0, drizzle_orm_1.ilike)(column, pattern);
989
+ if (this.config.dialect === "postgresql")
990
+ return (0, drizzle_orm_1.ilike)(column, pattern);
991
+ return this.likePattern(column, pattern);
992
+ }
993
+ likePattern(column, pattern) {
994
+ return this.config.dialect === "sqlite"
995
+ ? (0, drizzle_orm_1.sql) `${column} like ${pattern} escape '\\'`
996
+ : (0, drizzle_orm_1.like)(column, pattern);
893
997
  }
894
998
  parseQuery(query, options = {}) {
895
999
  const config = this.config;
@@ -1008,14 +1112,14 @@ class SqlBaseCrudService {
1008
1112
  const baseSelect = this.withCountKeys(this.withRequiredKeys(selection.base, nodes), options?.counts);
1009
1113
  conditions.push(...this.rowScope(options));
1010
1114
  let query = this.startSelect(db, baseSelect, joined, false, selection);
1011
- query = this.applyJoins(query, joined.map((node) => node.name))
1115
+ query = this.applyJoins(query, joined.map((node) => node.name), options)
1012
1116
  .where((0, drizzle_orm_1.and)(...conditions))
1013
1117
  .limit(1);
1014
1118
  query = this.applyLock(query, options);
1015
1119
  const result = await this.runRead(query);
1016
1120
  const rows = this.normalizeRelations(result, joined.map((node) => node.name));
1017
1121
  await Promise.all([
1018
- this.loadRelations(db, rows, nodes, selection),
1122
+ this.loadRelations(db, rows, nodes, selection, undefined, options),
1019
1123
  this.loadCounts(db, rows, options?.counts, options),
1020
1124
  ]);
1021
1125
  return rows[0] || null;
@@ -1055,22 +1159,22 @@ class SqlBaseCrudService {
1055
1159
  built.conditions.push(...this.rowScope(options));
1056
1160
  return built;
1057
1161
  }
1058
- buildCountQuery(db, conditions, filterRelations) {
1162
+ buildCountQuery(db, conditions, filterRelations, options) {
1059
1163
  let query = db
1060
1164
  .select({ count: (0, drizzle_orm_1.sql) `count(*)` })
1061
1165
  .from(this.config.table);
1062
- query = this.applyJoins(query, filterRelations);
1166
+ query = this.applyJoins(query, filterRelations, options);
1063
1167
  if (conditions.length > 0)
1064
1168
  query = query.where((0, drizzle_orm_1.and)(...conditions));
1065
1169
  return query;
1066
1170
  }
1067
1171
  async findAll(filters, pagination, options) {
1068
- const { sortBy, sortOrder = "desc" } = pagination || {};
1172
+ const { sortBy, sortOrder = "asc" } = pagination || {};
1069
1173
  const withCount = pagination?.withCount !== false;
1070
1174
  const { page, limit: safeLimit, offset, } = this.resolvePagination(pagination?.page, pagination?.limit);
1071
1175
  const plan = this.planList(filters, sortBy, sortOrder, options);
1072
1176
  let dataQuery = this.startSelect(plan.db, plan.baseSelect, plan.joined, plan.joinRelations.length > 0, plan.selection);
1073
- dataQuery = this.applyJoins(dataQuery, plan.joinRelations);
1177
+ dataQuery = this.applyJoins(dataQuery, plan.joinRelations, options);
1074
1178
  if (plan.conditions.length > 0) {
1075
1179
  dataQuery = dataQuery.where((0, drizzle_orm_1.and)(...plan.conditions));
1076
1180
  }
@@ -1082,13 +1186,13 @@ class SqlBaseCrudService {
1082
1186
  .offset(offset);
1083
1187
  dataQuery = this.applyLock(dataQuery, options);
1084
1188
  const countQuery = withCount
1085
- ? this.buildCountQuery(plan.db, plan.conditions, plan.filterRelations)
1189
+ ? this.buildCountQuery(plan.db, plan.conditions, plan.filterRelations, options)
1086
1190
  : Promise.resolve(undefined);
1087
1191
  const [fetched, totalResult] = await this.runRead(Promise.all([dataQuery, countQuery]));
1088
1192
  const data = fetched.slice(0, safeLimit);
1089
1193
  const rows = this.normalizeRelations(data, plan.joined.map((node) => node.name));
1090
1194
  await Promise.all([
1091
- this.loadRelations(plan.db, rows, plan.nodes, plan.selection),
1195
+ this.loadRelations(plan.db, rows, plan.nodes, plan.selection, undefined, options),
1092
1196
  this.loadCounts(plan.db, rows, options?.counts, options),
1093
1197
  ]);
1094
1198
  if (!withCount) {
@@ -1108,7 +1212,7 @@ class SqlBaseCrudService {
1108
1212
  };
1109
1213
  }
1110
1214
  async findByCursor(filters, pagination, options) {
1111
- const { sortBy, sortOrder = "desc", count = false } = pagination || {};
1215
+ const { sortBy, sortOrder = "asc", count = false } = pagination || {};
1112
1216
  const { limit } = this.resolvePagination(1, pagination?.limit);
1113
1217
  const plan = this.planList(filters, sortBy, sortOrder, options);
1114
1218
  const signature = plan.sort
@@ -1128,7 +1232,7 @@ class SqlBaseCrudService {
1128
1232
  fields[`${CURSOR_KEY}${i}`] = this.cursorField(spec.column);
1129
1233
  });
1130
1234
  let dataQuery = plan.db.select(fields).from(this.config.table);
1131
- dataQuery = this.applyJoins(dataQuery, plan.joinRelations);
1235
+ dataQuery = this.applyJoins(dataQuery, plan.joinRelations, options);
1132
1236
  if (conditions.length > 0)
1133
1237
  dataQuery = dataQuery.where((0, drizzle_orm_1.and)(...conditions));
1134
1238
  dataQuery = dataQuery
@@ -1138,7 +1242,7 @@ class SqlBaseCrudService {
1138
1242
  const [data, totalResult] = await this.runRead(Promise.all([
1139
1243
  dataQuery,
1140
1244
  count
1141
- ? this.buildCountQuery(plan.db, plan.conditions, plan.filterRelations)
1245
+ ? this.buildCountQuery(plan.db, plan.conditions, plan.filterRelations, options)
1142
1246
  : Promise.resolve(undefined),
1143
1247
  ]));
1144
1248
  const page = data.slice(0, limit);
@@ -1153,7 +1257,7 @@ class SqlBaseCrudService {
1153
1257
  }
1154
1258
  const rows = this.normalizeRelations(page, plan.joined.map((node) => node.name));
1155
1259
  await Promise.all([
1156
- this.loadRelations(plan.db, rows, plan.nodes, plan.selection),
1260
+ this.loadRelations(plan.db, rows, plan.nodes, plan.selection, undefined, options),
1157
1261
  this.loadCounts(plan.db, rows, options?.counts, options),
1158
1262
  ]);
1159
1263
  const out = { data: rows, limit, nextCursor };
@@ -1195,7 +1299,7 @@ class SqlBaseCrudService {
1195
1299
  return branches.length > 0 ? (0, drizzle_orm_1.or)(...branches) : (0, drizzle_orm_1.sql) `false`;
1196
1300
  }
1197
1301
  afterCursor(spec, value) {
1198
- const postgres = this.config.dialect !== "mysql";
1302
+ const postgres = this.config.dialect === "postgresql";
1199
1303
  const nullsFirst = postgres ? spec.order === "desc" : spec.order === "asc";
1200
1304
  if (value === null) {
1201
1305
  return nullsFirst ? (0, drizzle_orm_1.isNotNull)(spec.column) : undefined;
@@ -1229,7 +1333,7 @@ class SqlBaseCrudService {
1229
1333
  }
1230
1334
  const hosts = rows.filter(Boolean);
1231
1335
  await Promise.all([
1232
- this.loadRelations(db, hosts, nodes),
1336
+ this.loadRelations(db, hosts, nodes, undefined, undefined, options),
1233
1337
  this.loadCounts(db, hosts, options?.counts, options),
1234
1338
  ]);
1235
1339
  }
@@ -1391,8 +1495,13 @@ class SqlBaseCrudService {
1391
1495
  relatedRow(table, value, options) {
1392
1496
  const out = {};
1393
1497
  for (const [key, item] of Object.entries(value)) {
1394
- if (item !== undefined && this.columnOf(table, key))
1395
- out[key] = item;
1498
+ const column = this.columnOf(table, key);
1499
+ if (item === undefined ||
1500
+ !column ||
1501
+ (column.primary && column.hasDefault)) {
1502
+ continue;
1503
+ }
1504
+ out[key] = item;
1396
1505
  }
1397
1506
  const softDelete = this.config.softDelete;
1398
1507
  if (softDelete?.enabled)
@@ -1421,14 +1530,7 @@ class SqlBaseCrudService {
1421
1530
  const local = through[relation.throughLocalKey];
1422
1531
  const foreign = through[relation.throughForeignKey];
1423
1532
  const conditions = [(0, drizzle_orm_1.inArray)(referenced, wanted)];
1424
- const softDeleted = this.softDeleteCondition(target);
1425
- if (softDeleted)
1426
- conditions.push(softDeleted);
1427
- for (const [key, value] of Object.entries(this.resolveScope(options) ?? {})) {
1428
- if (!this.columnOf(target, key))
1429
- continue;
1430
- conditions.push(value === null ? (0, drizzle_orm_1.isNull)(target[key]) : (0, drizzle_orm_1.eq)(target[key], value));
1431
- }
1533
+ conditions.push(...this.relatedRowScope(target, options));
1432
1534
  const found = await tx
1433
1535
  .select({ id: referenced })
1434
1536
  .from(target)
@@ -1636,7 +1738,7 @@ class SqlBaseCrudService {
1636
1738
  }
1637
1739
  async softDelete(id, options) {
1638
1740
  if (!this.config.softDelete?.enabled) {
1639
- throw new Error("Soft delete is not enabled for this entity");
1741
+ throw new common_1.BadRequestException("Soft delete is not enabled for this entity");
1640
1742
  }
1641
1743
  const { transaction, hooks = {} } = options || {};
1642
1744
  const existing = await this.findForGuard(id, {
@@ -1678,18 +1780,16 @@ class SqlBaseCrudService {
1678
1780
  }
1679
1781
  async restore(id, options) {
1680
1782
  if (!this.config.softDelete?.enabled) {
1681
- throw new Error("Soft delete is not enabled for this entity");
1783
+ throw new common_1.BadRequestException("Soft delete is not enabled for this entity");
1682
1784
  }
1683
1785
  const { transaction, hooks = {} } = options || {};
1684
- if (options?.expectedVersion != null && this.config.versionColumn) {
1685
- const existing = await this.findForGuard(id, {
1686
- ...options,
1687
- withDeleted: true,
1688
- onlyDeleted: false,
1689
- });
1690
- if (!existing)
1691
- throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
1692
- }
1786
+ const existing = await this.findForGuard(id, {
1787
+ ...options,
1788
+ withDeleted: false,
1789
+ onlyDeleted: true,
1790
+ });
1791
+ if (!existing)
1792
+ throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
1693
1793
  if (!hooks.skipBefore)
1694
1794
  await this.beforeRestore(id);
1695
1795
  await this.emitEvent("before", "restore", false, { ids: [id], transaction }, options);
@@ -1769,6 +1869,7 @@ class SqlBaseCrudService {
1769
1869
  }
1770
1870
  async massUpsert(data, upsert = {}, options) {
1771
1871
  this.checkReadOptions(options);
1872
+ this.assertBatchSize(this.insertParameters(data));
1772
1873
  const message = "Mass upsert errors";
1773
1874
  return this.executeSqlTransaction(async (tx) => {
1774
1875
  const opts = { ...options, transaction: tx };
@@ -1873,21 +1974,25 @@ class SqlBaseCrudService {
1873
1974
  statement = insert.onDuplicateKeyUpdate({ set });
1874
1975
  }
1875
1976
  else {
1876
- const scoped = this.scopeConditions(options);
1977
+ const guard = [
1978
+ ...this.scopeConditions(options),
1979
+ ...[this.softDeleteCondition(table)].filter(Boolean),
1980
+ ];
1877
1981
  statement = insert.onConflictDoUpdate({
1878
1982
  target: targets.length === 1 ? targets[0] : targets,
1879
1983
  set,
1880
- ...(scoped.length > 0 && { where: (0, drizzle_orm_1.and)(...scoped) }),
1984
+ ...(guard.length > 0 && { where: (0, drizzle_orm_1.and)(...guard) }),
1881
1985
  });
1882
1986
  }
1883
1987
  if (!readBack)
1884
1988
  return statement.returning();
1885
1989
  await statement;
1886
1990
  const match = rows.map((row) => (0, drizzle_orm_1.and)(...targetKeys.map((key, i) => (0, drizzle_orm_1.eq)(targets[i], row[key]))));
1991
+ const softDeleted = this.softDeleteCondition(table);
1887
1992
  return db
1888
1993
  .select()
1889
1994
  .from(table)
1890
- .where(match.length === 1 ? match[0] : (0, drizzle_orm_1.or)(...match));
1995
+ .where((0, drizzle_orm_1.and)(match.length === 1 ? match[0] : (0, drizzle_orm_1.or)(...match), ...this.scopeConditions(options), ...(softDeleted ? [softDeleted] : [])));
1891
1996
  }
1892
1997
  excludedValue(column) {
1893
1998
  return this.config.dialect === "mysql"
@@ -1899,6 +2004,7 @@ class SqlBaseCrudService {
1899
2004
  return this.massCreatePerItem(data, options);
1900
2005
  }
1901
2006
  this.checkReadOptions(options);
2007
+ this.assertBatchSize(this.insertParameters(data));
1902
2008
  return this.executeSqlTransaction(async (tx) => {
1903
2009
  const opts = { ...options, transaction: tx };
1904
2010
  const rows = await this.eachItem(data.map((item, index) => ({
@@ -1957,6 +2063,7 @@ class SqlBaseCrudService {
1957
2063
  if (options?.bulkMode === "perItem") {
1958
2064
  return this.massUpdatePerItem(ids, data, options);
1959
2065
  }
2066
+ this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
1960
2067
  this.checkReadOptions(options);
1961
2068
  const message = "Mass update errors";
1962
2069
  return this.executeSqlTransaction(async (tx) => {
@@ -2020,10 +2127,11 @@ class SqlBaseCrudService {
2020
2127
  }
2021
2128
  async massSoftDelete(ids, options) {
2022
2129
  if (!this.config.softDelete?.enabled)
2023
- throw new Error("Soft delete not enabled");
2130
+ throw new common_1.BadRequestException("Soft delete not enabled");
2024
2131
  if (options?.bulkMode === "perItem") {
2025
2132
  return this.massSoftDeletePerItem(ids, options);
2026
2133
  }
2134
+ this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2027
2135
  const message = "Mass soft delete errors";
2028
2136
  return this.executeSqlTransaction(async (tx) => {
2029
2137
  const opts = {
@@ -2063,10 +2171,11 @@ class SqlBaseCrudService {
2063
2171
  }
2064
2172
  async massRestore(ids, options) {
2065
2173
  if (!this.config.softDelete?.enabled)
2066
- throw new Error("Soft delete not enabled");
2174
+ throw new common_1.BadRequestException("Soft delete not enabled");
2067
2175
  if (options?.bulkMode === "perItem") {
2068
2176
  return this.massRestorePerItem(ids, options);
2069
2177
  }
2178
+ this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2070
2179
  this.checkReadOptions(options);
2071
2180
  const message = "Mass restore errors";
2072
2181
  return this.executeSqlTransaction(async (tx) => {
@@ -2104,6 +2213,7 @@ class SqlBaseCrudService {
2104
2213
  if (options?.bulkMode === "perItem") {
2105
2214
  return this.massDeletePerItem(ids, options);
2106
2215
  }
2216
+ this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
2107
2217
  const message = "Mass delete errors";
2108
2218
  return this.executeSqlTransaction(async (tx) => {
2109
2219
  const opts = { ...options, transaction: tx };
@@ -2295,7 +2405,7 @@ class SqlBaseCrudService {
2295
2405
  this.checkReadOptions(options);
2296
2406
  const db = options?.transaction || this.config.db;
2297
2407
  const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
2298
- const result = await this.runRead(this.buildCountQuery(db, conditions, filterRelations));
2408
+ const result = await this.runRead(this.buildCountQuery(db, conditions, filterRelations, options));
2299
2409
  return parseInt(result[0]?.count?.toString() || "0", 10);
2300
2410
  }
2301
2411
  async countBy(column, filters, options) {
@@ -2308,7 +2418,7 @@ class SqlBaseCrudService {
2308
2418
  let query = db
2309
2419
  .select({ value: spec.column, count: total })
2310
2420
  .from(this.config.table);
2311
- query = this.applyJoins(query, joins);
2421
+ query = this.applyJoins(query, joins, options);
2312
2422
  if (conditions.length > 0)
2313
2423
  query = query.where((0, drizzle_orm_1.and)(...conditions));
2314
2424
  query = query.groupBy(spec.column).orderBy((0, drizzle_orm_1.desc)(total), (0, drizzle_orm_1.asc)(spec.column));
@@ -2418,9 +2528,7 @@ class SqlBaseCrudService {
2418
2528
  root: false,
2419
2529
  }, filter, db, new Set(), options));
2420
2530
  }
2421
- const softDeleted = this.softDeleteCondition(target);
2422
- if (softDeleted)
2423
- conditions.push(softDeleted);
2531
+ conditions.push(...this.relatedRowScope(target, options));
2424
2532
  const total = (0, drizzle_orm_1.sql) `count(*)`;
2425
2533
  if (this.relationType(relation) === "manyToMany") {
2426
2534
  const joinLocal = relation.through[relation.throughLocalKey];
@@ -2442,6 +2550,7 @@ class SqlBaseCrudService {
2442
2550
  }
2443
2551
  pushColumnCondition(conditions, column, value, caseInsensitive = this.config.sql?.caseSensitive === false) {
2444
2552
  if (Array.isArray(value)) {
2553
+ this.assertBatchSize(value.length);
2445
2554
  conditions.push((0, drizzle_orm_1.inArray)(column, value.map((item) => this.coerceFilterValue(column, item))));
2446
2555
  }
2447
2556
  else if ((0, merge_config_1.isPlainObject)(value)) {
@@ -2463,9 +2572,9 @@ class SqlBaseCrudService {
2463
2572
  const targetConditions = this.compileFilterAt(this.relationHost(relation, relation.table, name), value, db, new Set(), options);
2464
2573
  if (targetConditions.length === 0)
2465
2574
  return undefined;
2466
- return this.collectionCondition(relation, targetConditions, db, this.rootHost());
2575
+ return this.collectionCondition(relation, targetConditions, db, this.rootHost(), options);
2467
2576
  }
2468
- collectionCondition(relation, targetConditions, db, host) {
2577
+ collectionCondition(relation, targetConditions, db, host, options) {
2469
2578
  const target = relation.table;
2470
2579
  const manyToMany = this.relationType(relation) === "manyToMany";
2471
2580
  const joinLocal = manyToMany
@@ -2475,9 +2584,7 @@ class SqlBaseCrudService {
2475
2584
  if (!key)
2476
2585
  return undefined;
2477
2586
  const conditions = [...targetConditions];
2478
- const softDeleted = this.softDeleteCondition(target);
2479
- if (softDeleted)
2480
- conditions.push(softDeleted);
2587
+ conditions.push(...this.relatedRowScope(target, options));
2481
2588
  let subquery;
2482
2589
  if (manyToMany) {
2483
2590
  const referenced = target[relation.references ?? "id"];
@@ -2506,9 +2613,7 @@ class SqlBaseCrudService {
2506
2613
  return undefined;
2507
2614
  const parentKey = this.parentKeyOf(relation, host.source);
2508
2615
  const conditions = [];
2509
- const softDeleted = this.softDeleteCondition(target);
2510
- if (softDeleted)
2511
- conditions.push(softDeleted);
2616
+ conditions.push(...this.relatedRowScope(target, options));
2512
2617
  if (compiled.length > 0) {
2513
2618
  const matched = (0, drizzle_orm_1.and)(...compiled);
2514
2619
  conditions.push(quantifier === "every"
@@ -2537,11 +2642,10 @@ class SqlBaseCrudService {
2537
2642
  const compiled = this.compileFilterAt(this.relationHost(relation, target, name), inner, db, new Set(), options);
2538
2643
  if (compiled.length === 0)
2539
2644
  return undefined;
2540
- const softDeleted = this.softDeleteCondition(target);
2541
2645
  return (0, drizzle_orm_1.exists)(db
2542
2646
  .select({ one: (0, drizzle_orm_1.sql) `1` })
2543
2647
  .from(target)
2544
- .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(target[relation.references ?? "id"], host.table[relation.localKey]), ...compiled, ...(softDeleted ? [softDeleted] : []))));
2648
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(target[relation.references ?? "id"], host.table[relation.localKey]), ...compiled, ...this.relatedRowScope(target, options))));
2545
2649
  }
2546
2650
  rootHost() {
2547
2651
  return {
@@ -2562,6 +2666,7 @@ class SqlBaseCrudService {
2562
2666
  };
2563
2667
  }
2564
2668
  buildFilterConditions(filters, db = this.config.db, options) {
2669
+ (0, query_parser_1.assertFilterDepth)(filters);
2565
2670
  const joins = new Set();
2566
2671
  const conditions = this.compileFilterAt(this.rootHost(), filters, db, joins, options);
2567
2672
  return { conditions, relations: Array.from(joins) };
@@ -2638,7 +2743,7 @@ class SqlBaseCrudService {
2638
2743
  const compiled = this.compileFilterAt(this.relationHost(relation, relation.table, path), inner, db, new Set(), options);
2639
2744
  if (compiled.length === 0 && !quantified)
2640
2745
  continue;
2641
- const condition = this.collectionCondition(relation, compiled, db, host);
2746
+ const condition = this.collectionCondition(relation, compiled, db, host, options);
2642
2747
  if (condition)
2643
2748
  parts.push(condition);
2644
2749
  }
@@ -2694,8 +2799,24 @@ class SqlBaseCrudService {
2694
2799
  if (!Array.isArray(raw)) {
2695
2800
  throw new common_1.BadRequestException(`Filter operator "${op}" expects an array`);
2696
2801
  }
2802
+ this.assertBatchSize(raw.length);
2697
2803
  return raw;
2698
2804
  }
2805
+ static MAX_BATCH_PARAMETERS = 60_000;
2806
+ assertBatchSize(parameters) {
2807
+ const max = SqlBaseCrudService.MAX_BATCH_PARAMETERS;
2808
+ if (parameters > max) {
2809
+ throw new common_1.BadRequestException(`Too many values in one call (${parameters}); the limit is ${max}. Split the batch.`);
2810
+ }
2811
+ }
2812
+ insertParameters(rows) {
2813
+ let widest = 0;
2814
+ for (const row of rows) {
2815
+ if ((0, merge_config_1.isPlainObject)(row))
2816
+ widest = Math.max(widest, Object.keys(row).length);
2817
+ }
2818
+ return rows.length * (widest + 4);
2819
+ }
2699
2820
  requirePostgres(op) {
2700
2821
  if (this.config.dialect !== "postgresql") {
2701
2822
  throw new common_1.BadRequestException(`Filter operator "${op}" is only supported on PostgreSQL`);
@@ -2718,7 +2839,7 @@ class SqlBaseCrudService {
2718
2839
  : `%${escaped}%`;
2719
2840
  return insensitive || this.config.sql?.caseSensitive === false
2720
2841
  ? this.caseInsensitiveLike(column, pattern)
2721
- : (0, drizzle_orm_1.like)(column, pattern);
2842
+ : this.likePattern(column, pattern);
2722
2843
  }
2723
2844
  applyComplexFilter(conditions, column, filterObj) {
2724
2845
  const coerce = (value) => this.coerceFilterValue(column, value);
@@ -2750,7 +2871,7 @@ class SqlBaseCrudService {
2750
2871
  conditions.push((0, drizzle_orm_1.ne)(column, coerce(raw)));
2751
2872
  break;
2752
2873
  case "like":
2753
- conditions.push((0, drizzle_orm_1.like)(column, raw));
2874
+ conditions.push(this.likePattern(column, raw));
2754
2875
  break;
2755
2876
  case "ilike":
2756
2877
  conditions.push(this.caseInsensitiveLike(column, raw));
@@ -2808,10 +2929,22 @@ class SqlBaseCrudService {
2808
2929
  }
2809
2930
  }
2810
2931
  async executeSqlTransaction(operation, existingTransaction) {
2932
+ this.assertTransactions(existingTransaction ?? this.config.db);
2811
2933
  if (existingTransaction)
2812
2934
  return operation(existingTransaction);
2813
2935
  return this.config.db.transaction(operation);
2814
2936
  }
2937
+ assertTransactions(db) {
2938
+ if (this.config.dialect !== "sqlite")
2939
+ return;
2940
+ const kind = db?.constructor?.[drizzle_orm_1.entityKind];
2941
+ if (kind === "D1Database" || kind === "D1Transaction") {
2942
+ throw new crud_exceptions_1.TransactionException("Cloudflare D1 has no interactive transactions, so mass writes, nested writes and attach/detach/sync are not available on it. Use the single-row methods, or db.batch() yourself.");
2943
+ }
2944
+ if (db?.resultKind === "sync") {
2945
+ throw new crud_exceptions_1.TransactionException("This SQLite driver is synchronous (better-sqlite3, bun:sqlite, sql.js), and its transactions cannot wait for async work, so mass writes, nested writes and attach/detach/sync are not available on it. Use an async driver such as libSQL (@libsql/client).");
2946
+ }
2947
+ }
2815
2948
  getEntityName() {
2816
2949
  try {
2817
2950
  return (0, drizzle_orm_1.getTableName)(this.config.table) || "Entity";
@@ -2822,7 +2955,7 @@ class SqlBaseCrudService {
2822
2955
  }
2823
2956
  async fullTextSearch(searchTerm, searchColumns, pagination, options) {
2824
2957
  if (this.config.dialect !== "postgresql") {
2825
- throw new Error("Full-text search only supported in PostgreSQL");
2958
+ throw new common_1.BadRequestException("Full-text search only supported in PostgreSQL");
2826
2959
  }
2827
2960
  if (!Array.isArray(searchColumns) || searchColumns.length === 0) {
2828
2961
  throw new common_1.BadRequestException("Full-text search needs at least one column");
@@ -2835,31 +2968,37 @@ class SqlBaseCrudService {
2835
2968
  this.assertAllowed("searchableColumns", name, options);
2836
2969
  return column;
2837
2970
  });
2838
- const { transaction } = options || {};
2839
- const db = transaction || this.config.db;
2840
- const tsVector = drizzle_orm_1.sql.join(columns.map((column) => (0, drizzle_orm_1.sql) `to_tsvector('english', ${column})`), (0, drizzle_orm_1.sql) ` || `);
2841
- const tsQuery = (0, drizzle_orm_1.sql) `plainto_tsquery('english', ${searchTerm})`;
2842
- const matchExpr = (0, drizzle_orm_1.sql) `${tsVector} @@ ${tsQuery}`;
2843
- const whereExpr = (0, drizzle_orm_1.and)(matchExpr, ...this.rowScope(options));
2971
+ const { filters, language, queryMode, ...readOptions } = options ?? {};
2972
+ const { vector, query } = this.fullTextParts(columns, searchTerm, {
2973
+ language,
2974
+ queryMode,
2975
+ });
2844
2976
  const { page, limit, offset } = this.resolvePagination(pagination?.page, pagination?.limit);
2977
+ const plan = this.planList(filters, undefined, "asc", readOptions);
2978
+ const conditions = [(0, drizzle_orm_1.sql) `${vector} @@ ${query}`, ...plan.conditions];
2979
+ const joins = Array.from(new Set([
2980
+ ...plan.joined.map((node) => node.name),
2981
+ ...plan.filterRelations,
2982
+ ]));
2845
2983
  const pk = this.columnOf(this.config.table, this.config.primaryKey);
2846
- const orderBy = [(0, drizzle_orm_1.sql) `ts_rank(${tsVector}, ${tsQuery}) DESC`];
2984
+ const orderBy = [(0, drizzle_orm_1.sql) `ts_rank(${vector}, ${query}) DESC`];
2847
2985
  if (pk)
2848
2986
  orderBy.push((0, drizzle_orm_1.asc)(pk));
2849
- const dataQuery = db
2850
- .select()
2851
- .from(this.config.table)
2852
- .where(whereExpr)
2987
+ let dataQuery = this.startSelect(plan.db, plan.baseSelect, plan.joined, joins.length > 0, plan.selection);
2988
+ dataQuery = this.applyJoins(dataQuery, joins, readOptions)
2989
+ .where((0, drizzle_orm_1.and)(...conditions))
2853
2990
  .orderBy(...orderBy)
2854
2991
  .limit(limit)
2855
2992
  .offset(offset);
2856
- const countQuery = db
2857
- .select({ count: (0, drizzle_orm_1.sql) `count(*)` })
2858
- .from(this.config.table)
2859
- .where(whereExpr);
2860
- const [data, totalResult] = await this.runRead(Promise.all([dataQuery, countQuery]));
2993
+ const countQuery = this.buildCountQuery(plan.db, conditions, plan.filterRelations, readOptions);
2994
+ const [fetched, totalResult] = await this.runRead(Promise.all([dataQuery, countQuery]));
2861
2995
  const total = parseInt(totalResult[0]?.count?.toString() || "0", 10);
2862
- return { data, total, page, limit };
2996
+ const rows = this.normalizeRelations(fetched, plan.joined.map((node) => node.name));
2997
+ await Promise.all([
2998
+ this.loadRelations(plan.db, rows, plan.nodes, plan.selection, undefined, readOptions),
2999
+ this.loadCounts(plan.db, rows, readOptions.counts, readOptions),
3000
+ ]);
3001
+ return { data: rows, total, page, limit };
2863
3002
  }
2864
3003
  }
2865
3004
  exports.SqlBaseCrudService = SqlBaseCrudService;