@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.
- package/AGENT.md +17 -9
- package/CHANGELOG.md +56 -1
- package/README.md +56 -20
- package/dist/core/abstract/sql-base-crud.service.d.ts +13 -2
- package/dist/core/abstract/sql-base-crud.service.js +158 -46
- package/dist/core/abstract/sql-base-crud.service.js.map +1 -1
- package/dist/core/interfaces/drizzle-crud-config.interface.d.ts +16 -0
- package/dist/core/interfaces/sql-crud-config.interface.d.ts +9 -1
- package/dist/core/types/sql.types.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/modules/drizzle-connection.d.ts +3 -1
- package/dist/modules/drizzle-connection.js +49 -2
- package/dist/modules/drizzle-connection.js.map +1 -1
- package/dist/modules/drizzle-crud.module.d.ts +2 -6
- package/dist/modules/drizzle-crud.module.js +28 -6
- package/dist/modules/drizzle-crud.module.js.map +1 -1
- package/package.json +10 -2
|
@@ -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
|
|
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
|
|
401
|
-
node.relations[name] =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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" ||
|
|
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
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
if (
|
|
879
|
-
return query.for(
|
|
880
|
-
|
|
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
|
|
898
|
-
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
|
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
|
-
:
|
|
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(
|
|
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 {
|
|
2872
|
-
const
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
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(${
|
|
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
|
-
|
|
2883
|
-
|
|
2884
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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;
|