webspresso 0.0.103 → 0.0.105
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/AGENTS.md +6 -0
- package/.agents/ARCHITECTURE.md +23 -0
- package/.agents/CACHE.md +147 -0
- package/.agents/CLI.md +1 -1
- package/.agents/EARLY_HINTS.md +120 -0
- package/.agents/HEALTH.md +118 -0
- package/.agents/LIMITS.md +109 -0
- package/.agents/ORM.md +155 -1
- package/.agents/PLUGINS.md +28 -0
- package/.agents/RESILIENCE.md +235 -0
- package/AGENTS.md +6 -0
- package/README.md +2 -2
- package/bin/commands/add-docker.js +6 -6
- package/bin/commands/dev.js +4 -3
- package/bin/commands/new.js +5 -5
- package/bin/commands/openapi.js +1 -1
- package/bin/commands/start.js +1 -1
- package/bin/utils/orm-map-html.js +2 -8
- package/bin/utils/project.js +2 -2
- package/core/auth/hash.js +5 -4
- package/core/auth/jwt.js +6 -57
- package/core/auth/magic-link.js +4 -5
- package/core/auth/manager.js +3 -5
- package/core/auth/oauth.js +45 -30
- package/core/auth/totp.js +5 -16
- package/core/auth/user-agent.js +1 -21
- package/core/cache/byte-size.js +123 -0
- package/core/cache/index.js +21 -0
- package/core/cache/lru.js +477 -0
- package/core/cache/tagged-lru.js +346 -0
- package/core/commands/registry.js +6 -20
- package/core/content/renderer.js +1 -13
- package/core/early-hints/index.js +267 -0
- package/core/errors/domain.js +48 -0
- package/core/errors/http.js +31 -0
- package/core/errors/index.js +10 -0
- package/core/errors/middleware.js +2 -8
- package/core/errors/normalize.js +88 -2
- package/core/events/discovery.js +6 -24
- package/core/events/dispatcher.js +2 -7
- package/core/forms/form-builder.js +1 -9
- package/core/health/index.js +41 -0
- package/core/health/middleware.js +213 -0
- package/core/health/normalize.js +140 -0
- package/core/health/probes.js +203 -0
- package/core/limits/index.js +34 -0
- package/core/limits/middleware.js +317 -0
- package/core/limits/normalize.js +222 -0
- package/core/orm/batch.js +70 -0
- package/core/orm/cache/fingerprint.js +2 -2
- package/core/orm/cache/memory-provider.js +27 -110
- package/core/orm/index.js +76 -8
- package/core/orm/json-fields.js +22 -8
- package/core/orm/model.js +10 -0
- package/core/orm/query-builder.js +63 -9
- package/core/orm/query-filters.js +393 -0
- package/core/orm/repository.js +318 -19
- package/core/orm/schema-helpers.js +8 -10
- package/core/orm/scopes.js +321 -1
- package/core/orm/transaction.js +10 -2
- package/core/orm/utils.js +4 -86
- package/core/queue/adapters/database.js +4 -8
- package/core/queue/manager.js +55 -41
- package/core/resilience/circuit-breaker.js +391 -0
- package/core/resilience/errors.js +63 -0
- package/core/resilience/index.js +53 -0
- package/core/resilience/registry.js +97 -0
- package/core/resilience/retry.js +203 -0
- package/core/schema-cache.js +9 -2
- package/core/shutdown/http-adapter.js +4 -1
- package/core/shutdown/shutdown-manager.js +132 -31
- package/core/utils/array.js +12 -0
- package/core/utils/base64url.js +8 -0
- package/core/utils/crypto.js +191 -0
- package/core/utils/ensure-leading-slash.js +6 -0
- package/core/utils/escape-html.js +7 -0
- package/core/utils/escape-regexp.js +7 -0
- package/core/utils/fetch.js +121 -0
- package/core/utils/fs.js +144 -0
- package/core/utils/get-client-ip.js +6 -0
- package/core/utils/hmac.js +6 -0
- package/core/utils/html.js +72 -0
- package/core/utils/ip.js +86 -0
- package/core/utils/json.js +98 -0
- package/core/utils/object.js +106 -0
- package/core/utils/random.js +9 -0
- package/core/utils/regex.js +77 -0
- package/core/utils/safe-compare.js +3 -0
- package/core/utils/sha256.js +6 -0
- package/core/utils/string.js +87 -0
- package/core/utils/strip-trailing-slash.js +6 -0
- package/core/utils/time.js +96 -0
- package/core/utils/timeout.js +348 -0
- package/core/utils/url.js +106 -0
- package/core/utils/walk.js +7 -0
- package/index.d.ts +627 -2
- package/index.js +158 -0
- package/package.json +1 -2
- 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 +3 -80
- package/plugins/admin-panel/index.js +1 -1
- package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
- package/plugins/admin-panel/modules/menu.js +1 -1
- package/plugins/api-key/crypto.js +3 -26
- package/plugins/api-key/scopes.js +5 -7
- package/plugins/audit-log/api-handlers.js +8 -7
- package/plugins/audit-log/middleware.js +11 -2
- package/plugins/audit-log/parse.js +3 -3
- package/plugins/basic-auth/index.js +2 -26
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +7 -23
- package/plugins/dashboard/app.js +2 -2
- package/plugins/dashboard/index.js +5 -5
- package/plugins/data-exchange/record-selection.js +2 -5
- package/plugins/email/api-handlers.js +5 -18
- package/plugins/email/auth-bridge.js +3 -2
- package/plugins/file-manager/admin-component.js +3 -1
- package/plugins/file-manager/core.js +34 -4
- package/plugins/health-check.js +2 -1
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +5 -6
- package/plugins/mcp/auth.js +1 -13
- package/plugins/mcp/discovery.js +18 -17
- 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 +11 -3
- package/plugins/polar/src/webhooks.js +7 -13
- package/plugins/rate-limit/index.js +3 -1
- package/plugins/recaptcha/helpers.js +1 -7
- package/plugins/recaptcha/verify.js +37 -23
- package/plugins/redirect/index.js +57 -7
- package/plugins/rest-resources/index.js +1 -207
- package/plugins/rls/index.js +170 -0
- 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/tracking.js +21 -18
- package/plugins/sitemap.js +21 -17
- package/plugins/swagger.js +13 -17
- package/plugins/upload/index.js +4 -3
- package/plugins/upload/orphan-pruner.js +4 -3
- package/plugins/webhooks/crypto.js +6 -28
- package/plugins/webhooks/dispatcher.js +14 -9
- package/plugins/webhooks/index.js +6 -0
- package/plugins/webhooks/matcher.js +28 -17
- package/plugins/webhooks/sender.js +40 -13
- package/src/app-context.js +41 -5
- package/src/dev/banner.js +6 -2
- package/src/dev-reload/index.js +47 -6
- package/src/file-router.js +1 -3
- package/src/helpers.js +180 -33
- package/src/modules/module-discovery.js +1 -9
- package/src/pages/page-loader.js +53 -1
- package/src/plugin-manager.js +90 -17
- package/src/server.js +137 -26
- package/src/services/discovery.js +19 -40
- package/src/services/executor.js +86 -25
- package/src/services/memoize.js +3 -25
- package/src/services/registry.js +6 -0
- package/src/template/precompile.js +8 -19
|
@@ -383,12 +383,56 @@ class QueryBuilder {
|
|
|
383
383
|
return this;
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Set authenticated user context for RLS
|
|
388
|
+
* @param {Object|null} user - User object
|
|
389
|
+
* @returns {this}
|
|
390
|
+
*/
|
|
391
|
+
forUser(user) {
|
|
392
|
+
this.scopeContext.user = user;
|
|
393
|
+
return this;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Bypass Row Level Security on this query
|
|
398
|
+
* @returns {this}
|
|
399
|
+
*/
|
|
400
|
+
withoutRls() {
|
|
401
|
+
this.scopeContext.rlsDisabled = true;
|
|
402
|
+
return this;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Apply custom scope context
|
|
407
|
+
* @param {Object} customContext
|
|
408
|
+
* @returns {this}
|
|
409
|
+
*/
|
|
410
|
+
withContext(customContext) {
|
|
411
|
+
Object.assign(this.scopeContext, customContext);
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Explicitly set action mode for RLS
|
|
417
|
+
* @param {'read'|'update'|'delete'|'create'} action
|
|
418
|
+
* @returns {this}
|
|
419
|
+
*/
|
|
420
|
+
asAction(action) {
|
|
421
|
+
this.action = action;
|
|
422
|
+
return this;
|
|
423
|
+
}
|
|
424
|
+
|
|
386
425
|
/**
|
|
387
426
|
* Resolve active Knex instance (explicit or ambient transaction)
|
|
388
427
|
* @returns {import('knex').Knex|import('knex').Knex.Transaction}
|
|
389
428
|
*/
|
|
390
429
|
getActiveKnex() {
|
|
391
430
|
if (this.knex && (this.knex.isTransaction || typeof this.knex.commit === 'function')) {
|
|
431
|
+
if (typeof this.knex.isCompleted === 'function' && this.knex.isCompleted()) {
|
|
432
|
+
const ambientTrx = getAmbientTransaction();
|
|
433
|
+
if (ambientTrx) return ambientTrx;
|
|
434
|
+
return this.knex;
|
|
435
|
+
}
|
|
392
436
|
return this.knex;
|
|
393
437
|
}
|
|
394
438
|
const ambientTrx = getAmbientTransaction();
|
|
@@ -404,7 +448,14 @@ class QueryBuilder {
|
|
|
404
448
|
*/
|
|
405
449
|
_hookTrx() {
|
|
406
450
|
const active = this.getActiveKnex();
|
|
407
|
-
|
|
451
|
+
if (!active) return null;
|
|
452
|
+
if (active.isTransaction || typeof active.commit === 'function') {
|
|
453
|
+
if (typeof active.isCompleted === 'function' && active.isCompleted()) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
return active;
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
408
459
|
}
|
|
409
460
|
|
|
410
461
|
/**
|
|
@@ -414,12 +465,12 @@ class QueryBuilder {
|
|
|
414
465
|
* @returns {import('knex').Knex.QueryBuilder}
|
|
415
466
|
*/
|
|
416
467
|
toKnex(options = {}) {
|
|
417
|
-
const { includeLimitOffset = true } = options;
|
|
468
|
+
const { includeLimitOffset = true, action = this.action || 'read', mutationData = null } = options;
|
|
418
469
|
const activeKnex = this.getActiveKnex();
|
|
419
470
|
let qb = activeKnex(this.model.table);
|
|
420
471
|
|
|
421
|
-
// Apply global scopes
|
|
422
|
-
qb = applyScopes(qb, this.scopeContext, this.model);
|
|
472
|
+
// Apply global scopes (soft delete, multi-tenant, RLS)
|
|
473
|
+
qb = applyScopes(qb, this.scopeContext, this.model, action, mutationData);
|
|
423
474
|
|
|
424
475
|
// Apply selects
|
|
425
476
|
if (this.state.selects.length > 0) {
|
|
@@ -795,7 +846,7 @@ class QueryBuilder {
|
|
|
795
846
|
* @returns {Promise<number>} Number of deleted records
|
|
796
847
|
*/
|
|
797
848
|
async delete() {
|
|
798
|
-
const n = await this.toKnex().delete();
|
|
849
|
+
const n = await this.toKnex({ action: 'delete' }).delete();
|
|
799
850
|
if (this.cacheLayer && n > 0) {
|
|
800
851
|
this.cacheLayer.invalidateModelAll(this.model);
|
|
801
852
|
}
|
|
@@ -808,8 +859,10 @@ class QueryBuilder {
|
|
|
808
859
|
* @returns {Promise<number>} Number of updated records
|
|
809
860
|
*/
|
|
810
861
|
async update(data) {
|
|
862
|
+
const { checkUpdateRls } = require('./scopes');
|
|
863
|
+
checkUpdateRls(data, this.scopeContext, this.model);
|
|
811
864
|
const serialized = serializeJsonFields(data, this.jsonColumns);
|
|
812
|
-
const n = await this.toKnex().update(serialized);
|
|
865
|
+
const n = await this.toKnex({ action: 'update', mutationData: data }).update(serialized);
|
|
813
866
|
if (this.cacheLayer && n > 0) {
|
|
814
867
|
this.cacheLayer.invalidateModelAll(this.model);
|
|
815
868
|
}
|
|
@@ -839,14 +892,15 @@ class QueryBuilder {
|
|
|
839
892
|
clone() {
|
|
840
893
|
const cloned = new QueryBuilder(this.model, this.knex, { ...this.scopeContext }, this.cacheLayer);
|
|
841
894
|
cloned.jsonColumns = this.jsonColumns;
|
|
895
|
+
cloned.action = this.action;
|
|
842
896
|
cloned.state = {
|
|
843
|
-
wheres:
|
|
844
|
-
orderBys:
|
|
897
|
+
wheres: this.state.wheres.map(w => ({ ...w })),
|
|
898
|
+
orderBys: this.state.orderBys.map(o => ({ ...o })),
|
|
845
899
|
selects: [...this.state.selects],
|
|
846
900
|
withs: [...this.state.withs],
|
|
847
901
|
limitValue: this.state.limitValue,
|
|
848
902
|
offsetValue: this.state.offsetValue,
|
|
849
|
-
scopeContext:
|
|
903
|
+
scopeContext: cloned.scopeContext,
|
|
850
904
|
};
|
|
851
905
|
return cloned;
|
|
852
906
|
}
|
|
@@ -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
|
+
};
|