webspresso 0.0.98 → 0.0.99
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/ADMIN.md +11 -1
- package/.agents/AGENTS.md +1 -1
- package/.agents/AUTH.md +169 -0
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +73 -5
- package/.agents/QUEUE.md +84 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +8 -0
- package/core/orm/model.js +4 -0
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +82 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/index.d.ts +528 -0
- package/index.js +44 -0
- package/package.json +1 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +6 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/src/api/api-loader.js +1 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +137 -100
- package/src/helpers.js +9 -0
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
package/core/orm/eager-loader.js
CHANGED
|
@@ -1,31 +1,109 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Webspresso ORM - Eager Loader
|
|
3
|
-
* Batch loading algorithm for relations
|
|
3
|
+
* Batch loading algorithm for relations (supports direct & arbitrary nested relations)
|
|
4
4
|
* @module core/orm/eager-loader
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { resolveRelationModel, getRelationKeys } = require('./model');
|
|
8
8
|
const { applyScopes, createScopeContext } = require('./scopes');
|
|
9
|
+
const { getJsonColumns, deserializeJsonFields } = require('./json-fields');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
+
* Parse relation specifications into a grouped structure: Map<topLevelRelation, string[]>
|
|
13
|
+
* Supports dot notation ('author.profile'), arrays (['author', 'comments.author']),
|
|
14
|
+
* and nested relation chains of arbitrary depth.
|
|
15
|
+
*
|
|
16
|
+
* @param {Array<string|Object>} relationNames
|
|
17
|
+
* @returns {Map<string, string[]>}
|
|
18
|
+
*/
|
|
19
|
+
function parseRelationTree(relationNames) {
|
|
20
|
+
const tree = new Map();
|
|
21
|
+
|
|
22
|
+
function addPath(top, nested) {
|
|
23
|
+
if (!top || typeof top !== 'string') return;
|
|
24
|
+
const cleanTop = top.trim();
|
|
25
|
+
if (!cleanTop) return;
|
|
26
|
+
|
|
27
|
+
if (!tree.has(cleanTop)) {
|
|
28
|
+
tree.set(cleanTop, []);
|
|
29
|
+
}
|
|
30
|
+
if (nested) {
|
|
31
|
+
if (Array.isArray(nested)) {
|
|
32
|
+
for (const item of nested) {
|
|
33
|
+
if (item && typeof item === 'string') tree.get(cleanTop).push(item.trim());
|
|
34
|
+
}
|
|
35
|
+
} else if (typeof nested === 'string') {
|
|
36
|
+
const cleanNested = nested.trim();
|
|
37
|
+
if (cleanNested) tree.get(cleanTop).push(cleanNested);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function processItem(item) {
|
|
43
|
+
if (!item) return;
|
|
44
|
+
|
|
45
|
+
if (typeof item === 'string') {
|
|
46
|
+
const dotIndex = item.indexOf('.');
|
|
47
|
+
if (dotIndex === -1) {
|
|
48
|
+
addPath(item);
|
|
49
|
+
} else {
|
|
50
|
+
const top = item.slice(0, dotIndex);
|
|
51
|
+
const rest = item.slice(dotIndex + 1);
|
|
52
|
+
addPath(top, rest);
|
|
53
|
+
}
|
|
54
|
+
} else if (Array.isArray(item)) {
|
|
55
|
+
for (const sub of item) {
|
|
56
|
+
processItem(sub);
|
|
57
|
+
}
|
|
58
|
+
} else if (typeof item === 'object') {
|
|
59
|
+
for (const [key, value] of Object.entries(item)) {
|
|
60
|
+
if (value === true || value == null) {
|
|
61
|
+
processItem(key);
|
|
62
|
+
} else if (typeof value === 'string' || Array.isArray(value)) {
|
|
63
|
+
const dotIndex = key.indexOf('.');
|
|
64
|
+
if (dotIndex === -1) {
|
|
65
|
+
addPath(key, value);
|
|
66
|
+
} else {
|
|
67
|
+
const top = key.slice(0, dotIndex);
|
|
68
|
+
const rest = key.slice(dotIndex + 1);
|
|
69
|
+
const nestedValues = Array.isArray(value) ? value : [value];
|
|
70
|
+
for (const v of nestedValues) {
|
|
71
|
+
addPath(top, `${rest}.${v}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const list = Array.isArray(relationNames) ? relationNames : [relationNames];
|
|
80
|
+
for (const item of list) {
|
|
81
|
+
processItem(item);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return tree;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Load relations for a set of records using batch queries (supports nested relations)
|
|
12
89
|
* @param {Object[]} records - Records to load relations for
|
|
13
|
-
* @param {string
|
|
90
|
+
* @param {Array<string|Object>} relationNames - Names of relations to load (e.g. ['author.profile', 'comments.author'])
|
|
14
91
|
* @param {import('./types').ModelDefinition} model - Model definition
|
|
15
92
|
* @param {import('knex').Knex|import('knex').Knex.Transaction} knex - Knex instance
|
|
16
93
|
* @param {import('./types').ScopeContext} [scopeContext] - Scope context
|
|
17
94
|
* @returns {Promise<Object[]>} Records with relations attached
|
|
18
95
|
*/
|
|
19
96
|
async function loadRelations(records, relationNames, model, knex, scopeContext) {
|
|
20
|
-
if (!records.length || !relationNames.length) {
|
|
97
|
+
if (!records || !records.length || !relationNames || !relationNames.length) {
|
|
21
98
|
return records;
|
|
22
99
|
}
|
|
23
100
|
|
|
24
101
|
const context = scopeContext || createScopeContext();
|
|
102
|
+
const tree = parseRelationTree(relationNames);
|
|
25
103
|
|
|
26
|
-
// Process each relation
|
|
27
|
-
for (const relationName of
|
|
28
|
-
const relation = model.relations[relationName];
|
|
104
|
+
// Process each relation grouped by top-level
|
|
105
|
+
for (const [relationName, nestedPaths] of tree.entries()) {
|
|
106
|
+
const relation = model.relations?.[relationName];
|
|
29
107
|
if (!relation) {
|
|
30
108
|
console.warn(`Relation "${relationName}" not found on model "${model.name}"`);
|
|
31
109
|
continue;
|
|
@@ -43,6 +121,38 @@ async function loadRelations(records, relationNames, model, knex, scopeContext)
|
|
|
43
121
|
case 'hasOne':
|
|
44
122
|
await loadHasOne(records, relationName, localKey, foreignKey, relatedModel, knex, context);
|
|
45
123
|
break;
|
|
124
|
+
default:
|
|
125
|
+
console.warn(`Unsupported relation type "${relation.type}" for relation "${relationName}" on model "${model.name}"`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Process nested relations if any
|
|
130
|
+
if (nestedPaths.length > 0) {
|
|
131
|
+
const childRecords = [];
|
|
132
|
+
const seenRefs = new Set();
|
|
133
|
+
const len = records.length;
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < len; i++) {
|
|
136
|
+
const child = records[i][relationName];
|
|
137
|
+
if (!child) continue;
|
|
138
|
+
|
|
139
|
+
if (Array.isArray(child)) {
|
|
140
|
+
for (let j = 0; j < child.length; j++) {
|
|
141
|
+
const item = child[j];
|
|
142
|
+
if (item && typeof item === 'object' && !seenRefs.has(item)) {
|
|
143
|
+
seenRefs.add(item);
|
|
144
|
+
childRecords.push(item);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else if (typeof child === 'object' && !seenRefs.has(child)) {
|
|
148
|
+
seenRefs.add(child);
|
|
149
|
+
childRecords.push(child);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (childRecords.length > 0) {
|
|
154
|
+
await loadRelations(childRecords, nestedPaths, relatedModel, knex, context);
|
|
155
|
+
}
|
|
46
156
|
}
|
|
47
157
|
}
|
|
48
158
|
|
|
@@ -116,6 +226,14 @@ async function loadBelongsTo(records, relationName, localKey, foreignKey, relate
|
|
|
116
226
|
relatedModel
|
|
117
227
|
);
|
|
118
228
|
|
|
229
|
+
const jsonCols = getJsonColumns(relatedModel);
|
|
230
|
+
if (jsonCols.size > 0) {
|
|
231
|
+
const rLen = relatedRecords.length;
|
|
232
|
+
for (let i = 0; i < rLen; i++) {
|
|
233
|
+
deserializeJsonFields(relatedRecords[i], jsonCols);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
119
237
|
const relatedMap = new Map();
|
|
120
238
|
const rLen = relatedRecords.length;
|
|
121
239
|
const pk = relatedModel.primaryKey;
|
|
@@ -171,6 +289,14 @@ async function loadHasMany(records, relationName, localKey, foreignKey, relatedM
|
|
|
171
289
|
relatedModel
|
|
172
290
|
);
|
|
173
291
|
|
|
292
|
+
const jsonCols = getJsonColumns(relatedModel);
|
|
293
|
+
if (jsonCols.size > 0) {
|
|
294
|
+
const rLen = relatedRecords.length;
|
|
295
|
+
for (let i = 0; i < rLen; i++) {
|
|
296
|
+
deserializeJsonFields(relatedRecords[i], jsonCols);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
174
300
|
const relatedGroups = new Map();
|
|
175
301
|
const rLen = relatedRecords.length;
|
|
176
302
|
for (let i = 0; i < rLen; i++) {
|
|
@@ -229,6 +355,14 @@ async function loadHasOne(records, relationName, localKey, foreignKey, relatedMo
|
|
|
229
355
|
relatedModel
|
|
230
356
|
);
|
|
231
357
|
|
|
358
|
+
const jsonCols = getJsonColumns(relatedModel);
|
|
359
|
+
if (jsonCols.size > 0) {
|
|
360
|
+
const rLen = relatedRecords.length;
|
|
361
|
+
for (let i = 0; i < rLen; i++) {
|
|
362
|
+
deserializeJsonFields(relatedRecords[i], jsonCols);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
232
366
|
const relatedMap = new Map();
|
|
233
367
|
const rLen = relatedRecords.length;
|
|
234
368
|
for (let i = 0; i < rLen; i++) {
|
|
@@ -247,9 +381,9 @@ async function loadHasOne(records, relationName, localKey, foreignKey, relatedMo
|
|
|
247
381
|
}
|
|
248
382
|
|
|
249
383
|
/**
|
|
250
|
-
* Load a single relation for a single record
|
|
384
|
+
* Load a single relation (or nested relation path) for a single record
|
|
251
385
|
* @param {Object} record - Record to load relation for
|
|
252
|
-
* @param {string} relationName - Name of relation to load
|
|
386
|
+
* @param {string} relationName - Name or dot-separated path of relation to load (e.g. 'author.profile')
|
|
253
387
|
* @param {import('./types').ModelDefinition} model - Model definition
|
|
254
388
|
* @param {import('knex').Knex} knex - Knex instance
|
|
255
389
|
* @param {import('./types').ScopeContext} [scopeContext] - Scope context
|
|
@@ -267,5 +401,5 @@ module.exports = {
|
|
|
267
401
|
loadHasMany,
|
|
268
402
|
loadHasOne,
|
|
269
403
|
queryInChunks,
|
|
404
|
+
parseRelationTree,
|
|
270
405
|
};
|
|
271
|
-
|
package/core/orm/index.js
CHANGED
|
@@ -372,9 +372,17 @@ module.exports = {
|
|
|
372
372
|
Hooks,
|
|
373
373
|
HookCancellationError,
|
|
374
374
|
createEventContext,
|
|
375
|
+
// Eager loading & nested relations
|
|
376
|
+
loadRelations: require('./eager-loader').loadRelations,
|
|
377
|
+
loadRelation: require('./eager-loader').loadRelation,
|
|
378
|
+
parseRelationTree: require('./eager-loader').parseRelationTree,
|
|
375
379
|
// Query Complexity & DoS Protection
|
|
376
380
|
QueryComplexityError: require('./complexity').QueryComplexityError,
|
|
377
381
|
validateQueryComplexity: require('./complexity').validateQueryComplexity,
|
|
378
382
|
resolveQueryLimits: require('./complexity').resolveQueryLimits,
|
|
379
383
|
DEFAULT_QUERY_LIMITS: require('./complexity').DEFAULT_QUERY_LIMITS,
|
|
384
|
+
// Universal Full-Text Search
|
|
385
|
+
buildSearchClause: require('./search').buildSearchClause,
|
|
386
|
+
tokenizeSearchTerm: require('./search').tokenizeSearchTerm,
|
|
387
|
+
detectDialect: require('./search').detectDialect,
|
|
380
388
|
};
|
package/core/orm/model.js
CHANGED
|
@@ -33,6 +33,7 @@ function defineModel(options) {
|
|
|
33
33
|
hidden = [],
|
|
34
34
|
queryLimits = {},
|
|
35
35
|
cache: cacheOption,
|
|
36
|
+
searchable,
|
|
36
37
|
} = options;
|
|
37
38
|
|
|
38
39
|
// Validate required fields
|
|
@@ -131,6 +132,9 @@ function defineModel(options) {
|
|
|
131
132
|
allowInclude: Array.isArray(rest.allowInclude) ? rest.allowInclude.filter((x) => typeof x === 'string') : null,
|
|
132
133
|
},
|
|
133
134
|
hidden: Array.isArray(hidden) ? hidden : [],
|
|
135
|
+
searchable: Array.isArray(searchable)
|
|
136
|
+
? searchable
|
|
137
|
+
: (Array.isArray(admin.searchFields) ? admin.searchFields : null),
|
|
134
138
|
queryLimits: typeof queryLimits === 'object' && queryLimits !== null ? queryLimits : {},
|
|
135
139
|
hooks: {},
|
|
136
140
|
cache: cacheOption,
|
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
} = require('./json-fields');
|
|
16
16
|
const { getAmbientTransaction } = require('./transaction');
|
|
17
17
|
const { validateQueryComplexity } = require('./complexity');
|
|
18
|
+
const { buildSearchClause } = require('./search');
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Create a new query builder
|
|
@@ -232,6 +233,70 @@ class QueryBuilder {
|
|
|
232
233
|
return this;
|
|
233
234
|
}
|
|
234
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Add a raw ORDER BY clause
|
|
238
|
+
* @param {string} sql - Raw SQL expression
|
|
239
|
+
* @param {Array} [bindings=[]] - Parameter bindings
|
|
240
|
+
* @returns {this}
|
|
241
|
+
*/
|
|
242
|
+
orderByRaw(sql, bindings = []) {
|
|
243
|
+
this.state.orderBys.push({ raw: true, sql, bindings });
|
|
244
|
+
return this;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Full-Text / Keyword Search across columns
|
|
249
|
+
* Supports SQLite, PostgreSQL, and MySQL with dialect-aware FTS and tokenized LIKE fallback
|
|
250
|
+
* @param {string} term - Search query string
|
|
251
|
+
* @param {string|string[]} [columns] - Columns to search (defaults to model.searchable)
|
|
252
|
+
* @param {Object} [options] - Search options
|
|
253
|
+
* @param {'auto'|'fts'|'like'} [options.mode='auto'] - Search mode
|
|
254
|
+
* @param {string} [options.language='english'] - PostgreSQL text search language
|
|
255
|
+
* @param {boolean} [options.matchAllWords=true] - Require all words to match (AND)
|
|
256
|
+
* @param {boolean} [options.exact=false] - Match exact phrase
|
|
257
|
+
* @param {boolean} [options.rank=false] - Order by relevance rank (Postgres)
|
|
258
|
+
* @param {string} [options.ftsTable] - Separate SQLite FTS virtual table name
|
|
259
|
+
* @returns {this}
|
|
260
|
+
*/
|
|
261
|
+
search(term, columns, options = {}) {
|
|
262
|
+
const activeKnex = this.getActiveKnex();
|
|
263
|
+
const clause = buildSearchClause({
|
|
264
|
+
knex: activeKnex,
|
|
265
|
+
model: this.model,
|
|
266
|
+
term,
|
|
267
|
+
columns,
|
|
268
|
+
options,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (!clause) {
|
|
272
|
+
return this;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.state.wheres.push({
|
|
276
|
+
raw: true,
|
|
277
|
+
sql: clause.sql,
|
|
278
|
+
bindings: clause.bindings,
|
|
279
|
+
boolean: clause.boolean || 'and',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (clause.rankClause) {
|
|
283
|
+
this.orderByRaw(clause.rankClause.sql, clause.rankClause.bindings);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Add an OR Full-Text / Keyword Search clause
|
|
291
|
+
* @param {string} term - Search query string
|
|
292
|
+
* @param {string|string[]} [columns] - Columns to search
|
|
293
|
+
* @param {Object} [options] - Search options
|
|
294
|
+
* @returns {this}
|
|
295
|
+
*/
|
|
296
|
+
orSearch(term, columns, options = {}) {
|
|
297
|
+
return this.search(term, columns, { ...options, boolean: 'or' });
|
|
298
|
+
}
|
|
299
|
+
|
|
235
300
|
/**
|
|
236
301
|
* Set LIMIT
|
|
237
302
|
* @param {number} limit - Limit value
|
|
@@ -265,6 +330,15 @@ class QueryBuilder {
|
|
|
265
330
|
return this;
|
|
266
331
|
}
|
|
267
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Add relations to eager load (alias for with)
|
|
335
|
+
* @param {...string} relations - Relation names or nested paths (e.g. 'author.profile')
|
|
336
|
+
* @returns {this}
|
|
337
|
+
*/
|
|
338
|
+
include(...relations) {
|
|
339
|
+
return this.with(...relations);
|
|
340
|
+
}
|
|
341
|
+
|
|
268
342
|
/**
|
|
269
343
|
* Include soft-deleted records
|
|
270
344
|
* @returns {this}
|
|
@@ -376,7 +450,11 @@ class QueryBuilder {
|
|
|
376
450
|
|
|
377
451
|
// Apply order by
|
|
378
452
|
for (const orderBy of this.state.orderBys) {
|
|
379
|
-
|
|
453
|
+
if (orderBy.raw) {
|
|
454
|
+
qb = qb.orderByRaw(orderBy.sql, orderBy.bindings);
|
|
455
|
+
} else {
|
|
456
|
+
qb = qb.orderBy(orderBy.column, orderBy.direction);
|
|
457
|
+
}
|
|
380
458
|
}
|
|
381
459
|
|
|
382
460
|
if (includeLimitOffset) {
|
|
@@ -416,7 +494,7 @@ class QueryBuilder {
|
|
|
416
494
|
|
|
417
495
|
const withs = self.getWiths();
|
|
418
496
|
if (withs.length > 0) {
|
|
419
|
-
await loadRelations([result], ensureArray(withs), self.model, self.
|
|
497
|
+
await loadRelations([result], ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
|
|
420
498
|
}
|
|
421
499
|
|
|
422
500
|
ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, result, ctx);
|
|
@@ -482,7 +560,7 @@ class QueryBuilder {
|
|
|
482
560
|
|
|
483
561
|
const withs = self.getWiths();
|
|
484
562
|
if (withs.length > 0 && results.length > 0) {
|
|
485
|
-
await loadRelations(results, ensureArray(withs), self.model, self.
|
|
563
|
+
await loadRelations(results, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
|
|
486
564
|
}
|
|
487
565
|
|
|
488
566
|
for (const record of results) {
|
|
@@ -527,6 +605,14 @@ class QueryBuilder {
|
|
|
527
605
|
return this.list();
|
|
528
606
|
}
|
|
529
607
|
|
|
608
|
+
/**
|
|
609
|
+
* Alias for list()
|
|
610
|
+
* @returns {Promise<Object[]>}
|
|
611
|
+
*/
|
|
612
|
+
async find() {
|
|
613
|
+
return this.list();
|
|
614
|
+
}
|
|
615
|
+
|
|
530
616
|
/**
|
|
531
617
|
* Execute query and return count
|
|
532
618
|
* @returns {Promise<number>}
|
|
@@ -613,7 +699,7 @@ class QueryBuilder {
|
|
|
613
699
|
|
|
614
700
|
const withs = self.getWiths();
|
|
615
701
|
if (withs.length > 0 && data.length > 0) {
|
|
616
|
-
await loadRelations(data, ensureArray(withs), self.model, self.
|
|
702
|
+
await loadRelations(data, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
|
|
617
703
|
}
|
|
618
704
|
|
|
619
705
|
for (const record of data) {
|
package/core/orm/repository.js
CHANGED
|
@@ -73,7 +73,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
73
73
|
* @returns {Promise<Object|null>}
|
|
74
74
|
*/
|
|
75
75
|
async function findById(id, options = {}, emitEvents = true) {
|
|
76
|
-
const { with: withs = [], select } = options;
|
|
76
|
+
const { with: withs = options.include || [], select } = options;
|
|
77
77
|
const shouldEmit = emitEvents && (
|
|
78
78
|
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
79
79
|
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
@@ -144,7 +144,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
144
144
|
* @returns {Promise<Object|null>}
|
|
145
145
|
*/
|
|
146
146
|
async function findOne(conditions, options = {}) {
|
|
147
|
-
const { with: withs = [], select } = options;
|
|
147
|
+
const { with: withs = options.include || [], select } = options;
|
|
148
148
|
const shouldEmit = (
|
|
149
149
|
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
150
150
|
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
@@ -230,7 +230,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
230
230
|
* @returns {Promise<Object[]>}
|
|
231
231
|
*/
|
|
232
232
|
async function findAll(options = {}) {
|
|
233
|
-
const { with: withs = [], select } = options;
|
|
233
|
+
const { with: withs = options.include || [], select } = options;
|
|
234
234
|
const shouldEmit = (
|
|
235
235
|
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
236
236
|
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
@@ -619,10 +619,49 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
619
619
|
return query().with(...relations);
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Find records matching conditions (with optional relations to eager load)
|
|
624
|
+
* @param {Object} [conditions={}] - WHERE conditions
|
|
625
|
+
* @param {import('./types').FindOptions} [options={}] - Find options
|
|
626
|
+
* @returns {Promise<Object[]>}
|
|
627
|
+
*/
|
|
628
|
+
async function find(conditions = {}, options = {}) {
|
|
629
|
+
if (!conditions || Object.keys(conditions).length === 0) {
|
|
630
|
+
return findAll(options);
|
|
631
|
+
}
|
|
632
|
+
const withs = options.include || options.with || [];
|
|
633
|
+
let qb = query().where(conditions);
|
|
634
|
+
if (withs.length > 0) {
|
|
635
|
+
qb = qb.with(withs);
|
|
636
|
+
}
|
|
637
|
+
if (options.select && options.select.length > 0) {
|
|
638
|
+
qb = qb.select(options.select);
|
|
639
|
+
}
|
|
640
|
+
if (options.limit !== undefined) {
|
|
641
|
+
qb = qb.limit(options.limit);
|
|
642
|
+
}
|
|
643
|
+
if (options.offset !== undefined) {
|
|
644
|
+
qb = qb.offset(options.offset);
|
|
645
|
+
}
|
|
646
|
+
return qb.list();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Search records using Full-Text / Keyword Search
|
|
651
|
+
* @param {string} term - Search query
|
|
652
|
+
* @param {string|string[]} [columns] - Columns to search
|
|
653
|
+
* @param {Object} [options] - Search options
|
|
654
|
+
* @returns {import('./query-builder').QueryBuilder}
|
|
655
|
+
*/
|
|
656
|
+
function search(term, columns, options) {
|
|
657
|
+
return query().search(term, columns, options);
|
|
658
|
+
}
|
|
659
|
+
|
|
622
660
|
return {
|
|
623
661
|
findById,
|
|
624
662
|
findOne,
|
|
625
663
|
findAll,
|
|
664
|
+
find,
|
|
626
665
|
create,
|
|
627
666
|
createMany,
|
|
628
667
|
update,
|
|
@@ -631,10 +670,12 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
631
670
|
forceDelete,
|
|
632
671
|
restore,
|
|
633
672
|
query,
|
|
673
|
+
search,
|
|
634
674
|
raw,
|
|
635
675
|
count,
|
|
636
676
|
exists,
|
|
637
677
|
with: withRelations,
|
|
678
|
+
include: withRelations,
|
|
638
679
|
// Expose model for introspection
|
|
639
680
|
model,
|
|
640
681
|
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso ORM - Universal Full-Text Search (FTS)
|
|
3
|
+
* Database-agnostic full-text search abstraction for SQLite, PostgreSQL, and MySQL
|
|
4
|
+
* @module core/orm/search
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { ensureArray } = require('./utils');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Detect database dialect from Knex instance
|
|
11
|
+
* @param {import('knex').Knex} knex
|
|
12
|
+
* @returns {'sqlite'|'postgres'|'mysql'|'generic'}
|
|
13
|
+
*/
|
|
14
|
+
function detectDialect(knex) {
|
|
15
|
+
const client = knex?.client?.config?.client || knex?.client?.dialect || '';
|
|
16
|
+
const s = String(client).toLowerCase();
|
|
17
|
+
if (s.includes('sqlite')) return 'sqlite';
|
|
18
|
+
if (s.includes('pg') || s.includes('postgres')) return 'postgres';
|
|
19
|
+
if (s.includes('mysql') || s.includes('maria')) return 'mysql';
|
|
20
|
+
return 'generic';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tokenize search term into clean tokens, supporting quoted exact phrases
|
|
25
|
+
* e.g. 'apple "macbook pro" laptop' -> ['apple', 'macbook pro', 'laptop']
|
|
26
|
+
* @param {string} term
|
|
27
|
+
* @returns {string[]}
|
|
28
|
+
*/
|
|
29
|
+
function tokenizeSearchTerm(term) {
|
|
30
|
+
if (!term || typeof term !== 'string') return [];
|
|
31
|
+
const matches = term.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
32
|
+
return matches
|
|
33
|
+
.map((t) => t.replace(/^"|"$/g, '').trim())
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize and validate search columns for a model
|
|
39
|
+
* @param {Object} model
|
|
40
|
+
* @param {string|string[]} [columns]
|
|
41
|
+
* @returns {string[]}
|
|
42
|
+
*/
|
|
43
|
+
function normalizeSearchColumns(model, columns) {
|
|
44
|
+
if (columns != null && (Array.isArray(columns) ? columns.length > 0 : Boolean(columns))) {
|
|
45
|
+
return ensureArray(columns);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fallback to model searchable fields if configured
|
|
49
|
+
if (Array.isArray(model?.searchable) && model.searchable.length > 0) {
|
|
50
|
+
return model.searchable;
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(model?.admin?.searchFields) && model.admin.searchFields.length > 0) {
|
|
53
|
+
return model.admin.searchFields;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new Error(
|
|
57
|
+
`No searchable columns specified for model "${model?.name || 'Unknown'}". Pass columns to .search(term, columns) or define searchable: [...] on the model.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build SQL search clause and parameter bindings
|
|
63
|
+
* @param {Object} params
|
|
64
|
+
* @param {import('knex').Knex} params.knex - Knex instance
|
|
65
|
+
* @param {Object} params.model - Model definition
|
|
66
|
+
* @param {string} params.term - Search query
|
|
67
|
+
* @param {string|string[]} [params.columns] - Columns to search
|
|
68
|
+
* @param {Object} [params.options] - Search options
|
|
69
|
+
* @param {'auto'|'fts'|'like'} [params.options.mode='auto'] - Search mode
|
|
70
|
+
* @param {string} [params.options.language='english'] - PostgreSQL text search configuration
|
|
71
|
+
* @param {boolean} [params.options.matchAllWords=true] - Require all words to match (AND vs OR)
|
|
72
|
+
* @param {boolean} [params.options.exact=false] - Treat entire term as exact substring
|
|
73
|
+
* @param {boolean} [params.options.rank=false] - Order by search relevance rank (Postgres)
|
|
74
|
+
* @param {string} [params.options.ftsTable] - Separate SQLite FTS virtual table name
|
|
75
|
+
* @param {'and'|'or'} [params.options.boolean='and'] - Where condition boolean operator
|
|
76
|
+
* @returns {{ raw: true, sql: string, bindings: any[], boolean: 'and'|'or', rankClause: Object|null }|null}
|
|
77
|
+
*/
|
|
78
|
+
function buildSearchClause({ knex, model, term, columns, options = {} }) {
|
|
79
|
+
if (!term || typeof term !== 'string' || !term.trim()) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const cleanTerm = term.trim();
|
|
84
|
+
const searchColumns = normalizeSearchColumns(model, columns);
|
|
85
|
+
const dialect = detectDialect(knex);
|
|
86
|
+
const mode = options.mode || 'auto';
|
|
87
|
+
const language = options.language || 'english';
|
|
88
|
+
const matchAllWords = options.matchAllWords !== false;
|
|
89
|
+
const exact = options.exact === true;
|
|
90
|
+
const boolean = options.boolean === 'or' ? 'or' : 'and';
|
|
91
|
+
const rank = options.rank === true;
|
|
92
|
+
|
|
93
|
+
// 1. PostgreSQL Full-Text Search (when mode is 'fts' or 'auto' on postgres)
|
|
94
|
+
if (dialect === 'postgres' && (mode === 'fts' || mode === 'auto')) {
|
|
95
|
+
const colsSql = searchColumns
|
|
96
|
+
.map((c) => `COALESCE(${knex.ref(c)}::text, '')`)
|
|
97
|
+
.join(" || ' ' || ");
|
|
98
|
+
|
|
99
|
+
const queryFn = exact || cleanTerm.includes('"')
|
|
100
|
+
? 'websearch_to_tsquery'
|
|
101
|
+
: 'plainto_tsquery';
|
|
102
|
+
|
|
103
|
+
const sql = `to_tsvector(?, ${colsSql}) @@ ${queryFn}(?, ?)`;
|
|
104
|
+
const bindings = [language, language, cleanTerm];
|
|
105
|
+
|
|
106
|
+
let rankClause = null;
|
|
107
|
+
if (rank) {
|
|
108
|
+
rankClause = {
|
|
109
|
+
sql: `ts_rank(to_tsvector(?, ${colsSql}), ${queryFn}(?, ?)) DESC`,
|
|
110
|
+
bindings: [language, language, cleanTerm],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
raw: true,
|
|
116
|
+
sql,
|
|
117
|
+
bindings,
|
|
118
|
+
boolean,
|
|
119
|
+
rankClause,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 2. SQLite Native FTS5 (when mode is 'fts' on sqlite)
|
|
124
|
+
if (dialect === 'sqlite' && mode === 'fts') {
|
|
125
|
+
if (options.ftsTable) {
|
|
126
|
+
const ftsRef = knex.ref(options.ftsTable).toString();
|
|
127
|
+
const sql = `rowid IN (SELECT rowid FROM ${ftsRef} WHERE ${ftsRef} MATCH ?)`;
|
|
128
|
+
return {
|
|
129
|
+
raw: true,
|
|
130
|
+
sql,
|
|
131
|
+
bindings: [cleanTerm],
|
|
132
|
+
boolean,
|
|
133
|
+
rankClause: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const tableRef = knex.ref(model.table).toString();
|
|
138
|
+
const sql = `${tableRef} MATCH ?`;
|
|
139
|
+
return {
|
|
140
|
+
raw: true,
|
|
141
|
+
sql,
|
|
142
|
+
bindings: [cleanTerm],
|
|
143
|
+
boolean,
|
|
144
|
+
rankClause: null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 3. MySQL FullText (when mode is 'fts' on mysql)
|
|
149
|
+
if (dialect === 'mysql' && mode === 'fts') {
|
|
150
|
+
const colsList = searchColumns.map((c) => knex.ref(c).toString()).join(', ');
|
|
151
|
+
const sql = `MATCH(${colsList}) AGAINST(? IN BOOLEAN MODE)`;
|
|
152
|
+
return {
|
|
153
|
+
raw: true,
|
|
154
|
+
sql,
|
|
155
|
+
bindings: [cleanTerm],
|
|
156
|
+
boolean,
|
|
157
|
+
rankClause: null,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 4. Universal Tokenized LIKE Search (default for SQLite, MySQL, and mode: 'like')
|
|
162
|
+
if (exact) {
|
|
163
|
+
const pattern = `%${cleanTerm}%`;
|
|
164
|
+
const bindings = [];
|
|
165
|
+
const colClauses = searchColumns.map((c) => {
|
|
166
|
+
bindings.push(pattern);
|
|
167
|
+
return `LOWER(${knex.ref(c)}) LIKE LOWER(?)`;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
raw: true,
|
|
172
|
+
sql: `(${colClauses.join(' OR ')})`,
|
|
173
|
+
bindings,
|
|
174
|
+
boolean,
|
|
175
|
+
rankClause: null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const tokens = tokenizeSearchTerm(cleanTerm);
|
|
180
|
+
if (tokens.length === 0) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const wordClauses = [];
|
|
185
|
+
const bindings = [];
|
|
186
|
+
|
|
187
|
+
for (const token of tokens) {
|
|
188
|
+
const pattern = `%${token}%`;
|
|
189
|
+
const colClauses = searchColumns.map((c) => {
|
|
190
|
+
bindings.push(pattern);
|
|
191
|
+
return `LOWER(${knex.ref(c)}) LIKE LOWER(?)`;
|
|
192
|
+
});
|
|
193
|
+
wordClauses.push(`(${colClauses.join(' OR ')})`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const joinOp = matchAllWords ? ' AND ' : ' OR ';
|
|
197
|
+
const sql = `(${wordClauses.join(joinOp)})`;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
raw: true,
|
|
201
|
+
sql,
|
|
202
|
+
bindings,
|
|
203
|
+
boolean,
|
|
204
|
+
rankClause: null,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
detectDialect,
|
|
210
|
+
tokenizeSearchTerm,
|
|
211
|
+
normalizeSearchColumns,
|
|
212
|
+
buildSearchClause,
|
|
213
|
+
};
|