@avytrix/nestjs-drizzle-crud 0.0.0-stage → 3.6.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.
- package/AGENT.md +513 -0
- package/AGENTS.template.md +31 -0
- package/CHANGELOG.md +565 -0
- package/CLAUDE.template.md +18 -0
- package/LICENSE +21 -0
- package/README.md +1307 -2
- package/dist/controller/crud-controller.factory.d.ts +28 -0
- package/dist/controller/crud-controller.factory.js +279 -0
- package/dist/controller/crud-controller.factory.js.map +1 -0
- package/dist/core/abstract/sql-base-crud.service.d.ts +230 -0
- package/dist/core/abstract/sql-base-crud.service.js +2899 -0
- package/dist/core/abstract/sql-base-crud.service.js.map +1 -0
- package/dist/core/context/crud-context.d.ts +6 -0
- package/dist/core/context/crud-context.js +27 -0
- package/dist/core/context/crud-context.js.map +1 -0
- package/dist/core/events/crud-event-bus.d.ts +7 -0
- package/dist/core/events/crud-event-bus.js +30 -0
- package/dist/core/events/crud-event-bus.js.map +1 -0
- package/dist/core/interfaces/crud-service.interface.d.ts +54 -0
- package/dist/core/interfaces/crud-service.interface.js +3 -0
- package/dist/core/interfaces/crud-service.interface.js.map +1 -0
- package/dist/core/interfaces/drizzle-crud-config.interface.d.ts +39 -0
- package/dist/core/interfaces/drizzle-crud-config.interface.js +3 -0
- package/dist/core/interfaces/drizzle-crud-config.interface.js.map +1 -0
- package/dist/core/interfaces/events.interface.d.ts +39 -0
- package/dist/core/interfaces/events.interface.js +3 -0
- package/dist/core/interfaces/events.interface.js.map +1 -0
- package/dist/core/interfaces/query.interface.d.ts +112 -0
- package/dist/core/interfaces/query.interface.js +3 -0
- package/dist/core/interfaces/query.interface.js.map +1 -0
- package/dist/core/interfaces/scope.interface.d.ts +3 -0
- package/dist/core/interfaces/scope.interface.js +3 -0
- package/dist/core/interfaces/scope.interface.js.map +1 -0
- package/dist/core/interfaces/sql-crud-config.interface.d.ts +87 -0
- package/dist/core/interfaces/sql-crud-config.interface.js +3 -0
- package/dist/core/interfaces/sql-crud-config.interface.js.map +1 -0
- package/dist/core/types/sql.types.d.ts +7 -0
- package/dist/core/types/sql.types.js +3 -0
- package/dist/core/types/sql.types.js.map +1 -0
- package/dist/core/utils/cursor.d.ts +2 -0
- package/dist/core/utils/cursor.js +44 -0
- package/dist/core/utils/cursor.js.map +1 -0
- package/dist/core/utils/merge-config.d.ts +2 -0
- package/dist/core/utils/merge-config.js +22 -0
- package/dist/core/utils/merge-config.js.map +1 -0
- package/dist/core/utils/query-parser.d.ts +4 -0
- package/dist/core/utils/query-parser.js +538 -0
- package/dist/core/utils/query-parser.js.map +1 -0
- package/dist/decorators/crud-service.decorator.d.ts +4 -0
- package/dist/decorators/crud-service.decorator.js +10 -0
- package/dist/decorators/crud-service.decorator.js.map +1 -0
- package/dist/decorators/index.d.ts +1 -0
- package/dist/decorators/index.js +6 -0
- package/dist/decorators/index.js.map +1 -0
- package/dist/exceptions/crud.exceptions.d.ts +46 -0
- package/dist/exceptions/crud.exceptions.js +93 -0
- package/dist/exceptions/crud.exceptions.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/server.d.ts +8 -0
- package/dist/mcp/server.js +236 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/modules/drizzle-connection.d.ts +12 -0
- package/dist/modules/drizzle-connection.js +73 -0
- package/dist/modules/drizzle-connection.js.map +1 -0
- package/dist/modules/drizzle-crud.module.d.ts +11 -0
- package/dist/modules/drizzle-crud.module.js +165 -0
- package/dist/modules/drizzle-crud.module.js.map +1 -0
- package/dist/openapi/api-crud.decorators.d.ts +10 -0
- package/dist/openapi/api-crud.decorators.js +216 -0
- package/dist/openapi/api-crud.decorators.js.map +1 -0
- package/dist/test-utils/base-crud.spec-helper.d.ts +64 -0
- package/dist/test-utils/base-crud.spec-helper.js +119 -0
- package/dist/test-utils/base-crud.spec-helper.js.map +1 -0
- package/dist/test-utils/index.d.ts +2 -0
- package/dist/test-utils/index.js +8 -0
- package/dist/test-utils/index.js.map +1 -0
- package/dist/test-utils/test-factory.d.ts +29 -0
- package/dist/test-utils/test-factory.js +81 -0
- package/dist/test-utils/test-factory.js.map +1 -0
- package/package.json +110 -5
|
@@ -0,0 +1,2899 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SqlBaseCrudService = void 0;
|
|
4
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
5
|
+
const common_1 = require("@nestjs/common");
|
|
6
|
+
const crud_exceptions_1 = require("../../exceptions/crud.exceptions");
|
|
7
|
+
const crud_context_1 = require("../context/crud-context");
|
|
8
|
+
const cursor_1 = require("../utils/cursor");
|
|
9
|
+
const merge_config_1 = require("../utils/merge-config");
|
|
10
|
+
const query_parser_1 = require("../utils/query-parser");
|
|
11
|
+
const JOIN_KEY = "__crudJoinKey";
|
|
12
|
+
const CURSOR_KEY = "__crudCursor";
|
|
13
|
+
const ROLLBACK = Symbol("rollback");
|
|
14
|
+
const GROUP_KEYS = new Set(["and", "or", "not"]);
|
|
15
|
+
const QUANTIFIERS = new Set(["some", "none", "every"]);
|
|
16
|
+
const COUNT_KEY = "__crudCountKey";
|
|
17
|
+
let aliasSeq = 0;
|
|
18
|
+
function subqueryAlias(table) {
|
|
19
|
+
aliasSeq = (aliasSeq + 1) % 1_000_000;
|
|
20
|
+
return typeof drizzle_orm_1.aliasedTable === "function"
|
|
21
|
+
? (0, drizzle_orm_1.aliasedTable)(table, `crud_sq${aliasSeq}`)
|
|
22
|
+
: table;
|
|
23
|
+
}
|
|
24
|
+
class SqlBaseCrudService {
|
|
25
|
+
config;
|
|
26
|
+
defaultConfig = {
|
|
27
|
+
dialect: "postgresql",
|
|
28
|
+
db: null,
|
|
29
|
+
table: null,
|
|
30
|
+
primaryKey: "id",
|
|
31
|
+
primaryKeyType: "serial",
|
|
32
|
+
softDelete: { enabled: true, column: "deleted_at" },
|
|
33
|
+
timestamps: { createdAt: "created_at", updatedAt: "updated_at" },
|
|
34
|
+
pagination: { defaultLimit: 20, maxLimit: 100 },
|
|
35
|
+
sql: {
|
|
36
|
+
caseSensitive: false,
|
|
37
|
+
useReturning: true,
|
|
38
|
+
jsonSupport: true,
|
|
39
|
+
enableFullTextSearch: false,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
buildSelectFields(select) {
|
|
43
|
+
if (!select || select.length === 0)
|
|
44
|
+
return undefined;
|
|
45
|
+
const fields = {};
|
|
46
|
+
for (const field of select) {
|
|
47
|
+
const column = this.config.table[field];
|
|
48
|
+
if (column)
|
|
49
|
+
fields[field] = column;
|
|
50
|
+
}
|
|
51
|
+
return Object.keys(fields).length > 0 ? fields : undefined;
|
|
52
|
+
}
|
|
53
|
+
wasAffected(result) {
|
|
54
|
+
if (result == null)
|
|
55
|
+
return false;
|
|
56
|
+
if (Array.isArray(result)) {
|
|
57
|
+
if (typeof result.count === "number")
|
|
58
|
+
return result.count > 0;
|
|
59
|
+
if (result[0] && typeof result[0].affectedRows === "number")
|
|
60
|
+
return result[0].affectedRows > 0;
|
|
61
|
+
return result.length > 0;
|
|
62
|
+
}
|
|
63
|
+
if (typeof result.rowCount === "number")
|
|
64
|
+
return result.rowCount > 0;
|
|
65
|
+
if (typeof result.rowsAffected === "number")
|
|
66
|
+
return result.rowsAffected > 0;
|
|
67
|
+
if (typeof result.affectedRows === "number")
|
|
68
|
+
return result.affectedRows > 0;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
buildWhereConditionsFromPartial(where) {
|
|
72
|
+
const conditions = [];
|
|
73
|
+
for (const [key, value] of Object.entries(where ?? {})) {
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
const column = this.columnOf(this.config.table, key);
|
|
77
|
+
if (!column) {
|
|
78
|
+
throw new common_1.BadRequestException(`Unknown filter column: ${key}`);
|
|
79
|
+
}
|
|
80
|
+
if (value === null)
|
|
81
|
+
conditions.push((0, drizzle_orm_1.isNull)(column));
|
|
82
|
+
else
|
|
83
|
+
this.pushColumnCondition(conditions, column, value, false);
|
|
84
|
+
}
|
|
85
|
+
if (conditions.length === 0) {
|
|
86
|
+
throw new common_1.BadRequestException("findOne needs at least one condition; every value was undefined");
|
|
87
|
+
}
|
|
88
|
+
return conditions;
|
|
89
|
+
}
|
|
90
|
+
columnOf(table, name) {
|
|
91
|
+
if (!table || typeof name !== "string")
|
|
92
|
+
return undefined;
|
|
93
|
+
const columns = (0, drizzle_orm_1.getTableColumns)(table) ?? table;
|
|
94
|
+
return Object.hasOwn(columns, name) ? columns[name] : undefined;
|
|
95
|
+
}
|
|
96
|
+
primaryKeyOf(table) {
|
|
97
|
+
if (table === this.config.table)
|
|
98
|
+
return this.config.primaryKey;
|
|
99
|
+
const columns = (0, drizzle_orm_1.getTableColumns)(table) ?? {};
|
|
100
|
+
for (const [key, column] of Object.entries(columns)) {
|
|
101
|
+
if (column?.primary)
|
|
102
|
+
return key;
|
|
103
|
+
}
|
|
104
|
+
return "id";
|
|
105
|
+
}
|
|
106
|
+
isTextColumn(column) {
|
|
107
|
+
return /(Text|VarChar|Varchar|Char)$/.test(column?.columnType ?? "");
|
|
108
|
+
}
|
|
109
|
+
coerceFilterValue(column, value) {
|
|
110
|
+
if (column?.dataType !== "date" || value instanceof Date)
|
|
111
|
+
return value;
|
|
112
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
113
|
+
const date = new Date(value);
|
|
114
|
+
if (!Number.isNaN(date.getTime()))
|
|
115
|
+
return date;
|
|
116
|
+
}
|
|
117
|
+
throw new common_1.BadRequestException(`Invalid date filter value: ${value}`);
|
|
118
|
+
}
|
|
119
|
+
static ALLOWLIST_LABELS = {
|
|
120
|
+
filterableColumns: "Filtering on",
|
|
121
|
+
sortableColumns: "Sorting by",
|
|
122
|
+
selectableColumns: "Selecting",
|
|
123
|
+
searchableColumns: "Searching",
|
|
124
|
+
loadableRelations: "Loading the relation",
|
|
125
|
+
writableColumns: "Writing",
|
|
126
|
+
writableRelations: "Writing the relation",
|
|
127
|
+
};
|
|
128
|
+
assertAllowed(list, path, options) {
|
|
129
|
+
if (options?.skipAllowlists)
|
|
130
|
+
return;
|
|
131
|
+
const allowed = this.config[list];
|
|
132
|
+
if (!allowed)
|
|
133
|
+
return;
|
|
134
|
+
if (path === this.config.primaryKey &&
|
|
135
|
+
(list === "selectableColumns" || list === "sortableColumns")) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (allowed.includes(path))
|
|
139
|
+
return;
|
|
140
|
+
if (list === "loadableRelations" &&
|
|
141
|
+
allowed.some((entry) => entry.startsWith(`${path}.`))) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
throw new common_1.BadRequestException(`${SqlBaseCrudService.ALLOWLIST_LABELS[list]} "${path}" is not allowed`);
|
|
145
|
+
}
|
|
146
|
+
checkReadOptions(options) {
|
|
147
|
+
if (options?.skipAllowlists)
|
|
148
|
+
return;
|
|
149
|
+
for (const path of options?.relations ?? []) {
|
|
150
|
+
if (path)
|
|
151
|
+
this.assertAllowed("loadableRelations", path, options);
|
|
152
|
+
}
|
|
153
|
+
for (const path of options?.select ?? []) {
|
|
154
|
+
if (path)
|
|
155
|
+
this.assertAllowed("selectableColumns", path, options);
|
|
156
|
+
}
|
|
157
|
+
for (const column of options?.search?.columns ?? []) {
|
|
158
|
+
this.assertAllowed("searchableColumns", column, options);
|
|
159
|
+
}
|
|
160
|
+
for (const name of this.countNames(options?.counts)) {
|
|
161
|
+
this.assertAllowed("loadableRelations", name, options);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
rowScope(options) {
|
|
165
|
+
const out = this.scopeConditions(options);
|
|
166
|
+
const softDeleted = this.softDeleteScope(options);
|
|
167
|
+
if (softDeleted)
|
|
168
|
+
out.push(softDeleted);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
resolveScope(options) {
|
|
172
|
+
const scope = this.config.scope;
|
|
173
|
+
if (!scope || options?.skipScope)
|
|
174
|
+
return null;
|
|
175
|
+
const values = typeof scope === "function"
|
|
176
|
+
? scope(options?.context ?? crud_context_1.CrudContext.current() ?? {})
|
|
177
|
+
: scope;
|
|
178
|
+
if (values === null)
|
|
179
|
+
return null;
|
|
180
|
+
if (!(0, merge_config_1.isPlainObject)(values)) {
|
|
181
|
+
throw new Error(`[nestjs-drizzle-crud] ${this.getEntityName()}: scope must return an object of column values, or null`);
|
|
182
|
+
}
|
|
183
|
+
for (const [key, value] of Object.entries(values)) {
|
|
184
|
+
if (!this.columnOf(this.config.table, key)) {
|
|
185
|
+
throw new Error(`[nestjs-drizzle-crud] ${this.getEntityName()}: scope names "${key}", which is not a column`);
|
|
186
|
+
}
|
|
187
|
+
if (value === undefined) {
|
|
188
|
+
throw new crud_exceptions_1.MissingScopeException(this.getEntityName(), key);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return values;
|
|
192
|
+
}
|
|
193
|
+
scopeConditions(options, table = this.config.table) {
|
|
194
|
+
const values = this.resolveScope(options);
|
|
195
|
+
if (!values)
|
|
196
|
+
return [];
|
|
197
|
+
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
|
+
}
|
|
199
|
+
relatedRowScope(target, options) {
|
|
200
|
+
const out = this.relatedScopeConditions(target, options);
|
|
201
|
+
const softDeleted = this.softDeleteCondition(target);
|
|
202
|
+
if (softDeleted)
|
|
203
|
+
out.push(softDeleted);
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
relatedScopeConditions(target, options) {
|
|
207
|
+
return Object.entries(this.resolveScope(options) ?? {})
|
|
208
|
+
.filter(([key]) => this.columnOf(target, key))
|
|
209
|
+
.map(([key, value]) => value === null ? (0, drizzle_orm_1.isNull)(target[key]) : (0, drizzle_orm_1.eq)(target[key], value));
|
|
210
|
+
}
|
|
211
|
+
applyScopeToRow(row, options) {
|
|
212
|
+
const values = this.resolveScope(options);
|
|
213
|
+
if (values)
|
|
214
|
+
Object.assign(row, values);
|
|
215
|
+
}
|
|
216
|
+
softDeleteScope(options) {
|
|
217
|
+
const softDelete = this.config.softDelete;
|
|
218
|
+
if (options?.onlyDeleted) {
|
|
219
|
+
if (!softDelete?.enabled) {
|
|
220
|
+
throw new common_1.BadRequestException("onlyDeleted needs soft delete, which is off for this entity");
|
|
221
|
+
}
|
|
222
|
+
return (0, drizzle_orm_1.isNotNull)(this.config.table[softDelete.column]);
|
|
223
|
+
}
|
|
224
|
+
if (!softDelete?.enabled || options?.withDeleted)
|
|
225
|
+
return undefined;
|
|
226
|
+
return (0, drizzle_orm_1.isNull)(this.config.table[softDelete.column]);
|
|
227
|
+
}
|
|
228
|
+
managedColumns(operation) {
|
|
229
|
+
const out = [];
|
|
230
|
+
if (this.config.softDelete?.enabled)
|
|
231
|
+
out.push(this.config.softDelete.column);
|
|
232
|
+
if (this.config.versionColumn)
|
|
233
|
+
out.push(this.config.versionColumn);
|
|
234
|
+
if (operation === "update") {
|
|
235
|
+
out.push(this.config.primaryKey);
|
|
236
|
+
if (this.config.timestamps?.createdAt) {
|
|
237
|
+
out.push(this.config.timestamps.createdAt);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
filterWritable(entity, operation, options) {
|
|
243
|
+
const out = { ...entity };
|
|
244
|
+
const allowed = this.config.writableColumns;
|
|
245
|
+
if (allowed && !options?.skipAllowlists) {
|
|
246
|
+
for (const [key, value] of Object.entries(out)) {
|
|
247
|
+
if (value === undefined || !this.columnOf(this.config.table, key)) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!allowed.includes(key)) {
|
|
251
|
+
throw new crud_exceptions_1.ValidationFailedException(`the column "${key}" is not writable`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
if (allowed)
|
|
257
|
+
return out;
|
|
258
|
+
for (const key of this.managedColumns(operation))
|
|
259
|
+
delete out[key];
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
keyOf(value) {
|
|
263
|
+
const column = this.columnOf(this.config.table, this.config.primaryKey);
|
|
264
|
+
const key = String(value);
|
|
265
|
+
return column?.columnType === "PgUUID" ? key.toLowerCase() : key;
|
|
266
|
+
}
|
|
267
|
+
constructor(config) {
|
|
268
|
+
this.config = config;
|
|
269
|
+
this.config = (0, merge_config_1.mergeCrudConfig)(this.defaultConfig, config);
|
|
270
|
+
this.validateConfiguration();
|
|
271
|
+
}
|
|
272
|
+
validateConfiguration() {
|
|
273
|
+
if (!this.config.db)
|
|
274
|
+
throw new Error("Database instance is required");
|
|
275
|
+
if (!this.config.table)
|
|
276
|
+
throw new Error("Table configuration is required");
|
|
277
|
+
if (this.config.dialect === "mysql" && this.config.sql?.useReturning) {
|
|
278
|
+
console.warn("MySQL does not support RETURNING clause, disabling useReturning");
|
|
279
|
+
this.config.sql.useReturning = false;
|
|
280
|
+
}
|
|
281
|
+
this.validateColumns();
|
|
282
|
+
this.validateRelations(this.config.relations);
|
|
283
|
+
}
|
|
284
|
+
validateColumns() {
|
|
285
|
+
const table = this.config.table;
|
|
286
|
+
const check = (setting, name) => {
|
|
287
|
+
if (!name || this.columnOf(table, name))
|
|
288
|
+
return;
|
|
289
|
+
const declared = Object.entries((0, drizzle_orm_1.getTableColumns)(table) ?? {}).find(([, column]) => column?.name === name)?.[0];
|
|
290
|
+
const hint = declared
|
|
291
|
+
? `The column "${name}" is declared under the key "${declared}"; set ${setting} to "${declared}".`
|
|
292
|
+
: `Set ${setting} to the key of an existing column, or turn it off for this entity.`;
|
|
293
|
+
throw new Error(`[nestjs-drizzle-crud] ${this.getEntityName()}: ${setting} is "${name}", but the table has no column under that key. ${hint}`);
|
|
294
|
+
};
|
|
295
|
+
if (this.config.softDelete?.enabled) {
|
|
296
|
+
check("softDelete.column", this.config.softDelete.column);
|
|
297
|
+
}
|
|
298
|
+
if (this.config.timestamps) {
|
|
299
|
+
check("timestamps.createdAt", this.config.timestamps.createdAt);
|
|
300
|
+
check("timestamps.updatedAt", this.config.timestamps.updatedAt);
|
|
301
|
+
}
|
|
302
|
+
if (this.config.versionColumn) {
|
|
303
|
+
check("versionColumn", this.config.versionColumn);
|
|
304
|
+
}
|
|
305
|
+
const scope = this.config.scope;
|
|
306
|
+
if (scope && typeof scope !== "function") {
|
|
307
|
+
for (const key of Object.keys(scope))
|
|
308
|
+
check(`scope.${key}`, key);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
validateRelations(relations, path = "") {
|
|
312
|
+
for (const [name, relation] of Object.entries(relations || {})) {
|
|
313
|
+
const label = path ? `${path}.${name}` : name;
|
|
314
|
+
if (!relation?.table) {
|
|
315
|
+
throw new Error(`Relation "${label}" is missing its table`);
|
|
316
|
+
}
|
|
317
|
+
switch (this.relationType(relation)) {
|
|
318
|
+
case "hasMany":
|
|
319
|
+
if (!relation.foreignKey) {
|
|
320
|
+
throw new Error(`Relation "${label}" is hasMany and requires a foreignKey (the column on the related table pointing back here)`);
|
|
321
|
+
}
|
|
322
|
+
break;
|
|
323
|
+
case "manyToMany":
|
|
324
|
+
if (!relation.through ||
|
|
325
|
+
!relation.throughLocalKey ||
|
|
326
|
+
!relation.throughForeignKey) {
|
|
327
|
+
throw new Error(`Relation "${label}" is manyToMany and requires through, throughLocalKey and throughForeignKey (the join table and its two foreign-key columns)`);
|
|
328
|
+
}
|
|
329
|
+
break;
|
|
330
|
+
default:
|
|
331
|
+
if (!relation.localKey) {
|
|
332
|
+
throw new Error(`Relation "${label}" is belongsTo and requires a localKey (the foreign-key column on this table)`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
this.validateRelations(relation.relations, label);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async validateCreate(_data) { }
|
|
339
|
+
async validateUpdate(_id, _data) { }
|
|
340
|
+
mapCreateDtoToEntity(data) {
|
|
341
|
+
return { ...data };
|
|
342
|
+
}
|
|
343
|
+
mapUpdateDtoToEntity(data) {
|
|
344
|
+
return { ...data };
|
|
345
|
+
}
|
|
346
|
+
async beforeCreate(data) {
|
|
347
|
+
return data;
|
|
348
|
+
}
|
|
349
|
+
async afterCreate(_entity) { }
|
|
350
|
+
async beforeUpdate(_id, data) {
|
|
351
|
+
return data;
|
|
352
|
+
}
|
|
353
|
+
async afterUpdate(_entity) { }
|
|
354
|
+
async beforeDelete(_id) { }
|
|
355
|
+
async afterDelete(_id) { }
|
|
356
|
+
async beforeSoftDelete(_id) { }
|
|
357
|
+
async afterSoftDelete(_id) { }
|
|
358
|
+
async beforeRestore(_id) { }
|
|
359
|
+
async afterRestore(_entity) { }
|
|
360
|
+
relationType(relation) {
|
|
361
|
+
if (relation.type === "hasMany" || relation.type === "manyToMany")
|
|
362
|
+
return relation.type;
|
|
363
|
+
return "belongsTo";
|
|
364
|
+
}
|
|
365
|
+
isCollection(relation) {
|
|
366
|
+
return this.relationType(relation) !== "belongsTo";
|
|
367
|
+
}
|
|
368
|
+
eagerRelations(relations) {
|
|
369
|
+
const configured = this.config.relations || {};
|
|
370
|
+
return (relations || []).filter((name) => !!configured[name]);
|
|
371
|
+
}
|
|
372
|
+
relationTree(paths, configured = this.config.relations || {}) {
|
|
373
|
+
const roots = [];
|
|
374
|
+
for (const path of paths || []) {
|
|
375
|
+
let level = configured;
|
|
376
|
+
let siblings = roots;
|
|
377
|
+
for (const name of (path || "").split(".").filter(Boolean)) {
|
|
378
|
+
const config = level?.[name];
|
|
379
|
+
if (!config)
|
|
380
|
+
break;
|
|
381
|
+
let node = siblings.find((candidate) => candidate.name === name);
|
|
382
|
+
if (!node) {
|
|
383
|
+
node = { name, config, children: [] };
|
|
384
|
+
siblings.push(node);
|
|
385
|
+
}
|
|
386
|
+
level = config.relations || {};
|
|
387
|
+
siblings = node.children;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return roots;
|
|
391
|
+
}
|
|
392
|
+
parseSelect(select) {
|
|
393
|
+
const root = { base: [], relations: {} };
|
|
394
|
+
for (const entry of select || []) {
|
|
395
|
+
const segments = (entry || "").split(".").filter(Boolean);
|
|
396
|
+
if (segments.length === 0)
|
|
397
|
+
continue;
|
|
398
|
+
let node = root;
|
|
399
|
+
for (const name of segments.slice(0, -1)) {
|
|
400
|
+
if (!node.relations[name])
|
|
401
|
+
node.relations[name] = { base: [], relations: {} };
|
|
402
|
+
node = node.relations[name];
|
|
403
|
+
}
|
|
404
|
+
node.base.push(segments[segments.length - 1]);
|
|
405
|
+
}
|
|
406
|
+
return root;
|
|
407
|
+
}
|
|
408
|
+
selectFor(select, name) {
|
|
409
|
+
return select.relations[name] ?? { base: [], relations: {} };
|
|
410
|
+
}
|
|
411
|
+
keysForChildren(nodes, hostTable = this.config.table) {
|
|
412
|
+
return nodes
|
|
413
|
+
.map((node) => this.relationType(node.config) === "belongsTo"
|
|
414
|
+
? node.config.localKey
|
|
415
|
+
: this.parentKeyOf(node.config, hostTable))
|
|
416
|
+
.filter((key) => !!key);
|
|
417
|
+
}
|
|
418
|
+
parentKeyOf(relation, hostTable = this.config.table) {
|
|
419
|
+
return relation.parentKey ?? this.primaryKeyOf(hostTable);
|
|
420
|
+
}
|
|
421
|
+
withRequiredKeys(base, nodes) {
|
|
422
|
+
if (base.length === 0)
|
|
423
|
+
return base;
|
|
424
|
+
const keys = this.keysForChildren(nodes.filter((node) => !node.joined));
|
|
425
|
+
return keys.length > 0 ? Array.from(new Set([...base, ...keys])) : base;
|
|
426
|
+
}
|
|
427
|
+
buildSelection(base, joined, select = { base: [], relations: {} }) {
|
|
428
|
+
const allColumns = (0, drizzle_orm_1.getTableColumns)(this.config.table);
|
|
429
|
+
let fields;
|
|
430
|
+
if (base.length > 0) {
|
|
431
|
+
fields = {};
|
|
432
|
+
for (const name of base) {
|
|
433
|
+
if (allColumns[name])
|
|
434
|
+
fields[name] = allColumns[name];
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
fields = { ...allColumns };
|
|
439
|
+
}
|
|
440
|
+
for (const node of joined) {
|
|
441
|
+
const relColumns = (0, drizzle_orm_1.getTableColumns)(node.config.table);
|
|
442
|
+
const wanted = this.selectFor(select, node.name).base;
|
|
443
|
+
if (wanted.length > 0) {
|
|
444
|
+
const picked = {};
|
|
445
|
+
for (const name of [
|
|
446
|
+
...wanted,
|
|
447
|
+
...this.keysForChildren(node.children, node.config.table),
|
|
448
|
+
]) {
|
|
449
|
+
if (relColumns[name])
|
|
450
|
+
picked[name] = relColumns[name];
|
|
451
|
+
}
|
|
452
|
+
fields[node.name] =
|
|
453
|
+
Object.keys(picked).length > 0 ? picked : relColumns;
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
fields[node.name] = relColumns;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return fields;
|
|
460
|
+
}
|
|
461
|
+
startSelect(db, base, joined, hasJoins = false, select = { base: [], relations: {} }) {
|
|
462
|
+
if (joined.length > 0 || hasJoins) {
|
|
463
|
+
return db
|
|
464
|
+
.select(this.buildSelection(base, joined, select))
|
|
465
|
+
.from(this.config.table);
|
|
466
|
+
}
|
|
467
|
+
const fields = this.buildSelectFields(base);
|
|
468
|
+
return fields
|
|
469
|
+
? db.select(fields).from(this.config.table)
|
|
470
|
+
: db.select().from(this.config.table);
|
|
471
|
+
}
|
|
472
|
+
planJoins(nodes) {
|
|
473
|
+
const joined = [];
|
|
474
|
+
for (const node of nodes) {
|
|
475
|
+
if (this.isCollection(node.config) || !node.config.localKey)
|
|
476
|
+
continue;
|
|
477
|
+
node.joined = true;
|
|
478
|
+
joined.push(node);
|
|
479
|
+
}
|
|
480
|
+
return joined;
|
|
481
|
+
}
|
|
482
|
+
collectionOrderBy(relation) {
|
|
483
|
+
const out = [];
|
|
484
|
+
for (const spec of relation.orderBy ?? []) {
|
|
485
|
+
const column = relation.table[spec.column];
|
|
486
|
+
if (!column) {
|
|
487
|
+
console.warn(`[nestjs-drizzle-crud] relation orderBy references unknown column "${spec.column}"; skipping.`);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
out.push(spec.order === "desc" ? (0, drizzle_orm_1.desc)(column) : (0, drizzle_orm_1.asc)(column));
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
softDeleteCondition(table) {
|
|
495
|
+
const softDelete = this.config.softDelete;
|
|
496
|
+
if (!softDelete?.enabled)
|
|
497
|
+
return undefined;
|
|
498
|
+
const column = table[softDelete.column];
|
|
499
|
+
return column ? (0, drizzle_orm_1.isNull)(column) : undefined;
|
|
500
|
+
}
|
|
501
|
+
relationProjection(table, wanted, requiredKeys) {
|
|
502
|
+
if (wanted.length === 0)
|
|
503
|
+
return undefined;
|
|
504
|
+
const fields = {};
|
|
505
|
+
for (const name of [...wanted, ...requiredKeys]) {
|
|
506
|
+
const column = table[name];
|
|
507
|
+
if (column)
|
|
508
|
+
fields[name] = column;
|
|
509
|
+
}
|
|
510
|
+
return Object.keys(fields).length > 0 ? fields : undefined;
|
|
511
|
+
}
|
|
512
|
+
async loadRelations(db, hosts, nodes, select = { base: [], relations: {} }, hostTable = this.config.table, options) {
|
|
513
|
+
if (!nodes.length || !Array.isArray(hosts))
|
|
514
|
+
return;
|
|
515
|
+
const rows = hosts.filter((row) => row && typeof row === "object");
|
|
516
|
+
if (rows.length === 0)
|
|
517
|
+
return;
|
|
518
|
+
await Promise.all(nodes.map(async (node) => {
|
|
519
|
+
const childSelect = this.selectFor(select, node.name);
|
|
520
|
+
if (!node.joined) {
|
|
521
|
+
switch (this.relationType(node.config)) {
|
|
522
|
+
case "hasMany":
|
|
523
|
+
await this.loadHasMany(db, rows, node, childSelect, hostTable, options);
|
|
524
|
+
break;
|
|
525
|
+
case "manyToMany":
|
|
526
|
+
await this.loadManyToMany(db, rows, node, childSelect, hostTable, options);
|
|
527
|
+
break;
|
|
528
|
+
default:
|
|
529
|
+
await this.loadBelongsTo(db, rows, node, childSelect, options);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (node.children.length === 0)
|
|
533
|
+
return;
|
|
534
|
+
const nextHosts = this.isCollection(node.config)
|
|
535
|
+
? rows.flatMap((row) => row[node.name] ?? [])
|
|
536
|
+
: rows.map((row) => row[node.name]);
|
|
537
|
+
await this.loadRelations(db, nextHosts, node.children, childSelect, node.config.table, options);
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
540
|
+
async loadBelongsTo(db, hosts, node, select, options) {
|
|
541
|
+
const { localKey, references } = node.config;
|
|
542
|
+
if (!localKey)
|
|
543
|
+
return;
|
|
544
|
+
const target = node.config.table;
|
|
545
|
+
const referencedName = references ?? "id";
|
|
546
|
+
const referenced = target[referencedName];
|
|
547
|
+
if (!referenced)
|
|
548
|
+
return;
|
|
549
|
+
const ids = Array.from(new Set(hosts.map((host) => host[localKey]).filter((v) => v != null)));
|
|
550
|
+
if (ids.length === 0) {
|
|
551
|
+
for (const host of hosts)
|
|
552
|
+
host[node.name] = null;
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const fields = this.relationProjection(target, select.base, [
|
|
556
|
+
referencedName,
|
|
557
|
+
...this.keysForChildren(node.children, target),
|
|
558
|
+
]);
|
|
559
|
+
const conditions = [(0, drizzle_orm_1.inArray)(referenced, ids)];
|
|
560
|
+
conditions.push(...this.relatedRowScope(target, options));
|
|
561
|
+
const related = await (fields ? db.select(fields) : db.select())
|
|
562
|
+
.from(target)
|
|
563
|
+
.where((0, drizzle_orm_1.and)(...conditions));
|
|
564
|
+
const byKey = new Map();
|
|
565
|
+
for (const row of related ?? [])
|
|
566
|
+
byKey.set(row?.[referencedName], row);
|
|
567
|
+
for (const host of hosts) {
|
|
568
|
+
host[node.name] = byKey.get(host[localKey]) ?? null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async loadHasMany(db, hosts, node, select, hostTable = this.config.table, options) {
|
|
572
|
+
const { foreignKey } = node.config;
|
|
573
|
+
if (!foreignKey)
|
|
574
|
+
return;
|
|
575
|
+
const target = node.config.table;
|
|
576
|
+
const foreign = target[foreignKey];
|
|
577
|
+
if (!foreign)
|
|
578
|
+
return;
|
|
579
|
+
const parentKey = this.parentKeyOf(node.config, hostTable);
|
|
580
|
+
const ids = Array.from(new Set(hosts.map((host) => host[parentKey]).filter((v) => v != null)));
|
|
581
|
+
if (ids.length === 0) {
|
|
582
|
+
for (const host of hosts)
|
|
583
|
+
host[node.name] = [];
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const fields = this.relationProjection(target, select.base, [
|
|
587
|
+
foreignKey,
|
|
588
|
+
...this.keysForChildren(node.children, target),
|
|
589
|
+
]);
|
|
590
|
+
const conditions = [(0, drizzle_orm_1.inArray)(foreign, ids)];
|
|
591
|
+
conditions.push(...this.relatedRowScope(target, options));
|
|
592
|
+
let query = (fields ? db.select(fields) : db.select())
|
|
593
|
+
.from(target)
|
|
594
|
+
.where((0, drizzle_orm_1.and)(...conditions));
|
|
595
|
+
const orderBy = this.collectionOrderBy(node.config);
|
|
596
|
+
if (orderBy.length > 0)
|
|
597
|
+
query = query.orderBy(...orderBy);
|
|
598
|
+
const children = await query;
|
|
599
|
+
this.groupCollection(hosts, node.name, parentKey, children, foreignKey);
|
|
600
|
+
}
|
|
601
|
+
async loadManyToMany(db, hosts, node, select, hostTable = this.config.table, options) {
|
|
602
|
+
const { through, throughLocalKey, throughForeignKey, references } = node.config;
|
|
603
|
+
if (!through || !throughLocalKey || !throughForeignKey)
|
|
604
|
+
return;
|
|
605
|
+
const target = node.config.table;
|
|
606
|
+
const referencedName = references ?? "id";
|
|
607
|
+
const referenced = target[referencedName];
|
|
608
|
+
const joinLocal = through[throughLocalKey];
|
|
609
|
+
const joinForeign = through[throughForeignKey];
|
|
610
|
+
if (!referenced || !joinLocal || !joinForeign)
|
|
611
|
+
return;
|
|
612
|
+
const parentKey = this.parentKeyOf(node.config, hostTable);
|
|
613
|
+
const ids = Array.from(new Set(hosts.map((host) => host[parentKey]).filter((v) => v != null)));
|
|
614
|
+
if (ids.length === 0) {
|
|
615
|
+
for (const host of hosts)
|
|
616
|
+
host[node.name] = [];
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const columns = (0, drizzle_orm_1.getTableColumns)(target);
|
|
620
|
+
const wanted = select.base.length > 0
|
|
621
|
+
? [...select.base, ...this.keysForChildren(node.children, target)]
|
|
622
|
+
: Object.keys(columns);
|
|
623
|
+
const fields = { [JOIN_KEY]: joinLocal };
|
|
624
|
+
for (const name of wanted) {
|
|
625
|
+
if (columns[name])
|
|
626
|
+
fields[name] = columns[name];
|
|
627
|
+
}
|
|
628
|
+
const conditions = [(0, drizzle_orm_1.inArray)(joinLocal, ids)];
|
|
629
|
+
conditions.push(...this.relatedRowScope(target, options));
|
|
630
|
+
let query = db
|
|
631
|
+
.select(fields)
|
|
632
|
+
.from(through)
|
|
633
|
+
.innerJoin(target, (0, drizzle_orm_1.eq)(joinForeign, referenced))
|
|
634
|
+
.where((0, drizzle_orm_1.and)(...conditions));
|
|
635
|
+
const orderBy = this.collectionOrderBy(node.config);
|
|
636
|
+
if (orderBy.length > 0)
|
|
637
|
+
query = query.orderBy(...orderBy);
|
|
638
|
+
const rows = await query;
|
|
639
|
+
this.groupCollection(hosts, node.name, parentKey, rows, JOIN_KEY, true);
|
|
640
|
+
}
|
|
641
|
+
groupCollection(hosts, name, parentKey, rows, groupBy, stripGroupKey = false) {
|
|
642
|
+
const grouped = new Map();
|
|
643
|
+
for (const row of rows ?? []) {
|
|
644
|
+
if (!row)
|
|
645
|
+
continue;
|
|
646
|
+
const key = row[groupBy];
|
|
647
|
+
if (stripGroupKey)
|
|
648
|
+
delete row[groupBy];
|
|
649
|
+
const bucket = grouped.get(key);
|
|
650
|
+
if (bucket)
|
|
651
|
+
bucket.push(row);
|
|
652
|
+
else
|
|
653
|
+
grouped.set(key, [row]);
|
|
654
|
+
}
|
|
655
|
+
for (const host of hosts) {
|
|
656
|
+
host[name] = grouped.get(host[parentKey]) ?? [];
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
applyJoins(query, relationNames, options) {
|
|
660
|
+
const configured = this.config.relations || {};
|
|
661
|
+
let q = query;
|
|
662
|
+
for (const name of relationNames) {
|
|
663
|
+
const rel = configured[name];
|
|
664
|
+
if (!rel?.localKey || this.isCollection(rel))
|
|
665
|
+
continue;
|
|
666
|
+
const on = (0, drizzle_orm_1.eq)(this.config.table[rel.localKey], rel.table[rel.references ?? "id"]);
|
|
667
|
+
q = q.leftJoin(rel.table, (0, drizzle_orm_1.and)(on, ...this.relatedRowScope(rel.table, options)));
|
|
668
|
+
}
|
|
669
|
+
return q;
|
|
670
|
+
}
|
|
671
|
+
normalizeRelations(rows, eager) {
|
|
672
|
+
if (!eager.length || !Array.isArray(rows))
|
|
673
|
+
return rows;
|
|
674
|
+
return rows.map((row) => {
|
|
675
|
+
if (!row || typeof row !== "object")
|
|
676
|
+
return row;
|
|
677
|
+
const out = { ...row };
|
|
678
|
+
for (const name of eager) {
|
|
679
|
+
const related = out[name];
|
|
680
|
+
if (related &&
|
|
681
|
+
typeof related === "object" &&
|
|
682
|
+
Object.values(related).every((v) => v === null)) {
|
|
683
|
+
out[name] = null;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
buildOrderBy(sortBy, sortOrder = "asc") {
|
|
690
|
+
return this.resolveSort(sortBy, sortOrder).map((spec) => this.orderOf(spec));
|
|
691
|
+
}
|
|
692
|
+
orderOf(spec) {
|
|
693
|
+
return spec.order === "desc" ? (0, drizzle_orm_1.desc)(spec.column) : (0, drizzle_orm_1.asc)(spec.column);
|
|
694
|
+
}
|
|
695
|
+
resolveSort(sortBy, sortOrder = "asc", options) {
|
|
696
|
+
if (sortOrder !== "asc" && sortOrder !== "desc") {
|
|
697
|
+
throw new common_1.BadRequestException(`Invalid sortOrder: ${sortOrder} (expected 'asc' or 'desc')`);
|
|
698
|
+
}
|
|
699
|
+
const specs = [];
|
|
700
|
+
const add = (spec) => {
|
|
701
|
+
if (!specs.some((existing) => existing.column === spec.column)) {
|
|
702
|
+
specs.push(spec);
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
const entries = this.sortEntries(sortBy, sortOrder);
|
|
706
|
+
if (entries.length > 0) {
|
|
707
|
+
for (const entry of entries) {
|
|
708
|
+
this.assertAllowed("sortableColumns", entry.column, options);
|
|
709
|
+
add(this.resolveSortColumn(entry.column, entry.order ?? "asc"));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
for (const entry of this.config.defaultSort ?? []) {
|
|
714
|
+
try {
|
|
715
|
+
add(this.resolveSortColumn(entry.column, entry.order ?? "asc"));
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
console.warn(`[nestjs-drizzle-crud] defaultSort references unknown column "${entry.column}"; skipping.`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const pk = this.columnOf(this.config.table, this.config.primaryKey);
|
|
723
|
+
if (pk)
|
|
724
|
+
add({ key: this.config.primaryKey, column: pk, order: "asc" });
|
|
725
|
+
return specs;
|
|
726
|
+
}
|
|
727
|
+
sortEntries(sortBy, sortOrder) {
|
|
728
|
+
const fromString = (entry) => {
|
|
729
|
+
const name = entry.trim();
|
|
730
|
+
if (name.startsWith("-"))
|
|
731
|
+
return { column: name.slice(1), order: "desc" };
|
|
732
|
+
if (name.startsWith("+"))
|
|
733
|
+
return { column: name.slice(1), order: "asc" };
|
|
734
|
+
return { column: name, order: "asc" };
|
|
735
|
+
};
|
|
736
|
+
if (typeof sortBy === "string") {
|
|
737
|
+
if (!sortBy.trim())
|
|
738
|
+
return [];
|
|
739
|
+
if (!sortBy.includes(",")) {
|
|
740
|
+
const entry = fromString(sortBy);
|
|
741
|
+
return [
|
|
742
|
+
sortBy.trim().startsWith("-") || sortBy.trim().startsWith("+")
|
|
743
|
+
? entry
|
|
744
|
+
: { column: entry.column, order: sortOrder },
|
|
745
|
+
];
|
|
746
|
+
}
|
|
747
|
+
return sortBy
|
|
748
|
+
.split(",")
|
|
749
|
+
.filter((part) => part.trim())
|
|
750
|
+
.map(fromString);
|
|
751
|
+
}
|
|
752
|
+
if (!Array.isArray(sortBy))
|
|
753
|
+
return [];
|
|
754
|
+
return sortBy.map((entry) => {
|
|
755
|
+
if (typeof entry === "string")
|
|
756
|
+
return fromString(entry);
|
|
757
|
+
if (!(0, merge_config_1.isPlainObject)(entry) ||
|
|
758
|
+
typeof entry.column !== "string" ||
|
|
759
|
+
(entry.order !== undefined &&
|
|
760
|
+
entry.order !== "asc" &&
|
|
761
|
+
entry.order !== "desc")) {
|
|
762
|
+
throw new common_1.BadRequestException("Each sortBy entry is a column name or { column, order }");
|
|
763
|
+
}
|
|
764
|
+
return entry;
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
resolveSortColumn(key, order) {
|
|
768
|
+
const dot = key.indexOf(".");
|
|
769
|
+
if (dot < 0) {
|
|
770
|
+
const column = this.columnOf(this.config.table, key);
|
|
771
|
+
if (!column)
|
|
772
|
+
throw new common_1.BadRequestException(`Unknown sort column: ${key}`);
|
|
773
|
+
return { key, column, order };
|
|
774
|
+
}
|
|
775
|
+
const name = key.slice(0, dot);
|
|
776
|
+
const relations = this.config.relations || {};
|
|
777
|
+
const relation = Object.hasOwn(relations, name)
|
|
778
|
+
? relations[name]
|
|
779
|
+
: undefined;
|
|
780
|
+
const column = relation
|
|
781
|
+
? this.columnOf(relation.table, key.slice(dot + 1))
|
|
782
|
+
: undefined;
|
|
783
|
+
if (!relation || !column) {
|
|
784
|
+
throw new common_1.BadRequestException(`Unknown sort column: ${key}`);
|
|
785
|
+
}
|
|
786
|
+
if (this.isCollection(relation) || !relation.localKey) {
|
|
787
|
+
throw new common_1.BadRequestException(`Cannot sort by "${key}": only a belongs-to relation column can be sorted on`);
|
|
788
|
+
}
|
|
789
|
+
return { key, column, order, relation: name };
|
|
790
|
+
}
|
|
791
|
+
resolvePagination(page, limit) {
|
|
792
|
+
const maxLimit = this.config.pagination?.maxLimit ?? 100;
|
|
793
|
+
const defaultLimit = this.config.pagination?.defaultLimit ?? 20;
|
|
794
|
+
const rawPage = page == null || Number.isNaN(Number(page)) ? 1 : Math.floor(Number(page));
|
|
795
|
+
const rawLimit = limit == null || Number.isNaN(Number(limit))
|
|
796
|
+
? defaultLimit
|
|
797
|
+
: Math.floor(Number(limit));
|
|
798
|
+
const safePage = Math.max(1, rawPage);
|
|
799
|
+
const safeLimit = Math.min(Math.max(1, rawLimit), maxLimit);
|
|
800
|
+
return {
|
|
801
|
+
page: safePage,
|
|
802
|
+
limit: safeLimit,
|
|
803
|
+
offset: (safePage - 1) * safeLimit,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
isUniqueViolation(error) {
|
|
807
|
+
const code = error?.code ?? error?.cause?.code;
|
|
808
|
+
const errno = error?.errno ?? error?.cause?.errno;
|
|
809
|
+
return code === "23505" || code === "ER_DUP_ENTRY" || errno === 1062;
|
|
810
|
+
}
|
|
811
|
+
toDuplicateException(error) {
|
|
812
|
+
const detail = error?.detail ?? error?.cause?.detail ?? "";
|
|
813
|
+
const message = error?.cause?.message ?? error?.message ?? "";
|
|
814
|
+
const m = detail.match(/Key \((?<field>.+?)\)=\((?<value>.+?)\)/) ??
|
|
815
|
+
message.match(/Duplicate entry '(?<value>.*)' for key '(?:[^']*\.)?(?<field>[^']+)'/);
|
|
816
|
+
return new crud_exceptions_1.DuplicateEntityException(this.getEntityName(), m?.groups?.field ?? "unique field", m?.groups?.value ?? "");
|
|
817
|
+
}
|
|
818
|
+
static DATA_ERRORS = {
|
|
819
|
+
"23502": "a required value is missing",
|
|
820
|
+
"22001": "a value is too long for its column",
|
|
821
|
+
"23514": "a value violates a column check constraint",
|
|
822
|
+
"22003": "a numeric value is out of range",
|
|
823
|
+
"22P02": "a value does not match its column type",
|
|
824
|
+
"22007": "a date or time value has an invalid format",
|
|
825
|
+
"22008": "a date or time value is out of range",
|
|
826
|
+
"21000": "the same key appears more than once in one upsert",
|
|
827
|
+
};
|
|
828
|
+
isDataException(error) {
|
|
829
|
+
const code = error?.code ?? error?.cause?.code;
|
|
830
|
+
return Object.hasOwn(SqlBaseCrudService.DATA_ERRORS, code ?? "");
|
|
831
|
+
}
|
|
832
|
+
toDataException(error) {
|
|
833
|
+
const code = error?.code ?? error?.cause?.code;
|
|
834
|
+
const message = SqlBaseCrudService.DATA_ERRORS[code] ?? "invalid value";
|
|
835
|
+
const cause = error?.cause ?? error;
|
|
836
|
+
const column = error?.column ??
|
|
837
|
+
cause?.column ??
|
|
838
|
+
error?.column_name ??
|
|
839
|
+
cause?.column_name;
|
|
840
|
+
return new crud_exceptions_1.ValidationFailedException(column ? `${message}: ${column}` : message);
|
|
841
|
+
}
|
|
842
|
+
toClientError(error, operation = "write") {
|
|
843
|
+
if (this.isUniqueViolation(error))
|
|
844
|
+
return this.toDuplicateException(error);
|
|
845
|
+
if (this.isDataException(error))
|
|
846
|
+
return this.toDataException(error);
|
|
847
|
+
if (this.isForeignKeyViolation(error)) {
|
|
848
|
+
return operation === "delete"
|
|
849
|
+
? new crud_exceptions_1.EntityInUseException(this.getEntityName())
|
|
850
|
+
: this.toForeignKeyException(error);
|
|
851
|
+
}
|
|
852
|
+
return error;
|
|
853
|
+
}
|
|
854
|
+
async runRead(query) {
|
|
855
|
+
try {
|
|
856
|
+
return await query;
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
throw this.toClientError(error, "read");
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
isForeignKeyViolation(error) {
|
|
863
|
+
const code = error?.code ?? error?.cause?.code;
|
|
864
|
+
const errno = error?.errno ?? error?.cause?.errno;
|
|
865
|
+
return code === "23503" || errno === 1451 || errno === 1452;
|
|
866
|
+
}
|
|
867
|
+
toForeignKeyException(error) {
|
|
868
|
+
const detail = error?.detail ?? error?.cause?.detail ?? "";
|
|
869
|
+
const m = detail.match(/Key \((?<field>.+?)\)=/);
|
|
870
|
+
return new crud_exceptions_1.ForeignKeyViolationException(this.getEntityName(), m?.groups?.field ?? "reference");
|
|
871
|
+
}
|
|
872
|
+
applyLock(query, options) {
|
|
873
|
+
if (this.config.dialect !== "postgresql")
|
|
874
|
+
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;
|
|
881
|
+
}
|
|
882
|
+
buildSearchCondition(search) {
|
|
883
|
+
if (!search?.term || !search.columns?.length)
|
|
884
|
+
return undefined;
|
|
885
|
+
const term = String(search.term).trim();
|
|
886
|
+
if (!term)
|
|
887
|
+
return undefined;
|
|
888
|
+
const columns = search.columns
|
|
889
|
+
.map((columnName) => this.config.table[columnName])
|
|
890
|
+
.filter(Boolean);
|
|
891
|
+
if (columns.length === 0)
|
|
892
|
+
return undefined;
|
|
893
|
+
if (search.mode === "fullText") {
|
|
894
|
+
if (this.config.dialect !== "postgresql") {
|
|
895
|
+
throw new common_1.BadRequestException("Full-text search only supported in PostgreSQL");
|
|
896
|
+
}
|
|
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}`;
|
|
900
|
+
}
|
|
901
|
+
const pattern = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
|
|
902
|
+
const conditions = columns.map((column) => this.caseInsensitiveLike(column, pattern));
|
|
903
|
+
return conditions.length === 1 ? conditions[0] : (0, drizzle_orm_1.or)(...conditions);
|
|
904
|
+
}
|
|
905
|
+
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);
|
|
909
|
+
}
|
|
910
|
+
parseQuery(query, options = {}) {
|
|
911
|
+
const config = this.config;
|
|
912
|
+
return (0, query_parser_1.parseCrudQuery)(query, config.table, {
|
|
913
|
+
relations: config.relations,
|
|
914
|
+
primaryKey: config.primaryKey,
|
|
915
|
+
filterableColumns: config.filterableColumns,
|
|
916
|
+
sortableColumns: config.sortableColumns,
|
|
917
|
+
selectableColumns: config.selectableColumns,
|
|
918
|
+
searchableColumns: config.searchableColumns,
|
|
919
|
+
loadableRelations: config.loadableRelations,
|
|
920
|
+
...options,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
idKind() {
|
|
924
|
+
const column = this.columnOf(this.config.table, this.config.primaryKey);
|
|
925
|
+
if (column?.dataType === "bigint")
|
|
926
|
+
return "bigint";
|
|
927
|
+
if (column?.dataType === "number") {
|
|
928
|
+
return /Numeric|Decimal|Real|Double|Float/.test(column.columnType ?? "")
|
|
929
|
+
? "string"
|
|
930
|
+
: "int";
|
|
931
|
+
}
|
|
932
|
+
if (/UUID/i.test(column?.columnType ?? ""))
|
|
933
|
+
return "uuid";
|
|
934
|
+
return "string";
|
|
935
|
+
}
|
|
936
|
+
async parseId(raw) {
|
|
937
|
+
const value = String(raw);
|
|
938
|
+
const metadata = { type: "param", data: "id" };
|
|
939
|
+
switch (this.idKind()) {
|
|
940
|
+
case "int":
|
|
941
|
+
return new common_1.ParseIntPipe().transform(value, metadata);
|
|
942
|
+
case "uuid":
|
|
943
|
+
return new common_1.ParseUUIDPipe().transform(value, metadata);
|
|
944
|
+
case "bigint":
|
|
945
|
+
if (!/^-?\d+$/.test(value)) {
|
|
946
|
+
throw new common_1.BadRequestException("Validation failed (numeric string is expected)");
|
|
947
|
+
}
|
|
948
|
+
return BigInt(value);
|
|
949
|
+
default:
|
|
950
|
+
return value;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
async findForGuard(id, options) {
|
|
954
|
+
const version = this.config.versionColumn;
|
|
955
|
+
const row = await this.find(id, {
|
|
956
|
+
...options,
|
|
957
|
+
relations: [],
|
|
958
|
+
counts: undefined,
|
|
959
|
+
select: version
|
|
960
|
+
? [this.config.primaryKey, version]
|
|
961
|
+
: [this.config.primaryKey],
|
|
962
|
+
skipAllowlists: true,
|
|
963
|
+
});
|
|
964
|
+
if (row && version) {
|
|
965
|
+
this.assertVersion(id, row[version], options?.expectedVersion);
|
|
966
|
+
}
|
|
967
|
+
return row;
|
|
968
|
+
}
|
|
969
|
+
assertVersion(id, actual, expected) {
|
|
970
|
+
if (expected === undefined || expected === null)
|
|
971
|
+
return;
|
|
972
|
+
if (Number(actual) !== Number(expected)) {
|
|
973
|
+
throw new crud_exceptions_1.StaleVersionException(this.getEntityName(), id);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
writeWhere(ids, options, table = this.config.table) {
|
|
977
|
+
const pk = table[this.config.primaryKey];
|
|
978
|
+
const conditions = [
|
|
979
|
+
ids.length === 1 ? (0, drizzle_orm_1.eq)(pk, ids[0]) : (0, drizzle_orm_1.inArray)(pk, ids),
|
|
980
|
+
...this.scopeConditions(options, table),
|
|
981
|
+
];
|
|
982
|
+
const version = this.config.versionColumn;
|
|
983
|
+
if (version) {
|
|
984
|
+
if (ids.length === 1 &&
|
|
985
|
+
options?.expectedVersion != null &&
|
|
986
|
+
!options.expectedVersions) {
|
|
987
|
+
conditions.push((0, drizzle_orm_1.eq)(table[version], options.expectedVersion));
|
|
988
|
+
}
|
|
989
|
+
else if (options?.expectedVersions) {
|
|
990
|
+
const checks = ids.flatMap((id) => {
|
|
991
|
+
const expected = options.expectedVersions?.[this.keyOf(id)];
|
|
992
|
+
return expected == null
|
|
993
|
+
? [(0, drizzle_orm_1.eq)(pk, id)]
|
|
994
|
+
: [(0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(pk, id), (0, drizzle_orm_1.eq)(table[version], expected))];
|
|
995
|
+
});
|
|
996
|
+
conditions.push(checks.length === 1 ? checks[0] : (0, drizzle_orm_1.or)(...checks));
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return (0, drizzle_orm_1.and)(...conditions);
|
|
1000
|
+
}
|
|
1001
|
+
withVersionBump(set) {
|
|
1002
|
+
const version = this.config.versionColumn;
|
|
1003
|
+
if (!version)
|
|
1004
|
+
return set;
|
|
1005
|
+
return {
|
|
1006
|
+
...set,
|
|
1007
|
+
[version]: (0, drizzle_orm_1.sql) `${this.config.table[version]} + 1`,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
async find(id, options) {
|
|
1011
|
+
this.checkReadOptions(options);
|
|
1012
|
+
return this.findFirst([(0, drizzle_orm_1.eq)(this.config.table[this.config.primaryKey], id)], options);
|
|
1013
|
+
}
|
|
1014
|
+
async findOne(where, options) {
|
|
1015
|
+
this.checkReadOptions(options);
|
|
1016
|
+
return this.findFirst(this.buildWhereConditionsFromPartial(where), options);
|
|
1017
|
+
}
|
|
1018
|
+
async findFirst(conditions, options) {
|
|
1019
|
+
const { transaction, relations = [], select = [] } = options || {};
|
|
1020
|
+
const db = transaction || this.config.db;
|
|
1021
|
+
const nodes = this.relationTree(relations);
|
|
1022
|
+
const joined = this.planJoins(nodes);
|
|
1023
|
+
const selection = this.parseSelect(select);
|
|
1024
|
+
const baseSelect = this.withCountKeys(this.withRequiredKeys(selection.base, nodes), options?.counts);
|
|
1025
|
+
conditions.push(...this.rowScope(options));
|
|
1026
|
+
let query = this.startSelect(db, baseSelect, joined, false, selection);
|
|
1027
|
+
query = this.applyJoins(query, joined.map((node) => node.name), options)
|
|
1028
|
+
.where((0, drizzle_orm_1.and)(...conditions))
|
|
1029
|
+
.limit(1);
|
|
1030
|
+
query = this.applyLock(query, options);
|
|
1031
|
+
const result = await this.runRead(query);
|
|
1032
|
+
const rows = this.normalizeRelations(result, joined.map((node) => node.name));
|
|
1033
|
+
await Promise.all([
|
|
1034
|
+
this.loadRelations(db, rows, nodes, selection, undefined, options),
|
|
1035
|
+
this.loadCounts(db, rows, options?.counts, options),
|
|
1036
|
+
]);
|
|
1037
|
+
return rows[0] || null;
|
|
1038
|
+
}
|
|
1039
|
+
planList(filters, sortBy, sortOrder, options) {
|
|
1040
|
+
this.checkReadOptions(options);
|
|
1041
|
+
const { transaction, relations = [], select = [] } = options || {};
|
|
1042
|
+
const db = transaction || this.config.db;
|
|
1043
|
+
const nodes = this.relationTree(relations);
|
|
1044
|
+
const joined = this.planJoins(nodes);
|
|
1045
|
+
const selection = this.parseSelect(select);
|
|
1046
|
+
const baseSelect = this.withCountKeys(this.withRequiredKeys(selection.base, nodes), options?.counts);
|
|
1047
|
+
const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
|
|
1048
|
+
const sort = this.resolveSort(sortBy, sortOrder, options);
|
|
1049
|
+
const joinRelations = Array.from(new Set([
|
|
1050
|
+
...joined.map((node) => node.name),
|
|
1051
|
+
...filterRelations,
|
|
1052
|
+
...sort.flatMap((spec) => (spec.relation ? [spec.relation] : [])),
|
|
1053
|
+
]));
|
|
1054
|
+
return {
|
|
1055
|
+
db,
|
|
1056
|
+
nodes,
|
|
1057
|
+
joined,
|
|
1058
|
+
selection,
|
|
1059
|
+
baseSelect,
|
|
1060
|
+
conditions,
|
|
1061
|
+
filterRelations,
|
|
1062
|
+
sort,
|
|
1063
|
+
joinRelations,
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
listConditions(filters, db, options) {
|
|
1067
|
+
const built = this.buildFilterConditions(filters, db, options);
|
|
1068
|
+
const searchCondition = this.buildSearchCondition(options?.search);
|
|
1069
|
+
if (searchCondition)
|
|
1070
|
+
built.conditions.push(searchCondition);
|
|
1071
|
+
built.conditions.push(...this.rowScope(options));
|
|
1072
|
+
return built;
|
|
1073
|
+
}
|
|
1074
|
+
buildCountQuery(db, conditions, filterRelations, options) {
|
|
1075
|
+
let query = db
|
|
1076
|
+
.select({ count: (0, drizzle_orm_1.sql) `count(*)` })
|
|
1077
|
+
.from(this.config.table);
|
|
1078
|
+
query = this.applyJoins(query, filterRelations, options);
|
|
1079
|
+
if (conditions.length > 0)
|
|
1080
|
+
query = query.where((0, drizzle_orm_1.and)(...conditions));
|
|
1081
|
+
return query;
|
|
1082
|
+
}
|
|
1083
|
+
async findAll(filters, pagination, options) {
|
|
1084
|
+
const { sortBy, sortOrder = "asc" } = pagination || {};
|
|
1085
|
+
const withCount = pagination?.withCount !== false;
|
|
1086
|
+
const { page, limit: safeLimit, offset, } = this.resolvePagination(pagination?.page, pagination?.limit);
|
|
1087
|
+
const plan = this.planList(filters, sortBy, sortOrder, options);
|
|
1088
|
+
let dataQuery = this.startSelect(plan.db, plan.baseSelect, plan.joined, plan.joinRelations.length > 0, plan.selection);
|
|
1089
|
+
dataQuery = this.applyJoins(dataQuery, plan.joinRelations, options);
|
|
1090
|
+
if (plan.conditions.length > 0) {
|
|
1091
|
+
dataQuery = dataQuery.where((0, drizzle_orm_1.and)(...plan.conditions));
|
|
1092
|
+
}
|
|
1093
|
+
if (plan.sort.length > 0) {
|
|
1094
|
+
dataQuery = dataQuery.orderBy(...plan.sort.map((spec) => this.orderOf(spec)));
|
|
1095
|
+
}
|
|
1096
|
+
dataQuery = dataQuery
|
|
1097
|
+
.limit(withCount ? safeLimit : safeLimit + 1)
|
|
1098
|
+
.offset(offset);
|
|
1099
|
+
dataQuery = this.applyLock(dataQuery, options);
|
|
1100
|
+
const countQuery = withCount
|
|
1101
|
+
? this.buildCountQuery(plan.db, plan.conditions, plan.filterRelations, options)
|
|
1102
|
+
: Promise.resolve(undefined);
|
|
1103
|
+
const [fetched, totalResult] = await this.runRead(Promise.all([dataQuery, countQuery]));
|
|
1104
|
+
const data = fetched.slice(0, safeLimit);
|
|
1105
|
+
const rows = this.normalizeRelations(data, plan.joined.map((node) => node.name));
|
|
1106
|
+
await Promise.all([
|
|
1107
|
+
this.loadRelations(plan.db, rows, plan.nodes, plan.selection, undefined, options),
|
|
1108
|
+
this.loadCounts(plan.db, rows, options?.counts, options),
|
|
1109
|
+
]);
|
|
1110
|
+
if (!withCount) {
|
|
1111
|
+
return {
|
|
1112
|
+
data: rows,
|
|
1113
|
+
total: null,
|
|
1114
|
+
page,
|
|
1115
|
+
limit: safeLimit,
|
|
1116
|
+
hasMore: fetched.length > safeLimit,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
return {
|
|
1120
|
+
data: rows,
|
|
1121
|
+
total: parseInt(totalResult[0]?.count?.toString() || "0", 10),
|
|
1122
|
+
page,
|
|
1123
|
+
limit: safeLimit,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
async findByCursor(filters, pagination, options) {
|
|
1127
|
+
const { sortBy, sortOrder = "asc", count = false } = pagination || {};
|
|
1128
|
+
const { limit } = this.resolvePagination(1, pagination?.limit);
|
|
1129
|
+
const plan = this.planList(filters, sortBy, sortOrder, options);
|
|
1130
|
+
const signature = plan.sort
|
|
1131
|
+
.map((spec) => `${spec.key}:${spec.order}`)
|
|
1132
|
+
.join(",");
|
|
1133
|
+
const cursor = pagination?.cursor ?? pagination?.after;
|
|
1134
|
+
const conditions = [...plan.conditions];
|
|
1135
|
+
if (cursor !== undefined && cursor !== null && cursor !== "") {
|
|
1136
|
+
if (typeof cursor !== "string") {
|
|
1137
|
+
throw new common_1.BadRequestException("Invalid cursor");
|
|
1138
|
+
}
|
|
1139
|
+
const values = (0, cursor_1.decodeCursor)(cursor, signature, plan.sort.length);
|
|
1140
|
+
conditions.push(this.keysetCondition(plan.sort, values));
|
|
1141
|
+
}
|
|
1142
|
+
const fields = this.buildSelection(plan.baseSelect, plan.joined, plan.selection);
|
|
1143
|
+
plan.sort.forEach((spec, i) => {
|
|
1144
|
+
fields[`${CURSOR_KEY}${i}`] = this.cursorField(spec.column);
|
|
1145
|
+
});
|
|
1146
|
+
let dataQuery = plan.db.select(fields).from(this.config.table);
|
|
1147
|
+
dataQuery = this.applyJoins(dataQuery, plan.joinRelations, options);
|
|
1148
|
+
if (conditions.length > 0)
|
|
1149
|
+
dataQuery = dataQuery.where((0, drizzle_orm_1.and)(...conditions));
|
|
1150
|
+
dataQuery = dataQuery
|
|
1151
|
+
.orderBy(...plan.sort.map((spec) => this.orderOf(spec)))
|
|
1152
|
+
.limit(limit + 1);
|
|
1153
|
+
dataQuery = this.applyLock(dataQuery, options);
|
|
1154
|
+
const [data, totalResult] = await this.runRead(Promise.all([
|
|
1155
|
+
dataQuery,
|
|
1156
|
+
count
|
|
1157
|
+
? this.buildCountQuery(plan.db, plan.conditions, plan.filterRelations, options)
|
|
1158
|
+
: Promise.resolve(undefined),
|
|
1159
|
+
]));
|
|
1160
|
+
const page = data.slice(0, limit);
|
|
1161
|
+
const last = page[page.length - 1];
|
|
1162
|
+
const nextCursor = data.length > limit && last
|
|
1163
|
+
? (0, cursor_1.encodeCursor)(plan.sort.map((_, i) => last[`${CURSOR_KEY}${i}`]), signature)
|
|
1164
|
+
: null;
|
|
1165
|
+
for (const row of page) {
|
|
1166
|
+
plan.sort.forEach((_, i) => {
|
|
1167
|
+
delete row[`${CURSOR_KEY}${i}`];
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
const rows = this.normalizeRelations(page, plan.joined.map((node) => node.name));
|
|
1171
|
+
await Promise.all([
|
|
1172
|
+
this.loadRelations(plan.db, rows, plan.nodes, plan.selection, undefined, options),
|
|
1173
|
+
this.loadCounts(plan.db, rows, options?.counts, options),
|
|
1174
|
+
]);
|
|
1175
|
+
const out = { data: rows, limit, nextCursor };
|
|
1176
|
+
if (count) {
|
|
1177
|
+
out.total = parseInt(totalResult?.[0]?.count?.toString() || "0", 10);
|
|
1178
|
+
}
|
|
1179
|
+
return out;
|
|
1180
|
+
}
|
|
1181
|
+
cursorField(column) {
|
|
1182
|
+
if (column?.dataType !== "date")
|
|
1183
|
+
return column;
|
|
1184
|
+
return this.config.dialect === "mysql"
|
|
1185
|
+
? (0, drizzle_orm_1.sql) `cast(${column} as char)`
|
|
1186
|
+
: (0, drizzle_orm_1.sql) `cast(${column} as text)`;
|
|
1187
|
+
}
|
|
1188
|
+
compareCursor(column, op, value) {
|
|
1189
|
+
if (column?.dataType === "date") {
|
|
1190
|
+
return (0, drizzle_orm_1.sql) `${column} ${drizzle_orm_1.sql.raw(op)} ${value}`;
|
|
1191
|
+
}
|
|
1192
|
+
if (op === ">")
|
|
1193
|
+
return (0, drizzle_orm_1.gt)(column, value);
|
|
1194
|
+
if (op === "<")
|
|
1195
|
+
return (0, drizzle_orm_1.lt)(column, value);
|
|
1196
|
+
return (0, drizzle_orm_1.eq)(column, value);
|
|
1197
|
+
}
|
|
1198
|
+
keysetCondition(specs, values) {
|
|
1199
|
+
const branches = [];
|
|
1200
|
+
specs.forEach((spec, i) => {
|
|
1201
|
+
const after = this.afterCursor(spec, values[i]);
|
|
1202
|
+
if (!after)
|
|
1203
|
+
return;
|
|
1204
|
+
const ties = specs
|
|
1205
|
+
.slice(0, i)
|
|
1206
|
+
.map((previous, j) => values[j] === null
|
|
1207
|
+
? (0, drizzle_orm_1.isNull)(previous.column)
|
|
1208
|
+
: this.compareCursor(previous.column, "=", values[j]));
|
|
1209
|
+
branches.push(ties.length > 0 ? (0, drizzle_orm_1.and)(...ties, after) : after);
|
|
1210
|
+
});
|
|
1211
|
+
return branches.length > 0 ? (0, drizzle_orm_1.or)(...branches) : (0, drizzle_orm_1.sql) `false`;
|
|
1212
|
+
}
|
|
1213
|
+
afterCursor(spec, value) {
|
|
1214
|
+
const postgres = this.config.dialect !== "mysql";
|
|
1215
|
+
const nullsFirst = postgres ? spec.order === "desc" : spec.order === "asc";
|
|
1216
|
+
if (value === null) {
|
|
1217
|
+
return nullsFirst ? (0, drizzle_orm_1.isNotNull)(spec.column) : undefined;
|
|
1218
|
+
}
|
|
1219
|
+
const past = this.compareCursor(spec.column, spec.order === "asc" ? ">" : "<", value);
|
|
1220
|
+
return nullsFirst ? past : (0, drizzle_orm_1.or)(past, (0, drizzle_orm_1.isNull)(spec.column));
|
|
1221
|
+
}
|
|
1222
|
+
async emitEvent(phase, operation, bulk, payload, options) {
|
|
1223
|
+
const bus = this.config.events;
|
|
1224
|
+
if (!bus || options?.skipEvents)
|
|
1225
|
+
return;
|
|
1226
|
+
await bus.emit({
|
|
1227
|
+
entity: this.getEntityName(),
|
|
1228
|
+
operation,
|
|
1229
|
+
phase,
|
|
1230
|
+
bulk,
|
|
1231
|
+
ids: payload.ids ?? [],
|
|
1232
|
+
input: payload.input,
|
|
1233
|
+
rows: payload.rows ?? [],
|
|
1234
|
+
transaction: payload.transaction,
|
|
1235
|
+
context: options?.context ?? crud_context_1.CrudContext.current(),
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
idsOf(rows) {
|
|
1239
|
+
return rows.map((row) => row?.[this.config.primaryKey]);
|
|
1240
|
+
}
|
|
1241
|
+
async loadWriteRelations(db, rows, options) {
|
|
1242
|
+
const nodes = this.relationTree(options?.relations ?? []);
|
|
1243
|
+
if (nodes.length === 0 && this.countNames(options?.counts).length === 0) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const hosts = rows.filter(Boolean);
|
|
1247
|
+
await Promise.all([
|
|
1248
|
+
this.loadRelations(db, hosts, nodes, undefined, undefined, options),
|
|
1249
|
+
this.loadCounts(db, hosts, options?.counts, options),
|
|
1250
|
+
]);
|
|
1251
|
+
}
|
|
1252
|
+
expectedVersionOf(data, options) {
|
|
1253
|
+
const column = this.config.versionColumn;
|
|
1254
|
+
if (!column)
|
|
1255
|
+
return undefined;
|
|
1256
|
+
const raw = options?.expectedVersion ??
|
|
1257
|
+
((0, merge_config_1.isPlainObject)(data) ? data[column] : undefined);
|
|
1258
|
+
if (raw === undefined || raw === null || raw === "")
|
|
1259
|
+
return undefined;
|
|
1260
|
+
const version = Number(raw);
|
|
1261
|
+
if (!Number.isInteger(version)) {
|
|
1262
|
+
throw new crud_exceptions_1.ValidationFailedException(`"${column}" must be an integer version`);
|
|
1263
|
+
}
|
|
1264
|
+
return version;
|
|
1265
|
+
}
|
|
1266
|
+
async prepareCreate(data, options) {
|
|
1267
|
+
const hooks = options?.hooks ?? {};
|
|
1268
|
+
await this.validateCreate(data);
|
|
1269
|
+
const processedData = hooks.skipBefore
|
|
1270
|
+
? data
|
|
1271
|
+
: await this.beforeCreate(data);
|
|
1272
|
+
const mapped = this.mapCreateDtoToEntity(processedData);
|
|
1273
|
+
const version = this.config.versionColumn;
|
|
1274
|
+
if (version)
|
|
1275
|
+
delete mapped[version];
|
|
1276
|
+
const entityData = this.filterWritable(mapped, "create", options);
|
|
1277
|
+
const now = new Date();
|
|
1278
|
+
if (this.config.timestamps?.createdAt)
|
|
1279
|
+
entityData[this.config.timestamps.createdAt] = now;
|
|
1280
|
+
if (this.config.timestamps?.updatedAt)
|
|
1281
|
+
entityData[this.config.timestamps.updatedAt] = now;
|
|
1282
|
+
if (version)
|
|
1283
|
+
entityData[version] = 1;
|
|
1284
|
+
this.applyScopeToRow(entityData, options);
|
|
1285
|
+
return entityData;
|
|
1286
|
+
}
|
|
1287
|
+
async insertOne(db, entityData, options) {
|
|
1288
|
+
const insertQuery = db.insert(this.config.table).values(entityData);
|
|
1289
|
+
if (this.config.sql?.useReturning) {
|
|
1290
|
+
const result = await insertQuery.returning();
|
|
1291
|
+
return result[0];
|
|
1292
|
+
}
|
|
1293
|
+
const result = await insertQuery;
|
|
1294
|
+
const lastInsertId = entityData[this.config.primaryKey] ?? result?.[0]?.insertId;
|
|
1295
|
+
const createdEntity = await this.find(lastInsertId, {
|
|
1296
|
+
...options,
|
|
1297
|
+
relations: [],
|
|
1298
|
+
counts: undefined,
|
|
1299
|
+
transaction: db,
|
|
1300
|
+
});
|
|
1301
|
+
if (!createdEntity)
|
|
1302
|
+
throw new Error("Failed to create entity");
|
|
1303
|
+
return createdEntity;
|
|
1304
|
+
}
|
|
1305
|
+
async create(data, options) {
|
|
1306
|
+
const { transaction, hooks = {} } = options || {};
|
|
1307
|
+
this.checkReadOptions(options);
|
|
1308
|
+
const entityData = await this.prepareCreate(data, options);
|
|
1309
|
+
const nested = this.takeNested(entityData, options);
|
|
1310
|
+
const db = transaction || this.config.db;
|
|
1311
|
+
await this.emitEvent("before", "create", false, { input: data, transaction }, options);
|
|
1312
|
+
try {
|
|
1313
|
+
const createdEntity = nested.length === 0
|
|
1314
|
+
? await this.insertOne(db, entityData, options)
|
|
1315
|
+
: await this.executeSqlTransaction((tx) => this.insertNested(tx, entityData, nested, {
|
|
1316
|
+
...options,
|
|
1317
|
+
transaction: tx,
|
|
1318
|
+
}), transaction);
|
|
1319
|
+
if (!hooks.skipAfter)
|
|
1320
|
+
await this.afterCreate(createdEntity);
|
|
1321
|
+
await this.emitEvent("after", "create", false, {
|
|
1322
|
+
ids: this.idsOf([createdEntity]),
|
|
1323
|
+
input: data,
|
|
1324
|
+
rows: [createdEntity],
|
|
1325
|
+
transaction,
|
|
1326
|
+
}, options);
|
|
1327
|
+
await this.loadWriteRelations(db, [createdEntity], options);
|
|
1328
|
+
return createdEntity;
|
|
1329
|
+
}
|
|
1330
|
+
catch (error) {
|
|
1331
|
+
throw this.toClientError(error, "write");
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
takeNested(entity, options) {
|
|
1335
|
+
const allowed = this.config.writableRelations;
|
|
1336
|
+
if (!allowed)
|
|
1337
|
+
return [];
|
|
1338
|
+
const relations = this.config.relations || {};
|
|
1339
|
+
const out = [];
|
|
1340
|
+
for (const [key, value] of Object.entries(entity)) {
|
|
1341
|
+
if (value === undefined ||
|
|
1342
|
+
this.columnOf(this.config.table, key) ||
|
|
1343
|
+
!Object.hasOwn(relations, key)) {
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
if (!allowed.includes(key) && !options?.skipAllowlists) {
|
|
1347
|
+
throw new crud_exceptions_1.ValidationFailedException(`the relation "${key}" is not writable`);
|
|
1348
|
+
}
|
|
1349
|
+
delete entity[key];
|
|
1350
|
+
if (value !== null)
|
|
1351
|
+
out.push({ name: key, relation: relations[key], value });
|
|
1352
|
+
}
|
|
1353
|
+
return out;
|
|
1354
|
+
}
|
|
1355
|
+
async insertNested(tx, row, nested, options) {
|
|
1356
|
+
if (!this.config.sql?.useReturning) {
|
|
1357
|
+
throw new crud_exceptions_1.ValidationFailedException("nested writes need RETURNING, which this dialect does not have");
|
|
1358
|
+
}
|
|
1359
|
+
for (const { name, relation, value } of nested) {
|
|
1360
|
+
if (this.isCollection(relation))
|
|
1361
|
+
continue;
|
|
1362
|
+
if (!(0, merge_config_1.isPlainObject)(value)) {
|
|
1363
|
+
throw new crud_exceptions_1.ValidationFailedException(`the relation "${name}" takes an object to create`);
|
|
1364
|
+
}
|
|
1365
|
+
const [parent] = await tx
|
|
1366
|
+
.insert(relation.table)
|
|
1367
|
+
.values(this.relatedRow(relation.table, value, options))
|
|
1368
|
+
.returning();
|
|
1369
|
+
row[relation.localKey] = parent[relation.references ?? "id"];
|
|
1370
|
+
}
|
|
1371
|
+
const created = await this.insertOne(tx, row, options);
|
|
1372
|
+
for (const { name, relation, value } of nested) {
|
|
1373
|
+
if (!this.isCollection(relation))
|
|
1374
|
+
continue;
|
|
1375
|
+
if (!Array.isArray(value)) {
|
|
1376
|
+
throw new crud_exceptions_1.ValidationFailedException(`the relation "${name}" takes an array`);
|
|
1377
|
+
}
|
|
1378
|
+
const parentValue = created[this.parentKeyOf(relation)];
|
|
1379
|
+
if (this.relationType(relation) === "hasMany") {
|
|
1380
|
+
const children = value.map((item) => {
|
|
1381
|
+
if (!(0, merge_config_1.isPlainObject)(item)) {
|
|
1382
|
+
throw new crud_exceptions_1.ValidationFailedException(`each "${name}" entry must be an object`);
|
|
1383
|
+
}
|
|
1384
|
+
return {
|
|
1385
|
+
...this.relatedRow(relation.table, item, options),
|
|
1386
|
+
[relation.foreignKey]: parentValue,
|
|
1387
|
+
};
|
|
1388
|
+
});
|
|
1389
|
+
if (children.length > 0) {
|
|
1390
|
+
await tx.insert(relation.table).values(children);
|
|
1391
|
+
}
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
const ids = value.filter((item) => !(0, merge_config_1.isPlainObject)(item));
|
|
1395
|
+
const fresh = value.filter((item) => (0, merge_config_1.isPlainObject)(item));
|
|
1396
|
+
if (fresh.length > 0) {
|
|
1397
|
+
const inserted = await tx
|
|
1398
|
+
.insert(relation.table)
|
|
1399
|
+
.values(fresh.map((item) => this.relatedRow(relation.table, item, options)))
|
|
1400
|
+
.returning();
|
|
1401
|
+
ids.push(...inserted.map((r) => r[relation.references ?? "id"]));
|
|
1402
|
+
}
|
|
1403
|
+
await this.linkTargets(tx, relation, parentValue, ids, options);
|
|
1404
|
+
}
|
|
1405
|
+
return created;
|
|
1406
|
+
}
|
|
1407
|
+
relatedRow(table, value, options) {
|
|
1408
|
+
const out = {};
|
|
1409
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1410
|
+
const column = this.columnOf(table, key);
|
|
1411
|
+
if (item === undefined ||
|
|
1412
|
+
!column ||
|
|
1413
|
+
(column.primary && column.hasDefault)) {
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
out[key] = item;
|
|
1417
|
+
}
|
|
1418
|
+
const softDelete = this.config.softDelete;
|
|
1419
|
+
if (softDelete?.enabled)
|
|
1420
|
+
delete out[softDelete.column];
|
|
1421
|
+
const now = new Date();
|
|
1422
|
+
for (const key of [
|
|
1423
|
+
this.config.timestamps?.createdAt,
|
|
1424
|
+
this.config.timestamps?.updatedAt,
|
|
1425
|
+
]) {
|
|
1426
|
+
if (key && this.columnOf(table, key))
|
|
1427
|
+
out[key] = now;
|
|
1428
|
+
}
|
|
1429
|
+
for (const [key, item] of Object.entries(this.resolveScope(options) ?? {})) {
|
|
1430
|
+
if (this.columnOf(table, key))
|
|
1431
|
+
out[key] = item;
|
|
1432
|
+
}
|
|
1433
|
+
return out;
|
|
1434
|
+
}
|
|
1435
|
+
async linkTargets(tx, relation, parentValue, ids, options) {
|
|
1436
|
+
const wanted = this.uniqueValues(ids);
|
|
1437
|
+
if (wanted.length === 0)
|
|
1438
|
+
return;
|
|
1439
|
+
const target = relation.table;
|
|
1440
|
+
const referenced = target[relation.references ?? "id"];
|
|
1441
|
+
const through = relation.through;
|
|
1442
|
+
const local = through[relation.throughLocalKey];
|
|
1443
|
+
const foreign = through[relation.throughForeignKey];
|
|
1444
|
+
const conditions = [(0, drizzle_orm_1.inArray)(referenced, wanted)];
|
|
1445
|
+
conditions.push(...this.relatedRowScope(target, options));
|
|
1446
|
+
const found = await tx
|
|
1447
|
+
.select({ id: referenced })
|
|
1448
|
+
.from(target)
|
|
1449
|
+
.where((0, drizzle_orm_1.and)(...conditions));
|
|
1450
|
+
const present = new Set(found.map((row) => this.valueKey(row.id)));
|
|
1451
|
+
const missing = wanted.filter((id) => !present.has(this.valueKey(id)));
|
|
1452
|
+
if (missing.length > 0) {
|
|
1453
|
+
const name = this.tableName(target);
|
|
1454
|
+
throw new crud_exceptions_1.BulkOperationException("Unknown related ids", missing.map((id) => ({
|
|
1455
|
+
id,
|
|
1456
|
+
error: new crud_exceptions_1.EntityNotFoundException(name, id),
|
|
1457
|
+
})));
|
|
1458
|
+
}
|
|
1459
|
+
const linked = await tx
|
|
1460
|
+
.select({ id: foreign })
|
|
1461
|
+
.from(through)
|
|
1462
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(local, parentValue), (0, drizzle_orm_1.inArray)(foreign, wanted)));
|
|
1463
|
+
const already = new Set(linked.map((row) => this.valueKey(row.id)));
|
|
1464
|
+
const add = wanted.filter((id) => !already.has(this.valueKey(id)));
|
|
1465
|
+
if (add.length > 0) {
|
|
1466
|
+
await tx.insert(through).values(add.map((id) => ({
|
|
1467
|
+
[relation.throughLocalKey]: parentValue,
|
|
1468
|
+
[relation.throughForeignKey]: id,
|
|
1469
|
+
})));
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
async attach(id, relation, relatedIds, options) {
|
|
1473
|
+
return this.changeLinks("attach", id, relation, relatedIds, options);
|
|
1474
|
+
}
|
|
1475
|
+
async detach(id, relation, relatedIds, options) {
|
|
1476
|
+
return this.changeLinks("detach", id, relation, relatedIds, options);
|
|
1477
|
+
}
|
|
1478
|
+
async sync(id, relation, relatedIds, options) {
|
|
1479
|
+
return this.changeLinks("sync", id, relation, relatedIds, options);
|
|
1480
|
+
}
|
|
1481
|
+
async changeLinks(operation, id, name, relatedIds, options) {
|
|
1482
|
+
const relations = this.config.relations || {};
|
|
1483
|
+
const relation = Object.hasOwn(relations, name)
|
|
1484
|
+
? relations[name]
|
|
1485
|
+
: undefined;
|
|
1486
|
+
if (!relation || this.relationType(relation) !== "manyToMany") {
|
|
1487
|
+
throw new common_1.BadRequestException(`"${name}" is not a many-to-many relation of ${this.getEntityName()}`);
|
|
1488
|
+
}
|
|
1489
|
+
const allowed = this.config.writableRelations;
|
|
1490
|
+
if (allowed && !allowed.includes(name) && !options?.skipAllowlists) {
|
|
1491
|
+
throw new crud_exceptions_1.ValidationFailedException(`the relation "${name}" is not writable`);
|
|
1492
|
+
}
|
|
1493
|
+
if (relatedIds !== undefined &&
|
|
1494
|
+
(!Array.isArray(relatedIds) ||
|
|
1495
|
+
relatedIds.some((item) => item === null || typeof item === "object"))) {
|
|
1496
|
+
throw new common_1.BadRequestException(`${operation} takes an array of ids`);
|
|
1497
|
+
}
|
|
1498
|
+
if (operation !== "detach" && relatedIds === undefined) {
|
|
1499
|
+
throw new common_1.BadRequestException(`${operation} takes an array of ids`);
|
|
1500
|
+
}
|
|
1501
|
+
this.checkReadOptions(options);
|
|
1502
|
+
const ids = this.uniqueValues(relatedIds ?? []);
|
|
1503
|
+
const parentKey = this.parentKeyOf(relation);
|
|
1504
|
+
return this.executeSqlTransaction(async (tx) => {
|
|
1505
|
+
const opts = { ...options, transaction: tx };
|
|
1506
|
+
const parent = await this.find(id, {
|
|
1507
|
+
...opts,
|
|
1508
|
+
relations: [],
|
|
1509
|
+
counts: undefined,
|
|
1510
|
+
select: [this.config.primaryKey, parentKey],
|
|
1511
|
+
skipAllowlists: true,
|
|
1512
|
+
});
|
|
1513
|
+
if (!parent)
|
|
1514
|
+
throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
|
|
1515
|
+
await this.emitEvent("before", operation, false, { ids: [id], input: relatedIds, transaction: tx }, options);
|
|
1516
|
+
const parentValue = parent[parentKey];
|
|
1517
|
+
const through = relation.through;
|
|
1518
|
+
const local = through[relation.throughLocalKey];
|
|
1519
|
+
const foreign = through[relation.throughForeignKey];
|
|
1520
|
+
if (operation === "detach") {
|
|
1521
|
+
if (relatedIds === undefined) {
|
|
1522
|
+
await tx.delete(through).where((0, drizzle_orm_1.eq)(local, parentValue));
|
|
1523
|
+
}
|
|
1524
|
+
else if (ids.length > 0) {
|
|
1525
|
+
await tx
|
|
1526
|
+
.delete(through)
|
|
1527
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(local, parentValue), (0, drizzle_orm_1.inArray)(foreign, ids)));
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
else {
|
|
1531
|
+
await this.linkTargets(tx, relation, parentValue, ids, opts);
|
|
1532
|
+
if (operation === "sync") {
|
|
1533
|
+
await tx
|
|
1534
|
+
.delete(through)
|
|
1535
|
+
.where(ids.length > 0
|
|
1536
|
+
? (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(local, parentValue), (0, drizzle_orm_1.notInArray)(foreign, ids))
|
|
1537
|
+
: (0, drizzle_orm_1.eq)(local, parentValue));
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
const row = (await this.find(id, {
|
|
1541
|
+
...opts,
|
|
1542
|
+
relations: Array.from(new Set([name, ...(options?.relations ?? [])])),
|
|
1543
|
+
skipAllowlists: true,
|
|
1544
|
+
}));
|
|
1545
|
+
await this.emitEvent("after", operation, false, { ids: [id], input: relatedIds, rows: [row], transaction: tx }, options);
|
|
1546
|
+
return row;
|
|
1547
|
+
}, options?.transaction);
|
|
1548
|
+
}
|
|
1549
|
+
valueKey(value) {
|
|
1550
|
+
return String(value).toLowerCase();
|
|
1551
|
+
}
|
|
1552
|
+
uniqueValues(values) {
|
|
1553
|
+
const seen = new Set();
|
|
1554
|
+
return values.filter((value) => {
|
|
1555
|
+
const key = this.valueKey(value);
|
|
1556
|
+
if (seen.has(key))
|
|
1557
|
+
return false;
|
|
1558
|
+
seen.add(key);
|
|
1559
|
+
return true;
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
tableName(table) {
|
|
1563
|
+
try {
|
|
1564
|
+
return (0, drizzle_orm_1.getTableName)(table) || "Entity";
|
|
1565
|
+
}
|
|
1566
|
+
catch {
|
|
1567
|
+
return "Entity";
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
prepareUpdate(data, options) {
|
|
1571
|
+
const mapped = this.mapUpdateDtoToEntity(data);
|
|
1572
|
+
if (this.config.versionColumn)
|
|
1573
|
+
delete mapped[this.config.versionColumn];
|
|
1574
|
+
for (const key of Object.keys(this.resolveScope(options) ?? {})) {
|
|
1575
|
+
delete mapped[key];
|
|
1576
|
+
}
|
|
1577
|
+
const entityData = this.filterWritable(mapped, "update", options);
|
|
1578
|
+
const settable = Object.keys(entityData).filter((key) => entityData[key] !== undefined &&
|
|
1579
|
+
!!this.columnOf(this.config.table, key));
|
|
1580
|
+
if (settable.length === 0) {
|
|
1581
|
+
throw new crud_exceptions_1.ValidationFailedException("there are no columns to update");
|
|
1582
|
+
}
|
|
1583
|
+
return entityData;
|
|
1584
|
+
}
|
|
1585
|
+
async update(id, data, options) {
|
|
1586
|
+
const { transaction, hooks = {} } = options || {};
|
|
1587
|
+
this.checkReadOptions(options);
|
|
1588
|
+
const expected = this.expectedVersionOf(data, options);
|
|
1589
|
+
const opts = { ...options, expectedVersion: expected };
|
|
1590
|
+
const existing = await this.findForGuard(id, opts);
|
|
1591
|
+
if (!existing)
|
|
1592
|
+
throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
|
|
1593
|
+
await this.validateUpdate(id, data);
|
|
1594
|
+
const processedData = hooks.skipBefore
|
|
1595
|
+
? data
|
|
1596
|
+
: await this.beforeUpdate(id, data);
|
|
1597
|
+
const entityData = this.prepareUpdate(processedData, opts);
|
|
1598
|
+
if (this.config.timestamps?.updatedAt) {
|
|
1599
|
+
entityData[this.config.timestamps.updatedAt] = new Date();
|
|
1600
|
+
}
|
|
1601
|
+
const db = transaction || this.config.db;
|
|
1602
|
+
await this.emitEvent("before", "update", false, { ids: [id], input: data, transaction }, options);
|
|
1603
|
+
try {
|
|
1604
|
+
const [updatedEntity] = await this.updateWhereIn(db, this.withVersionBump(entityData), [id], opts);
|
|
1605
|
+
if (!updatedEntity) {
|
|
1606
|
+
if (expected !== undefined) {
|
|
1607
|
+
throw new crud_exceptions_1.StaleVersionException(this.getEntityName(), id);
|
|
1608
|
+
}
|
|
1609
|
+
throw new Error("Failed to update entity");
|
|
1610
|
+
}
|
|
1611
|
+
if (!hooks.skipAfter)
|
|
1612
|
+
await this.afterUpdate(updatedEntity);
|
|
1613
|
+
await this.emitEvent("after", "update", false, { ids: [id], input: data, rows: [updatedEntity], transaction }, options);
|
|
1614
|
+
await this.loadWriteRelations(db, [updatedEntity], options);
|
|
1615
|
+
return updatedEntity;
|
|
1616
|
+
}
|
|
1617
|
+
catch (error) {
|
|
1618
|
+
throw this.toClientError(error, "write");
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
async updateWhereIn(db, set, ids, options) {
|
|
1622
|
+
const query = db
|
|
1623
|
+
.update(this.config.table)
|
|
1624
|
+
.set(set)
|
|
1625
|
+
.where(this.writeWhere(ids, options));
|
|
1626
|
+
if (this.config.sql?.useReturning) {
|
|
1627
|
+
return this.inIdOrder(await query.returning(), ids);
|
|
1628
|
+
}
|
|
1629
|
+
const result = await query;
|
|
1630
|
+
if (!this.wasAffected(result))
|
|
1631
|
+
return [];
|
|
1632
|
+
return this.fetchByIds(db, ids, options);
|
|
1633
|
+
}
|
|
1634
|
+
async fetchByIds(db, ids, options) {
|
|
1635
|
+
const pk = this.config.table[this.config.primaryKey];
|
|
1636
|
+
const rows = await db
|
|
1637
|
+
.select()
|
|
1638
|
+
.from(this.config.table)
|
|
1639
|
+
.where((0, drizzle_orm_1.and)(ids.length === 1 ? (0, drizzle_orm_1.eq)(pk, ids[0]) : (0, drizzle_orm_1.inArray)(pk, ids), ...this.scopeConditions(options)));
|
|
1640
|
+
return this.inIdOrder(rows, ids);
|
|
1641
|
+
}
|
|
1642
|
+
inIdOrder(rows, ids) {
|
|
1643
|
+
const byKey = new Map();
|
|
1644
|
+
for (const row of rows ?? []) {
|
|
1645
|
+
byKey.set(this.keyOf(row?.[this.config.primaryKey]), row);
|
|
1646
|
+
}
|
|
1647
|
+
return ids
|
|
1648
|
+
.map((id) => byKey.get(this.keyOf(id)))
|
|
1649
|
+
.filter((row) => !!row);
|
|
1650
|
+
}
|
|
1651
|
+
async softDelete(id, options) {
|
|
1652
|
+
if (!this.config.softDelete?.enabled) {
|
|
1653
|
+
throw new common_1.BadRequestException("Soft delete is not enabled for this entity");
|
|
1654
|
+
}
|
|
1655
|
+
const { transaction, hooks = {} } = options || {};
|
|
1656
|
+
const existing = await this.findForGuard(id, {
|
|
1657
|
+
...options,
|
|
1658
|
+
withDeleted: false,
|
|
1659
|
+
onlyDeleted: false,
|
|
1660
|
+
});
|
|
1661
|
+
if (!existing)
|
|
1662
|
+
throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
|
|
1663
|
+
if (!hooks.skipBefore)
|
|
1664
|
+
await this.beforeSoftDelete(id);
|
|
1665
|
+
await this.emitEvent("before", "softDelete", false, { ids: [id], transaction }, options);
|
|
1666
|
+
const db = transaction || this.config.db;
|
|
1667
|
+
let updateQuery = db
|
|
1668
|
+
.update(this.config.table)
|
|
1669
|
+
.set(this.softDeleteSet(new Date()))
|
|
1670
|
+
.where(this.writeWhere([id], options));
|
|
1671
|
+
if (this.config.sql?.useReturning)
|
|
1672
|
+
updateQuery = updateQuery.returning();
|
|
1673
|
+
const result = await this.runRead(updateQuery);
|
|
1674
|
+
const success = this.wasAffected(result);
|
|
1675
|
+
if (!success && options?.expectedVersion != null) {
|
|
1676
|
+
throw new crud_exceptions_1.StaleVersionException(this.getEntityName(), id);
|
|
1677
|
+
}
|
|
1678
|
+
if (success && !hooks.skipAfter)
|
|
1679
|
+
await this.afterSoftDelete(id);
|
|
1680
|
+
if (success) {
|
|
1681
|
+
await this.emitEvent("after", "softDelete", false, { ids: [id], transaction }, options);
|
|
1682
|
+
}
|
|
1683
|
+
return success;
|
|
1684
|
+
}
|
|
1685
|
+
softDeleteSet(deletedAt) {
|
|
1686
|
+
return this.withVersionBump({
|
|
1687
|
+
[this.config.softDelete.column]: deletedAt,
|
|
1688
|
+
...(this.config.timestamps?.updatedAt && {
|
|
1689
|
+
[this.config.timestamps.updatedAt]: new Date(),
|
|
1690
|
+
}),
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
async restore(id, options) {
|
|
1694
|
+
if (!this.config.softDelete?.enabled) {
|
|
1695
|
+
throw new common_1.BadRequestException("Soft delete is not enabled for this entity");
|
|
1696
|
+
}
|
|
1697
|
+
const { transaction, hooks = {} } = options || {};
|
|
1698
|
+
const existing = await this.findForGuard(id, {
|
|
1699
|
+
...options,
|
|
1700
|
+
withDeleted: false,
|
|
1701
|
+
onlyDeleted: true,
|
|
1702
|
+
});
|
|
1703
|
+
if (!existing)
|
|
1704
|
+
throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
|
|
1705
|
+
if (!hooks.skipBefore)
|
|
1706
|
+
await this.beforeRestore(id);
|
|
1707
|
+
await this.emitEvent("before", "restore", false, { ids: [id], transaction }, options);
|
|
1708
|
+
const db = transaction || this.config.db;
|
|
1709
|
+
const [restoredEntity] = await this.runRead(this.updateWhereIn(db, this.softDeleteSet(null), [id], options));
|
|
1710
|
+
if (!restoredEntity) {
|
|
1711
|
+
if (options?.expectedVersion != null && this.config.versionColumn) {
|
|
1712
|
+
throw new crud_exceptions_1.StaleVersionException(this.getEntityName(), id);
|
|
1713
|
+
}
|
|
1714
|
+
throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
|
|
1715
|
+
}
|
|
1716
|
+
if (!hooks.skipAfter)
|
|
1717
|
+
await this.afterRestore(restoredEntity);
|
|
1718
|
+
await this.emitEvent("after", "restore", false, { ids: [id], rows: [restoredEntity], transaction }, options);
|
|
1719
|
+
await this.loadWriteRelations(db, [restoredEntity], options);
|
|
1720
|
+
return restoredEntity;
|
|
1721
|
+
}
|
|
1722
|
+
async delete(id, options) {
|
|
1723
|
+
const { transaction, hooks = {} } = options || {};
|
|
1724
|
+
const existing = await this.findForGuard(id, options);
|
|
1725
|
+
if (!existing)
|
|
1726
|
+
throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id);
|
|
1727
|
+
if (!hooks.skipBefore)
|
|
1728
|
+
await this.beforeDelete(id);
|
|
1729
|
+
await this.emitEvent("before", "delete", false, { ids: [id], transaction }, options);
|
|
1730
|
+
const db = transaction || this.config.db;
|
|
1731
|
+
const deleteQuery = db
|
|
1732
|
+
.delete(this.config.table)
|
|
1733
|
+
.where(this.writeWhere([id], options));
|
|
1734
|
+
try {
|
|
1735
|
+
const result = await deleteQuery;
|
|
1736
|
+
const success = this.wasAffected(result);
|
|
1737
|
+
if (!success && options?.expectedVersion != null) {
|
|
1738
|
+
throw new crud_exceptions_1.StaleVersionException(this.getEntityName(), id);
|
|
1739
|
+
}
|
|
1740
|
+
if (success && !hooks.skipAfter)
|
|
1741
|
+
await this.afterDelete(id);
|
|
1742
|
+
if (success) {
|
|
1743
|
+
await this.emitEvent("after", "delete", false, { ids: [id], transaction }, options);
|
|
1744
|
+
}
|
|
1745
|
+
return success;
|
|
1746
|
+
}
|
|
1747
|
+
catch (error) {
|
|
1748
|
+
throw this.toClientError(error, "delete");
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
async forceDelete(id, options) {
|
|
1752
|
+
return this.delete(id, {
|
|
1753
|
+
...options,
|
|
1754
|
+
withDeleted: true,
|
|
1755
|
+
onlyDeleted: false,
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
async upsert(data, upsert = {}, options) {
|
|
1759
|
+
this.checkReadOptions(options);
|
|
1760
|
+
const entityData = await this.prepareCreate(data, options);
|
|
1761
|
+
const db = options?.transaction || this.config.db;
|
|
1762
|
+
await this.emitEvent("before", "upsert", false, { input: data, transaction: options?.transaction }, options);
|
|
1763
|
+
let row;
|
|
1764
|
+
try {
|
|
1765
|
+
[row] = await this.upsertRows(db, [entityData], upsert, options);
|
|
1766
|
+
}
|
|
1767
|
+
catch (error) {
|
|
1768
|
+
throw this.toClientError(error, "write");
|
|
1769
|
+
}
|
|
1770
|
+
if (!row) {
|
|
1771
|
+
throw new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), this.conflictValue(entityData, upsert));
|
|
1772
|
+
}
|
|
1773
|
+
await this.emitEvent("after", "upsert", false, {
|
|
1774
|
+
ids: this.idsOf([row]),
|
|
1775
|
+
input: data,
|
|
1776
|
+
rows: [row],
|
|
1777
|
+
transaction: options?.transaction,
|
|
1778
|
+
}, options);
|
|
1779
|
+
await this.loadWriteRelations(db, [row], options);
|
|
1780
|
+
return row;
|
|
1781
|
+
}
|
|
1782
|
+
async massUpsert(data, upsert = {}, options) {
|
|
1783
|
+
this.checkReadOptions(options);
|
|
1784
|
+
this.assertBatchSize(this.insertParameters(data));
|
|
1785
|
+
const message = "Mass upsert errors";
|
|
1786
|
+
return this.executeSqlTransaction(async (tx) => {
|
|
1787
|
+
const opts = { ...options, transaction: tx };
|
|
1788
|
+
const rows = await this.eachItem(data.map((item, index) => ({
|
|
1789
|
+
index,
|
|
1790
|
+
run: () => this.prepareCreate(item, opts),
|
|
1791
|
+
})), message);
|
|
1792
|
+
if (rows.length === 0)
|
|
1793
|
+
return [];
|
|
1794
|
+
await this.emitEvent("before", "upsert", true, { input: data, transaction: tx }, options);
|
|
1795
|
+
const written = await this.runSet(tx, message, (sp) => this.upsertRows(sp, rows, upsert, opts), rows.map((row, index) => ({
|
|
1796
|
+
index,
|
|
1797
|
+
run: (sp) => this.upsertRows(sp, [row], upsert, opts),
|
|
1798
|
+
})), "write");
|
|
1799
|
+
if (written.length < rows.length) {
|
|
1800
|
+
const keys = this.conflictKeys(upsert);
|
|
1801
|
+
const seen = new Set(written.map((row) => keys.map((k) => this.valueKey(row[k])).join("|")));
|
|
1802
|
+
throw new crud_exceptions_1.BulkOperationException(message, rows.flatMap((row, index) => seen.has(keys.map((k) => this.valueKey(row[k])).join("|"))
|
|
1803
|
+
? []
|
|
1804
|
+
: [
|
|
1805
|
+
{
|
|
1806
|
+
index,
|
|
1807
|
+
error: new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), this.conflictValue(row, upsert)),
|
|
1808
|
+
},
|
|
1809
|
+
]));
|
|
1810
|
+
}
|
|
1811
|
+
await this.emitEvent("after", "upsert", true, {
|
|
1812
|
+
ids: this.idsOf(written),
|
|
1813
|
+
input: data,
|
|
1814
|
+
rows: written,
|
|
1815
|
+
transaction: tx,
|
|
1816
|
+
}, options);
|
|
1817
|
+
await this.loadWriteRelations(tx, written, options);
|
|
1818
|
+
return written;
|
|
1819
|
+
}, options?.transaction);
|
|
1820
|
+
}
|
|
1821
|
+
conflictKeys(upsert) {
|
|
1822
|
+
return upsert.conflictTarget === undefined
|
|
1823
|
+
? [this.config.primaryKey]
|
|
1824
|
+
: [].concat(upsert.conflictTarget);
|
|
1825
|
+
}
|
|
1826
|
+
conflictValue(row, upsert) {
|
|
1827
|
+
return this.conflictKeys(upsert)
|
|
1828
|
+
.map((key) => String(row[key]))
|
|
1829
|
+
.join(", ");
|
|
1830
|
+
}
|
|
1831
|
+
async upsertRows(db, rows, upsert, options) {
|
|
1832
|
+
const table = this.config.table;
|
|
1833
|
+
const targetKeys = this.conflictKeys(upsert);
|
|
1834
|
+
if (targetKeys.length === 0) {
|
|
1835
|
+
throw new common_1.BadRequestException("upsert needs a conflictTarget");
|
|
1836
|
+
}
|
|
1837
|
+
const targets = targetKeys.map((key) => {
|
|
1838
|
+
const column = this.columnOf(table, key);
|
|
1839
|
+
if (!column) {
|
|
1840
|
+
throw new common_1.BadRequestException(`Unknown conflict target column: ${key}`);
|
|
1841
|
+
}
|
|
1842
|
+
return column;
|
|
1843
|
+
});
|
|
1844
|
+
const scope = this.resolveScope(options);
|
|
1845
|
+
const version = this.config.versionColumn;
|
|
1846
|
+
const keep = new Set([
|
|
1847
|
+
...targetKeys,
|
|
1848
|
+
this.config.primaryKey,
|
|
1849
|
+
this.config.timestamps?.createdAt,
|
|
1850
|
+
version,
|
|
1851
|
+
...Object.keys(scope ?? {}),
|
|
1852
|
+
].filter(Boolean));
|
|
1853
|
+
const updateKeys = (upsert.update ??
|
|
1854
|
+
Array.from(new Set(rows.flatMap((row) => Object.keys(row).filter((key) => row[key] !== undefined &&
|
|
1855
|
+
!keep.has(key) &&
|
|
1856
|
+
!!this.columnOf(table, key)))))).filter((key) => key !== version && !(scope && Object.hasOwn(scope, key)));
|
|
1857
|
+
const set = {};
|
|
1858
|
+
for (const key of updateKeys) {
|
|
1859
|
+
const column = this.columnOf(table, key);
|
|
1860
|
+
if (!column) {
|
|
1861
|
+
throw new common_1.BadRequestException(`Unknown upsert column: ${key}`);
|
|
1862
|
+
}
|
|
1863
|
+
set[key] = this.excludedValue(column);
|
|
1864
|
+
}
|
|
1865
|
+
if (Object.keys(set).length === 0) {
|
|
1866
|
+
set[targetKeys[0]] = this.excludedValue(targets[0]);
|
|
1867
|
+
}
|
|
1868
|
+
if (version)
|
|
1869
|
+
set[version] = (0, drizzle_orm_1.sql) `${table[version]} + 1`;
|
|
1870
|
+
const readBack = !this.config.sql?.useReturning;
|
|
1871
|
+
if (readBack) {
|
|
1872
|
+
for (const row of rows) {
|
|
1873
|
+
for (const key of targetKeys) {
|
|
1874
|
+
if (row[key] === undefined) {
|
|
1875
|
+
throw new crud_exceptions_1.ValidationFailedException(`upsert needs a value for "${key}" to read the row back`);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
const insert = db.insert(table).values(rows);
|
|
1881
|
+
let statement;
|
|
1882
|
+
if (this.config.dialect === "mysql") {
|
|
1883
|
+
if (scope) {
|
|
1884
|
+
throw new common_1.BadRequestException("upsert on a scoped entity needs PostgreSQL");
|
|
1885
|
+
}
|
|
1886
|
+
statement = insert.onDuplicateKeyUpdate({ set });
|
|
1887
|
+
}
|
|
1888
|
+
else {
|
|
1889
|
+
const guard = [
|
|
1890
|
+
...this.scopeConditions(options),
|
|
1891
|
+
...[this.softDeleteCondition(table)].filter(Boolean),
|
|
1892
|
+
];
|
|
1893
|
+
statement = insert.onConflictDoUpdate({
|
|
1894
|
+
target: targets.length === 1 ? targets[0] : targets,
|
|
1895
|
+
set,
|
|
1896
|
+
...(guard.length > 0 && { where: (0, drizzle_orm_1.and)(...guard) }),
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
if (!readBack)
|
|
1900
|
+
return statement.returning();
|
|
1901
|
+
await statement;
|
|
1902
|
+
const match = rows.map((row) => (0, drizzle_orm_1.and)(...targetKeys.map((key, i) => (0, drizzle_orm_1.eq)(targets[i], row[key]))));
|
|
1903
|
+
const softDeleted = this.softDeleteCondition(table);
|
|
1904
|
+
return db
|
|
1905
|
+
.select()
|
|
1906
|
+
.from(table)
|
|
1907
|
+
.where((0, drizzle_orm_1.and)(match.length === 1 ? match[0] : (0, drizzle_orm_1.or)(...match), ...this.scopeConditions(options), ...(softDeleted ? [softDeleted] : [])));
|
|
1908
|
+
}
|
|
1909
|
+
excludedValue(column) {
|
|
1910
|
+
return this.config.dialect === "mysql"
|
|
1911
|
+
? (0, drizzle_orm_1.sql) `values(${drizzle_orm_1.sql.identifier(column.name)})`
|
|
1912
|
+
: (0, drizzle_orm_1.sql) `excluded.${drizzle_orm_1.sql.identifier(column.name)}`;
|
|
1913
|
+
}
|
|
1914
|
+
async massCreate(data, options) {
|
|
1915
|
+
if (options?.bulkMode === "perItem") {
|
|
1916
|
+
return this.massCreatePerItem(data, options);
|
|
1917
|
+
}
|
|
1918
|
+
this.checkReadOptions(options);
|
|
1919
|
+
this.assertBatchSize(this.insertParameters(data));
|
|
1920
|
+
return this.executeSqlTransaction(async (tx) => {
|
|
1921
|
+
const opts = { ...options, transaction: tx };
|
|
1922
|
+
const rows = await this.eachItem(data.map((item, index) => ({
|
|
1923
|
+
index,
|
|
1924
|
+
run: () => this.prepareCreate(item, opts),
|
|
1925
|
+
})), "Mass create errors");
|
|
1926
|
+
if (rows.length === 0)
|
|
1927
|
+
return [];
|
|
1928
|
+
await this.emitEvent("before", "create", true, { input: data, transaction: tx }, options);
|
|
1929
|
+
const created = await this.runSet(tx, "Mass create errors", (sp) => this.insertMany(sp, rows, opts), rows.map((row, index) => ({
|
|
1930
|
+
index,
|
|
1931
|
+
run: (sp) => this.insertOne(sp, row, opts),
|
|
1932
|
+
})), "write");
|
|
1933
|
+
if (!options?.hooks?.skipAfter) {
|
|
1934
|
+
for (const entity of created)
|
|
1935
|
+
await this.afterCreate(entity);
|
|
1936
|
+
}
|
|
1937
|
+
await this.emitEvent("after", "create", true, {
|
|
1938
|
+
ids: this.idsOf(created),
|
|
1939
|
+
input: data,
|
|
1940
|
+
rows: created,
|
|
1941
|
+
transaction: tx,
|
|
1942
|
+
}, options);
|
|
1943
|
+
await this.loadWriteRelations(tx, created, options);
|
|
1944
|
+
return created;
|
|
1945
|
+
}, options?.transaction);
|
|
1946
|
+
}
|
|
1947
|
+
async insertMany(db, rows, options) {
|
|
1948
|
+
if (this.config.sql?.useReturning) {
|
|
1949
|
+
return db.insert(this.config.table).values(rows).returning();
|
|
1950
|
+
}
|
|
1951
|
+
const pk = this.config.primaryKey;
|
|
1952
|
+
if (rows.every((row) => row[pk] !== undefined)) {
|
|
1953
|
+
await db.insert(this.config.table).values(rows);
|
|
1954
|
+
return this.fetchByIds(db, rows.map((row) => row[pk]), options);
|
|
1955
|
+
}
|
|
1956
|
+
const created = [];
|
|
1957
|
+
for (const row of rows)
|
|
1958
|
+
created.push(await this.insertOne(db, row, options));
|
|
1959
|
+
return created;
|
|
1960
|
+
}
|
|
1961
|
+
assertAllWritten(ids, written, message, options) {
|
|
1962
|
+
if (written.length >= ids.length)
|
|
1963
|
+
return;
|
|
1964
|
+
const done = new Set(written.map((row) => this.keyOf(row?.[this.config.primaryKey])));
|
|
1965
|
+
throw new crud_exceptions_1.BulkOperationException(message, ids
|
|
1966
|
+
.filter((id) => !done.has(this.keyOf(id)))
|
|
1967
|
+
.map((id) => ({
|
|
1968
|
+
id,
|
|
1969
|
+
error: options?.expectedVersions?.[this.keyOf(id)] != null
|
|
1970
|
+
? new crud_exceptions_1.StaleVersionException(this.getEntityName(), id)
|
|
1971
|
+
: new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id),
|
|
1972
|
+
})));
|
|
1973
|
+
}
|
|
1974
|
+
async massUpdate(ids, data, options) {
|
|
1975
|
+
if (options?.bulkMode === "perItem") {
|
|
1976
|
+
return this.massUpdatePerItem(ids, data, options);
|
|
1977
|
+
}
|
|
1978
|
+
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
1979
|
+
this.checkReadOptions(options);
|
|
1980
|
+
const message = "Mass update errors";
|
|
1981
|
+
return this.executeSqlTransaction(async (tx) => {
|
|
1982
|
+
const opts = { ...options, transaction: tx };
|
|
1983
|
+
const hooks = options?.hooks ?? {};
|
|
1984
|
+
const unique = this.uniqueIds(ids);
|
|
1985
|
+
if (unique.length === 0)
|
|
1986
|
+
return [];
|
|
1987
|
+
await this.assertAllExist(tx, unique, opts, message);
|
|
1988
|
+
const sets = await this.eachItem(unique.map((id) => ({
|
|
1989
|
+
id,
|
|
1990
|
+
run: async () => {
|
|
1991
|
+
await this.validateUpdate(id, data);
|
|
1992
|
+
const processed = hooks.skipBefore
|
|
1993
|
+
? data
|
|
1994
|
+
: await this.beforeUpdate(id, data);
|
|
1995
|
+
return this.prepareUpdate(processed, opts);
|
|
1996
|
+
},
|
|
1997
|
+
})), message);
|
|
1998
|
+
await this.emitEvent("before", "update", true, { ids: unique, input: data, transaction: tx }, options);
|
|
1999
|
+
const now = new Date();
|
|
2000
|
+
const stamp = (set) => this.withVersionBump(this.config.timestamps?.updatedAt
|
|
2001
|
+
? { ...set, [this.config.timestamps.updatedAt]: now }
|
|
2002
|
+
: set);
|
|
2003
|
+
let updated;
|
|
2004
|
+
const first = this.fingerprint(sets[0]);
|
|
2005
|
+
if (sets.every((set) => this.fingerprint(set) === first)) {
|
|
2006
|
+
const set = stamp(sets[0]);
|
|
2007
|
+
updated = await this.runSet(tx, message, (sp) => this.updateWhereIn(sp, set, unique, opts), unique.map((id) => ({
|
|
2008
|
+
id,
|
|
2009
|
+
run: (sp) => this.updateWhereIn(sp, set, [id], opts),
|
|
2010
|
+
})), "write");
|
|
2011
|
+
}
|
|
2012
|
+
else {
|
|
2013
|
+
const rows = await this.eachItem(unique.map((id, i) => ({
|
|
2014
|
+
id,
|
|
2015
|
+
run: () => this.savepoint(tx, (sp) => this.updateWhereIn(sp, stamp(sets[i]), [id], opts)),
|
|
2016
|
+
})), message, "write");
|
|
2017
|
+
updated = rows.flat();
|
|
2018
|
+
}
|
|
2019
|
+
this.assertAllWritten(unique, updated, message, options);
|
|
2020
|
+
if (!hooks.skipAfter) {
|
|
2021
|
+
for (const entity of updated)
|
|
2022
|
+
await this.afterUpdate(entity);
|
|
2023
|
+
}
|
|
2024
|
+
await this.emitEvent("after", "update", true, { ids: unique, input: data, rows: updated, transaction: tx }, options);
|
|
2025
|
+
await this.loadWriteRelations(tx, updated, options);
|
|
2026
|
+
return this.inIdOrder(updated, ids);
|
|
2027
|
+
}, options?.transaction);
|
|
2028
|
+
}
|
|
2029
|
+
async runKeyed(statement, ids) {
|
|
2030
|
+
if (this.config.sql?.useReturning) {
|
|
2031
|
+
return statement.returning({
|
|
2032
|
+
[this.config.primaryKey]: this.config.table[this.config.primaryKey],
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
const result = await statement;
|
|
2036
|
+
return this.wasAffected(result)
|
|
2037
|
+
? ids.map((id) => ({ [this.config.primaryKey]: id }))
|
|
2038
|
+
: [];
|
|
2039
|
+
}
|
|
2040
|
+
async massSoftDelete(ids, options) {
|
|
2041
|
+
if (!this.config.softDelete?.enabled)
|
|
2042
|
+
throw new common_1.BadRequestException("Soft delete not enabled");
|
|
2043
|
+
if (options?.bulkMode === "perItem") {
|
|
2044
|
+
return this.massSoftDeletePerItem(ids, options);
|
|
2045
|
+
}
|
|
2046
|
+
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2047
|
+
const message = "Mass soft delete errors";
|
|
2048
|
+
return this.executeSqlTransaction(async (tx) => {
|
|
2049
|
+
const opts = {
|
|
2050
|
+
...options,
|
|
2051
|
+
transaction: tx,
|
|
2052
|
+
withDeleted: false,
|
|
2053
|
+
onlyDeleted: false,
|
|
2054
|
+
};
|
|
2055
|
+
const hooks = options?.hooks ?? {};
|
|
2056
|
+
const unique = this.uniqueIds(ids);
|
|
2057
|
+
if (unique.length === 0)
|
|
2058
|
+
return true;
|
|
2059
|
+
await this.assertAllExist(tx, unique, opts, message);
|
|
2060
|
+
if (!hooks.skipBefore) {
|
|
2061
|
+
await this.eachItem(unique.map((id) => ({ id, run: () => this.beforeSoftDelete(id) })), message);
|
|
2062
|
+
}
|
|
2063
|
+
await this.emitEvent("before", "softDelete", true, { ids: unique, transaction: tx }, options);
|
|
2064
|
+
const set = this.softDeleteSet(new Date());
|
|
2065
|
+
const written = await this.runSet(tx, message, (sp) => this.runKeyed(sp
|
|
2066
|
+
.update(this.config.table)
|
|
2067
|
+
.set(set)
|
|
2068
|
+
.where(this.writeWhere(unique, opts)), unique), unique.map((id) => ({
|
|
2069
|
+
id,
|
|
2070
|
+
run: (sp) => sp
|
|
2071
|
+
.update(this.config.table)
|
|
2072
|
+
.set(set)
|
|
2073
|
+
.where(this.writeWhere([id], opts)),
|
|
2074
|
+
})), "write");
|
|
2075
|
+
this.assertAllWritten(unique, written, message, options);
|
|
2076
|
+
if (!hooks.skipAfter) {
|
|
2077
|
+
for (const id of unique)
|
|
2078
|
+
await this.afterSoftDelete(id);
|
|
2079
|
+
}
|
|
2080
|
+
await this.emitEvent("after", "softDelete", true, { ids: unique, transaction: tx }, options);
|
|
2081
|
+
return true;
|
|
2082
|
+
}, options?.transaction);
|
|
2083
|
+
}
|
|
2084
|
+
async massRestore(ids, options) {
|
|
2085
|
+
if (!this.config.softDelete?.enabled)
|
|
2086
|
+
throw new common_1.BadRequestException("Soft delete not enabled");
|
|
2087
|
+
if (options?.bulkMode === "perItem") {
|
|
2088
|
+
return this.massRestorePerItem(ids, options);
|
|
2089
|
+
}
|
|
2090
|
+
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2091
|
+
this.checkReadOptions(options);
|
|
2092
|
+
const message = "Mass restore errors";
|
|
2093
|
+
return this.executeSqlTransaction(async (tx) => {
|
|
2094
|
+
const opts = {
|
|
2095
|
+
...options,
|
|
2096
|
+
transaction: tx,
|
|
2097
|
+
withDeleted: true,
|
|
2098
|
+
onlyDeleted: false,
|
|
2099
|
+
};
|
|
2100
|
+
const hooks = options?.hooks ?? {};
|
|
2101
|
+
const unique = this.uniqueIds(ids);
|
|
2102
|
+
if (unique.length === 0)
|
|
2103
|
+
return [];
|
|
2104
|
+
await this.assertAllExist(tx, unique, opts, message);
|
|
2105
|
+
if (!hooks.skipBefore) {
|
|
2106
|
+
await this.eachItem(unique.map((id) => ({ id, run: () => this.beforeRestore(id) })), message);
|
|
2107
|
+
}
|
|
2108
|
+
await this.emitEvent("before", "restore", true, { ids: unique, transaction: tx }, options);
|
|
2109
|
+
const set = this.softDeleteSet(null);
|
|
2110
|
+
const restored = await this.runSet(tx, message, (sp) => this.updateWhereIn(sp, set, unique, opts), unique.map((id) => ({
|
|
2111
|
+
id,
|
|
2112
|
+
run: (sp) => this.updateWhereIn(sp, set, [id], opts),
|
|
2113
|
+
})), "write");
|
|
2114
|
+
this.assertAllWritten(unique, restored, message, options);
|
|
2115
|
+
if (!hooks.skipAfter) {
|
|
2116
|
+
for (const entity of restored)
|
|
2117
|
+
await this.afterRestore(entity);
|
|
2118
|
+
}
|
|
2119
|
+
await this.emitEvent("after", "restore", true, { ids: unique, rows: restored, transaction: tx }, options);
|
|
2120
|
+
await this.loadWriteRelations(tx, restored, options);
|
|
2121
|
+
return this.inIdOrder(restored, ids);
|
|
2122
|
+
}, options?.transaction);
|
|
2123
|
+
}
|
|
2124
|
+
async massDelete(ids, options) {
|
|
2125
|
+
if (options?.bulkMode === "perItem") {
|
|
2126
|
+
return this.massDeletePerItem(ids, options);
|
|
2127
|
+
}
|
|
2128
|
+
this.assertBatchSize(Array.isArray(ids) ? ids.length : 0);
|
|
2129
|
+
const message = "Mass delete errors";
|
|
2130
|
+
return this.executeSqlTransaction(async (tx) => {
|
|
2131
|
+
const opts = { ...options, transaction: tx };
|
|
2132
|
+
const hooks = options?.hooks ?? {};
|
|
2133
|
+
const unique = this.uniqueIds(ids);
|
|
2134
|
+
if (unique.length === 0)
|
|
2135
|
+
return true;
|
|
2136
|
+
await this.assertAllExist(tx, unique, opts, message);
|
|
2137
|
+
if (!hooks.skipBefore) {
|
|
2138
|
+
await this.eachItem(unique.map((id) => ({ id, run: () => this.beforeDelete(id) })), message);
|
|
2139
|
+
}
|
|
2140
|
+
await this.emitEvent("before", "delete", true, { ids: unique, transaction: tx }, options);
|
|
2141
|
+
const written = await this.runSet(tx, message, (sp) => this.runKeyed(sp.delete(this.config.table).where(this.writeWhere(unique, opts)), unique), unique.map((id) => ({
|
|
2142
|
+
id,
|
|
2143
|
+
run: (sp) => sp.delete(this.config.table).where(this.writeWhere([id], opts)),
|
|
2144
|
+
})), "delete");
|
|
2145
|
+
this.assertAllWritten(unique, written, message, options);
|
|
2146
|
+
if (!hooks.skipAfter) {
|
|
2147
|
+
for (const id of unique)
|
|
2148
|
+
await this.afterDelete(id);
|
|
2149
|
+
}
|
|
2150
|
+
await this.emitEvent("after", "delete", true, { ids: unique, transaction: tx }, options);
|
|
2151
|
+
return true;
|
|
2152
|
+
}, options?.transaction);
|
|
2153
|
+
}
|
|
2154
|
+
async massForceDelete(ids, options) {
|
|
2155
|
+
return this.massDelete(ids, {
|
|
2156
|
+
...options,
|
|
2157
|
+
withDeleted: true,
|
|
2158
|
+
onlyDeleted: false,
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
uniqueIds(ids) {
|
|
2162
|
+
const seen = new Set();
|
|
2163
|
+
return (ids ?? []).filter((id) => {
|
|
2164
|
+
const key = this.keyOf(id);
|
|
2165
|
+
if (seen.has(key))
|
|
2166
|
+
return false;
|
|
2167
|
+
seen.add(key);
|
|
2168
|
+
return true;
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
fingerprint(value) {
|
|
2172
|
+
return JSON.stringify(value, (_key, v) => typeof v === "bigint" ? `${v}n` : v);
|
|
2173
|
+
}
|
|
2174
|
+
async assertAllExist(tx, ids, options, message) {
|
|
2175
|
+
const table = this.config.table;
|
|
2176
|
+
const pk = table[this.config.primaryKey];
|
|
2177
|
+
const scope = this.rowScope(options);
|
|
2178
|
+
const where = (condition) => (0, drizzle_orm_1.and)(condition, ...scope);
|
|
2179
|
+
const version = this.config.versionColumn;
|
|
2180
|
+
const fields = { id: pk };
|
|
2181
|
+
if (version)
|
|
2182
|
+
fields.version = table[version];
|
|
2183
|
+
const found = await this.runSet(tx, message, (sp) => sp
|
|
2184
|
+
.select(fields)
|
|
2185
|
+
.from(table)
|
|
2186
|
+
.where(where((0, drizzle_orm_1.inArray)(pk, ids))), ids.map((id) => ({
|
|
2187
|
+
id,
|
|
2188
|
+
run: (sp) => sp
|
|
2189
|
+
.select(fields)
|
|
2190
|
+
.from(table)
|
|
2191
|
+
.where(where((0, drizzle_orm_1.eq)(pk, id))),
|
|
2192
|
+
})), "write");
|
|
2193
|
+
const present = new Map(found.map((row) => [this.keyOf(row.id), row]));
|
|
2194
|
+
const errors = [];
|
|
2195
|
+
for (const id of ids) {
|
|
2196
|
+
const row = present.get(this.keyOf(id));
|
|
2197
|
+
if (!row) {
|
|
2198
|
+
errors.push({
|
|
2199
|
+
id,
|
|
2200
|
+
error: new crud_exceptions_1.EntityNotFoundException(this.getEntityName(), id),
|
|
2201
|
+
});
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
const expected = options.expectedVersions?.[this.keyOf(id)];
|
|
2205
|
+
if (version && expected != null && Number(row.version) !== expected) {
|
|
2206
|
+
errors.push({
|
|
2207
|
+
id,
|
|
2208
|
+
error: new crud_exceptions_1.StaleVersionException(this.getEntityName(), id),
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
if (errors.length > 0)
|
|
2213
|
+
throw new crud_exceptions_1.BulkOperationException(message, errors);
|
|
2214
|
+
}
|
|
2215
|
+
async eachItem(items, message, operation = "write") {
|
|
2216
|
+
const results = [];
|
|
2217
|
+
const errors = [];
|
|
2218
|
+
for (const item of items) {
|
|
2219
|
+
try {
|
|
2220
|
+
results.push(await item.run());
|
|
2221
|
+
}
|
|
2222
|
+
catch (error) {
|
|
2223
|
+
errors.push({
|
|
2224
|
+
...this.itemRef(item),
|
|
2225
|
+
error: this.toClientError(error, operation),
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
if (errors.length > 0)
|
|
2230
|
+
throw new crud_exceptions_1.BulkOperationException(message, errors);
|
|
2231
|
+
return results;
|
|
2232
|
+
}
|
|
2233
|
+
itemRef(item) {
|
|
2234
|
+
return item.index !== undefined ? { index: item.index } : { id: item.id };
|
|
2235
|
+
}
|
|
2236
|
+
async runSet(tx, message, statement, items, operation) {
|
|
2237
|
+
try {
|
|
2238
|
+
return await this.savepoint(tx, statement);
|
|
2239
|
+
}
|
|
2240
|
+
catch (error) {
|
|
2241
|
+
const failures = await this.diagnose(tx, items, operation);
|
|
2242
|
+
if (failures.length > 0) {
|
|
2243
|
+
throw new crud_exceptions_1.BulkOperationException(message, failures);
|
|
2244
|
+
}
|
|
2245
|
+
throw this.toClientError(error, operation);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
async diagnose(tx, items, operation) {
|
|
2249
|
+
const failures = [];
|
|
2250
|
+
try {
|
|
2251
|
+
await this.savepoint(tx, async (outer) => {
|
|
2252
|
+
for (const item of items) {
|
|
2253
|
+
try {
|
|
2254
|
+
await this.savepoint(outer, item.run);
|
|
2255
|
+
}
|
|
2256
|
+
catch (error) {
|
|
2257
|
+
failures.push({
|
|
2258
|
+
...this.itemRef(item),
|
|
2259
|
+
error: this.toClientError(error, operation),
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
throw ROLLBACK;
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
catch (error) {
|
|
2267
|
+
if (error !== ROLLBACK)
|
|
2268
|
+
throw error;
|
|
2269
|
+
}
|
|
2270
|
+
return failures;
|
|
2271
|
+
}
|
|
2272
|
+
async massCreatePerItem(data, options) {
|
|
2273
|
+
return this.executeSqlTransaction((tx) => this.eachItem(data.map((item, index) => ({
|
|
2274
|
+
index,
|
|
2275
|
+
run: () => this.savepoint(tx, (sp) => this.create(item, { ...options, transaction: sp })),
|
|
2276
|
+
})), "Mass create errors"), options?.transaction);
|
|
2277
|
+
}
|
|
2278
|
+
async massUpdatePerItem(ids, data, options) {
|
|
2279
|
+
return this.executeSqlTransaction((tx) => this.eachItem(ids.map((id) => ({
|
|
2280
|
+
id,
|
|
2281
|
+
run: () => this.savepoint(tx, (sp) => this.update(id, data, { ...options, transaction: sp })),
|
|
2282
|
+
})), "Mass update errors"), options?.transaction);
|
|
2283
|
+
}
|
|
2284
|
+
async massSoftDeletePerItem(ids, options) {
|
|
2285
|
+
await this.executeSqlTransaction((tx) => this.eachItem(ids.map((id) => ({
|
|
2286
|
+
id,
|
|
2287
|
+
run: () => this.savepoint(tx, (sp) => this.softDelete(id, { ...options, transaction: sp })),
|
|
2288
|
+
})), "Mass soft delete errors"), options?.transaction);
|
|
2289
|
+
return true;
|
|
2290
|
+
}
|
|
2291
|
+
async massRestorePerItem(ids, options) {
|
|
2292
|
+
return this.executeSqlTransaction((tx) => this.eachItem(ids.map((id) => ({
|
|
2293
|
+
id,
|
|
2294
|
+
run: () => this.savepoint(tx, (sp) => this.restore(id, { ...options, transaction: sp })),
|
|
2295
|
+
})), "Mass restore errors"), options?.transaction);
|
|
2296
|
+
}
|
|
2297
|
+
async massDeletePerItem(ids, options) {
|
|
2298
|
+
await this.executeSqlTransaction((tx) => this.eachItem(ids.map((id) => ({
|
|
2299
|
+
id,
|
|
2300
|
+
run: () => this.savepoint(tx, (sp) => this.delete(id, { ...options, transaction: sp })),
|
|
2301
|
+
})), "Mass delete errors", "delete"), options?.transaction);
|
|
2302
|
+
return true;
|
|
2303
|
+
}
|
|
2304
|
+
savepoint(tx, run) {
|
|
2305
|
+
return typeof tx?.transaction === "function"
|
|
2306
|
+
? tx.transaction(run)
|
|
2307
|
+
: run(tx);
|
|
2308
|
+
}
|
|
2309
|
+
async exists(id, options) {
|
|
2310
|
+
const entity = await this.find(id, {
|
|
2311
|
+
...options,
|
|
2312
|
+
select: [this.config.primaryKey],
|
|
2313
|
+
});
|
|
2314
|
+
return !!entity;
|
|
2315
|
+
}
|
|
2316
|
+
async count(filters, options) {
|
|
2317
|
+
this.checkReadOptions(options);
|
|
2318
|
+
const db = options?.transaction || this.config.db;
|
|
2319
|
+
const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
|
|
2320
|
+
const result = await this.runRead(this.buildCountQuery(db, conditions, filterRelations, options));
|
|
2321
|
+
return parseInt(result[0]?.count?.toString() || "0", 10);
|
|
2322
|
+
}
|
|
2323
|
+
async countBy(column, filters, options) {
|
|
2324
|
+
this.checkReadOptions(options);
|
|
2325
|
+
const db = options?.transaction || this.config.db;
|
|
2326
|
+
const spec = this.resolveGroupColumn(column, options);
|
|
2327
|
+
const { conditions, relations: filterRelations } = this.listConditions(filters, db, options);
|
|
2328
|
+
const joins = Array.from(new Set([...filterRelations, ...(spec.relation ? [spec.relation] : [])]));
|
|
2329
|
+
const total = (0, drizzle_orm_1.sql) `count(*)`;
|
|
2330
|
+
let query = db
|
|
2331
|
+
.select({ value: spec.column, count: total })
|
|
2332
|
+
.from(this.config.table);
|
|
2333
|
+
query = this.applyJoins(query, joins, options);
|
|
2334
|
+
if (conditions.length > 0)
|
|
2335
|
+
query = query.where((0, drizzle_orm_1.and)(...conditions));
|
|
2336
|
+
query = query.groupBy(spec.column).orderBy((0, drizzle_orm_1.desc)(total), (0, drizzle_orm_1.asc)(spec.column));
|
|
2337
|
+
const rows = await this.runRead(query);
|
|
2338
|
+
return rows.map((row) => ({
|
|
2339
|
+
value: row.value,
|
|
2340
|
+
count: Number(row.count),
|
|
2341
|
+
}));
|
|
2342
|
+
}
|
|
2343
|
+
resolveGroupColumn(key, options) {
|
|
2344
|
+
if (typeof key !== "string" || !key) {
|
|
2345
|
+
throw new common_1.BadRequestException("countBy needs a column");
|
|
2346
|
+
}
|
|
2347
|
+
const dot = key.indexOf(".");
|
|
2348
|
+
let resolved;
|
|
2349
|
+
if (dot < 0) {
|
|
2350
|
+
const column = this.columnOf(this.config.table, key);
|
|
2351
|
+
if (column)
|
|
2352
|
+
resolved = { column };
|
|
2353
|
+
}
|
|
2354
|
+
else {
|
|
2355
|
+
const name = key.slice(0, dot);
|
|
2356
|
+
const relations = this.config.relations || {};
|
|
2357
|
+
const relation = Object.hasOwn(relations, name)
|
|
2358
|
+
? relations[name]
|
|
2359
|
+
: undefined;
|
|
2360
|
+
const column = relation && !this.isCollection(relation) && relation.localKey
|
|
2361
|
+
? this.columnOf(relation.table, key.slice(dot + 1))
|
|
2362
|
+
: undefined;
|
|
2363
|
+
if (column)
|
|
2364
|
+
resolved = { column, relation: name };
|
|
2365
|
+
}
|
|
2366
|
+
if (!resolved) {
|
|
2367
|
+
throw new common_1.BadRequestException(`Unknown countBy column: ${key}`);
|
|
2368
|
+
}
|
|
2369
|
+
this.assertAllowed("filterableColumns", key, options);
|
|
2370
|
+
return resolved;
|
|
2371
|
+
}
|
|
2372
|
+
countNames(counts) {
|
|
2373
|
+
if (!counts)
|
|
2374
|
+
return [];
|
|
2375
|
+
if (Array.isArray(counts))
|
|
2376
|
+
return counts.filter(Boolean);
|
|
2377
|
+
if (!(0, merge_config_1.isPlainObject)(counts)) {
|
|
2378
|
+
throw new common_1.BadRequestException("counts must be a list of relation names or an object of filters");
|
|
2379
|
+
}
|
|
2380
|
+
return Object.keys(counts);
|
|
2381
|
+
}
|
|
2382
|
+
withCountKeys(base, counts) {
|
|
2383
|
+
if (base.length === 0)
|
|
2384
|
+
return base;
|
|
2385
|
+
const keys = this.countNames(counts).flatMap((name) => {
|
|
2386
|
+
const relation = this.config.relations?.[name];
|
|
2387
|
+
return relation ? [this.parentKeyOf(relation)] : [];
|
|
2388
|
+
});
|
|
2389
|
+
return keys.length > 0 ? Array.from(new Set([...base, ...keys])) : base;
|
|
2390
|
+
}
|
|
2391
|
+
async loadCounts(db, rows, counts, options) {
|
|
2392
|
+
const names = this.countNames(counts);
|
|
2393
|
+
if (names.length === 0 || !Array.isArray(rows))
|
|
2394
|
+
return;
|
|
2395
|
+
const relations = this.config.relations || {};
|
|
2396
|
+
const plan = names.map((name) => {
|
|
2397
|
+
const relation = Object.hasOwn(relations, name)
|
|
2398
|
+
? relations[name]
|
|
2399
|
+
: undefined;
|
|
2400
|
+
if (!relation) {
|
|
2401
|
+
throw new common_1.BadRequestException(`Unknown relation to count: ${name}`);
|
|
2402
|
+
}
|
|
2403
|
+
if (!this.isCollection(relation)) {
|
|
2404
|
+
throw new common_1.BadRequestException(`Cannot count "${name}": only a has-many or many-to-many relation has a count`);
|
|
2405
|
+
}
|
|
2406
|
+
const filter = !Array.isArray(counts) && (0, merge_config_1.isPlainObject)(counts?.[name])
|
|
2407
|
+
? counts?.[name]
|
|
2408
|
+
: undefined;
|
|
2409
|
+
return { name, relation, filter };
|
|
2410
|
+
});
|
|
2411
|
+
const hosts = rows.filter((row) => row && typeof row === "object");
|
|
2412
|
+
for (const host of hosts)
|
|
2413
|
+
host._count = {};
|
|
2414
|
+
if (hosts.length === 0)
|
|
2415
|
+
return;
|
|
2416
|
+
await Promise.all(plan.map(async ({ name, relation, filter }) => {
|
|
2417
|
+
const parentKey = this.parentKeyOf(relation);
|
|
2418
|
+
const ids = Array.from(new Set(hosts.map((host) => host[parentKey]).filter((v) => v != null)));
|
|
2419
|
+
const totals = new Map();
|
|
2420
|
+
if (ids.length > 0) {
|
|
2421
|
+
const result = await this.runRead(this.countQuery(db, relation, ids, name, filter, options));
|
|
2422
|
+
for (const row of result) {
|
|
2423
|
+
totals.set(String(row[COUNT_KEY]), Number(row.count));
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
for (const host of hosts) {
|
|
2427
|
+
host._count[name] = totals.get(String(host[parentKey])) ?? 0;
|
|
2428
|
+
}
|
|
2429
|
+
}));
|
|
2430
|
+
}
|
|
2431
|
+
countQuery(db, relation, ids, name, filter, options) {
|
|
2432
|
+
const target = relation.table;
|
|
2433
|
+
const conditions = [];
|
|
2434
|
+
if (filter) {
|
|
2435
|
+
conditions.push(...this.compileFilterAt({
|
|
2436
|
+
table: target,
|
|
2437
|
+
source: target,
|
|
2438
|
+
relations: relation.relations ?? {},
|
|
2439
|
+
prefix: name,
|
|
2440
|
+
root: false,
|
|
2441
|
+
}, filter, db, new Set(), options));
|
|
2442
|
+
}
|
|
2443
|
+
conditions.push(...this.relatedRowScope(target, options));
|
|
2444
|
+
const total = (0, drizzle_orm_1.sql) `count(*)`;
|
|
2445
|
+
if (this.relationType(relation) === "manyToMany") {
|
|
2446
|
+
const joinLocal = relation.through[relation.throughLocalKey];
|
|
2447
|
+
const joinForeign = relation.through[relation.throughForeignKey];
|
|
2448
|
+
const referenced = target[relation.references ?? "id"];
|
|
2449
|
+
return db
|
|
2450
|
+
.select({ [COUNT_KEY]: joinLocal, count: total })
|
|
2451
|
+
.from(relation.through)
|
|
2452
|
+
.innerJoin(target, (0, drizzle_orm_1.eq)(joinForeign, referenced))
|
|
2453
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(joinLocal, ids), ...conditions))
|
|
2454
|
+
.groupBy(joinLocal);
|
|
2455
|
+
}
|
|
2456
|
+
const foreign = target[relation.foreignKey];
|
|
2457
|
+
return db
|
|
2458
|
+
.select({ [COUNT_KEY]: foreign, count: total })
|
|
2459
|
+
.from(target)
|
|
2460
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(foreign, ids), ...conditions))
|
|
2461
|
+
.groupBy(foreign);
|
|
2462
|
+
}
|
|
2463
|
+
pushColumnCondition(conditions, column, value, caseInsensitive = this.config.sql?.caseSensitive === false) {
|
|
2464
|
+
if (Array.isArray(value)) {
|
|
2465
|
+
this.assertBatchSize(value.length);
|
|
2466
|
+
conditions.push((0, drizzle_orm_1.inArray)(column, value.map((item) => this.coerceFilterValue(column, item))));
|
|
2467
|
+
}
|
|
2468
|
+
else if ((0, merge_config_1.isPlainObject)(value)) {
|
|
2469
|
+
this.applyComplexFilter(conditions, column, value);
|
|
2470
|
+
}
|
|
2471
|
+
else {
|
|
2472
|
+
const coerced = this.coerceFilterValue(column, value);
|
|
2473
|
+
if (typeof coerced === "string" &&
|
|
2474
|
+
caseInsensitive &&
|
|
2475
|
+
this.isTextColumn(column)) {
|
|
2476
|
+
conditions.push((0, drizzle_orm_1.sql) `lower(${column}) = lower(${coerced})`);
|
|
2477
|
+
}
|
|
2478
|
+
else {
|
|
2479
|
+
conditions.push((0, drizzle_orm_1.eq)(column, coerced));
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
buildCollectionFilter(relation, value, db = this.config.db, name = "relation", options) {
|
|
2484
|
+
const targetConditions = this.compileFilterAt(this.relationHost(relation, relation.table, name), value, db, new Set(), options);
|
|
2485
|
+
if (targetConditions.length === 0)
|
|
2486
|
+
return undefined;
|
|
2487
|
+
return this.collectionCondition(relation, targetConditions, db, this.rootHost(), options);
|
|
2488
|
+
}
|
|
2489
|
+
collectionCondition(relation, targetConditions, db, host, options) {
|
|
2490
|
+
const target = relation.table;
|
|
2491
|
+
const manyToMany = this.relationType(relation) === "manyToMany";
|
|
2492
|
+
const joinLocal = manyToMany
|
|
2493
|
+
? relation.through?.[relation.throughLocalKey ?? ""]
|
|
2494
|
+
: undefined;
|
|
2495
|
+
const key = manyToMany ? joinLocal : target[relation.foreignKey ?? ""];
|
|
2496
|
+
if (!key)
|
|
2497
|
+
return undefined;
|
|
2498
|
+
const conditions = [...targetConditions];
|
|
2499
|
+
conditions.push(...this.relatedRowScope(target, options));
|
|
2500
|
+
let subquery;
|
|
2501
|
+
if (manyToMany) {
|
|
2502
|
+
const referenced = target[relation.references ?? "id"];
|
|
2503
|
+
const joinForeign = relation.through?.[relation.throughForeignKey ?? ""];
|
|
2504
|
+
if (!referenced || !joinForeign)
|
|
2505
|
+
return undefined;
|
|
2506
|
+
subquery = db
|
|
2507
|
+
.select({ value: key })
|
|
2508
|
+
.from(relation.through)
|
|
2509
|
+
.innerJoin(target, (0, drizzle_orm_1.eq)(joinForeign, referenced))
|
|
2510
|
+
.where((0, drizzle_orm_1.and)(...conditions));
|
|
2511
|
+
}
|
|
2512
|
+
else {
|
|
2513
|
+
subquery = db
|
|
2514
|
+
.select({ value: key })
|
|
2515
|
+
.from(target)
|
|
2516
|
+
.where((0, drizzle_orm_1.and)(...conditions));
|
|
2517
|
+
}
|
|
2518
|
+
const parentKey = this.parentKeyOf(relation, host.source);
|
|
2519
|
+
return (0, drizzle_orm_1.inArray)(host.table[parentKey], subquery);
|
|
2520
|
+
}
|
|
2521
|
+
quantifiedCollection(quantifier, relation, inner, name, db, host, options) {
|
|
2522
|
+
const target = subqueryAlias(relation.table);
|
|
2523
|
+
const compiled = this.compileFilterAt(this.relationHost(relation, target, name), inner, db, new Set(), options);
|
|
2524
|
+
if (quantifier === "every" && compiled.length === 0)
|
|
2525
|
+
return undefined;
|
|
2526
|
+
const parentKey = this.parentKeyOf(relation, host.source);
|
|
2527
|
+
const conditions = [];
|
|
2528
|
+
conditions.push(...this.relatedRowScope(target, options));
|
|
2529
|
+
if (compiled.length > 0) {
|
|
2530
|
+
const matched = (0, drizzle_orm_1.and)(...compiled);
|
|
2531
|
+
conditions.push(quantifier === "every"
|
|
2532
|
+
? (0, drizzle_orm_1.not)((0, drizzle_orm_1.sql) `coalesce(${matched}, false)`)
|
|
2533
|
+
: matched);
|
|
2534
|
+
}
|
|
2535
|
+
let subquery;
|
|
2536
|
+
if (this.relationType(relation) === "manyToMany") {
|
|
2537
|
+
const through = subqueryAlias(relation.through);
|
|
2538
|
+
subquery = db
|
|
2539
|
+
.select({ one: (0, drizzle_orm_1.sql) `1` })
|
|
2540
|
+
.from(through)
|
|
2541
|
+
.innerJoin(target, (0, drizzle_orm_1.eq)(through[relation.throughForeignKey], target[relation.references ?? "id"]))
|
|
2542
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(through[relation.throughLocalKey], host.table[parentKey]), ...conditions));
|
|
2543
|
+
}
|
|
2544
|
+
else {
|
|
2545
|
+
subquery = db
|
|
2546
|
+
.select({ one: (0, drizzle_orm_1.sql) `1` })
|
|
2547
|
+
.from(target)
|
|
2548
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(target[relation.foreignKey], host.table[parentKey]), ...conditions));
|
|
2549
|
+
}
|
|
2550
|
+
return (0, drizzle_orm_1.notExists)(subquery);
|
|
2551
|
+
}
|
|
2552
|
+
belongsToExists(relation, inner, name, db, host, options) {
|
|
2553
|
+
const target = subqueryAlias(relation.table);
|
|
2554
|
+
const compiled = this.compileFilterAt(this.relationHost(relation, target, name), inner, db, new Set(), options);
|
|
2555
|
+
if (compiled.length === 0)
|
|
2556
|
+
return undefined;
|
|
2557
|
+
return (0, drizzle_orm_1.exists)(db
|
|
2558
|
+
.select({ one: (0, drizzle_orm_1.sql) `1` })
|
|
2559
|
+
.from(target)
|
|
2560
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(target[relation.references ?? "id"], host.table[relation.localKey]), ...compiled, ...this.relatedRowScope(target, options))));
|
|
2561
|
+
}
|
|
2562
|
+
rootHost() {
|
|
2563
|
+
return {
|
|
2564
|
+
table: this.config.table,
|
|
2565
|
+
source: this.config.table,
|
|
2566
|
+
relations: this.config.relations || {},
|
|
2567
|
+
prefix: "",
|
|
2568
|
+
root: true,
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
relationHost(relation, table, path) {
|
|
2572
|
+
return {
|
|
2573
|
+
table,
|
|
2574
|
+
source: relation.table,
|
|
2575
|
+
relations: relation.relations ?? {},
|
|
2576
|
+
prefix: path,
|
|
2577
|
+
root: false,
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
buildFilterConditions(filters, db = this.config.db, options) {
|
|
2581
|
+
(0, query_parser_1.assertFilterDepth)(filters);
|
|
2582
|
+
const joins = new Set();
|
|
2583
|
+
const conditions = this.compileFilterAt(this.rootHost(), filters, db, joins, options);
|
|
2584
|
+
return { conditions, relations: Array.from(joins) };
|
|
2585
|
+
}
|
|
2586
|
+
isGroupKey(host, key) {
|
|
2587
|
+
if (!GROUP_KEYS.has(key) || this.columnOf(host.source, key))
|
|
2588
|
+
return false;
|
|
2589
|
+
return !Object.hasOwn(host.relations, key);
|
|
2590
|
+
}
|
|
2591
|
+
compileFilterAt(host, filters, db, joins, options) {
|
|
2592
|
+
const conditions = [];
|
|
2593
|
+
if (filters === undefined || filters === null)
|
|
2594
|
+
return conditions;
|
|
2595
|
+
if (!(0, merge_config_1.isPlainObject)(filters)) {
|
|
2596
|
+
throw new common_1.BadRequestException("A filter must be an object");
|
|
2597
|
+
}
|
|
2598
|
+
const path = (key) => (host.prefix ? `${host.prefix}.${key}` : key);
|
|
2599
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
2600
|
+
if (value === undefined || value === null)
|
|
2601
|
+
continue;
|
|
2602
|
+
if (this.isGroupKey(host, key)) {
|
|
2603
|
+
const group = this.compileGroup(key, value, (member) => this.compileFilterAt(host, member, db, joins, options));
|
|
2604
|
+
if (group)
|
|
2605
|
+
conditions.push(group);
|
|
2606
|
+
continue;
|
|
2607
|
+
}
|
|
2608
|
+
const column = this.columnOf(host.source, key);
|
|
2609
|
+
const relation = !column && Object.hasOwn(host.relations, key)
|
|
2610
|
+
? host.relations[key]
|
|
2611
|
+
: undefined;
|
|
2612
|
+
if (relation) {
|
|
2613
|
+
if (!(0, merge_config_1.isPlainObject)(value)) {
|
|
2614
|
+
throw new common_1.BadRequestException(`Filter on relation "${path(key)}" must be an object of column filters`);
|
|
2615
|
+
}
|
|
2616
|
+
const condition = this.compileRelationFilter(host, key, relation, value, db, joins, options);
|
|
2617
|
+
if (condition)
|
|
2618
|
+
conditions.push(condition);
|
|
2619
|
+
continue;
|
|
2620
|
+
}
|
|
2621
|
+
if (!column) {
|
|
2622
|
+
throw new common_1.BadRequestException(`Unknown filter column: ${path(key)}`);
|
|
2623
|
+
}
|
|
2624
|
+
this.assertAllowed("filterableColumns", path(key), options);
|
|
2625
|
+
this.pushColumnCondition(conditions, host.table[key] ?? column, value);
|
|
2626
|
+
}
|
|
2627
|
+
return conditions;
|
|
2628
|
+
}
|
|
2629
|
+
compileRelationFilter(host, name, relation, value, db, joins, options) {
|
|
2630
|
+
const path = host.prefix ? `${host.prefix}.${name}` : name;
|
|
2631
|
+
if (!this.isCollection(relation)) {
|
|
2632
|
+
if (host.root && relation.localKey) {
|
|
2633
|
+
const inner = this.compileFilterAt(this.relationHost(relation, relation.table, path), value, db, joins, options);
|
|
2634
|
+
if (inner.length === 0)
|
|
2635
|
+
return undefined;
|
|
2636
|
+
joins.add(name);
|
|
2637
|
+
return inner.length === 1 ? inner[0] : (0, drizzle_orm_1.and)(...inner);
|
|
2638
|
+
}
|
|
2639
|
+
return this.belongsToExists(relation, value, path, db, host, options);
|
|
2640
|
+
}
|
|
2641
|
+
const keys = Object.keys(value);
|
|
2642
|
+
const quantified = keys.length > 0 &&
|
|
2643
|
+
keys.every((key) => QUANTIFIERS.has(key) && !this.columnOf(relation.table, key));
|
|
2644
|
+
const parts = [];
|
|
2645
|
+
const quantifiers = quantified
|
|
2646
|
+
? Object.entries(value)
|
|
2647
|
+
: [["some", value]];
|
|
2648
|
+
for (const [quantifier, inner] of quantifiers) {
|
|
2649
|
+
if (inner === undefined || inner === null)
|
|
2650
|
+
continue;
|
|
2651
|
+
if (!(0, merge_config_1.isPlainObject)(inner)) {
|
|
2652
|
+
throw new common_1.BadRequestException(`Filter "${path}.${quantifier}" must be an object of column filters`);
|
|
2653
|
+
}
|
|
2654
|
+
if (quantifier === "some") {
|
|
2655
|
+
const compiled = this.compileFilterAt(this.relationHost(relation, relation.table, path), inner, db, new Set(), options);
|
|
2656
|
+
if (compiled.length === 0 && !quantified)
|
|
2657
|
+
continue;
|
|
2658
|
+
const condition = this.collectionCondition(relation, compiled, db, host, options);
|
|
2659
|
+
if (condition)
|
|
2660
|
+
parts.push(condition);
|
|
2661
|
+
}
|
|
2662
|
+
else {
|
|
2663
|
+
const condition = this.quantifiedCollection(quantifier, relation, inner, path, db, host, options);
|
|
2664
|
+
if (condition)
|
|
2665
|
+
parts.push(condition);
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
if (parts.length === 0)
|
|
2669
|
+
return undefined;
|
|
2670
|
+
return parts.length === 1 ? parts[0] : (0, drizzle_orm_1.and)(...parts);
|
|
2671
|
+
}
|
|
2672
|
+
compileGroup(op, value, compile) {
|
|
2673
|
+
if (op === "not") {
|
|
2674
|
+
if (!(0, merge_config_1.isPlainObject)(value)) {
|
|
2675
|
+
throw new common_1.BadRequestException('Filter group "not" expects a filter object');
|
|
2676
|
+
}
|
|
2677
|
+
const inner = compile(value);
|
|
2678
|
+
return inner.length > 0 ? (0, drizzle_orm_1.not)((0, drizzle_orm_1.and)(...inner)) : undefined;
|
|
2679
|
+
}
|
|
2680
|
+
if (!Array.isArray(value)) {
|
|
2681
|
+
throw new common_1.BadRequestException(`Filter group "${op}" expects an array of filter objects`);
|
|
2682
|
+
}
|
|
2683
|
+
const parts = [];
|
|
2684
|
+
for (const member of value) {
|
|
2685
|
+
if (!(0, merge_config_1.isPlainObject)(member)) {
|
|
2686
|
+
throw new common_1.BadRequestException(`Filter group "${op}" expects an array of filter objects`);
|
|
2687
|
+
}
|
|
2688
|
+
const inner = compile(member);
|
|
2689
|
+
if (inner.length === 0) {
|
|
2690
|
+
if (op === "or")
|
|
2691
|
+
return undefined;
|
|
2692
|
+
continue;
|
|
2693
|
+
}
|
|
2694
|
+
parts.push(inner.length === 1 ? inner[0] : (0, drizzle_orm_1.and)(...inner));
|
|
2695
|
+
}
|
|
2696
|
+
if (parts.length === 0)
|
|
2697
|
+
return undefined;
|
|
2698
|
+
return op === "or" ? (0, drizzle_orm_1.or)(...parts) : (0, drizzle_orm_1.and)(...parts);
|
|
2699
|
+
}
|
|
2700
|
+
buildWhereConditions(filters) {
|
|
2701
|
+
return this.buildFilterConditions(filters).conditions;
|
|
2702
|
+
}
|
|
2703
|
+
parseFlag(op, value) {
|
|
2704
|
+
if (value === true || value === "true")
|
|
2705
|
+
return true;
|
|
2706
|
+
if (value === false || value === "false")
|
|
2707
|
+
return false;
|
|
2708
|
+
throw new common_1.BadRequestException(`Invalid value for "${op}": expected true or false`);
|
|
2709
|
+
}
|
|
2710
|
+
expectArray(op, raw) {
|
|
2711
|
+
if (!Array.isArray(raw)) {
|
|
2712
|
+
throw new common_1.BadRequestException(`Filter operator "${op}" expects an array`);
|
|
2713
|
+
}
|
|
2714
|
+
this.assertBatchSize(raw.length);
|
|
2715
|
+
return raw;
|
|
2716
|
+
}
|
|
2717
|
+
static MAX_BATCH_PARAMETERS = 60_000;
|
|
2718
|
+
assertBatchSize(parameters) {
|
|
2719
|
+
const max = SqlBaseCrudService.MAX_BATCH_PARAMETERS;
|
|
2720
|
+
if (parameters > max) {
|
|
2721
|
+
throw new common_1.BadRequestException(`Too many values in one call (${parameters}); the limit is ${max}. Split the batch.`);
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
insertParameters(rows) {
|
|
2725
|
+
let widest = 0;
|
|
2726
|
+
for (const row of rows) {
|
|
2727
|
+
if ((0, merge_config_1.isPlainObject)(row))
|
|
2728
|
+
widest = Math.max(widest, Object.keys(row).length);
|
|
2729
|
+
}
|
|
2730
|
+
return rows.length * (widest + 4);
|
|
2731
|
+
}
|
|
2732
|
+
requirePostgres(op) {
|
|
2733
|
+
if (this.config.dialect !== "postgresql") {
|
|
2734
|
+
throw new common_1.BadRequestException(`Filter operator "${op}" is only supported on PostgreSQL`);
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
patternCondition(column, op, raw) {
|
|
2738
|
+
if (typeof raw !== "string") {
|
|
2739
|
+
throw new common_1.BadRequestException(`Filter operator "${op}" expects a string`);
|
|
2740
|
+
}
|
|
2741
|
+
if (!this.isTextColumn(column)) {
|
|
2742
|
+
throw new common_1.BadRequestException(`Filter operator "${op}" only works on text columns`);
|
|
2743
|
+
}
|
|
2744
|
+
const insensitive = /^i[A-Z]/.test(op);
|
|
2745
|
+
const kind = insensitive ? op[1].toLowerCase() + op.slice(2) : op;
|
|
2746
|
+
const escaped = raw.replace(/[\\%_]/g, "\\$&");
|
|
2747
|
+
const pattern = kind === "startsWith"
|
|
2748
|
+
? `${escaped}%`
|
|
2749
|
+
: kind === "endsWith"
|
|
2750
|
+
? `%${escaped}`
|
|
2751
|
+
: `%${escaped}%`;
|
|
2752
|
+
return insensitive || this.config.sql?.caseSensitive === false
|
|
2753
|
+
? this.caseInsensitiveLike(column, pattern)
|
|
2754
|
+
: (0, drizzle_orm_1.like)(column, pattern);
|
|
2755
|
+
}
|
|
2756
|
+
applyComplexFilter(conditions, column, filterObj) {
|
|
2757
|
+
const coerce = (value) => this.coerceFilterValue(column, value);
|
|
2758
|
+
for (const [op, raw] of Object.entries(filterObj)) {
|
|
2759
|
+
if (raw === undefined)
|
|
2760
|
+
continue;
|
|
2761
|
+
if ((op === "gt" || op === "gte" || op === "lt" || op === "lte") &&
|
|
2762
|
+
typeof raw === "number" &&
|
|
2763
|
+
!Number.isFinite(raw)) {
|
|
2764
|
+
throw new common_1.BadRequestException(`Invalid numeric filter operand for "${op}": ${raw}`);
|
|
2765
|
+
}
|
|
2766
|
+
switch (op) {
|
|
2767
|
+
case "eq":
|
|
2768
|
+
conditions.push((0, drizzle_orm_1.eq)(column, coerce(raw)));
|
|
2769
|
+
break;
|
|
2770
|
+
case "gt":
|
|
2771
|
+
conditions.push((0, drizzle_orm_1.gt)(column, coerce(raw)));
|
|
2772
|
+
break;
|
|
2773
|
+
case "gte":
|
|
2774
|
+
conditions.push((0, drizzle_orm_1.gte)(column, coerce(raw)));
|
|
2775
|
+
break;
|
|
2776
|
+
case "lt":
|
|
2777
|
+
conditions.push((0, drizzle_orm_1.lt)(column, coerce(raw)));
|
|
2778
|
+
break;
|
|
2779
|
+
case "lte":
|
|
2780
|
+
conditions.push((0, drizzle_orm_1.lte)(column, coerce(raw)));
|
|
2781
|
+
break;
|
|
2782
|
+
case "neq":
|
|
2783
|
+
conditions.push((0, drizzle_orm_1.ne)(column, coerce(raw)));
|
|
2784
|
+
break;
|
|
2785
|
+
case "like":
|
|
2786
|
+
conditions.push((0, drizzle_orm_1.like)(column, raw));
|
|
2787
|
+
break;
|
|
2788
|
+
case "ilike":
|
|
2789
|
+
conditions.push(this.caseInsensitiveLike(column, raw));
|
|
2790
|
+
break;
|
|
2791
|
+
case "in":
|
|
2792
|
+
conditions.push((0, drizzle_orm_1.inArray)(column, this.expectArray(op, raw).map(coerce)));
|
|
2793
|
+
break;
|
|
2794
|
+
case "notIn":
|
|
2795
|
+
conditions.push((0, drizzle_orm_1.notInArray)(column, this.expectArray(op, raw).map(coerce)));
|
|
2796
|
+
break;
|
|
2797
|
+
case "between": {
|
|
2798
|
+
const range = this.expectArray(op, raw);
|
|
2799
|
+
if (range.length !== 2) {
|
|
2800
|
+
throw new common_1.BadRequestException('Filter operator "between" expects [from, to]');
|
|
2801
|
+
}
|
|
2802
|
+
conditions.push((0, drizzle_orm_1.between)(column, coerce(range[0]), coerce(range[1])));
|
|
2803
|
+
break;
|
|
2804
|
+
}
|
|
2805
|
+
case "isNull":
|
|
2806
|
+
conditions.push(this.parseFlag(op, raw) ? (0, drizzle_orm_1.isNull)(column) : (0, drizzle_orm_1.isNotNull)(column));
|
|
2807
|
+
break;
|
|
2808
|
+
case "isNotNull":
|
|
2809
|
+
conditions.push(this.parseFlag(op, raw) ? (0, drizzle_orm_1.isNotNull)(column) : (0, drizzle_orm_1.isNull)(column));
|
|
2810
|
+
break;
|
|
2811
|
+
case "startsWith":
|
|
2812
|
+
case "endsWith":
|
|
2813
|
+
case "contains":
|
|
2814
|
+
case "iStartsWith":
|
|
2815
|
+
case "iEndsWith":
|
|
2816
|
+
case "iContains":
|
|
2817
|
+
conditions.push(this.patternCondition(column, op, raw));
|
|
2818
|
+
break;
|
|
2819
|
+
case "arrayContains":
|
|
2820
|
+
case "arrayOverlaps": {
|
|
2821
|
+
this.requirePostgres(op);
|
|
2822
|
+
if (column?.dataType !== "array") {
|
|
2823
|
+
throw new common_1.BadRequestException(`Filter operator "${op}" only works on array columns`);
|
|
2824
|
+
}
|
|
2825
|
+
const values = this.expectArray(op, raw);
|
|
2826
|
+
conditions.push(op === "arrayContains"
|
|
2827
|
+
? (0, drizzle_orm_1.arrayContains)(column, values)
|
|
2828
|
+
: (0, drizzle_orm_1.arrayOverlaps)(column, values));
|
|
2829
|
+
break;
|
|
2830
|
+
}
|
|
2831
|
+
case "jsonContains":
|
|
2832
|
+
this.requirePostgres(op);
|
|
2833
|
+
if (column?.columnType !== "PgJsonb") {
|
|
2834
|
+
throw new common_1.BadRequestException('Filter operator "jsonContains" only works on jsonb columns');
|
|
2835
|
+
}
|
|
2836
|
+
conditions.push((0, drizzle_orm_1.sql) `${column} @> ${JSON.stringify(raw)}::jsonb`);
|
|
2837
|
+
break;
|
|
2838
|
+
default:
|
|
2839
|
+
throw new common_1.BadRequestException(`Unknown filter operator: ${op}`);
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
async executeSqlTransaction(operation, existingTransaction) {
|
|
2844
|
+
if (existingTransaction)
|
|
2845
|
+
return operation(existingTransaction);
|
|
2846
|
+
return this.config.db.transaction(operation);
|
|
2847
|
+
}
|
|
2848
|
+
getEntityName() {
|
|
2849
|
+
try {
|
|
2850
|
+
return (0, drizzle_orm_1.getTableName)(this.config.table) || "Entity";
|
|
2851
|
+
}
|
|
2852
|
+
catch {
|
|
2853
|
+
return "Entity";
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
async fullTextSearch(searchTerm, searchColumns, pagination, options) {
|
|
2857
|
+
if (this.config.dialect !== "postgresql") {
|
|
2858
|
+
throw new common_1.BadRequestException("Full-text search only supported in PostgreSQL");
|
|
2859
|
+
}
|
|
2860
|
+
if (!Array.isArray(searchColumns) || searchColumns.length === 0) {
|
|
2861
|
+
throw new common_1.BadRequestException("Full-text search needs at least one column");
|
|
2862
|
+
}
|
|
2863
|
+
const columns = searchColumns.map((name) => {
|
|
2864
|
+
const column = this.columnOf(this.config.table, name);
|
|
2865
|
+
if (!column) {
|
|
2866
|
+
throw new common_1.BadRequestException(`Unknown search column: ${name}`);
|
|
2867
|
+
}
|
|
2868
|
+
this.assertAllowed("searchableColumns", name, options);
|
|
2869
|
+
return column;
|
|
2870
|
+
});
|
|
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));
|
|
2877
|
+
const { page, limit, offset } = this.resolvePagination(pagination?.page, pagination?.limit);
|
|
2878
|
+
const pk = this.columnOf(this.config.table, this.config.primaryKey);
|
|
2879
|
+
const orderBy = [(0, drizzle_orm_1.sql) `ts_rank(${tsVector}, ${tsQuery}) DESC`];
|
|
2880
|
+
if (pk)
|
|
2881
|
+
orderBy.push((0, drizzle_orm_1.asc)(pk));
|
|
2882
|
+
const dataQuery = db
|
|
2883
|
+
.select()
|
|
2884
|
+
.from(this.config.table)
|
|
2885
|
+
.where(whereExpr)
|
|
2886
|
+
.orderBy(...orderBy)
|
|
2887
|
+
.limit(limit)
|
|
2888
|
+
.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]));
|
|
2894
|
+
const total = parseInt(totalResult[0]?.count?.toString() || "0", 10);
|
|
2895
|
+
return { data, total, page, limit };
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
exports.SqlBaseCrudService = SqlBaseCrudService;
|
|
2899
|
+
//# sourceMappingURL=sql-base-crud.service.js.map
|