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.
@@ -5,75 +5,51 @@
5
5
  *
6
6
  * Parses Django-style __ field lookups and applies them to a knex query.
7
7
  *
8
- * Supported lookup types:
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
- * Comparison
11
- * field__exact = (default, same as no suffix)
12
- * field__not !=
13
- * field__gt >
14
- * field__gte >=
15
- * field__lt <
16
- * field__lte <=
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
- 'isnull', 'between', 'notin', 'in',
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. "age__gte", "profile__city__icontains"
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 (for relationship traversal)
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
- // No __ at all plain equality, pass straight through
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
- // Relationship traversal: more than one segment in the field path
88
- if (fieldPath.length > 1) {
89
- return this._applyRelational(q, fieldPath, lookup, value, ModelClass, method);
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
- const column = fieldPath[0];
93
- return this._applyLookup(q, column, lookup || 'exact', value, method);
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) q[`${method}Null`] ? q[`${method}Null`](column) : q.whereNull(column);
126
- else q[`${method}NotNull`] ? q[`${method}NotNull`](column) : q.whereNotNull(column);
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[method](column, 'ilike', `%${value}%`);
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[method](column, 'ilike', `${value}%`);
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[method](column, 'ilike', `%${value}`);
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
- // ── Date/time extractions ──────────────────────────────────────────
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
- // ─── Date part extraction — dialect-aware ─────────────────────────────────
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
- // PostgreSQL: EXTRACT(YEAR FROM column) = value
196
- const pgPart = part.toUpperCase();
197
- q[method](q.client.raw(`EXTRACT(${pgPart} FROM "${column}")`, []), value);
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
- // MySQL: YEAR(column) = value etc.
201
- const fn = part.toUpperCase();
202
- q[method](q.client.raw(`${fn}(\`${column}\`)`, []), value);
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: strftime('%Y', column) = value
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
- q[method](q.client.raw(`strftime('${fmt}', "${column}")`, []), String(value).padStart(part === 'year' ? 4 : 2, '0'));
212
- }
213
- }
214
-
215
- // ─── Relationship traversal ───────────────────────────────────────────────
216
-
217
- /**
218
- * Handle multi-segment paths like profile__city or post__author__role.
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;