millas 0.2.20 → 0.2.23
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/package.json +1 -1
- package/src/admin/QueryEngine.js +17 -13
- package/src/cli.js +3 -0
- package/src/core/db.js +9 -8
- package/src/events/EventEmitter.js +12 -1
- package/src/facades/Database.js +55 -34
- package/src/orm/drivers/DatabaseManager.js +12 -0
- package/src/orm/fields/index.js +18 -0
- package/src/orm/migration/MigrationWriter.js +6 -0
- package/src/orm/migration/ModelInspector.js +4 -0
- package/src/orm/migration/operations/column.js +59 -95
- package/src/orm/migration/operations/fields.js +6 -6
- package/src/orm/migration/operations/models.js +3 -3
- package/src/orm/model/Model.js +293 -61
- package/src/orm/query/F.js +98 -0
- package/src/orm/query/LookupParser.js +316 -157
- package/src/orm/query/QueryBuilder.js +230 -7
- package/src/providers/DatabaseServiceProvider.js +2 -2
|
@@ -5,75 +5,51 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Parses Django-style __ field lookups and applies them to a knex query.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Supports unlimited depth traversal — each __ segment is either:
|
|
9
|
+
* - A relation name → auto-JOIN (BelongsTo) or EXISTS subquery (HasMany/M2M)
|
|
10
|
+
* - A lookup suffix → terminal condition (exact, icontains, gte, etc.)
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* Null checks
|
|
19
|
-
* field__isnull IS NULL (value: true) / IS NOT NULL (value: false)
|
|
20
|
-
*
|
|
21
|
-
* Range / set
|
|
22
|
-
* field__in IN (array)
|
|
23
|
-
* field__notin NOT IN (array)
|
|
24
|
-
* field__between BETWEEN [min, max]
|
|
25
|
-
*
|
|
26
|
-
* String matching
|
|
27
|
-
* field__contains LIKE %value% (case-sensitive)
|
|
28
|
-
* field__icontains ILIKE %value% (case-insensitive)
|
|
29
|
-
* field__startswith LIKE value%
|
|
30
|
-
* field__istartswith ILIKE value%
|
|
31
|
-
* field__endswith LIKE %value
|
|
32
|
-
* field__iendswith ILIKE %value
|
|
33
|
-
* field__like LIKE <raw pattern> (you supply the %)
|
|
34
|
-
*
|
|
35
|
-
* Date / time extraction (SQLite: strftime, PG/MySQL: EXTRACT / DATE_FORMAT)
|
|
36
|
-
* field__year
|
|
37
|
-
* field__month
|
|
38
|
-
* field__day
|
|
39
|
-
* field__hour
|
|
40
|
-
* field__minute
|
|
41
|
-
* field__second
|
|
42
|
-
*
|
|
43
|
-
* Relationship traversal (requires Model.fields with references)
|
|
44
|
-
* profile__city__icontains → joins profiles, filters on city
|
|
45
|
-
*
|
|
46
|
-
* Usage (internal — called by QueryBuilder):
|
|
47
|
-
* LookupParser.apply(knexQuery, 'age__gte', 18, ModelClass)
|
|
48
|
-
* LookupParser.apply(knexQuery, 'profile__city', 'Nairobi', ModelClass)
|
|
12
|
+
* Examples:
|
|
13
|
+
* 'age__gte' → WHERE age >= 18
|
|
14
|
+
* 'author__name' → JOIN users, WHERE users.name = ?
|
|
15
|
+
* 'author__profile__city__icontains' → JOIN users JOIN profiles, WHERE LOWER(city) LIKE ?
|
|
16
|
+
* 'unit_categories__unit_type__in' → WHERE EXISTS (SELECT 1 FROM unit_categories WHERE ...)
|
|
17
|
+
* 'tags__name__icontains' → WHERE EXISTS (SELECT 1 FROM pivot JOIN tags WHERE ...)
|
|
18
|
+
* 'pk' → WHERE id = ? (pk shorthand)
|
|
49
19
|
*/
|
|
50
20
|
class LookupParser {
|
|
51
21
|
|
|
52
|
-
/**
|
|
53
|
-
* All recognised lookup suffixes, in order from longest to shortest so
|
|
54
|
-
* that e.g. "icontains" is matched before a hypothetical "contains" variant.
|
|
55
|
-
*/
|
|
56
22
|
static LOOKUPS = [
|
|
57
|
-
'icontains', 'istartswith', 'iendswith',
|
|
58
|
-
'contains', 'startswith', 'endswith', 'like',
|
|
59
|
-
'
|
|
23
|
+
'icontains', 'istartswith', 'iendswith', 'iexact',
|
|
24
|
+
'contains', 'startswith', 'endswith', 'ilike', 'like',
|
|
25
|
+
'iregex', 'regex',
|
|
26
|
+
'isnull', 'between', 'range', 'notin', 'in',
|
|
60
27
|
'exact', 'not',
|
|
61
28
|
'gte', 'lte', 'gt', 'lt',
|
|
62
29
|
'year', 'month', 'day', 'hour', 'minute', 'second',
|
|
30
|
+
'date', 'time', 'week', 'week_day', 'quarter',
|
|
63
31
|
];
|
|
64
32
|
|
|
65
33
|
/**
|
|
66
34
|
* Parse a lookup key and apply the appropriate knex constraint.
|
|
67
35
|
*
|
|
68
36
|
* @param {object} q — knex query builder (mutated in place)
|
|
69
|
-
* @param {string} key — e.g.
|
|
37
|
+
* @param {string} key — e.g. 'age__gte', 'author__profile__city__icontains'
|
|
70
38
|
* @param {*} value — the comparison value
|
|
71
|
-
* @param {class} ModelClass — the root Model class
|
|
39
|
+
* @param {class} ModelClass — the root Model class
|
|
72
40
|
* @param {string} [method] — 'where' | 'orWhere' (default: 'where')
|
|
73
|
-
* @returns {object} the (mutated) knex query
|
|
74
41
|
*/
|
|
75
42
|
static apply(q, key, value, ModelClass, method = 'where') {
|
|
76
|
-
//
|
|
43
|
+
// pk shorthand — resolve to actual primary key
|
|
44
|
+
if (key === 'pk' || key === 'pk__exact') {
|
|
45
|
+
q[method](ModelClass.primaryKey || 'id', value);
|
|
46
|
+
return q;
|
|
47
|
+
}
|
|
48
|
+
if (key.startsWith('pk__')) {
|
|
49
|
+
key = (ModelClass.primaryKey || 'id') + '__' + key.slice(4);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// No __ at all → plain equality
|
|
77
53
|
if (!key.includes('__')) {
|
|
78
54
|
q[method](key, value);
|
|
79
55
|
return q;
|
|
@@ -81,16 +57,208 @@ class LookupParser {
|
|
|
81
57
|
|
|
82
58
|
const parts = key.split('__');
|
|
83
59
|
const lookup = this._extractLookup(parts);
|
|
84
|
-
// Everything left after stripping the lookup is the field path
|
|
85
60
|
const fieldPath = lookup ? parts.slice(0, -1) : parts;
|
|
86
61
|
|
|
87
|
-
//
|
|
88
|
-
if (fieldPath.length
|
|
89
|
-
return this.
|
|
62
|
+
// Single field, no relation traversal
|
|
63
|
+
if (fieldPath.length === 1) {
|
|
64
|
+
return this._applyLookup(q, fieldPath[0], lookup || 'exact', value, method);
|
|
90
65
|
}
|
|
91
66
|
|
|
92
|
-
|
|
93
|
-
return this.
|
|
67
|
+
// Multi-segment — walk the relation chain
|
|
68
|
+
return this._applyDeep(q, fieldPath, lookup || 'exact', value, ModelClass, method);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Deep relation traversal ──────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Walk a multi-segment field path, resolving each segment as either:
|
|
75
|
+
* - A relation → JOIN (BelongsTo/HasOne) or EXISTS subquery (HasMany/M2M)
|
|
76
|
+
* - A plain column → apply the lookup
|
|
77
|
+
*
|
|
78
|
+
* Matches Django's names_to_path() + setup_joins() behaviour.
|
|
79
|
+
*/
|
|
80
|
+
static _applyDeep(q, fieldPath, lookup, value, ModelClass, method) {
|
|
81
|
+
let currentModel = ModelClass;
|
|
82
|
+
const joinedTables = new Set(); // track already-joined tables to avoid duplicates
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < fieldPath.length; i++) {
|
|
85
|
+
const segment = fieldPath[i];
|
|
86
|
+
const isLast = i === fieldPath.length - 1;
|
|
87
|
+
const relations = currentModel._effectiveRelations
|
|
88
|
+
? currentModel._effectiveRelations()
|
|
89
|
+
: (currentModel.relations || {});
|
|
90
|
+
|
|
91
|
+
const rel = relations[segment];
|
|
92
|
+
|
|
93
|
+
if (!rel) {
|
|
94
|
+
// Not a relation — treat as a column on the current model
|
|
95
|
+
const column = currentModel.table
|
|
96
|
+
? `${currentModel.table}.${segment}`
|
|
97
|
+
: segment;
|
|
98
|
+
return this._applyLookup(q, column, lookup, value, method);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const BelongsTo = require('../relations/BelongsTo');
|
|
102
|
+
const HasOne = require('../relations/HasOne');
|
|
103
|
+
const HasMany = require('../relations/HasMany');
|
|
104
|
+
const BelongsToMany = require('../relations/BelongsToMany');
|
|
105
|
+
|
|
106
|
+
if (rel instanceof BelongsTo || rel instanceof HasOne) {
|
|
107
|
+
// Forward FK or HasOne — safe to JOIN
|
|
108
|
+
const RelatedModel = rel._related;
|
|
109
|
+
if (!RelatedModel) {
|
|
110
|
+
// Can't resolve — fall back to flat column
|
|
111
|
+
return this._applyLookup(q, segment, lookup, value, method);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (rel instanceof BelongsTo) {
|
|
115
|
+
// FK is on current table: current.fk_col = related.pk
|
|
116
|
+
const fkCol = rel._foreignKey;
|
|
117
|
+
const ownerKey = rel._ownerKey || 'id';
|
|
118
|
+
const joinKey = `${currentModel.table}__${RelatedModel.table}`;
|
|
119
|
+
|
|
120
|
+
if (!joinedTables.has(joinKey)) {
|
|
121
|
+
q = q.join(
|
|
122
|
+
RelatedModel.table,
|
|
123
|
+
`${currentModel.table}.${fkCol}`,
|
|
124
|
+
'=',
|
|
125
|
+
`${RelatedModel.table}.${ownerKey}`
|
|
126
|
+
);
|
|
127
|
+
joinedTables.add(joinKey);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
// HasOne — FK is on related table: related.fk_col = current.pk
|
|
131
|
+
const fkCol = rel._foreignKey;
|
|
132
|
+
const localKey = rel._localKey || 'id';
|
|
133
|
+
const joinKey = `${currentModel.table}__${RelatedModel.table}`;
|
|
134
|
+
|
|
135
|
+
if (!joinedTables.has(joinKey)) {
|
|
136
|
+
q = q.join(
|
|
137
|
+
RelatedModel.table,
|
|
138
|
+
`${RelatedModel.table}.${fkCol}`,
|
|
139
|
+
'=',
|
|
140
|
+
`${currentModel.table}.${localKey}`
|
|
141
|
+
);
|
|
142
|
+
joinedTables.add(joinKey);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (isLast) {
|
|
147
|
+
// Last segment is the relation itself — compare its PK
|
|
148
|
+
const RelatedModel = rel._related;
|
|
149
|
+
const pkCol = `${RelatedModel.table}.${RelatedModel.primaryKey || 'id'}`;
|
|
150
|
+
return this._applyLookup(q, pkCol, lookup, value, method);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
currentModel = rel._related;
|
|
154
|
+
|
|
155
|
+
} else if (rel instanceof HasMany || rel instanceof BelongsToMany) {
|
|
156
|
+
// Reverse relation — use EXISTS subquery to avoid row multiplication
|
|
157
|
+
// This matches Django's split_exclude / subquery strategy for N-to-many
|
|
158
|
+
const remainingPath = fieldPath.slice(i + 1);
|
|
159
|
+
const remainingLookup = lookup;
|
|
160
|
+
|
|
161
|
+
return this._applyExistsSubquery(
|
|
162
|
+
q, rel, remainingPath, remainingLookup, value, currentModel, method,
|
|
163
|
+
rel instanceof BelongsToMany
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Reached end of path without hitting a terminal — shouldn't happen
|
|
169
|
+
return q;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Apply an EXISTS subquery for HasMany and BelongsToMany relations.
|
|
174
|
+
*
|
|
175
|
+
* Generates:
|
|
176
|
+
* WHERE EXISTS (
|
|
177
|
+
* SELECT 1 FROM related_table
|
|
178
|
+
* WHERE related_table.fk = current_table.pk
|
|
179
|
+
* AND related_table.column [lookup] value
|
|
180
|
+
* )
|
|
181
|
+
*
|
|
182
|
+
* For M2M:
|
|
183
|
+
* WHERE EXISTS (
|
|
184
|
+
* SELECT 1 FROM pivot_table
|
|
185
|
+
* JOIN related_table ON pivot.related_fk = related.pk
|
|
186
|
+
* WHERE pivot.owner_fk = current.pk
|
|
187
|
+
* AND related_table.column [lookup] value
|
|
188
|
+
* )
|
|
189
|
+
*/
|
|
190
|
+
static _applyExistsSubquery(q, rel, remainingPath, lookup, value, ownerModel, method, isM2M) {
|
|
191
|
+
const HasMany = require('../relations/HasMany');
|
|
192
|
+
const BelongsToMany = require('../relations/BelongsToMany');
|
|
193
|
+
const DatabaseManager = require('../drivers/DatabaseManager');
|
|
194
|
+
|
|
195
|
+
const db = DatabaseManager.connection(ownerModel.connection || null);
|
|
196
|
+
|
|
197
|
+
if (isM2M) {
|
|
198
|
+
const RelatedModel = rel._related;
|
|
199
|
+
const pivotTable = rel._pivotTable;
|
|
200
|
+
const foreignPivotKey = rel._foreignPivotKey;
|
|
201
|
+
const relatedPivotKey = rel._relatedPivotKey;
|
|
202
|
+
const localKey = rel._localKey || 'id';
|
|
203
|
+
const relatedKey = rel._relatedKey || 'id';
|
|
204
|
+
|
|
205
|
+
q[method](function () {
|
|
206
|
+
let sub = db(pivotTable)
|
|
207
|
+
.select(db.raw('1'))
|
|
208
|
+
.join(
|
|
209
|
+
RelatedModel.table,
|
|
210
|
+
`${RelatedModel.table}.${relatedKey}`,
|
|
211
|
+
'=',
|
|
212
|
+
`${pivotTable}.${relatedPivotKey}`
|
|
213
|
+
)
|
|
214
|
+
.whereRaw(
|
|
215
|
+
`${pivotTable}.${foreignPivotKey} = ${ownerModel.table}.${localKey}`
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (remainingPath.length > 0) {
|
|
219
|
+
const colName = `${RelatedModel.table}.${remainingPath.join('__')}`;
|
|
220
|
+
LookupParser._applyLookup(sub, colName, lookup, value, 'where');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this.whereExists(sub);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
} else {
|
|
227
|
+
// HasMany
|
|
228
|
+
const RelatedModel = rel._related;
|
|
229
|
+
const foreignKey = rel._foreignKey;
|
|
230
|
+
const localKey = rel._localKey || 'id';
|
|
231
|
+
|
|
232
|
+
q[method](function () {
|
|
233
|
+
let sub = db(RelatedModel.table)
|
|
234
|
+
.select(db.raw('1'))
|
|
235
|
+
.whereRaw(
|
|
236
|
+
`${RelatedModel.table}.${foreignKey} = ${ownerModel.table}.${localKey}`
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (remainingPath.length > 0) {
|
|
240
|
+
// Remaining path could be a simple column or another deep lookup
|
|
241
|
+
// Qualify the column with the table name to avoid ambiguity
|
|
242
|
+
const remainingKey = remainingPath.join('__');
|
|
243
|
+
// If it's a simple column (no further relation hops), qualify it
|
|
244
|
+
const firstSegment = remainingPath[0];
|
|
245
|
+
const relRelations = RelatedModel._effectiveRelations
|
|
246
|
+
? RelatedModel._effectiveRelations()
|
|
247
|
+
: (RelatedModel.relations || {});
|
|
248
|
+
const isRelation = !!relRelations[firstSegment];
|
|
249
|
+
if (!isRelation && remainingPath.length === 1) {
|
|
250
|
+
// Simple column — qualify with table name
|
|
251
|
+
LookupParser._applyLookup(sub, `${RelatedModel.table}.${firstSegment}`, lookup, value, 'where');
|
|
252
|
+
} else {
|
|
253
|
+
LookupParser.apply(sub, remainingKey, value, RelatedModel, 'where');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.whereExists(sub);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return q;
|
|
94
262
|
}
|
|
95
263
|
|
|
96
264
|
// ─── Lookup application ───────────────────────────────────────────────────
|
|
@@ -101,6 +269,10 @@ class LookupParser {
|
|
|
101
269
|
q[method](column, value);
|
|
102
270
|
break;
|
|
103
271
|
|
|
272
|
+
case 'iexact':
|
|
273
|
+
this._applyILike(q, column, value, method);
|
|
274
|
+
break;
|
|
275
|
+
|
|
104
276
|
case 'not':
|
|
105
277
|
q[method](column, '!=', value);
|
|
106
278
|
break;
|
|
@@ -122,8 +294,13 @@ class LookupParser {
|
|
|
122
294
|
break;
|
|
123
295
|
|
|
124
296
|
case 'isnull':
|
|
125
|
-
if (value)
|
|
126
|
-
|
|
297
|
+
if (value) {
|
|
298
|
+
if (typeof q.whereNull === 'function') q.whereNull(column);
|
|
299
|
+
else q[method](column, null);
|
|
300
|
+
} else {
|
|
301
|
+
if (typeof q.whereNotNull === 'function') q.whereNotNull(column);
|
|
302
|
+
else q[method](column, '!=', null);
|
|
303
|
+
}
|
|
127
304
|
break;
|
|
128
305
|
|
|
129
306
|
case 'in':
|
|
@@ -135,6 +312,7 @@ class LookupParser {
|
|
|
135
312
|
break;
|
|
136
313
|
|
|
137
314
|
case 'between':
|
|
315
|
+
case 'range':
|
|
138
316
|
q[`${method}Between`]
|
|
139
317
|
? q[`${method}Between`](column, value)
|
|
140
318
|
: q.whereBetween(column, value);
|
|
@@ -145,7 +323,7 @@ class LookupParser {
|
|
|
145
323
|
break;
|
|
146
324
|
|
|
147
325
|
case 'icontains':
|
|
148
|
-
q
|
|
326
|
+
this._applyILike(q, column, `%${value}%`, method);
|
|
149
327
|
break;
|
|
150
328
|
|
|
151
329
|
case 'startswith':
|
|
@@ -153,7 +331,7 @@ class LookupParser {
|
|
|
153
331
|
break;
|
|
154
332
|
|
|
155
333
|
case 'istartswith':
|
|
156
|
-
q
|
|
334
|
+
this._applyILike(q, column, `${value}%`, method);
|
|
157
335
|
break;
|
|
158
336
|
|
|
159
337
|
case 'endswith':
|
|
@@ -161,144 +339,125 @@ class LookupParser {
|
|
|
161
339
|
break;
|
|
162
340
|
|
|
163
341
|
case 'iendswith':
|
|
164
|
-
q
|
|
342
|
+
this._applyILike(q, column, `%${value}`, method);
|
|
165
343
|
break;
|
|
166
344
|
|
|
167
345
|
case 'like':
|
|
168
346
|
q[method](column, 'like', value);
|
|
169
347
|
break;
|
|
170
348
|
|
|
171
|
-
|
|
349
|
+
case 'ilike':
|
|
350
|
+
this._applyILike(q, column, value, method);
|
|
351
|
+
break;
|
|
352
|
+
|
|
353
|
+
case 'regex':
|
|
354
|
+
this._applyRegex(q, column, value, method, false);
|
|
355
|
+
break;
|
|
356
|
+
|
|
357
|
+
case 'iregex':
|
|
358
|
+
this._applyRegex(q, column, value, method, true);
|
|
359
|
+
break;
|
|
360
|
+
|
|
172
361
|
case 'year':
|
|
173
362
|
case 'month':
|
|
174
363
|
case 'day':
|
|
175
364
|
case 'hour':
|
|
176
365
|
case 'minute':
|
|
177
366
|
case 'second':
|
|
367
|
+
case 'date':
|
|
368
|
+
case 'time':
|
|
369
|
+
case 'week':
|
|
370
|
+
case 'week_day':
|
|
371
|
+
case 'quarter':
|
|
178
372
|
this._applyDatePart(q, column, lookup, value, method);
|
|
179
373
|
break;
|
|
180
374
|
|
|
181
375
|
default:
|
|
182
|
-
// Unrecognised suffix — treat whole key as column name, do equality
|
|
183
376
|
q[method](column, value);
|
|
184
377
|
}
|
|
185
378
|
|
|
186
379
|
return q;
|
|
187
380
|
}
|
|
188
381
|
|
|
189
|
-
// ───
|
|
382
|
+
// ─── Dialect-aware helpers ────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
static _applyILike(q, column, pattern, method) {
|
|
385
|
+
const client = q.client?.config?.client || 'sqlite3';
|
|
386
|
+
if (client.includes('pg') || client.includes('postgres')) {
|
|
387
|
+
q[method](column, 'ilike', pattern);
|
|
388
|
+
} else {
|
|
389
|
+
// SQLite / MySQL: LOWER() both sides for Unicode safety
|
|
390
|
+
const col = column.includes('.') ? column : `\`${column}\``;
|
|
391
|
+
q[method](q.client.raw(`LOWER(${col})`), 'like', pattern.toLowerCase());
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
static _applyRegex(q, column, pattern, method, caseInsensitive) {
|
|
396
|
+
const client = q.client?.config?.client || 'sqlite3';
|
|
397
|
+
if (client.includes('pg') || client.includes('postgres')) {
|
|
398
|
+
const op = caseInsensitive ? '~*' : '~';
|
|
399
|
+
q[method](q.client.raw(`"${column}" ${op} ?`, [pattern]));
|
|
400
|
+
} else if (client.includes('mysql') || client.includes('maria')) {
|
|
401
|
+
const op = caseInsensitive ? 'REGEXP' : 'REGEXP BINARY';
|
|
402
|
+
q[method](q.client.raw(`\`${column}\` ${op} ?`, [pattern]));
|
|
403
|
+
} else {
|
|
404
|
+
console.warn(`[Millas] regex lookup is not supported on SQLite. Falling back to LIKE.`);
|
|
405
|
+
q[method](column, 'like', `%${pattern}%`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
190
408
|
|
|
191
409
|
static _applyDatePart(q, column, part, value, method) {
|
|
192
410
|
const client = q.client?.config?.client || 'sqlite3';
|
|
193
411
|
|
|
194
412
|
if (client.includes('pg') || client.includes('postgres')) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
413
|
+
const pgMap = {
|
|
414
|
+
year: 'YEAR', month: 'MONTH', day: 'DAY',
|
|
415
|
+
hour: 'HOUR', minute: 'MINUTE', second: 'SECOND',
|
|
416
|
+
week: 'WEEK', quarter: 'QUARTER',
|
|
417
|
+
date: 'DATE', time: 'TIME', week_day: 'DOW',
|
|
418
|
+
};
|
|
419
|
+
const pgPart = pgMap[part] || part.toUpperCase();
|
|
420
|
+
if (part === 'date') {
|
|
421
|
+
q[method](q.client.raw(`"${column}"::date`), value);
|
|
422
|
+
} else if (part === 'time') {
|
|
423
|
+
q[method](q.client.raw(`"${column}"::time`), value);
|
|
424
|
+
} else {
|
|
425
|
+
q[method](q.client.raw(`EXTRACT(${pgPart} FROM "${column}")`), value);
|
|
426
|
+
}
|
|
198
427
|
|
|
199
428
|
} else if (client.includes('mysql') || client.includes('maria')) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
429
|
+
const mysqlMap = {
|
|
430
|
+
year: 'YEAR', month: 'MONTH', day: 'DAY',
|
|
431
|
+
hour: 'HOUR', minute: 'MINUTE', second: 'SECOND',
|
|
432
|
+
week: 'WEEK', quarter: 'QUARTER',
|
|
433
|
+
date: 'DATE', time: 'TIME', week_day: 'DAYOFWEEK',
|
|
434
|
+
};
|
|
435
|
+
const fn = mysqlMap[part] || part.toUpperCase();
|
|
436
|
+
q[method](q.client.raw(`${fn}(\`${column}\`)`), value);
|
|
203
437
|
|
|
204
438
|
} else {
|
|
205
|
-
// SQLite
|
|
439
|
+
// SQLite
|
|
206
440
|
const fmtMap = {
|
|
207
441
|
year: '%Y', month: '%m', day: '%d',
|
|
208
442
|
hour: '%H', minute: '%M', second: '%S',
|
|
443
|
+
date: '%Y-%m-%d', time: '%H:%M:%S',
|
|
444
|
+
week: '%W', week_day: '%w', quarter: null,
|
|
209
445
|
};
|
|
210
446
|
const fmt = fmtMap[part];
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
*
|
|
220
|
-
* Resolves each hop using Model.fields[x].references, auto-joins the
|
|
221
|
-
* necessary tables, then applies the final lookup on the leaf column.
|
|
222
|
-
*
|
|
223
|
-
* @param {object} q — knex query
|
|
224
|
-
* @param {string[]} fieldPath — e.g. ['profile', 'city']
|
|
225
|
-
* @param {string} lookup — e.g. 'icontains'
|
|
226
|
-
* @param {*} value
|
|
227
|
-
* @param {class} ModelClass — root model
|
|
228
|
-
* @param {string} method — 'where' | 'orWhere'
|
|
229
|
-
*/
|
|
230
|
-
static _applyRelational(q, fieldPath, lookup, value, ModelClass, method) {
|
|
231
|
-
let currentModel = ModelClass;
|
|
232
|
-
const joinedTables = new Set();
|
|
233
|
-
|
|
234
|
-
// Walk all segments except the last — each one is a relationship hop
|
|
235
|
-
for (let i = 0; i < fieldPath.length - 1; i++) {
|
|
236
|
-
const segment = fieldPath[i];
|
|
237
|
-
const fields = currentModel.fields || {};
|
|
238
|
-
|
|
239
|
-
// Try to find a field whose name matches the segment
|
|
240
|
-
const fieldDef = fields[segment] || fields[`${segment}_id`];
|
|
241
|
-
|
|
242
|
-
if (!fieldDef || !fieldDef.references) {
|
|
243
|
-
// No references info — fall back to treating the whole path as a
|
|
244
|
-
// raw column name with underscores (best-effort)
|
|
245
|
-
const fallbackCol = fieldPath.join('_');
|
|
246
|
-
return this._applyLookup(q, fallbackCol, lookup || 'exact', value, method);
|
|
447
|
+
if (!fmt) {
|
|
448
|
+
q[method](q.client.raw(`CAST((strftime('%m', \`${column}\`) + 2) / 3 AS INTEGER)`), value);
|
|
449
|
+
} else {
|
|
450
|
+
const pad = part === 'year' ? 4 : 2;
|
|
451
|
+
q[method](
|
|
452
|
+
q.client.raw(`strftime('${fmt}', \`${column}\`)`),
|
|
453
|
+
String(value).padStart(pad, '0')
|
|
454
|
+
);
|
|
247
455
|
}
|
|
248
|
-
|
|
249
|
-
const { table: relTable, column: relColumn } = fieldDef.references;
|
|
250
|
-
const localColumn = fieldDef.references.localKey || segment + '_id';
|
|
251
|
-
const joinKey = `${currentModel.table}__${relTable}`;
|
|
252
|
-
|
|
253
|
-
if (!joinedTables.has(joinKey)) {
|
|
254
|
-
q.join(relTable, `${currentModel.table}.${localColumn}`, '=', `${relTable}.${relColumn}`);
|
|
255
|
-
joinedTables.add(joinKey);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Advance — try to resolve the related model for the next hop.
|
|
259
|
-
// We do a best-effort require() from the models directory.
|
|
260
|
-
currentModel = this._resolveRelatedModel(relTable) || { table: relTable, fields: {} };
|
|
261
456
|
}
|
|
262
|
-
|
|
263
|
-
// The final segment is the leaf column on the last joined table
|
|
264
|
-
const leafColumn = `${currentModel.table}.${fieldPath[fieldPath.length - 1]}`;
|
|
265
|
-
return this._applyLookup(q, leafColumn, lookup || 'exact', value, method);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Try to resolve a related Model class by table name.
|
|
270
|
-
* Looks in process.cwd()/app/models/ (best-effort, graceful failure).
|
|
271
|
-
*/
|
|
272
|
-
static _resolveRelatedModel(tableName) {
|
|
273
|
-
try {
|
|
274
|
-
const path = require('path');
|
|
275
|
-
const fs = require('fs');
|
|
276
|
-
const modelsDir = path.join(process.cwd(), 'app', 'models');
|
|
277
|
-
|
|
278
|
-
if (!fs.existsSync(modelsDir)) return null;
|
|
279
|
-
|
|
280
|
-
const files = fs.readdirSync(modelsDir).filter(f => f.endsWith('.js'));
|
|
281
|
-
|
|
282
|
-
for (const file of files) {
|
|
283
|
-
try {
|
|
284
|
-
const cls = require(path.join(modelsDir, file));
|
|
285
|
-
const ModelClass = typeof cls === 'function' ? cls
|
|
286
|
-
: Object.values(cls).find(v => typeof v === 'function');
|
|
287
|
-
|
|
288
|
-
if (ModelClass && ModelClass.table === tableName) return ModelClass;
|
|
289
|
-
} catch { /* skip */ }
|
|
290
|
-
}
|
|
291
|
-
} catch { /* skip */ }
|
|
292
|
-
|
|
293
|
-
return null;
|
|
294
457
|
}
|
|
295
458
|
|
|
296
459
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
297
460
|
|
|
298
|
-
/**
|
|
299
|
-
* Given the split parts array, return the lookup suffix if the last
|
|
300
|
-
* element is a known lookup keyword, otherwise return null.
|
|
301
|
-
*/
|
|
302
461
|
static _extractLookup(parts) {
|
|
303
462
|
const last = parts[parts.length - 1];
|
|
304
463
|
return this.LOOKUPS.includes(last) ? last : null;
|