@avytrix/nestjs-drizzle-crud 3.6.0 → 3.7.1

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) {
@@ -390,15 +394,19 @@ class SqlBaseCrudService {
390
394
  return roots;
391
395
  }
392
396
  parseSelect(select) {
393
- const root = { base: [], relations: {} };
397
+ const tree = () => ({
398
+ base: [],
399
+ relations: Object.create(null),
400
+ });
401
+ const root = tree();
394
402
  for (const entry of select || []) {
395
403
  const segments = (entry || "").split(".").filter(Boolean);
396
404
  if (segments.length === 0)
397
405
  continue;
398
406
  let node = root;
399
407
  for (const name of segments.slice(0, -1)) {
400
- if (!node.relations[name])
401
- node.relations[name] = { base: [], relations: {} };
408
+ if (!Object.hasOwn(node.relations, name))
409
+ node.relations[name] = tree();
402
410
  node = node.relations[name];
403
411
  }
404
412
  node.base.push(segments[segments.length - 1]);
@@ -406,7 +414,9 @@ class SqlBaseCrudService {
406
414
  return root;
407
415
  }
408
416
  selectFor(select, name) {
409
- return select.relations[name] ?? { base: [], relations: {} };
417
+ return Object.hasOwn(select.relations, name)
418
+ ? select.relations[name]
419
+ : { base: [], relations: {} };
410
420
  }
411
421
  keysForChildren(nodes, hostTable = this.config.table) {
412
422
  return nodes
@@ -806,13 +816,32 @@ class SqlBaseCrudService {
806
816
  isUniqueViolation(error) {
807
817
  const code = error?.code ?? error?.cause?.code;
808
818
  const errno = error?.errno ?? error?.cause?.errno;
809
- return code === "23505" || code === "ER_DUP_ENTRY" || errno === 1062;
819
+ const sqlite = this.sqliteConstraint(error);
820
+ return (code === "23505" ||
821
+ code === "ER_DUP_ENTRY" ||
822
+ errno === 1062 ||
823
+ sqlite === "UNIQUE" ||
824
+ sqlite === "PRIMARYKEY");
825
+ }
826
+ sqliteConstraint(error) {
827
+ if (this.config.dialect !== "sqlite")
828
+ return undefined;
829
+ for (const e of [error, error?.cause]) {
830
+ const code = e?.extendedCode ?? e?.code;
831
+ if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT_")) {
832
+ return code.slice("SQLITE_CONSTRAINT_".length);
833
+ }
834
+ }
835
+ const message = `${error?.cause?.message ?? ""} ${error?.message ?? ""}`;
836
+ const m = message.match(/(UNIQUE|PRIMARY KEY|FOREIGN KEY|NOT NULL|CHECK) constraint failed/);
837
+ return m?.[1]?.replace(" ", "");
810
838
  }
811
839
  toDuplicateException(error) {
812
840
  const detail = error?.detail ?? error?.cause?.detail ?? "";
813
841
  const message = error?.cause?.message ?? error?.message ?? "";
814
842
  const m = detail.match(/Key \((?<field>.+?)\)=\((?<value>.+?)\)/) ??
815
- message.match(/Duplicate entry '(?<value>.*)' for key '(?:[^']*\.)?(?<field>[^']+)'/);
843
+ message.match(/Duplicate entry '(?<value>.*)' for key '(?:[^']*\.)?(?<field>[^']+)'/) ??
844
+ message.match(/UNIQUE constraint failed: (?:[^.,\s]+\.)?(?<field>[^,\s]+)/);
816
845
  return new crud_exceptions_1.DuplicateEntityException(this.getEntityName(), m?.groups?.field ?? "unique field", m?.groups?.value ?? "");
817
846
  }
818
847
  static DATA_ERRORS = {
@@ -825,18 +854,53 @@ class SqlBaseCrudService {
825
854
  "22008": "a date or time value is out of range",
826
855
  "21000": "the same key appears more than once in one upsert",
827
856
  };
828
- isDataException(error) {
857
+ static MYSQL_DATA_ERRORS = {
858
+ 1048: "a required value is missing",
859
+ 1364: "a required value is missing",
860
+ 1406: "a value is too long for its column",
861
+ 3819: "a value violates a column check constraint",
862
+ 1264: "a numeric value is out of range",
863
+ 1265: "a value does not match its column type",
864
+ 1366: "a value does not match its column type",
865
+ 1292: "a date or time value has an invalid format",
866
+ };
867
+ static SQLITE_DATA_ERRORS = {
868
+ NOTNULL: "a required value is missing",
869
+ CHECK: "a value violates a column check constraint",
870
+ };
871
+ dataErrorMessage(error) {
829
872
  const code = error?.code ?? error?.cause?.code;
830
- return Object.hasOwn(SqlBaseCrudService.DATA_ERRORS, code ?? "");
873
+ if (Object.hasOwn(SqlBaseCrudService.DATA_ERRORS, code ?? "")) {
874
+ return SqlBaseCrudService.DATA_ERRORS[code];
875
+ }
876
+ if (this.config.dialect === "mysql") {
877
+ const errno = error?.errno ?? error?.cause?.errno;
878
+ return Object.hasOwn(SqlBaseCrudService.MYSQL_DATA_ERRORS, errno ?? "")
879
+ ? SqlBaseCrudService.MYSQL_DATA_ERRORS[errno]
880
+ : undefined;
881
+ }
882
+ const sqlite = this.sqliteConstraint(error) ?? "";
883
+ return Object.hasOwn(SqlBaseCrudService.SQLITE_DATA_ERRORS, sqlite)
884
+ ? SqlBaseCrudService.SQLITE_DATA_ERRORS[sqlite]
885
+ : undefined;
886
+ }
887
+ isDataException(error) {
888
+ return this.dataErrorMessage(error) !== undefined;
831
889
  }
832
890
  toDataException(error) {
833
- const code = error?.code ?? error?.cause?.code;
834
- const message = SqlBaseCrudService.DATA_ERRORS[code] ?? "invalid value";
891
+ const message = this.dataErrorMessage(error) ?? "invalid value";
835
892
  const cause = error?.cause ?? error;
893
+ const text = String(cause?.message ?? "");
836
894
  const column = error?.column ??
837
895
  cause?.column ??
838
896
  error?.column_name ??
839
- cause?.column_name;
897
+ cause?.column_name ??
898
+ (this.config.dialect === "mysql"
899
+ ? text.match(/column '([^']+)'/i)?.[1]
900
+ : undefined) ??
901
+ (this.config.dialect === "sqlite"
902
+ ? text.match(/NOT NULL constraint failed: (?:[^.\s]+\.)?(\S+)/)?.[1]
903
+ : undefined);
840
904
  return new crud_exceptions_1.ValidationFailedException(column ? `${message}: ${column}` : message);
841
905
  }
842
906
  toClientError(error, operation = "write") {
@@ -862,7 +926,10 @@ class SqlBaseCrudService {
862
926
  isForeignKeyViolation(error) {
863
927
  const code = error?.code ?? error?.cause?.code;
864
928
  const errno = error?.errno ?? error?.cause?.errno;
865
- return code === "23503" || errno === 1451 || errno === 1452;
929
+ return (code === "23503" ||
930
+ errno === 1451 ||
931
+ errno === 1452 ||
932
+ this.sqliteConstraint(error) === "FOREIGNKEY");
866
933
  }
867
934
  toForeignKeyException(error) {
868
935
  const detail = error?.detail ?? error?.cause?.detail ?? "";
@@ -870,14 +937,37 @@ class SqlBaseCrudService {
870
937
  return new crud_exceptions_1.ForeignKeyViolationException(this.getEntityName(), m?.groups?.field ?? "reference");
871
938
  }
872
939
  applyLock(query, options) {
873
- if (this.config.dialect !== "postgresql")
940
+ const strength = options?.forNoKeyUpdate
941
+ ? "no key update"
942
+ : options?.lock && options.lock !== "none"
943
+ ? options.lock
944
+ : undefined;
945
+ if (!strength)
874
946
  return query;
875
- const of = { of: this.config.table };
876
- if (options?.forNoKeyUpdate)
877
- return query.for("no key update", of);
878
- if (options?.lock && options.lock !== "none")
879
- return query.for(options.lock, of);
880
- return query;
947
+ if (this.config.dialect === "postgresql") {
948
+ return query.for(strength, { of: this.config.table });
949
+ }
950
+ if (this.config.dialect === "mysql") {
951
+ return query.for(strength === "share" ? "share" : "update");
952
+ }
953
+ throw new common_1.BadRequestException(`Row locks (lock, forNoKeyUpdate) are not supported on ${this.config.dialect}`);
954
+ }
955
+ textSearchConfig(language = "english") {
956
+ if (typeof language !== "string" ||
957
+ !/^[A-Za-z_][A-Za-z0-9_]{0,62}(\.[A-Za-z_][A-Za-z0-9_]{0,62})?$/.test(language)) {
958
+ throw new common_1.BadRequestException(`Invalid full-text search language: ${String(language)}`);
959
+ }
960
+ return drizzle_orm_1.sql.raw(`'${language}'`);
961
+ }
962
+ fullTextParts(columns, term, options = {}) {
963
+ const config = this.textSearchConfig(options.language);
964
+ const mode = options.queryMode ?? "plain";
965
+ if (mode !== "plain" && mode !== "websearch") {
966
+ throw new common_1.BadRequestException(`Invalid full-text query mode: ${String(mode)} (expected 'plain' or 'websearch')`);
967
+ }
968
+ 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) ` || `);
970
+ return { vector, query: (0, drizzle_orm_1.sql) `${parse}(${config}, ${term})` };
881
971
  }
882
972
  buildSearchCondition(search) {
883
973
  if (!search?.term || !search.columns?.length)
@@ -894,18 +984,22 @@ class SqlBaseCrudService {
894
984
  if (this.config.dialect !== "postgresql") {
895
985
  throw new common_1.BadRequestException("Full-text search only supported in PostgreSQL");
896
986
  }
897
- const tsVector = drizzle_orm_1.sql.join(columns.map((column) => (0, drizzle_orm_1.sql) `to_tsvector('english', ${column})`), (0, drizzle_orm_1.sql) ` || `);
898
- const tsQuery = (0, drizzle_orm_1.sql) `plainto_tsquery('english', ${term})`;
899
- return (0, drizzle_orm_1.sql) `${tsVector} @@ ${tsQuery}`;
987
+ const { vector, query } = this.fullTextParts(columns, term, search);
988
+ return (0, drizzle_orm_1.sql) `${vector} @@ ${query}`;
900
989
  }
901
990
  const pattern = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
902
991
  const conditions = columns.map((column) => this.caseInsensitiveLike(column, pattern));
903
992
  return conditions.length === 1 ? conditions[0] : (0, drizzle_orm_1.or)(...conditions);
904
993
  }
905
994
  caseInsensitiveLike(column, pattern) {
906
- return this.config.dialect === "mysql"
907
- ? (0, drizzle_orm_1.like)(column, pattern)
908
- : (0, drizzle_orm_1.ilike)(column, pattern);
995
+ if (this.config.dialect === "postgresql")
996
+ return (0, drizzle_orm_1.ilike)(column, pattern);
997
+ return this.likePattern(column, pattern);
998
+ }
999
+ likePattern(column, pattern) {
1000
+ return this.config.dialect === "sqlite"
1001
+ ? (0, drizzle_orm_1.sql) `${column} like ${pattern} escape '\\'`
1002
+ : (0, drizzle_orm_1.like)(column, pattern);
909
1003
  }
910
1004
  parseQuery(query, options = {}) {
911
1005
  const config = this.config;
@@ -1211,7 +1305,7 @@ class SqlBaseCrudService {
1211
1305
  return branches.length > 0 ? (0, drizzle_orm_1.or)(...branches) : (0, drizzle_orm_1.sql) `false`;
1212
1306
  }
1213
1307
  afterCursor(spec, value) {
1214
- const postgres = this.config.dialect !== "mysql";
1308
+ const postgres = this.config.dialect === "postgresql";
1215
1309
  const nullsFirst = postgres ? spec.order === "desc" : spec.order === "asc";
1216
1310
  if (value === null) {
1217
1311
  return nullsFirst ? (0, drizzle_orm_1.isNotNull)(spec.column) : undefined;
@@ -1881,7 +1975,7 @@ class SqlBaseCrudService {
1881
1975
  let statement;
1882
1976
  if (this.config.dialect === "mysql") {
1883
1977
  if (scope) {
1884
- throw new common_1.BadRequestException("upsert on a scoped entity needs PostgreSQL");
1978
+ throw new common_1.BadRequestException("upsert on a scoped entity needs PostgreSQL or SQLite");
1885
1979
  }
1886
1980
  statement = insert.onDuplicateKeyUpdate({ set });
1887
1981
  }
@@ -2751,7 +2845,7 @@ class SqlBaseCrudService {
2751
2845
  : `%${escaped}%`;
2752
2846
  return insensitive || this.config.sql?.caseSensitive === false
2753
2847
  ? this.caseInsensitiveLike(column, pattern)
2754
- : (0, drizzle_orm_1.like)(column, pattern);
2848
+ : this.likePattern(column, pattern);
2755
2849
  }
2756
2850
  applyComplexFilter(conditions, column, filterObj) {
2757
2851
  const coerce = (value) => this.coerceFilterValue(column, value);
@@ -2783,7 +2877,7 @@ class SqlBaseCrudService {
2783
2877
  conditions.push((0, drizzle_orm_1.ne)(column, coerce(raw)));
2784
2878
  break;
2785
2879
  case "like":
2786
- conditions.push((0, drizzle_orm_1.like)(column, raw));
2880
+ conditions.push(this.likePattern(column, raw));
2787
2881
  break;
2788
2882
  case "ilike":
2789
2883
  conditions.push(this.caseInsensitiveLike(column, raw));
@@ -2841,10 +2935,22 @@ class SqlBaseCrudService {
2841
2935
  }
2842
2936
  }
2843
2937
  async executeSqlTransaction(operation, existingTransaction) {
2938
+ this.assertTransactions(existingTransaction ?? this.config.db);
2844
2939
  if (existingTransaction)
2845
2940
  return operation(existingTransaction);
2846
2941
  return this.config.db.transaction(operation);
2847
2942
  }
2943
+ assertTransactions(db) {
2944
+ if (this.config.dialect !== "sqlite")
2945
+ return;
2946
+ const kind = db?.constructor?.[drizzle_orm_1.entityKind];
2947
+ if (kind === "D1Database" || kind === "D1Transaction") {
2948
+ 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
+ }
2950
+ if (db?.resultKind === "sync") {
2951
+ 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).");
2952
+ }
2953
+ }
2848
2954
  getEntityName() {
2849
2955
  try {
2850
2956
  return (0, drizzle_orm_1.getTableName)(this.config.table) || "Entity";
@@ -2868,31 +2974,37 @@ class SqlBaseCrudService {
2868
2974
  this.assertAllowed("searchableColumns", name, options);
2869
2975
  return column;
2870
2976
  });
2871
- const { transaction } = options || {};
2872
- const db = transaction || this.config.db;
2873
- const tsVector = drizzle_orm_1.sql.join(columns.map((column) => (0, drizzle_orm_1.sql) `to_tsvector('english', ${column})`), (0, drizzle_orm_1.sql) ` || `);
2874
- const tsQuery = (0, drizzle_orm_1.sql) `plainto_tsquery('english', ${searchTerm})`;
2875
- const matchExpr = (0, drizzle_orm_1.sql) `${tsVector} @@ ${tsQuery}`;
2876
- const whereExpr = (0, drizzle_orm_1.and)(matchExpr, ...this.rowScope(options));
2977
+ const { filters, language, queryMode, ...readOptions } = options ?? {};
2978
+ const { vector, query } = this.fullTextParts(columns, searchTerm, {
2979
+ language,
2980
+ queryMode,
2981
+ });
2877
2982
  const { page, limit, offset } = this.resolvePagination(pagination?.page, pagination?.limit);
2983
+ const plan = this.planList(filters, undefined, "asc", readOptions);
2984
+ const conditions = [(0, drizzle_orm_1.sql) `${vector} @@ ${query}`, ...plan.conditions];
2985
+ const joins = Array.from(new Set([
2986
+ ...plan.joined.map((node) => node.name),
2987
+ ...plan.filterRelations,
2988
+ ]));
2878
2989
  const pk = this.columnOf(this.config.table, this.config.primaryKey);
2879
- const orderBy = [(0, drizzle_orm_1.sql) `ts_rank(${tsVector}, ${tsQuery}) DESC`];
2990
+ const orderBy = [(0, drizzle_orm_1.sql) `ts_rank(${vector}, ${query}) DESC`];
2880
2991
  if (pk)
2881
2992
  orderBy.push((0, drizzle_orm_1.asc)(pk));
2882
- const dataQuery = db
2883
- .select()
2884
- .from(this.config.table)
2885
- .where(whereExpr)
2993
+ let dataQuery = this.startSelect(plan.db, plan.baseSelect, plan.joined, joins.length > 0, plan.selection);
2994
+ dataQuery = this.applyJoins(dataQuery, joins, readOptions)
2995
+ .where((0, drizzle_orm_1.and)(...conditions))
2886
2996
  .orderBy(...orderBy)
2887
2997
  .limit(limit)
2888
2998
  .offset(offset);
2889
- const countQuery = db
2890
- .select({ count: (0, drizzle_orm_1.sql) `count(*)` })
2891
- .from(this.config.table)
2892
- .where(whereExpr);
2893
- const [data, totalResult] = await this.runRead(Promise.all([dataQuery, countQuery]));
2999
+ const countQuery = this.buildCountQuery(plan.db, conditions, plan.filterRelations, readOptions);
3000
+ const [fetched, totalResult] = await this.runRead(Promise.all([dataQuery, countQuery]));
2894
3001
  const total = parseInt(totalResult[0]?.count?.toString() || "0", 10);
2895
- return { data, total, page, limit };
3002
+ 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
+ ]);
3007
+ return { data: rows, total, page, limit };
2896
3008
  }
2897
3009
  }
2898
3010
  exports.SqlBaseCrudService = SqlBaseCrudService;