webspresso 0.0.102 → 0.0.104
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/.agents/ORM.md +13 -4
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
- package/README.md +2 -2
- package/bin/commands/dev.js +3 -2
- package/bin/utils/orm-map-html.js +2 -8
- package/core/auth/jwt.js +12 -21
- package/core/auth/oauth.js +38 -14
- package/core/auth/totp.js +3 -15
- package/core/auth/user-agent.js +59 -60
- package/core/compression/compression-stream.js +2 -2
- package/core/compression/middleware.js +1 -1
- package/core/compression/negotiate.js +11 -4
- package/core/content/renderer.js +1 -13
- package/core/errors/middleware.js +2 -8
- package/core/events/dispatcher.js +56 -33
- package/core/forms/form-builder.js +1 -9
- package/core/openapi/orm-components.js +2 -3
- package/core/orm/cache/listeners.js +12 -2
- package/core/orm/index.js +5 -0
- package/core/orm/json-fields.js +7 -1
- package/core/orm/model.js +99 -8
- package/core/orm/query-builder.js +71 -25
- package/core/orm/query-filters.js +393 -0
- package/core/orm/repository.js +42 -17
- package/core/orm/schema-helpers.js +49 -0
- package/core/orm/scopes.js +40 -9
- package/core/orm/types.js +12 -1
- package/core/queue/manager.js +5 -0
- package/core/shutdown/shutdown-manager.js +16 -16
- package/core/utils/crypto.js +73 -0
- package/core/utils/escape-html.js +7 -0
- package/core/utils/fetch.js +120 -0
- package/core/utils/get-client-ip.js +6 -0
- package/core/utils/html.js +72 -0
- package/core/utils/ip.js +86 -0
- package/core/utils/safe-compare.js +3 -0
- package/core/validation/index.js +9 -3
- package/index.d.ts +33 -6
- package/index.js +10 -0
- package/package.json +2 -1
- package/plugins/admin-panel/auth.js +15 -7
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
- package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
- package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
- package/plugins/admin-panel/core/api-extensions.js +1 -75
- package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
- package/plugins/api-key/crypto.js +1 -24
- package/plugins/audit-log/api-handlers.js +11 -1
- package/plugins/audit-log/middleware.js +50 -6
- package/plugins/basic-auth/index.js +2 -26
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +3 -15
- package/plugins/email/service.js +32 -1
- package/plugins/file-manager/admin-component.js +3 -1
- package/plugins/file-manager/api-handlers.js +10 -17
- package/plugins/file-manager/core.js +34 -4
- package/plugins/file-manager/index.js +11 -9
- package/plugins/file-manager/security.js +18 -6
- package/plugins/mcp/auth.js +1 -13
- package/plugins/mcp/index.js +23 -1
- package/plugins/mcp/transports/sse.js +10 -1
- package/plugins/orm-cache-admin/admin-component.js +3 -2
- package/plugins/polar/src/api.js +9 -2
- package/plugins/polar/src/webhooks.js +3 -8
- package/plugins/recaptcha/helpers.js +1 -7
- package/plugins/recaptcha/verify.js +37 -23
- package/plugins/redirect/index.js +51 -3
- package/plugins/rest-resources/index.js +1 -207
- package/plugins/schema-explorer/admin-component.js +4 -2
- package/plugins/schema-explorer.js +40 -29
- package/plugins/seo-checker/index.js +5 -2
- package/plugins/site-analytics/bot-patterns.js +3 -0
- package/plugins/site-analytics/client-error-handler.js +3 -1
- package/plugins/site-analytics/index.js +10 -1
- package/plugins/site-analytics/tracking.js +265 -63
- package/plugins/sitemap.js +18 -15
- package/plugins/swagger.js +9 -14
- package/plugins/webhooks/crypto.js +1 -18
- package/plugins/webhooks/dispatcher.js +6 -1
- package/plugins/webhooks/matcher.js +25 -10
- package/plugins/webhooks/sender.js +32 -2
- package/src/api/api-loader.js +1 -1
- package/src/dev-reload/index.js +47 -6
- package/src/discovery/scan-routes.js +14 -4
- package/src/file-router.js +62 -6
- package/src/helpers.js +157 -32
- package/src/plugin-manager.js +34 -13
- package/src/routing/route-table.js +14 -4
- package/src/server.js +25 -9
- package/src/services/validator.js +57 -32
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso ORM - Universal Query Filter Engine
|
|
3
|
+
*
|
|
4
|
+
* Unified column filtering engine powering both REST Resources and Admin Panel.
|
|
5
|
+
* Supports comparison operators, pattern matching, sets, ranges, nullability,
|
|
6
|
+
* and column type coercion.
|
|
7
|
+
*
|
|
8
|
+
* @module core/orm/query-filters
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const RESERVED_QUERY_KEYS = new Set([
|
|
14
|
+
'page',
|
|
15
|
+
'perPage',
|
|
16
|
+
'per_page',
|
|
17
|
+
'limit',
|
|
18
|
+
'offset',
|
|
19
|
+
'sort',
|
|
20
|
+
'order',
|
|
21
|
+
'include',
|
|
22
|
+
'trashed',
|
|
23
|
+
'fields',
|
|
24
|
+
'select',
|
|
25
|
+
'search',
|
|
26
|
+
'q',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalizes an operator name to canonical form
|
|
31
|
+
* @param {string} op
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function normalizeOperator(op) {
|
|
35
|
+
const s = String(op || 'contains').toLowerCase().trim();
|
|
36
|
+
switch (s) {
|
|
37
|
+
case 'equals':
|
|
38
|
+
case '=':
|
|
39
|
+
case 'eq':
|
|
40
|
+
return 'eq';
|
|
41
|
+
|
|
42
|
+
case 'ne':
|
|
43
|
+
case '!=':
|
|
44
|
+
case '<>':
|
|
45
|
+
case 'not_equals':
|
|
46
|
+
case 'notequals':
|
|
47
|
+
return 'ne';
|
|
48
|
+
|
|
49
|
+
case 'gt':
|
|
50
|
+
case '>':
|
|
51
|
+
return 'gt';
|
|
52
|
+
|
|
53
|
+
case 'gte':
|
|
54
|
+
case '>=':
|
|
55
|
+
return 'gte';
|
|
56
|
+
|
|
57
|
+
case 'lt':
|
|
58
|
+
case '<':
|
|
59
|
+
return 'lt';
|
|
60
|
+
|
|
61
|
+
case 'lte':
|
|
62
|
+
case '<=':
|
|
63
|
+
return 'lte';
|
|
64
|
+
|
|
65
|
+
case 'in':
|
|
66
|
+
return 'in';
|
|
67
|
+
|
|
68
|
+
case 'nin':
|
|
69
|
+
case 'notin':
|
|
70
|
+
case 'not_in':
|
|
71
|
+
return 'notin';
|
|
72
|
+
|
|
73
|
+
case 'like':
|
|
74
|
+
return 'like';
|
|
75
|
+
|
|
76
|
+
case 'ilike':
|
|
77
|
+
return 'ilike';
|
|
78
|
+
|
|
79
|
+
case 'contains':
|
|
80
|
+
return 'contains';
|
|
81
|
+
|
|
82
|
+
case 'starts_with':
|
|
83
|
+
case 'startswith':
|
|
84
|
+
return 'startswith';
|
|
85
|
+
|
|
86
|
+
case 'ends_with':
|
|
87
|
+
case 'endswith':
|
|
88
|
+
return 'endswith';
|
|
89
|
+
|
|
90
|
+
case 'is_null':
|
|
91
|
+
case 'isnull':
|
|
92
|
+
case 'null':
|
|
93
|
+
return 'is_null';
|
|
94
|
+
|
|
95
|
+
case 'is_not_null':
|
|
96
|
+
case 'isnotnull':
|
|
97
|
+
case 'not_null':
|
|
98
|
+
case 'notnull':
|
|
99
|
+
return 'is_not_null';
|
|
100
|
+
|
|
101
|
+
case 'between':
|
|
102
|
+
return 'between';
|
|
103
|
+
|
|
104
|
+
default:
|
|
105
|
+
return s;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Applies a single filter condition to one or more query builder instances
|
|
111
|
+
*
|
|
112
|
+
* @param {Array<Object>} targets - Query builder instances to mutate
|
|
113
|
+
* @param {string} column - Target column name
|
|
114
|
+
* @param {string} rawOp - Operator name
|
|
115
|
+
* @param {unknown} rawVal - Value or object containing from/to
|
|
116
|
+
* @param {Object} [meta] - Optional column metadata from model definition
|
|
117
|
+
*/
|
|
118
|
+
function applyFilterCondition(targets, column, rawOp, rawVal, meta = null) {
|
|
119
|
+
if (!targets || targets.length === 0) return;
|
|
120
|
+
|
|
121
|
+
const colType = meta?.type;
|
|
122
|
+
const operator = normalizeOperator(rawOp);
|
|
123
|
+
let val = rawVal;
|
|
124
|
+
|
|
125
|
+
// Handle boolean column type coercion
|
|
126
|
+
if (colType === 'boolean' && typeof val === 'string') {
|
|
127
|
+
if (val === 'true' || val === '1') {
|
|
128
|
+
val = true;
|
|
129
|
+
} else if (val === 'false' || val === '0') {
|
|
130
|
+
val = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Handle nullability operators
|
|
135
|
+
if (operator === 'is_null') {
|
|
136
|
+
const isTrue = val === undefined || val === null || val === true || val === 'true' || val === '1' || val === 1 || val === '';
|
|
137
|
+
if (isTrue) {
|
|
138
|
+
for (const t of targets) t.whereNull(column);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (operator === 'is_not_null') {
|
|
144
|
+
const isTrue = val === undefined || val === null || val === true || val === 'true' || val === '1' || val === 1 || val === '';
|
|
145
|
+
if (isTrue) {
|
|
146
|
+
for (const t of targets) t.whereNotNull(column);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Handle between operator
|
|
152
|
+
if (operator === 'between') {
|
|
153
|
+
let from = undefined;
|
|
154
|
+
let to = undefined;
|
|
155
|
+
|
|
156
|
+
if (Array.isArray(val)) {
|
|
157
|
+
from = val[0];
|
|
158
|
+
to = val[1];
|
|
159
|
+
} else if (typeof val === 'string') {
|
|
160
|
+
const parts = val.split(',').map((s) => s.trim());
|
|
161
|
+
from = parts[0];
|
|
162
|
+
to = parts[1];
|
|
163
|
+
} else if (val && typeof val === 'object') {
|
|
164
|
+
from = val.from;
|
|
165
|
+
to = val.to;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const hasFrom = from !== undefined && from !== null && from !== '';
|
|
169
|
+
const hasTo = to !== undefined && to !== null && to !== '';
|
|
170
|
+
|
|
171
|
+
if (hasFrom && hasTo) {
|
|
172
|
+
for (const t of targets) {
|
|
173
|
+
t.where(column, '>=', from);
|
|
174
|
+
t.where(column, '<=', to);
|
|
175
|
+
}
|
|
176
|
+
} else if (hasFrom) {
|
|
177
|
+
for (const t of targets) t.where(column, '>=', from);
|
|
178
|
+
} else if (hasTo) {
|
|
179
|
+
for (const t of targets) t.where(column, '<=', to);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Handle in & notin
|
|
185
|
+
if (operator === 'in') {
|
|
186
|
+
const arr = Array.isArray(val)
|
|
187
|
+
? val
|
|
188
|
+
: (typeof val === 'string' ? val.split(',').map((s) => s.trim()).filter(Boolean) : (val !== undefined && val !== '' ? [val] : []));
|
|
189
|
+
if (arr.length > 0) {
|
|
190
|
+
for (const t of targets) t.whereIn(column, arr);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (operator === 'notin') {
|
|
196
|
+
const arr = Array.isArray(val)
|
|
197
|
+
? val
|
|
198
|
+
: (typeof val === 'string' ? val.split(',').map((s) => s.trim()).filter(Boolean) : (val !== undefined && val !== '' ? [val] : []));
|
|
199
|
+
if (arr.length > 0) {
|
|
200
|
+
for (const t of targets) t.whereNotIn(column, arr);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Ignore inactive empty values for standard scalar operators
|
|
206
|
+
if (val === undefined || val === null || val === '') {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
switch (operator) {
|
|
211
|
+
case 'contains':
|
|
212
|
+
for (const t of targets) t.where(column, 'like', `%${val}%`);
|
|
213
|
+
break;
|
|
214
|
+
|
|
215
|
+
case 'startswith':
|
|
216
|
+
for (const t of targets) t.where(column, 'like', `${val}%`);
|
|
217
|
+
break;
|
|
218
|
+
|
|
219
|
+
case 'endswith':
|
|
220
|
+
for (const t of targets) t.where(column, 'like', `%${val}`);
|
|
221
|
+
break;
|
|
222
|
+
|
|
223
|
+
case 'like':
|
|
224
|
+
for (const t of targets) t.where(column, 'like', String(val));
|
|
225
|
+
break;
|
|
226
|
+
|
|
227
|
+
case 'ilike':
|
|
228
|
+
for (const t of targets) t.where(column, 'ilike', String(val));
|
|
229
|
+
break;
|
|
230
|
+
|
|
231
|
+
case 'gt':
|
|
232
|
+
for (const t of targets) t.where(column, '>', val);
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
case 'gte':
|
|
236
|
+
for (const t of targets) t.where(column, '>=', val);
|
|
237
|
+
break;
|
|
238
|
+
|
|
239
|
+
case 'lt':
|
|
240
|
+
for (const t of targets) t.where(column, '<', val);
|
|
241
|
+
break;
|
|
242
|
+
|
|
243
|
+
case 'lte':
|
|
244
|
+
for (const t of targets) t.where(column, '<=', val);
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
case 'ne':
|
|
248
|
+
for (const t of targets) t.where(column, '!=', val);
|
|
249
|
+
break;
|
|
250
|
+
|
|
251
|
+
case 'eq':
|
|
252
|
+
default:
|
|
253
|
+
for (const t of targets) t.where(column, '=', val);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Builds a query with filters applied (used by Admin Panel and Repositories)
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} repoOrQuery - Repository instance or Knex QueryBuilder
|
|
262
|
+
* @param {Object} filters - Filter object: { column: { op, value, from, to } } or { column: value }
|
|
263
|
+
* @param {Object} [options] - Options
|
|
264
|
+
* @param {boolean} [options.onlyTrashed] - For soft delete: only trashed records
|
|
265
|
+
* @param {Object} [options.model] - Optional ModelDefinition for column verification
|
|
266
|
+
* @returns {Object} Query builder with filters applied
|
|
267
|
+
*/
|
|
268
|
+
function buildFilteredQuery(repoOrQuery, filters, options = {}) {
|
|
269
|
+
let query = (repoOrQuery && typeof repoOrQuery.query === 'function')
|
|
270
|
+
? repoOrQuery.query()
|
|
271
|
+
: repoOrQuery;
|
|
272
|
+
|
|
273
|
+
if (options.onlyTrashed && query && typeof query.onlyTrashed === 'function') {
|
|
274
|
+
query = query.onlyTrashed();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!filters || typeof filters !== 'object' || Object.keys(filters).length === 0) {
|
|
278
|
+
return query;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const model = options.model || (repoOrQuery && repoOrQuery.model);
|
|
282
|
+
const columns = model?.columns;
|
|
283
|
+
|
|
284
|
+
for (const [column, filter] of Object.entries(filters)) {
|
|
285
|
+
if (!filter) continue;
|
|
286
|
+
if (columns && typeof columns.has === 'function' && !columns.has(column)) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const meta = columns && typeof columns.get === 'function' ? columns.get(column) : null;
|
|
291
|
+
|
|
292
|
+
if (typeof filter === 'object' && !Array.isArray(filter)) {
|
|
293
|
+
const rawOp = filter.op || filter.operator;
|
|
294
|
+
const normOp = rawOp ? normalizeOperator(rawOp) : null;
|
|
295
|
+
if (normOp === 'is_null' || normOp === 'is_not_null') {
|
|
296
|
+
applyFilterCondition([query], column, normOp, filter.value !== undefined ? filter.value : true, meta);
|
|
297
|
+
} else if (normOp === 'between') {
|
|
298
|
+
applyFilterCondition([query], column, rawOp, filter, meta);
|
|
299
|
+
} else if (filter.value !== undefined) {
|
|
300
|
+
applyFilterCondition([query], column, rawOp || 'contains', filter.value, meta);
|
|
301
|
+
} else {
|
|
302
|
+
for (const [nestedOp, nestedVal] of Object.entries(filter)) {
|
|
303
|
+
applyFilterCondition([query], column, nestedOp, nestedVal, meta);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
applyFilterCondition([query], column, 'eq', filter, meta);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return query;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Extracts and applies column filters from an HTTP request or filter object to query builders
|
|
316
|
+
* (used by REST Resources and API routes)
|
|
317
|
+
*
|
|
318
|
+
* @param {Object} query - Main QueryBuilder
|
|
319
|
+
* @param {Object} [countQuery] - Optional Count QueryBuilder
|
|
320
|
+
* @param {import('./types').ModelDefinition} model - Model definition
|
|
321
|
+
* @param {import('express').Request|Object} reqOrQuery - Express request or query object
|
|
322
|
+
* @returns {{ query: Object, countQuery: Object }} Mutated query instances
|
|
323
|
+
*/
|
|
324
|
+
function applyColumnFilters(query, countQuery, model, reqOrQuery) {
|
|
325
|
+
const q = query;
|
|
326
|
+
const c = countQuery;
|
|
327
|
+
const targets = [q, c].filter(Boolean);
|
|
328
|
+
|
|
329
|
+
const rawQuery = (reqOrQuery && typeof reqOrQuery === 'object' && 'query' in reqOrQuery)
|
|
330
|
+
? reqOrQuery.query
|
|
331
|
+
: (reqOrQuery || {});
|
|
332
|
+
|
|
333
|
+
if (!rawQuery || typeof rawQuery !== 'object') {
|
|
334
|
+
return { query: q, countQuery: c };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const hasColumns = model && model.columns && typeof model.columns.has === 'function';
|
|
338
|
+
|
|
339
|
+
// 1. Process dedicated filter object: ?filter[col]=... or ?filter[col][op]=... or ?filter={ col: { op, value } }
|
|
340
|
+
if (rawQuery.filter && typeof rawQuery.filter === 'object') {
|
|
341
|
+
for (const [col, colFilter] of Object.entries(rawQuery.filter)) {
|
|
342
|
+
if (hasColumns && !model.columns.has(col)) continue;
|
|
343
|
+
const meta = hasColumns ? model.columns.get(col) : null;
|
|
344
|
+
|
|
345
|
+
if (colFilter && typeof colFilter === 'object' && !Array.isArray(colFilter)) {
|
|
346
|
+
const rawColOp = colFilter.op || colFilter.operator;
|
|
347
|
+
const normColOp = rawColOp ? normalizeOperator(rawColOp) : null;
|
|
348
|
+
if (normColOp === 'is_null' || normColOp === 'is_not_null') {
|
|
349
|
+
applyFilterCondition(targets, col, normColOp, colFilter.value !== undefined ? colFilter.value : true, meta);
|
|
350
|
+
} else if (colFilter.op !== undefined && colFilter.value !== undefined) {
|
|
351
|
+
applyFilterCondition(targets, col, colFilter.op, colFilter.value, meta);
|
|
352
|
+
} else if (colFilter.from !== undefined || colFilter.to !== undefined) {
|
|
353
|
+
applyFilterCondition(targets, col, colFilter.op || 'between', colFilter, meta);
|
|
354
|
+
} else {
|
|
355
|
+
for (const [op, val] of Object.entries(colFilter)) {
|
|
356
|
+
applyFilterCondition(targets, col, op, val, meta);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} else if (colFilter !== undefined && colFilter !== '') {
|
|
360
|
+
applyFilterCondition(targets, col, 'eq', colFilter, meta);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 2. Process top-level query parameters: ?price[gte]=100 or ?status=active
|
|
366
|
+
for (const [key, value] of Object.entries(rawQuery)) {
|
|
367
|
+
if (RESERVED_QUERY_KEYS.has(key) || key === 'filter') {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (hasColumns && !model.columns.has(key)) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const meta = hasColumns ? model.columns.get(key) : null;
|
|
374
|
+
|
|
375
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
376
|
+
for (const [op, val] of Object.entries(value)) {
|
|
377
|
+
applyFilterCondition(targets, key, op, val, meta);
|
|
378
|
+
}
|
|
379
|
+
} else if (value !== undefined && value !== '') {
|
|
380
|
+
applyFilterCondition(targets, key, 'eq', value, meta);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { query: q, countQuery: c };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
RESERVED_QUERY_KEYS,
|
|
389
|
+
normalizeOperator,
|
|
390
|
+
applyFilterCondition,
|
|
391
|
+
buildFilteredQuery,
|
|
392
|
+
applyColumnFilters,
|
|
393
|
+
};
|
package/core/orm/repository.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
applyUpdateModifiers,
|
|
13
13
|
getSoftDeleteData,
|
|
14
14
|
getRestoreData,
|
|
15
|
+
getSoftDeleteColumn,
|
|
15
16
|
createScopeContext,
|
|
16
17
|
applyScopes,
|
|
17
18
|
} = require('./scopes');
|
|
@@ -20,6 +21,24 @@ const { ModelEvents, createEventContext, Hooks, HookCancellationError } = requir
|
|
|
20
21
|
const { getJsonColumns, serializeJsonFields, deserializeJsonFields } = require('./json-fields');
|
|
21
22
|
const { getAmbientTransaction } = require('./transaction');
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Get or cache partial schema for model
|
|
26
|
+
* @param {import('./types').ModelDefinition} model
|
|
27
|
+
* @returns {import('zod').ZodTypeAny}
|
|
28
|
+
*/
|
|
29
|
+
function getPartialSchema(model) {
|
|
30
|
+
if (model && model._partialSchema) {
|
|
31
|
+
return model._partialSchema;
|
|
32
|
+
}
|
|
33
|
+
const partial = (model && model.schema && typeof model.schema.partial === 'function')
|
|
34
|
+
? model.schema.partial()
|
|
35
|
+
: model?.schema;
|
|
36
|
+
if (model) {
|
|
37
|
+
model._partialSchema = partial;
|
|
38
|
+
}
|
|
39
|
+
return partial;
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
/**
|
|
24
43
|
* Create a repository for a model
|
|
25
44
|
* @param {import('./types').ModelDefinition} model - Model definition
|
|
@@ -29,7 +48,7 @@ const { getAmbientTransaction } = require('./transaction');
|
|
|
29
48
|
* @returns {import('./types').Repository}
|
|
30
49
|
*/
|
|
31
50
|
function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
32
|
-
const scopeContext = initialContext
|
|
51
|
+
const scopeContext = initialContext ? { ...initialContext } : createScopeContext();
|
|
33
52
|
const jsonColumns = getJsonColumns(model);
|
|
34
53
|
const hasJson = jsonColumns.size > 0;
|
|
35
54
|
|
|
@@ -302,7 +321,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
302
321
|
}
|
|
303
322
|
|
|
304
323
|
// Validate with Zod schema (partial for insert - allows auto fields to be missing)
|
|
305
|
-
const validated = model
|
|
324
|
+
const validated = getPartialSchema(model).parse(workingData);
|
|
306
325
|
|
|
307
326
|
// Emit afterValidation
|
|
308
327
|
ModelEvents.emit(model.name, Hooks.AFTER_VALIDATION, validated, ctx);
|
|
@@ -381,7 +400,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
381
400
|
}
|
|
382
401
|
|
|
383
402
|
// Validate with Zod schema (partial for update)
|
|
384
|
-
const validated = model
|
|
403
|
+
const validated = getPartialSchema(model).parse(workingData);
|
|
385
404
|
|
|
386
405
|
// Emit afterValidation
|
|
387
406
|
ModelEvents.emit(model.name, Hooks.AFTER_VALIDATION, validated, ctx);
|
|
@@ -438,7 +457,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
438
457
|
*/
|
|
439
458
|
async function updateWhere(conditions, data) {
|
|
440
459
|
// Validate with Zod schema (partial for update)
|
|
441
|
-
const validated = model
|
|
460
|
+
const validated = getPartialSchema(model).parse(data);
|
|
442
461
|
|
|
443
462
|
// Apply update modifiers (timestamps)
|
|
444
463
|
let updateData = applyUpdateModifiers(validated, model);
|
|
@@ -493,9 +512,10 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
493
512
|
const parentVal = record[localKey];
|
|
494
513
|
if (parentVal === undefined || parentVal === null) continue;
|
|
495
514
|
|
|
515
|
+
const deletedCol = getSoftDeleteColumn(relatedModel);
|
|
496
516
|
let childQuery = activeKnex(relatedModel.table)
|
|
497
517
|
.where(foreignKey, parentVal)
|
|
498
|
-
.whereNull(
|
|
518
|
+
.whereNull(deletedCol);
|
|
499
519
|
|
|
500
520
|
if (scopeContext && scopeContext.tenantId !== undefined && relatedModel.scopes.tenant) {
|
|
501
521
|
childQuery = childQuery.where(relatedModel.scopes.tenant, scopeContext.tenantId);
|
|
@@ -508,7 +528,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
508
528
|
|
|
509
529
|
await activeKnex(relatedModel.table)
|
|
510
530
|
.whereIn(relatedModel.primaryKey, childIds)
|
|
511
|
-
.update(getSoftDeleteData(deletedAt));
|
|
531
|
+
.update(getSoftDeleteData(deletedAt, relatedModel));
|
|
512
532
|
|
|
513
533
|
if (cacheLayer) {
|
|
514
534
|
cacheLayer.invalidateModelAll(relatedModel);
|
|
@@ -637,9 +657,10 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
637
657
|
const parentVal = trashedRecord[localKey];
|
|
638
658
|
if (parentVal === undefined || parentVal === null) continue;
|
|
639
659
|
|
|
660
|
+
const deletedCol = getSoftDeleteColumn(relatedModel);
|
|
640
661
|
let childQuery = activeKnex(relatedModel.table)
|
|
641
662
|
.where(foreignKey, parentVal)
|
|
642
|
-
.whereNotNull(
|
|
663
|
+
.whereNotNull(deletedCol);
|
|
643
664
|
|
|
644
665
|
if (scopeContext && scopeContext.tenantId !== undefined && relatedModel.scopes.tenant) {
|
|
645
666
|
childQuery = childQuery.where(relatedModel.scopes.tenant, scopeContext.tenantId);
|
|
@@ -647,7 +668,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
647
668
|
|
|
648
669
|
// Selective restore by matching deletion timestamp
|
|
649
670
|
if (!options.allChildren && parentDeletedAt != null) {
|
|
650
|
-
childQuery = childQuery.where(
|
|
671
|
+
childQuery = childQuery.where(deletedCol, parentDeletedAt);
|
|
651
672
|
}
|
|
652
673
|
|
|
653
674
|
const children = await childQuery.select('*');
|
|
@@ -658,14 +679,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
658
679
|
);
|
|
659
680
|
if (hasDeeper) {
|
|
660
681
|
for (const child of children) {
|
|
661
|
-
|
|
682
|
+
const childDeletedAt = child[deletedCol] || child.deleted_at || parentDeletedAt;
|
|
683
|
+
await cascadeRestore(relatedModel, child, childDeletedAt, options, visited);
|
|
662
684
|
}
|
|
663
685
|
}
|
|
664
686
|
|
|
665
687
|
const childIds = children.map(c => c[relatedModel.primaryKey]);
|
|
666
688
|
await activeKnex(relatedModel.table)
|
|
667
689
|
.whereIn(relatedModel.primaryKey, childIds)
|
|
668
|
-
.update(getRestoreData());
|
|
690
|
+
.update(getRestoreData(relatedModel));
|
|
669
691
|
|
|
670
692
|
if (cacheLayer) {
|
|
671
693
|
cacheLayer.invalidateModelAll(relatedModel);
|
|
@@ -674,7 +696,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
674
696
|
if (ModelEvents.hasListeners(relatedModel.name, Hooks.AFTER_RESTORE)) {
|
|
675
697
|
for (const child of children) {
|
|
676
698
|
const childCtx = createEventContext(relatedModel.name, 'restore', getHookTrx());
|
|
677
|
-
ModelEvents.emit(relatedModel.name, Hooks.AFTER_RESTORE, { ...child, deleted_at: null }, childCtx);
|
|
699
|
+
ModelEvents.emit(relatedModel.name, Hooks.AFTER_RESTORE, { ...child, [deletedCol]: null, deleted_at: null }, childCtx);
|
|
678
700
|
}
|
|
679
701
|
}
|
|
680
702
|
}
|
|
@@ -708,7 +730,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
708
730
|
if (model.scopes.softDelete) {
|
|
709
731
|
const updated = await baseQuery()
|
|
710
732
|
.where(model.primaryKey, id)
|
|
711
|
-
.update(getSoftDeleteData(deletedAt));
|
|
733
|
+
.update(getSoftDeleteData(deletedAt, model));
|
|
712
734
|
success = updated > 0;
|
|
713
735
|
|
|
714
736
|
if (success) {
|
|
@@ -791,10 +813,12 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
791
813
|
|
|
792
814
|
const ctx = createEventContext(model.name, 'restore', getHookTrx());
|
|
793
815
|
|
|
816
|
+
const deletedCol = getSoftDeleteColumn(model);
|
|
817
|
+
|
|
794
818
|
// Get the trashed record for hooks (bypass soft delete scope)
|
|
795
819
|
const trashedRecord = await getActiveKnex()(model.table)
|
|
796
820
|
.where(model.primaryKey, id)
|
|
797
|
-
.whereNotNull(
|
|
821
|
+
.whereNotNull(deletedCol)
|
|
798
822
|
.first();
|
|
799
823
|
|
|
800
824
|
if (!trashedRecord) {
|
|
@@ -810,8 +834,8 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
810
834
|
// Update directly without scopes (to find trashed record)
|
|
811
835
|
const updated = await getActiveKnex()(model.table)
|
|
812
836
|
.where(model.primaryKey, id)
|
|
813
|
-
.whereNotNull(
|
|
814
|
-
.update(getRestoreData());
|
|
837
|
+
.whereNotNull(deletedCol)
|
|
838
|
+
.update(getRestoreData(model));
|
|
815
839
|
|
|
816
840
|
if (updated === 0) {
|
|
817
841
|
return null;
|
|
@@ -822,7 +846,8 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
822
846
|
}
|
|
823
847
|
|
|
824
848
|
if (!options || options.cascade !== false) {
|
|
825
|
-
|
|
849
|
+
const parentDeletedAt = trashedRecord[deletedCol] || trashedRecord.deleted_at;
|
|
850
|
+
await cascadeRestore(model, trashedRecord, parentDeletedAt, options);
|
|
826
851
|
}
|
|
827
852
|
|
|
828
853
|
const record = await findById(id, {}, false);
|
|
@@ -838,7 +863,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
838
863
|
* @returns {import('./query-builder').QueryBuilder}
|
|
839
864
|
*/
|
|
840
865
|
function query() {
|
|
841
|
-
return createQueryBuilder(model, getActiveKnex(), scopeContext, cacheLayer);
|
|
866
|
+
return createQueryBuilder(model, getActiveKnex(), { ...scopeContext }, cacheLayer);
|
|
842
867
|
}
|
|
843
868
|
|
|
844
869
|
/**
|
|
@@ -778,6 +778,55 @@ function createSchemaHelpers(z) {
|
|
|
778
778
|
...rest,
|
|
779
779
|
}, z);
|
|
780
780
|
},
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Timestamp pair columns (createdAt and updatedAt)
|
|
784
|
+
* @param {Object} [options={}]
|
|
785
|
+
* @param {boolean} [options.snakeCase=false] - Use snake_case (created_at, updated_at)
|
|
786
|
+
* @param {string} [options.createdAt] - Custom created column name
|
|
787
|
+
* @param {string} [options.updatedAt] - Custom updated column name
|
|
788
|
+
* @returns {{ [key: string]: SchemaBuilder }}
|
|
789
|
+
*/
|
|
790
|
+
timestamps(options = {}) {
|
|
791
|
+
const isSnake = options.snakeCase || options.format === 'snake';
|
|
792
|
+
const createdKey = options.createdAt || (isSnake ? 'created_at' : 'createdAt');
|
|
793
|
+
const updatedKey = options.updatedAt || (isSnake ? 'updated_at' : 'updatedAt');
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
[createdKey]: helpers.timestamp({ auto: 'create', ...options }),
|
|
797
|
+
[updatedKey]: helpers.timestamp({ auto: 'update', ...options }),
|
|
798
|
+
};
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Soft delete column helper (returns a single SchemaBuilder for deletedAt/deleted_at)
|
|
803
|
+
* @param {Partial<import('./types').ColumnMeta>} [options={}]
|
|
804
|
+
* @returns {SchemaBuilder}
|
|
805
|
+
*/
|
|
806
|
+
softDelete(options = {}) {
|
|
807
|
+
return helpers.timestamp({
|
|
808
|
+
nullable: true,
|
|
809
|
+
...options,
|
|
810
|
+
});
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Soft delete field object (returns { deletedAt: SchemaBuilder } or { deleted_at: SchemaBuilder })
|
|
815
|
+
* @param {Object} [options={}]
|
|
816
|
+
* @param {boolean} [options.snakeCase=false] - Use snake_case (deleted_at)
|
|
817
|
+
* @param {string} [options.deletedAt] - Custom deleted column name
|
|
818
|
+
* @returns {{ [key: string]: SchemaBuilder }}
|
|
819
|
+
*/
|
|
820
|
+
softDeletes(options = {}) {
|
|
821
|
+
const isSnake = options.snakeCase || options.format === 'snake';
|
|
822
|
+
const deletedKey = options.deletedAt || (isSnake ? 'deleted_at' : 'deletedAt');
|
|
823
|
+
return {
|
|
824
|
+
[deletedKey]: helpers.timestamp({
|
|
825
|
+
nullable: true,
|
|
826
|
+
...options,
|
|
827
|
+
}),
|
|
828
|
+
};
|
|
829
|
+
},
|
|
781
830
|
};
|
|
782
831
|
|
|
783
832
|
return helpers;
|