@zero-server/orm 0.9.1 → 0.9.3
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/LICENSE +21 -21
- package/index.d.ts +1 -1
- package/index.js +35 -35
- package/lib/debug.js +372 -0
- package/lib/orm/adapters/json.js +290 -0
- package/lib/orm/adapters/memory.js +764 -0
- package/lib/orm/adapters/mongo.js +764 -0
- package/lib/orm/adapters/mysql.js +933 -0
- package/lib/orm/adapters/postgres.js +1144 -0
- package/lib/orm/adapters/redis.js +1534 -0
- package/lib/orm/adapters/sql-base.js +212 -0
- package/lib/orm/adapters/sqlite.js +858 -0
- package/lib/orm/audit.js +649 -0
- package/lib/orm/cache.js +394 -0
- package/lib/orm/geo.js +387 -0
- package/lib/orm/index.js +784 -0
- package/lib/orm/migrate.js +432 -0
- package/lib/orm/model.js +1706 -0
- package/lib/orm/plugin.js +375 -0
- package/lib/orm/procedures.js +836 -0
- package/lib/orm/profiler.js +233 -0
- package/lib/orm/query.js +1772 -0
- package/lib/orm/replicas.js +241 -0
- package/lib/orm/schema.js +307 -0
- package/lib/orm/search.js +380 -0
- package/lib/orm/seed/data/commerce.js +136 -0
- package/lib/orm/seed/data/internet.js +111 -0
- package/lib/orm/seed/data/locations.js +204 -0
- package/lib/orm/seed/data/names.js +338 -0
- package/lib/orm/seed/data/person.js +128 -0
- package/lib/orm/seed/data/phone.js +211 -0
- package/lib/orm/seed/data/words.js +134 -0
- package/lib/orm/seed/factory.js +178 -0
- package/lib/orm/seed/fake.js +1186 -0
- package/lib/orm/seed/index.js +18 -0
- package/lib/orm/seed/rng.js +71 -0
- package/lib/orm/seed/seeder.js +125 -0
- package/lib/orm/seed/unique.js +68 -0
- package/lib/orm/snapshot.js +366 -0
- package/lib/orm/tenancy.js +605 -0
- package/lib/orm/views.js +350 -0
- package/package.json +12 -2
- package/types/app.d.ts +223 -0
- package/types/auth.d.ts +520 -0
- package/types/body.d.ts +14 -0
- package/types/cli.d.ts +2 -0
- package/types/cluster.d.ts +75 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +316 -0
- package/types/fetch.d.ts +43 -0
- package/types/grpc.d.ts +432 -0
- package/types/index.d.ts +384 -0
- package/types/lifecycle.d.ts +60 -0
- package/types/middleware.d.ts +320 -0
- package/types/observe.d.ts +304 -0
- package/types/orm.d.ts +1887 -0
- package/types/request.d.ts +109 -0
- package/types/response.d.ts +157 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/websocket.d.ts +126 -0
package/lib/orm/query.js
ADDED
|
@@ -0,0 +1,1772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module orm/query
|
|
3
|
+
* @description Fluent query builder that produces adapter-agnostic query objects.
|
|
4
|
+
* Each method returns `this` for chaining. Call `.exec()` or
|
|
5
|
+
* `await` the query to execute it against the adapter.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const users = await User.query()
|
|
9
|
+
* .where('age', '>', 18)
|
|
10
|
+
* .where('role', 'admin')
|
|
11
|
+
* .orderBy('name', 'asc')
|
|
12
|
+
* .limit(10)
|
|
13
|
+
* .offset(20)
|
|
14
|
+
* .select('name', 'email');
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fluent query builder.
|
|
19
|
+
* Builds an abstract query descriptor that adapters can translate to their
|
|
20
|
+
* native query language (SQL, MongoDB filter, in-memory filter, etc.).
|
|
21
|
+
*/
|
|
22
|
+
const log = require('../debug')('zero:orm:query');
|
|
23
|
+
|
|
24
|
+
// -- Security whitelists ---------------------------------
|
|
25
|
+
|
|
26
|
+
const VALID_OPERATORS = new Set([
|
|
27
|
+
'=', '!=', '<>', '>', '<', '>=', '<=',
|
|
28
|
+
'LIKE', 'NOT LIKE', 'IN', 'NOT IN',
|
|
29
|
+
'BETWEEN', 'NOT BETWEEN', 'IS NULL', 'IS NOT NULL',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const VALID_DIRECTIONS = new Set(['ASC', 'DESC']);
|
|
33
|
+
|
|
34
|
+
class Query
|
|
35
|
+
{
|
|
36
|
+
/**
|
|
37
|
+
* @constructor
|
|
38
|
+
* @param {object} model - The Model class to query.
|
|
39
|
+
* @param {object} adapter - The database adapter instance.
|
|
40
|
+
*/
|
|
41
|
+
constructor(model, adapter)
|
|
42
|
+
{
|
|
43
|
+
/** @private */ this._model = model;
|
|
44
|
+
/** @private */ this._adapter = adapter;
|
|
45
|
+
/** @private */ this._action = 'select';
|
|
46
|
+
/** @private */ this._fields = null; // null = all
|
|
47
|
+
/** @private */ this._where = [];
|
|
48
|
+
/** @private */ this._orderBy = [];
|
|
49
|
+
/** @private */ this._limitVal = null;
|
|
50
|
+
/** @private */ this._offsetVal = null;
|
|
51
|
+
/** @private */ this._data = null;
|
|
52
|
+
/** @private */ this._joins = [];
|
|
53
|
+
/** @private */ this._groupBy = [];
|
|
54
|
+
/** @private */ this._having = [];
|
|
55
|
+
/** @private */ this._distinct = false;
|
|
56
|
+
/** @private */ this._includeDeleted = false;
|
|
57
|
+
/** @private */ this._eagerLoad = [];
|
|
58
|
+
/** @private */ this._eagerCount = [];
|
|
59
|
+
/** @private */ this._useReplica = false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// -- Selection --------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Select specific columns.
|
|
66
|
+
*
|
|
67
|
+
* @param {...string} fields - Column names to select.
|
|
68
|
+
* @returns {Query} `this` for chaining.
|
|
69
|
+
*/
|
|
70
|
+
select(...fields)
|
|
71
|
+
{
|
|
72
|
+
this._fields = fields.flat();
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Select distinct rows.
|
|
78
|
+
* @returns {Query} `this` for chaining.
|
|
79
|
+
*/
|
|
80
|
+
distinct()
|
|
81
|
+
{
|
|
82
|
+
this._distinct = true;
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// -- Filtering --------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Add a WHERE condition.
|
|
90
|
+
*
|
|
91
|
+
* Accepts multiple forms:
|
|
92
|
+
* - `where('age', 18)` → `age = 18`
|
|
93
|
+
* - `where('age', '>', 18)` → `age > 18`
|
|
94
|
+
* - `where({ role: 'admin', active: true })` → `role = 'admin' AND active = true`
|
|
95
|
+
*
|
|
96
|
+
* @param {string|object} field - Column name or condition object.
|
|
97
|
+
* @param {string} [op] - Operator (=, !=, >, <, >=, <=, LIKE, IN, NOT IN, BETWEEN, IS NULL, IS NOT NULL).
|
|
98
|
+
* @param {*} [value] - Value to compare against.
|
|
99
|
+
* @returns {Query} `this` for chaining.
|
|
100
|
+
*/
|
|
101
|
+
where(field, op, value)
|
|
102
|
+
{
|
|
103
|
+
if (typeof field === 'object' && field !== null)
|
|
104
|
+
{
|
|
105
|
+
for (const [k, v] of Object.entries(field))
|
|
106
|
+
{
|
|
107
|
+
this._where.push({ field: k, op: '=', value: v, logic: 'AND' });
|
|
108
|
+
}
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (value === undefined) { value = op; op = '='; }
|
|
113
|
+
const upper = op.toUpperCase();
|
|
114
|
+
if (!VALID_OPERATORS.has(upper)) throw new Error(`Invalid query operator: ${op}`);
|
|
115
|
+
this._where.push({ field, op: upper, value, logic: 'AND' });
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Add an OR WHERE condition.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} field - Column name.
|
|
123
|
+
* @param {string} [op] - Operator.
|
|
124
|
+
* @param {*} [value] - Value.
|
|
125
|
+
* @returns {Query} `this` for chaining.
|
|
126
|
+
*/
|
|
127
|
+
orWhere(field, op, value)
|
|
128
|
+
{
|
|
129
|
+
if (value === undefined) { value = op; op = '='; }
|
|
130
|
+
const upper = op.toUpperCase();
|
|
131
|
+
if (!VALID_OPERATORS.has(upper)) throw new Error(`Invalid query operator: ${op}`);
|
|
132
|
+
this._where.push({ field, op: upper, value, logic: 'OR' });
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* WHERE column IS NULL.
|
|
138
|
+
* @param {string} field - Column name.
|
|
139
|
+
* @returns {Query} This query for chaining.
|
|
140
|
+
*/
|
|
141
|
+
whereNull(field)
|
|
142
|
+
{
|
|
143
|
+
this._where.push({ field, op: 'IS NULL', value: null, logic: 'AND' });
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* WHERE column IS NOT NULL.
|
|
149
|
+
* @param {string} field - Column name.
|
|
150
|
+
* @returns {Query} This query for chaining.
|
|
151
|
+
*/
|
|
152
|
+
whereNotNull(field)
|
|
153
|
+
{
|
|
154
|
+
this._where.push({ field, op: 'IS NOT NULL', value: null, logic: 'AND' });
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* WHERE column IN (...values).
|
|
160
|
+
* @param {string} field - Column name.
|
|
161
|
+
* @param {Array} values - Array of values to match.
|
|
162
|
+
* @returns {Query} This query for chaining.
|
|
163
|
+
*/
|
|
164
|
+
whereIn(field, values)
|
|
165
|
+
{
|
|
166
|
+
this._where.push({ field, op: 'IN', value: values, logic: 'AND' });
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* WHERE column NOT IN (...values).
|
|
172
|
+
* @param {string} field - Column name.
|
|
173
|
+
* @param {Array} values - Array of values to match.
|
|
174
|
+
* @returns {Query} This query for chaining.
|
|
175
|
+
*/
|
|
176
|
+
whereNotIn(field, values)
|
|
177
|
+
{
|
|
178
|
+
this._where.push({ field, op: 'NOT IN', value: values, logic: 'AND' });
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* WHERE column BETWEEN low AND high.
|
|
184
|
+
* @param {string} field - Column name.
|
|
185
|
+
* @param {*} low - Lower bound.
|
|
186
|
+
* @param {*} high - Upper bound.
|
|
187
|
+
* @returns {Query} This query for chaining.
|
|
188
|
+
*/
|
|
189
|
+
whereBetween(field, low, high)
|
|
190
|
+
{
|
|
191
|
+
this._where.push({ field, op: 'BETWEEN', value: [low, high], logic: 'AND' });
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* WHERE column NOT BETWEEN low AND high.
|
|
197
|
+
* @param {string} field - Column name.
|
|
198
|
+
* @param {*} low - Lower bound.
|
|
199
|
+
* @param {*} high - Upper bound.
|
|
200
|
+
* @returns {Query} This query for chaining.
|
|
201
|
+
*/
|
|
202
|
+
whereNotBetween(field, low, high)
|
|
203
|
+
{
|
|
204
|
+
this._where.push({ field, op: 'NOT BETWEEN', value: [low, high], logic: 'AND' });
|
|
205
|
+
return this;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* WHERE column LIKE pattern.
|
|
210
|
+
* @param {string} field - Column name.
|
|
211
|
+
* @param {string} pattern - SQL LIKE pattern (% and _ wildcards).
|
|
212
|
+
* @returns {Query} This query for chaining.
|
|
213
|
+
*/
|
|
214
|
+
whereLike(field, pattern)
|
|
215
|
+
{
|
|
216
|
+
this._where.push({ field, op: 'LIKE', value: pattern, logic: 'AND' });
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// -- Ordering ---------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* ORDER BY a column.
|
|
224
|
+
* @param {string} field - Column name.
|
|
225
|
+
* @param {string} [dir='asc'] - Direction: 'asc' or 'desc'.
|
|
226
|
+
* @returns {Query} This query for chaining.
|
|
227
|
+
*/
|
|
228
|
+
orderBy(field, dir = 'asc')
|
|
229
|
+
{
|
|
230
|
+
const upper = dir.toUpperCase();
|
|
231
|
+
if (!VALID_DIRECTIONS.has(upper)) throw new Error(`Invalid orderBy direction: ${dir}`);
|
|
232
|
+
this._orderBy.push({ field, dir: upper });
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// -- Pagination -------------------------------------
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* LIMIT results.
|
|
240
|
+
* @param {number} n - Number of rows.
|
|
241
|
+
* @returns {Query} This query for chaining.
|
|
242
|
+
*/
|
|
243
|
+
limit(n)
|
|
244
|
+
{
|
|
245
|
+
this._limitVal = n;
|
|
246
|
+
return this;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* OFFSET results.
|
|
251
|
+
* @param {number} n - Number of rows.
|
|
252
|
+
* @returns {Query} This query for chaining.
|
|
253
|
+
*/
|
|
254
|
+
offset(n)
|
|
255
|
+
{
|
|
256
|
+
this._offsetVal = n;
|
|
257
|
+
return this;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Convenience: page(pageNum, perPage).
|
|
262
|
+
* @param {number} page - 1-indexed page number.
|
|
263
|
+
* @param {number} perPage - Items per page.
|
|
264
|
+
* @returns {Query} This query for chaining.
|
|
265
|
+
*/
|
|
266
|
+
page(page, perPage = 20)
|
|
267
|
+
{
|
|
268
|
+
this._limitVal = perPage;
|
|
269
|
+
this._offsetVal = (Math.max(1, page) - 1) * perPage;
|
|
270
|
+
return this;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// -- Grouping ---------------------------------------
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* GROUP BY column(s).
|
|
277
|
+
* @param {...string} fields - Column names.
|
|
278
|
+
* @returns {Query} This query for chaining.
|
|
279
|
+
*/
|
|
280
|
+
groupBy(...fields)
|
|
281
|
+
{
|
|
282
|
+
this._groupBy.push(...fields.flat());
|
|
283
|
+
return this;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* HAVING (used with GROUP BY).
|
|
288
|
+
* @param {string} field - Column name.
|
|
289
|
+
* @param {string} [op] - Comparison operator (default '=').
|
|
290
|
+
* @param {*} [value] - Value to check.
|
|
291
|
+
* @returns {Query} This query for chaining.
|
|
292
|
+
*/
|
|
293
|
+
having(field, op, value)
|
|
294
|
+
{
|
|
295
|
+
if (value === undefined) { value = op; op = '='; }
|
|
296
|
+
this._having.push({ field, op: op.toUpperCase(), value });
|
|
297
|
+
return this;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// -- Joins ------------------------------------------
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* INNER JOIN.
|
|
304
|
+
* @param {string} table - Table to join.
|
|
305
|
+
* @param {string} localKey - Local column.
|
|
306
|
+
* @param {string} foreignKey - Foreign column.
|
|
307
|
+
* @returns {Query} This query for chaining.
|
|
308
|
+
*/
|
|
309
|
+
join(table, localKey, foreignKey)
|
|
310
|
+
{
|
|
311
|
+
this._joins.push({ type: 'INNER', table, localKey, foreignKey });
|
|
312
|
+
return this;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* LEFT JOIN.
|
|
317
|
+
* @param {string} table - Join table name.
|
|
318
|
+
* @param {string} localKey - Local column for the join.
|
|
319
|
+
* @param {string} foreignKey - Foreign column for the join.
|
|
320
|
+
* @returns {Query} This query for chaining.
|
|
321
|
+
*/
|
|
322
|
+
leftJoin(table, localKey, foreignKey)
|
|
323
|
+
{
|
|
324
|
+
this._joins.push({ type: 'LEFT', table, localKey, foreignKey });
|
|
325
|
+
return this;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* RIGHT JOIN.
|
|
330
|
+
* @param {string} table - Join table name.
|
|
331
|
+
* @param {string} localKey - Local column for the join.
|
|
332
|
+
* @param {string} foreignKey - Foreign column for the join.
|
|
333
|
+
* @returns {Query} This query for chaining.
|
|
334
|
+
*/
|
|
335
|
+
rightJoin(table, localKey, foreignKey)
|
|
336
|
+
{
|
|
337
|
+
this._joins.push({ type: 'RIGHT', table, localKey, foreignKey });
|
|
338
|
+
return this;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// -- Soft Delete ------------------------------------
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Include soft-deleted records in results.
|
|
345
|
+
* @returns {Query} This query for chaining.
|
|
346
|
+
*/
|
|
347
|
+
withDeleted()
|
|
348
|
+
{
|
|
349
|
+
this._includeDeleted = true;
|
|
350
|
+
return this;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// -- Eager Loading ----------------------------------
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Eager-load one or more relationships.
|
|
357
|
+
* Batches related queries to avoid the N+1 problem.
|
|
358
|
+
* Accepts either relation names or a relation name + a scope function to
|
|
359
|
+
* constrain the sub-query.
|
|
360
|
+
*
|
|
361
|
+
* @param {...string|object} relations - Relation names or `{ RelationName: q => q.where(...) }`.
|
|
362
|
+
* @returns {Query} This query for chaining.
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
365
|
+
* // Load all posts with their comments and author:
|
|
366
|
+
* const posts = await Post.query().with('Comment', 'Author');
|
|
367
|
+
*
|
|
368
|
+
* // Constrain the eager load:
|
|
369
|
+
* const posts = await Post.query().with({ Comment: q => q.where('approved', true).limit(5) });
|
|
370
|
+
*/
|
|
371
|
+
with(...relations)
|
|
372
|
+
{
|
|
373
|
+
for (const rel of relations)
|
|
374
|
+
{
|
|
375
|
+
if (typeof rel === 'string')
|
|
376
|
+
{
|
|
377
|
+
this._eagerLoad.push({ name: rel, scope: null });
|
|
378
|
+
}
|
|
379
|
+
else if (typeof rel === 'object' && rel !== null)
|
|
380
|
+
{
|
|
381
|
+
for (const [name, scope] of Object.entries(rel))
|
|
382
|
+
{
|
|
383
|
+
this._eagerLoad.push({ name, scope: typeof scope === 'function' ? scope : null });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return this;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Alias for with() — mirrors Entity Framework include syntax.
|
|
392
|
+
* @param {...string|object} relations - Relation names or config objects to eager-load.
|
|
393
|
+
* @returns {Query} This query for chaining.
|
|
394
|
+
*/
|
|
395
|
+
include(...relations)
|
|
396
|
+
{
|
|
397
|
+
return this.with(...relations);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Eager-count one or more relationships without loading the records.
|
|
402
|
+
* Adds a `RelationName_count` field to each result instance.
|
|
403
|
+
*
|
|
404
|
+
* @param {...string} relations - Relation names to count.
|
|
405
|
+
* @returns {Query} This query for chaining.
|
|
406
|
+
*
|
|
407
|
+
* @example
|
|
408
|
+
* const authors = await Author.query().withCount('Book');
|
|
409
|
+
* // authors[0].Book_count => 3
|
|
410
|
+
*/
|
|
411
|
+
withCount(...relations)
|
|
412
|
+
{
|
|
413
|
+
for (const rel of relations)
|
|
414
|
+
{
|
|
415
|
+
if (typeof rel === 'string')
|
|
416
|
+
{
|
|
417
|
+
this._eagerCount.push(rel);
|
|
418
|
+
}
|
|
419
|
+
else if (typeof rel === 'object' && rel !== null)
|
|
420
|
+
{
|
|
421
|
+
for (const name of Object.keys(rel))
|
|
422
|
+
{
|
|
423
|
+
this._eagerCount.push(name);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return this;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Force this query to execute against a read replica (if configured).
|
|
432
|
+
* Falls back to primary adapter if no replica manager is attached.
|
|
433
|
+
*
|
|
434
|
+
* @returns {Query} This query for chaining.
|
|
435
|
+
*/
|
|
436
|
+
onReplica()
|
|
437
|
+
{
|
|
438
|
+
this._useReplica = true;
|
|
439
|
+
return this;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Get the query execution plan from the adapter.
|
|
444
|
+
* For SQL adapters, returns EXPLAIN / EXPLAIN QUERY PLAN output.
|
|
445
|
+
* For the memory adapter, returns a plan description object.
|
|
446
|
+
*
|
|
447
|
+
* @param {object} [options] - Adapter-specific options.
|
|
448
|
+
* @param {boolean} [options.analyze] - Include ANALYZE (PostgreSQL).
|
|
449
|
+
* @returns {Promise<*>} Execution plan from the adapter.
|
|
450
|
+
*/
|
|
451
|
+
async explain(options = {})
|
|
452
|
+
{
|
|
453
|
+
const descriptor = this.build();
|
|
454
|
+
if (typeof this._adapter.explain === 'function')
|
|
455
|
+
{
|
|
456
|
+
return this._adapter.explain(descriptor, options);
|
|
457
|
+
}
|
|
458
|
+
return { plan: 'Adapter does not support EXPLAIN', descriptor };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Apply a named scope from the model.
|
|
463
|
+
* Allows chaining multiple scopes on a single query.
|
|
464
|
+
*
|
|
465
|
+
* @param {string} name - Scope name.
|
|
466
|
+
* @param {...*} [args] - Additional arguments for the scope function.
|
|
467
|
+
* @returns {Query} This query for chaining.
|
|
468
|
+
*
|
|
469
|
+
* @example
|
|
470
|
+
* await User.query().scope('active').scope('olderThan', 21).limit(5);
|
|
471
|
+
*/
|
|
472
|
+
scope(name, ...args)
|
|
473
|
+
{
|
|
474
|
+
const scopes = this._model.scopes;
|
|
475
|
+
if (!scopes || typeof scopes[name] !== 'function')
|
|
476
|
+
{
|
|
477
|
+
throw new Error(`Unknown scope "${name}" on ${this._model.name}`);
|
|
478
|
+
}
|
|
479
|
+
scopes[name](this, ...args);
|
|
480
|
+
return this;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// -- Execution --------------------------------------
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Build the abstract query descriptor.
|
|
487
|
+
* @returns {object} Adapter-agnostic query object.
|
|
488
|
+
*/
|
|
489
|
+
build()
|
|
490
|
+
{
|
|
491
|
+
// If withDeleted() was called, remove soft-delete filters
|
|
492
|
+
let where = this._where;
|
|
493
|
+
if (this._includeDeleted)
|
|
494
|
+
{
|
|
495
|
+
where = where.filter(w => !(w.field === 'deletedAt' && w.op === 'IS NULL'));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
action: this._action,
|
|
500
|
+
table: this._model.table,
|
|
501
|
+
fields: this._fields,
|
|
502
|
+
where,
|
|
503
|
+
orderBy: this._orderBy,
|
|
504
|
+
limit: this._limitVal,
|
|
505
|
+
offset: this._offsetVal,
|
|
506
|
+
data: this._data,
|
|
507
|
+
joins: this._joins,
|
|
508
|
+
groupBy: this._groupBy,
|
|
509
|
+
having: this._having,
|
|
510
|
+
distinct: this._distinct,
|
|
511
|
+
includeDeleted: this._includeDeleted,
|
|
512
|
+
schema: this._model.schema,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Execute the query and return results.
|
|
518
|
+
* @returns {Promise<Array<object>>} Matching rows.
|
|
519
|
+
*/
|
|
520
|
+
async exec()
|
|
521
|
+
{
|
|
522
|
+
const descriptor = this.build();
|
|
523
|
+
log.debug('%s %s', descriptor.action, descriptor.table);
|
|
524
|
+
|
|
525
|
+
// Route to replica if requested
|
|
526
|
+
const adapter = (this._useReplica && this._adapter._replicaManager)
|
|
527
|
+
? this._adapter._replicaManager.getReadAdapter()
|
|
528
|
+
: this._adapter;
|
|
529
|
+
|
|
530
|
+
// Profiling
|
|
531
|
+
const profiler = this._adapter._profiler;
|
|
532
|
+
const start = profiler ? process.hrtime() : null;
|
|
533
|
+
|
|
534
|
+
let rows;
|
|
535
|
+
try { rows = await adapter.execute(descriptor); }
|
|
536
|
+
catch (e) { log.error('%s %s failed: %s', descriptor.action, descriptor.table, e.message); throw e; }
|
|
537
|
+
|
|
538
|
+
// Record to profiler
|
|
539
|
+
if (profiler && start)
|
|
540
|
+
{
|
|
541
|
+
const diff = process.hrtime(start);
|
|
542
|
+
profiler.record({
|
|
543
|
+
table: descriptor.table,
|
|
544
|
+
action: descriptor.action,
|
|
545
|
+
duration: diff[0] * 1000 + diff[1] / 1e6,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Wrap results in model instances
|
|
550
|
+
if (this._action === 'select')
|
|
551
|
+
{
|
|
552
|
+
const instances = rows.map(row => this._model._fromRow(row));
|
|
553
|
+
|
|
554
|
+
// Batch eager-load relationships (avoids N+1)
|
|
555
|
+
if (this._eagerLoad.length > 0 && instances.length > 0)
|
|
556
|
+
{
|
|
557
|
+
await this._loadEager(instances);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Eager count relationships
|
|
561
|
+
if (this._eagerCount.length > 0 && instances.length > 0)
|
|
562
|
+
{
|
|
563
|
+
await this._loadEagerCount(instances);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return instances;
|
|
567
|
+
}
|
|
568
|
+
return rows;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Batch-load eager relationships for a set of instances.
|
|
573
|
+
* Uses a single query per relationship instead of one per instance.
|
|
574
|
+
* @param {Array} instances - Model instances to hydrate.
|
|
575
|
+
* @private
|
|
576
|
+
*/
|
|
577
|
+
async _loadEager(instances)
|
|
578
|
+
{
|
|
579
|
+
const ctor = this._model;
|
|
580
|
+
for (const { name, scope } of this._eagerLoad)
|
|
581
|
+
{
|
|
582
|
+
const rel = ctor._relations && ctor._relations[name];
|
|
583
|
+
if (!rel) throw new Error(`Unknown relation "${name}" on ${ctor.name}`);
|
|
584
|
+
|
|
585
|
+
switch (rel.type)
|
|
586
|
+
{
|
|
587
|
+
case 'hasMany':
|
|
588
|
+
{
|
|
589
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
590
|
+
if (!keys.length) break;
|
|
591
|
+
let q = rel.model.query().whereIn(rel.foreignKey, keys);
|
|
592
|
+
if (scope) scope(q);
|
|
593
|
+
const related = await q.exec();
|
|
594
|
+
const grouped = new Map();
|
|
595
|
+
for (const r of related)
|
|
596
|
+
{
|
|
597
|
+
const fk = r[rel.foreignKey];
|
|
598
|
+
if (!grouped.has(fk)) grouped.set(fk, []);
|
|
599
|
+
grouped.get(fk).push(r);
|
|
600
|
+
}
|
|
601
|
+
for (const inst of instances)
|
|
602
|
+
inst[name] = grouped.get(inst[rel.localKey]) || [];
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
case 'hasOne':
|
|
606
|
+
{
|
|
607
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
608
|
+
if (!keys.length) break;
|
|
609
|
+
let q = rel.model.query().whereIn(rel.foreignKey, keys);
|
|
610
|
+
if (scope) scope(q);
|
|
611
|
+
const related = await q.exec();
|
|
612
|
+
const byFk = new Map();
|
|
613
|
+
for (const r of related) byFk.set(r[rel.foreignKey], r);
|
|
614
|
+
for (const inst of instances)
|
|
615
|
+
inst[name] = byFk.get(inst[rel.localKey]) || null;
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
case 'belongsTo':
|
|
619
|
+
{
|
|
620
|
+
const keys = [...new Set(instances.map(i => i[rel.foreignKey]).filter(v => v != null))];
|
|
621
|
+
if (!keys.length) break;
|
|
622
|
+
let q = rel.model.query().whereIn(rel.localKey, keys);
|
|
623
|
+
if (scope) scope(q);
|
|
624
|
+
const related = await q.exec();
|
|
625
|
+
const byPk = new Map();
|
|
626
|
+
for (const r of related) byPk.set(r[rel.localKey], r);
|
|
627
|
+
for (const inst of instances)
|
|
628
|
+
inst[name] = byPk.get(inst[rel.foreignKey]) || null;
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
case 'belongsToMany':
|
|
632
|
+
{
|
|
633
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
634
|
+
if (!keys.length) break;
|
|
635
|
+
// Batch query junction table
|
|
636
|
+
const junctionRows = await ctor._adapter.execute({
|
|
637
|
+
action: 'select', table: rel.through,
|
|
638
|
+
fields: [rel.foreignKey, rel.otherKey],
|
|
639
|
+
where: [{ field: rel.foreignKey, op: 'IN', value: keys, logic: 'AND' }],
|
|
640
|
+
orderBy: [], joins: [], groupBy: [], having: [],
|
|
641
|
+
limit: null, offset: null, distinct: false,
|
|
642
|
+
});
|
|
643
|
+
const relatedIds = [...new Set(junctionRows.map(r => r[rel.otherKey]))];
|
|
644
|
+
if (!relatedIds.length) { for (const inst of instances) inst[name] = []; break; }
|
|
645
|
+
let q = rel.model.query().whereIn(rel.relatedKey, relatedIds);
|
|
646
|
+
if (scope) scope(q);
|
|
647
|
+
const related = await q.exec();
|
|
648
|
+
const byPk = new Map();
|
|
649
|
+
for (const r of related) byPk.set(r[rel.relatedKey], r);
|
|
650
|
+
// Group by parent
|
|
651
|
+
const jMap = new Map();
|
|
652
|
+
for (const jr of junctionRows)
|
|
653
|
+
{
|
|
654
|
+
const fk = jr[rel.foreignKey];
|
|
655
|
+
if (!jMap.has(fk)) jMap.set(fk, []);
|
|
656
|
+
const r = byPk.get(jr[rel.otherKey]);
|
|
657
|
+
if (r) jMap.get(fk).push(r);
|
|
658
|
+
}
|
|
659
|
+
for (const inst of instances)
|
|
660
|
+
inst[name] = jMap.get(inst[rel.localKey]) || [];
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
case 'morphOne':
|
|
664
|
+
{
|
|
665
|
+
const typeCol = `${rel.morphName}_type`;
|
|
666
|
+
const idCol = `${rel.morphName}_id`;
|
|
667
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
668
|
+
if (!keys.length) break;
|
|
669
|
+
let q = rel.model.query().where(typeCol, ctor.name).whereIn(idCol, keys);
|
|
670
|
+
if (scope) scope(q);
|
|
671
|
+
const related = await q.exec();
|
|
672
|
+
const byId = new Map();
|
|
673
|
+
for (const r of related) byId.set(r[idCol], r);
|
|
674
|
+
for (const inst of instances)
|
|
675
|
+
inst[name] = byId.get(inst[rel.localKey]) || null;
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
case 'morphMany':
|
|
679
|
+
{
|
|
680
|
+
const typeCol = `${rel.morphName}_type`;
|
|
681
|
+
const idCol = `${rel.morphName}_id`;
|
|
682
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
683
|
+
if (!keys.length) break;
|
|
684
|
+
let q = rel.model.query().where(typeCol, ctor.name).whereIn(idCol, keys);
|
|
685
|
+
if (scope) scope(q);
|
|
686
|
+
const related = await q.exec();
|
|
687
|
+
const grouped = new Map();
|
|
688
|
+
for (const r of related)
|
|
689
|
+
{
|
|
690
|
+
const fk = r[idCol];
|
|
691
|
+
if (!grouped.has(fk)) grouped.set(fk, []);
|
|
692
|
+
grouped.get(fk).push(r);
|
|
693
|
+
}
|
|
694
|
+
for (const inst of instances)
|
|
695
|
+
inst[name] = grouped.get(inst[rel.localKey]) || [];
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
case 'hasManyThrough':
|
|
699
|
+
{
|
|
700
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
701
|
+
if (!keys.length) break;
|
|
702
|
+
// Batch through model
|
|
703
|
+
const throughRecords = await rel.through.query().whereIn(rel.firstKey, keys).exec();
|
|
704
|
+
if (!throughRecords.length) { for (const inst of instances) inst[name] = []; break; }
|
|
705
|
+
const throughIds = [...new Set(throughRecords.map(r => r[rel.secondLocalKey]))];
|
|
706
|
+
let q = rel.model.query().whereIn(rel.secondKey, throughIds);
|
|
707
|
+
if (scope) scope(q);
|
|
708
|
+
const related = await q.exec();
|
|
709
|
+
// Map related by secondKey
|
|
710
|
+
const bySecond = new Map();
|
|
711
|
+
for (const r of related)
|
|
712
|
+
{
|
|
713
|
+
const sk = r[rel.secondKey];
|
|
714
|
+
if (!bySecond.has(sk)) bySecond.set(sk, []);
|
|
715
|
+
bySecond.get(sk).push(r);
|
|
716
|
+
}
|
|
717
|
+
// Map through records by firstKey to parent
|
|
718
|
+
const parentMap = new Map();
|
|
719
|
+
for (const tr of throughRecords)
|
|
720
|
+
{
|
|
721
|
+
const fk = tr[rel.firstKey];
|
|
722
|
+
if (!parentMap.has(fk)) parentMap.set(fk, []);
|
|
723
|
+
const items = bySecond.get(tr[rel.secondLocalKey]) || [];
|
|
724
|
+
parentMap.get(fk).push(...items);
|
|
725
|
+
}
|
|
726
|
+
for (const inst of instances)
|
|
727
|
+
inst[name] = parentMap.get(inst[rel.localKey]) || [];
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Batch-count eager relationships for a set of instances.
|
|
736
|
+
* Adds `RelationName_count` to each instance without loading the full records.
|
|
737
|
+
* Uses a single query per relationship.
|
|
738
|
+
* @param {Array} instances - Model instances to hydrate.
|
|
739
|
+
* @private
|
|
740
|
+
*/
|
|
741
|
+
async _loadEagerCount(instances)
|
|
742
|
+
{
|
|
743
|
+
const ctor = this._model;
|
|
744
|
+
for (const name of this._eagerCount)
|
|
745
|
+
{
|
|
746
|
+
const rel = ctor._relations && ctor._relations[name];
|
|
747
|
+
if (!rel) throw new Error(`Unknown relation "${name}" on ${ctor.name}`);
|
|
748
|
+
|
|
749
|
+
const countKey = `${name}_count`;
|
|
750
|
+
|
|
751
|
+
switch (rel.type)
|
|
752
|
+
{
|
|
753
|
+
case 'hasMany':
|
|
754
|
+
case 'hasOne':
|
|
755
|
+
{
|
|
756
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
757
|
+
if (!keys.length) { for (const inst of instances) inst[countKey] = 0; break; }
|
|
758
|
+
// Fetch only the FK column to minimise data transfer
|
|
759
|
+
const related = await rel.model.query().whereIn(rel.foreignKey, keys).select(rel.foreignKey).exec();
|
|
760
|
+
const counts = new Map();
|
|
761
|
+
for (const r of related)
|
|
762
|
+
{
|
|
763
|
+
const fk = r[rel.foreignKey];
|
|
764
|
+
counts.set(fk, (counts.get(fk) || 0) + 1);
|
|
765
|
+
}
|
|
766
|
+
for (const inst of instances)
|
|
767
|
+
inst[countKey] = counts.get(inst[rel.localKey]) || 0;
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
case 'belongsTo':
|
|
771
|
+
{
|
|
772
|
+
const keys = [...new Set(instances.map(i => i[rel.foreignKey]).filter(v => v != null))];
|
|
773
|
+
if (!keys.length) { for (const inst of instances) inst[countKey] = 0; break; }
|
|
774
|
+
const related = await rel.model.query().whereIn(rel.localKey, keys).select(rel.localKey).exec();
|
|
775
|
+
const byPk = new Set(related.map(r => r[rel.localKey]));
|
|
776
|
+
for (const inst of instances)
|
|
777
|
+
inst[countKey] = byPk.has(inst[rel.foreignKey]) ? 1 : 0;
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
case 'belongsToMany':
|
|
781
|
+
{
|
|
782
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
783
|
+
if (!keys.length) { for (const inst of instances) inst[countKey] = 0; break; }
|
|
784
|
+
const junctionRows = await ctor._adapter.execute({
|
|
785
|
+
action: 'select', table: rel.through,
|
|
786
|
+
fields: [rel.foreignKey],
|
|
787
|
+
where: [{ field: rel.foreignKey, op: 'IN', value: keys, logic: 'AND' }],
|
|
788
|
+
orderBy: [], joins: [], groupBy: [], having: [],
|
|
789
|
+
limit: null, offset: null, distinct: false,
|
|
790
|
+
});
|
|
791
|
+
const counts = new Map();
|
|
792
|
+
for (const jr of junctionRows)
|
|
793
|
+
{
|
|
794
|
+
const fk = jr[rel.foreignKey];
|
|
795
|
+
counts.set(fk, (counts.get(fk) || 0) + 1);
|
|
796
|
+
}
|
|
797
|
+
for (const inst of instances)
|
|
798
|
+
inst[countKey] = counts.get(inst[rel.localKey]) || 0;
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
case 'morphOne':
|
|
802
|
+
{
|
|
803
|
+
const typeCol = `${rel.morphName}_type`;
|
|
804
|
+
const idCol = `${rel.morphName}_id`;
|
|
805
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
806
|
+
if (!keys.length) { for (const inst of instances) inst[countKey] = 0; break; }
|
|
807
|
+
const related = await rel.model.query().where(typeCol, ctor.name).whereIn(idCol, keys).select(idCol).exec();
|
|
808
|
+
const byId = new Set(related.map(r => r[idCol]));
|
|
809
|
+
for (const inst of instances)
|
|
810
|
+
inst[countKey] = byId.has(inst[rel.localKey]) ? 1 : 0;
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
case 'morphMany':
|
|
814
|
+
{
|
|
815
|
+
const typeCol = `${rel.morphName}_type`;
|
|
816
|
+
const idCol = `${rel.morphName}_id`;
|
|
817
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
818
|
+
if (!keys.length) { for (const inst of instances) inst[countKey] = 0; break; }
|
|
819
|
+
const related = await rel.model.query().where(typeCol, ctor.name).whereIn(idCol, keys).select(idCol).exec();
|
|
820
|
+
const counts = new Map();
|
|
821
|
+
for (const r of related)
|
|
822
|
+
{
|
|
823
|
+
const fk = r[idCol];
|
|
824
|
+
counts.set(fk, (counts.get(fk) || 0) + 1);
|
|
825
|
+
}
|
|
826
|
+
for (const inst of instances)
|
|
827
|
+
inst[countKey] = counts.get(inst[rel.localKey]) || 0;
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
case 'hasManyThrough':
|
|
831
|
+
{
|
|
832
|
+
const keys = [...new Set(instances.map(i => i[rel.localKey]).filter(v => v != null))];
|
|
833
|
+
if (!keys.length) { for (const inst of instances) inst[countKey] = 0; break; }
|
|
834
|
+
const throughRecords = await rel.through.query().whereIn(rel.firstKey, keys).select(rel.firstKey, rel.secondLocalKey).exec();
|
|
835
|
+
const counts = new Map();
|
|
836
|
+
for (const tr of throughRecords)
|
|
837
|
+
{
|
|
838
|
+
const fk = tr[rel.firstKey];
|
|
839
|
+
counts.set(fk, (counts.get(fk) || 0) + 1);
|
|
840
|
+
}
|
|
841
|
+
for (const inst of instances)
|
|
842
|
+
inst[countKey] = counts.get(inst[rel.localKey]) || 0;
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Execute and return the first result.
|
|
851
|
+
* @returns {Promise<object|null>} Matching row or null.
|
|
852
|
+
*/
|
|
853
|
+
async first()
|
|
854
|
+
{
|
|
855
|
+
this._limitVal = 1;
|
|
856
|
+
const results = await this.exec();
|
|
857
|
+
return results[0] || null;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Count matching records.
|
|
862
|
+
* @returns {Promise<number>} Number of matching records.
|
|
863
|
+
*/
|
|
864
|
+
async count()
|
|
865
|
+
{
|
|
866
|
+
const descriptor = this.build();
|
|
867
|
+
descriptor.action = 'count';
|
|
868
|
+
|
|
869
|
+
const adapter = (this._useReplica && this._adapter._replicaManager)
|
|
870
|
+
? this._adapter._replicaManager.getReadAdapter()
|
|
871
|
+
: this._adapter;
|
|
872
|
+
|
|
873
|
+
const profiler = this._adapter._profiler;
|
|
874
|
+
const start = profiler ? process.hrtime() : null;
|
|
875
|
+
|
|
876
|
+
let result;
|
|
877
|
+
try { result = await adapter.execute(descriptor); }
|
|
878
|
+
catch (e) { log.error('count %s failed: %s', descriptor.table, e.message); throw e; }
|
|
879
|
+
|
|
880
|
+
if (profiler && start)
|
|
881
|
+
{
|
|
882
|
+
const diff = process.hrtime(start);
|
|
883
|
+
profiler.record({
|
|
884
|
+
table: descriptor.table,
|
|
885
|
+
action: 'count',
|
|
886
|
+
duration: diff[0] * 1000 + diff[1] / 1e6,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return result;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Check whether any matching records exist.
|
|
895
|
+
* @returns {Promise<boolean>} True if any matching records exist.
|
|
896
|
+
*/
|
|
897
|
+
async exists()
|
|
898
|
+
{
|
|
899
|
+
const c = await this.count();
|
|
900
|
+
return c > 0;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Get an array of values for a single column.
|
|
905
|
+
*
|
|
906
|
+
* @param {string} field - Column name to extract.
|
|
907
|
+
* @returns {Promise<Array<*>>} Array of values.
|
|
908
|
+
*
|
|
909
|
+
* @example
|
|
910
|
+
* const emails = await User.query().pluck('email');
|
|
911
|
+
* // => ['alice@a.com', 'bob@b.com']
|
|
912
|
+
*/
|
|
913
|
+
async pluck(field)
|
|
914
|
+
{
|
|
915
|
+
this._fields = [field];
|
|
916
|
+
const rows = await this.exec();
|
|
917
|
+
return rows.map(r => r[field]);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* SUM of a numeric column.
|
|
922
|
+
* @param {string} field - Column name.
|
|
923
|
+
* @returns {Promise<number>} Sum of the column values.
|
|
924
|
+
*/
|
|
925
|
+
async sum(field)
|
|
926
|
+
{
|
|
927
|
+
const descriptor = this.build();
|
|
928
|
+
descriptor.action = 'aggregate';
|
|
929
|
+
descriptor.aggregateFn = 'sum';
|
|
930
|
+
descriptor.aggregateField = field;
|
|
931
|
+
// Fallback for adapters without native aggregate
|
|
932
|
+
if (typeof this._adapter.aggregate === 'function')
|
|
933
|
+
{
|
|
934
|
+
return this._adapter.aggregate(descriptor);
|
|
935
|
+
}
|
|
936
|
+
const rows = await this.exec();
|
|
937
|
+
return rows.reduce((acc, r) => acc + (Number(r[field]) || 0), 0);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* AVG of a numeric column.
|
|
942
|
+
* @param {string} field - Column name.
|
|
943
|
+
* @returns {Promise<number>} Average of the column values.
|
|
944
|
+
*/
|
|
945
|
+
async avg(field)
|
|
946
|
+
{
|
|
947
|
+
const descriptor = this.build();
|
|
948
|
+
descriptor.action = 'aggregate';
|
|
949
|
+
descriptor.aggregateFn = 'avg';
|
|
950
|
+
descriptor.aggregateField = field;
|
|
951
|
+
if (typeof this._adapter.aggregate === 'function')
|
|
952
|
+
{
|
|
953
|
+
return this._adapter.aggregate(descriptor);
|
|
954
|
+
}
|
|
955
|
+
const rows = await this.exec();
|
|
956
|
+
if (!rows.length) return 0;
|
|
957
|
+
return rows.reduce((acc, r) => acc + (Number(r[field]) || 0), 0) / rows.length;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* MIN of a column.
|
|
962
|
+
* @param {string} field - Column name.
|
|
963
|
+
* @returns {Promise<*>} Minimum value in the column.
|
|
964
|
+
*/
|
|
965
|
+
async min(field)
|
|
966
|
+
{
|
|
967
|
+
const descriptor = this.build();
|
|
968
|
+
descriptor.action = 'aggregate';
|
|
969
|
+
descriptor.aggregateFn = 'min';
|
|
970
|
+
descriptor.aggregateField = field;
|
|
971
|
+
if (typeof this._adapter.aggregate === 'function')
|
|
972
|
+
{
|
|
973
|
+
return this._adapter.aggregate(descriptor);
|
|
974
|
+
}
|
|
975
|
+
const rows = await this.exec();
|
|
976
|
+
if (!rows.length) return null;
|
|
977
|
+
return rows.reduce((m, r) => (r[field] < m ? r[field] : m), rows[0][field]);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* MAX of a column.
|
|
982
|
+
* @param {string} field - Column name.
|
|
983
|
+
* @returns {Promise<*>} Maximum value in the column.
|
|
984
|
+
*/
|
|
985
|
+
async max(field)
|
|
986
|
+
{
|
|
987
|
+
const descriptor = this.build();
|
|
988
|
+
descriptor.action = 'aggregate';
|
|
989
|
+
descriptor.aggregateFn = 'max';
|
|
990
|
+
descriptor.aggregateField = field;
|
|
991
|
+
if (typeof this._adapter.aggregate === 'function')
|
|
992
|
+
{
|
|
993
|
+
return this._adapter.aggregate(descriptor);
|
|
994
|
+
}
|
|
995
|
+
const rows = await this.exec();
|
|
996
|
+
if (!rows.length) return null;
|
|
997
|
+
return rows.reduce((m, r) => (r[field] > m ? r[field] : m), rows[0][field]);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Make Query thenable — allows `await query`.
|
|
1002
|
+
* @param {Function} resolve - Fulfillment handler.
|
|
1003
|
+
* @param {Function} reject - Rejection handler.
|
|
1004
|
+
* @returns {Promise} Result of exec().
|
|
1005
|
+
*/
|
|
1006
|
+
then(resolve, reject)
|
|
1007
|
+
{
|
|
1008
|
+
return this.exec().then(resolve, reject);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Catch rejections from the executed query.
|
|
1013
|
+
* @param {Function} reject - Rejection handler.
|
|
1014
|
+
* @returns {Promise} Result of exec().
|
|
1015
|
+
*/
|
|
1016
|
+
catch(reject)
|
|
1017
|
+
{
|
|
1018
|
+
return this.exec().catch(reject);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// -- LINQ-Inspired Utilities ------------------------
|
|
1022
|
+
|
|
1023
|
+
// -- Aliases --
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Alias for limit (LINQ naming).
|
|
1027
|
+
* @param {number} n - Number of rows.
|
|
1028
|
+
* @returns {Query} This query for chaining.
|
|
1029
|
+
*/
|
|
1030
|
+
take(n)
|
|
1031
|
+
{
|
|
1032
|
+
return this.limit(n);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Alias for offset (LINQ naming).
|
|
1037
|
+
* @param {number} n - Number of rows.
|
|
1038
|
+
* @returns {Query} This query for chaining.
|
|
1039
|
+
*/
|
|
1040
|
+
skip(n)
|
|
1041
|
+
{
|
|
1042
|
+
return this.offset(n);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Alias for exec — explicitly convert to array.
|
|
1047
|
+
* @returns {Promise<Array>} Matching rows as an array.
|
|
1048
|
+
*/
|
|
1049
|
+
toArray()
|
|
1050
|
+
{
|
|
1051
|
+
return this.exec();
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Shorthand for orderBy(field, 'desc').
|
|
1056
|
+
* @param {string} field - Column name.
|
|
1057
|
+
* @returns {Query} This query for chaining.
|
|
1058
|
+
*/
|
|
1059
|
+
orderByDesc(field)
|
|
1060
|
+
{
|
|
1061
|
+
return this.orderBy(field, 'desc');
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* C# alias: OrderByDescending.
|
|
1066
|
+
* @param {string} field - Column name.
|
|
1067
|
+
* @returns {Query} This query for chaining.
|
|
1068
|
+
*/
|
|
1069
|
+
orderByDescending(field)
|
|
1070
|
+
{
|
|
1071
|
+
return this.orderBy(field, 'desc');
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Alias for first() — C# FirstOrDefault returns null on empty.
|
|
1076
|
+
* @returns {Promise<object|null>} Matching row or null.
|
|
1077
|
+
*/
|
|
1078
|
+
firstOrDefault()
|
|
1079
|
+
{
|
|
1080
|
+
return this.first();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Alias for avg() — C# naming.
|
|
1085
|
+
* @param {string} field - Column name.
|
|
1086
|
+
* @returns {Promise<number>} Average of the column values.
|
|
1087
|
+
*/
|
|
1088
|
+
average(field)
|
|
1089
|
+
{
|
|
1090
|
+
return this.avg(field);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Alias for reduce() — C# Aggregate naming.
|
|
1095
|
+
* @param {Function} fn - Callback function.
|
|
1096
|
+
* @param {*} seed - Initial accumulator value.
|
|
1097
|
+
* @returns {Promise<*>} Accumulated value.
|
|
1098
|
+
*/
|
|
1099
|
+
aggregate(fn, seed)
|
|
1100
|
+
{
|
|
1101
|
+
return this.reduce(fn, seed);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// -- Element Operators --
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Execute and return the last result.
|
|
1108
|
+
* Reverses the first orderBy or defaults to primary key DESC.
|
|
1109
|
+
* @returns {Promise<object|null>} Matching row or null.
|
|
1110
|
+
*/
|
|
1111
|
+
async last()
|
|
1112
|
+
{
|
|
1113
|
+
if (this._orderBy.length)
|
|
1114
|
+
{
|
|
1115
|
+
const first = this._orderBy[0];
|
|
1116
|
+
first.dir = first.dir === 'ASC' ? 'DESC' : 'ASC';
|
|
1117
|
+
}
|
|
1118
|
+
else
|
|
1119
|
+
{
|
|
1120
|
+
const pk = this._model._primaryKey ? this._model._primaryKey() : 'id';
|
|
1121
|
+
this._orderBy.push({ field: pk, dir: 'DESC' });
|
|
1122
|
+
}
|
|
1123
|
+
this._limitVal = 1;
|
|
1124
|
+
const results = await this.exec();
|
|
1125
|
+
return results[0] || null;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Alias for last() — C# naming.
|
|
1130
|
+
* @returns {Promise<object|null>} Matching row or null.
|
|
1131
|
+
*/
|
|
1132
|
+
lastOrDefault()
|
|
1133
|
+
{
|
|
1134
|
+
return this.last();
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Returns the only element. Throws if count !== 1.
|
|
1139
|
+
* @returns {Promise<object>} The single matching row.
|
|
1140
|
+
*/
|
|
1141
|
+
async single()
|
|
1142
|
+
{
|
|
1143
|
+
this._limitVal = 2;
|
|
1144
|
+
const results = await this.exec();
|
|
1145
|
+
if (results.length === 0) throw new Error('Sequence contains no elements');
|
|
1146
|
+
if (results.length > 1) throw new Error('Sequence contains more than one element');
|
|
1147
|
+
return results[0];
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Returns the only element, or null if empty. Throws if more than one.
|
|
1152
|
+
* @returns {Promise<object|null>} Matching row or null.
|
|
1153
|
+
*/
|
|
1154
|
+
async singleOrDefault()
|
|
1155
|
+
{
|
|
1156
|
+
this._limitVal = 2;
|
|
1157
|
+
const results = await this.exec();
|
|
1158
|
+
if (results.length > 1) throw new Error('Sequence contains more than one element');
|
|
1159
|
+
return results[0] || null;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Get element at a specific index.
|
|
1164
|
+
* @param {number} index - 0-based index.
|
|
1165
|
+
* @returns {Promise<object>} Row at the given index.
|
|
1166
|
+
*/
|
|
1167
|
+
async elementAt(index)
|
|
1168
|
+
{
|
|
1169
|
+
this._offsetVal = index;
|
|
1170
|
+
this._limitVal = 1;
|
|
1171
|
+
const results = await this.exec();
|
|
1172
|
+
if (results.length === 0) throw new Error('Index out of range');
|
|
1173
|
+
return results[0];
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Get element at index, or null if out of range.
|
|
1178
|
+
* @param {number} index - 0-based index.
|
|
1179
|
+
* @returns {Promise<object|null>} Matching row or null.
|
|
1180
|
+
*/
|
|
1181
|
+
async elementAtOrDefault(index)
|
|
1182
|
+
{
|
|
1183
|
+
this._offsetVal = index;
|
|
1184
|
+
this._limitVal = 1;
|
|
1185
|
+
const results = await this.exec();
|
|
1186
|
+
return results[0] || null;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Returns results, or an array with defaultValue if empty.
|
|
1191
|
+
* @param {*} defaultValue - Fallback value if empty.
|
|
1192
|
+
* @returns {Promise<Array>} Results, or [defaultValue] if empty.
|
|
1193
|
+
*/
|
|
1194
|
+
async defaultIfEmpty(defaultValue)
|
|
1195
|
+
{
|
|
1196
|
+
const results = await this.exec();
|
|
1197
|
+
return results.length ? results : [defaultValue];
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// -- Quantifiers --
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Returns true if any elements match. With a predicate, filters post-execution.
|
|
1204
|
+
* Without a predicate, equivalent to exists().
|
|
1205
|
+
* @param {Function} [predicate] - Filter function.
|
|
1206
|
+
* @returns {Promise<boolean>} True if any elements match.
|
|
1207
|
+
*/
|
|
1208
|
+
async any(predicate)
|
|
1209
|
+
{
|
|
1210
|
+
if (!predicate) return this.exists();
|
|
1211
|
+
const results = await this.exec();
|
|
1212
|
+
return results.some(predicate);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Returns true if all elements match the predicate.
|
|
1217
|
+
* @param {Function} predicate - Filter function.
|
|
1218
|
+
* @returns {Promise<boolean>} True if all elements match the predicate.
|
|
1219
|
+
*/
|
|
1220
|
+
async all(predicate)
|
|
1221
|
+
{
|
|
1222
|
+
const results = await this.exec();
|
|
1223
|
+
return results.length > 0 && results.every(predicate);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Returns true if any record has the given value for a column.
|
|
1228
|
+
* @param {string} field - Column name.
|
|
1229
|
+
* @param {*} value - Value to check.
|
|
1230
|
+
* @returns {Promise<boolean>} True if any record has the given value.
|
|
1231
|
+
*/
|
|
1232
|
+
async contains(field, value)
|
|
1233
|
+
{
|
|
1234
|
+
this._where.push({ field, op: '=', value, logic: 'AND' });
|
|
1235
|
+
return this.exists();
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Compares results of this query with another for equality.
|
|
1240
|
+
* @param {Query|Array} other - Another query or array.
|
|
1241
|
+
* @param {Function} [compareFn] - Custom equality function (a, b) => boolean.
|
|
1242
|
+
* @returns {Promise<boolean>} True if both sequences are equal.
|
|
1243
|
+
*/
|
|
1244
|
+
async sequenceEqual(other, compareFn)
|
|
1245
|
+
{
|
|
1246
|
+
const a = await this.exec();
|
|
1247
|
+
const b = Array.isArray(other) ? other : await other.exec();
|
|
1248
|
+
if (a.length !== b.length) return false;
|
|
1249
|
+
const cmp = compareFn || ((x, y) => JSON.stringify(x) === JSON.stringify(y));
|
|
1250
|
+
for (let i = 0; i < a.length; i++)
|
|
1251
|
+
{
|
|
1252
|
+
if (!cmp(a[i], b[i])) return false;
|
|
1253
|
+
}
|
|
1254
|
+
return true;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// -- Ordering --
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Add secondary sort ascending (use after orderBy).
|
|
1261
|
+
* @param {string} field - Column name.
|
|
1262
|
+
* @returns {Query} This query for chaining.
|
|
1263
|
+
*/
|
|
1264
|
+
thenBy(field)
|
|
1265
|
+
{
|
|
1266
|
+
this._orderBy.push({ field, dir: 'ASC' });
|
|
1267
|
+
return this;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Add secondary sort descending (use after orderBy).
|
|
1272
|
+
* @param {string} field - Column name.
|
|
1273
|
+
* @returns {Query} This query for chaining.
|
|
1274
|
+
*/
|
|
1275
|
+
thenByDescending(field)
|
|
1276
|
+
{
|
|
1277
|
+
this._orderBy.push({ field, dir: 'DESC' });
|
|
1278
|
+
return this;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// -- Set Operations --
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Append results from another query or array.
|
|
1285
|
+
* @param {Query|Array} other - Query or array to combine with.
|
|
1286
|
+
* @returns {Promise<Array>} Combined results.
|
|
1287
|
+
*/
|
|
1288
|
+
async concat(other)
|
|
1289
|
+
{
|
|
1290
|
+
const a = await this.exec();
|
|
1291
|
+
const b = Array.isArray(other) ? other : await other.exec();
|
|
1292
|
+
return a.concat(b);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Distinct union of this query's results with another.
|
|
1297
|
+
* @param {Query|Array} other - Query or array to combine with.
|
|
1298
|
+
* @param {Function} [keyFn] - Key selector for equality (default: JSON.stringify).
|
|
1299
|
+
* @returns {Promise<Array>} Distinct combined results.
|
|
1300
|
+
*/
|
|
1301
|
+
async union(other, keyFn)
|
|
1302
|
+
{
|
|
1303
|
+
const a = await this.exec();
|
|
1304
|
+
const b = Array.isArray(other) ? other : await other.exec();
|
|
1305
|
+
const key = keyFn || (item => JSON.stringify(item));
|
|
1306
|
+
const seen = new Set(a.map(key));
|
|
1307
|
+
const result = [...a];
|
|
1308
|
+
for (const item of b)
|
|
1309
|
+
{
|
|
1310
|
+
const k = key(item);
|
|
1311
|
+
if (!seen.has(k)) { seen.add(k); result.push(item); }
|
|
1312
|
+
}
|
|
1313
|
+
return result;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* Elements common to both this query and another.
|
|
1318
|
+
* @param {Query|Array} other - Query or array to combine with.
|
|
1319
|
+
* @param {Function} [keyFn] - Key selector for equality.
|
|
1320
|
+
* @returns {Promise<Array>} Common elements.
|
|
1321
|
+
*/
|
|
1322
|
+
async intersect(other, keyFn)
|
|
1323
|
+
{
|
|
1324
|
+
const a = await this.exec();
|
|
1325
|
+
const b = Array.isArray(other) ? other : await other.exec();
|
|
1326
|
+
const key = keyFn || (item => JSON.stringify(item));
|
|
1327
|
+
const bKeys = new Set(b.map(key));
|
|
1328
|
+
return a.filter(item => bKeys.has(key(item)));
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Elements in this query but not in other.
|
|
1333
|
+
* @param {Query|Array} other - Query or array to combine with.
|
|
1334
|
+
* @param {Function} [keyFn] - Key selector for equality.
|
|
1335
|
+
* @returns {Promise<Array>} Elements only in this query.
|
|
1336
|
+
*/
|
|
1337
|
+
async except(other, keyFn)
|
|
1338
|
+
{
|
|
1339
|
+
const a = await this.exec();
|
|
1340
|
+
const b = Array.isArray(other) ? other : await other.exec();
|
|
1341
|
+
const key = keyFn || (item => JSON.stringify(item));
|
|
1342
|
+
const bKeys = new Set(b.map(key));
|
|
1343
|
+
return a.filter(item => !bKeys.has(key(item)));
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// -- Projection --
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* FlatMap — project each element to an array and flatten.
|
|
1350
|
+
* @param {Function} fn - (item, index) => Array
|
|
1351
|
+
* @returns {Promise<Array>} Flattened projected results.
|
|
1352
|
+
*/
|
|
1353
|
+
async selectMany(fn)
|
|
1354
|
+
{
|
|
1355
|
+
const results = await this.exec();
|
|
1356
|
+
return results.flatMap(fn);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Combine two result sets element-wise.
|
|
1361
|
+
* @param {Query|Array} other - Query or array to combine with.
|
|
1362
|
+
* @param {Function} fn - (a, b) => result
|
|
1363
|
+
* @returns {Promise<Array>} Element-wise combined results.
|
|
1364
|
+
*/
|
|
1365
|
+
async zip(other, fn)
|
|
1366
|
+
{
|
|
1367
|
+
const a = await this.exec();
|
|
1368
|
+
const b = Array.isArray(other) ? other : await other.exec();
|
|
1369
|
+
const len = Math.min(a.length, b.length);
|
|
1370
|
+
const result = new Array(len);
|
|
1371
|
+
for (let i = 0; i < len; i++) result[i] = fn(a[i], b[i]);
|
|
1372
|
+
return result;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Convert results to a Map keyed by a selector.
|
|
1377
|
+
* @param {Function} keyFn - (item) => key
|
|
1378
|
+
* @param {Function} [valueFn] - (item) => value. Defaults to the item itself.
|
|
1379
|
+
* @returns {Promise<Map>} Keyed result map.
|
|
1380
|
+
*/
|
|
1381
|
+
async toDictionary(keyFn, valueFn)
|
|
1382
|
+
{
|
|
1383
|
+
const results = await this.exec();
|
|
1384
|
+
const map = new Map();
|
|
1385
|
+
const val = valueFn || (item => item);
|
|
1386
|
+
for (const item of results)
|
|
1387
|
+
{
|
|
1388
|
+
const k = keyFn(item);
|
|
1389
|
+
if (map.has(k)) throw new Error(`Duplicate key: ${k}`);
|
|
1390
|
+
map.set(k, val(item));
|
|
1391
|
+
}
|
|
1392
|
+
return map;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Group results into a Map of arrays keyed by a selector.
|
|
1397
|
+
* @param {Function} keyFn - (item) => groupKey
|
|
1398
|
+
* @returns {Promise<Map>} Keyed result map.
|
|
1399
|
+
*/
|
|
1400
|
+
async toLookup(keyFn)
|
|
1401
|
+
{
|
|
1402
|
+
const results = await this.exec();
|
|
1403
|
+
const map = new Map();
|
|
1404
|
+
for (const item of results)
|
|
1405
|
+
{
|
|
1406
|
+
const k = keyFn(item);
|
|
1407
|
+
if (!map.has(k)) map.set(k, []);
|
|
1408
|
+
map.get(k).push(item);
|
|
1409
|
+
}
|
|
1410
|
+
return map;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// -- Partitioning --
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* Take elements while predicate returns true (post-execution).
|
|
1417
|
+
* @param {Function} predicate - (item, index) => boolean
|
|
1418
|
+
* @returns {Promise<Array>} Leading elements matching the predicate.
|
|
1419
|
+
*/
|
|
1420
|
+
async takeWhile(predicate)
|
|
1421
|
+
{
|
|
1422
|
+
const results = await this.exec();
|
|
1423
|
+
const out = [];
|
|
1424
|
+
for (let i = 0; i < results.length; i++)
|
|
1425
|
+
{
|
|
1426
|
+
if (!predicate(results[i], i)) break;
|
|
1427
|
+
out.push(results[i]);
|
|
1428
|
+
}
|
|
1429
|
+
return out;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Skip elements while predicate returns true, then return the rest.
|
|
1434
|
+
* @param {Function} predicate - (item, index) => boolean
|
|
1435
|
+
* @returns {Promise<Array>} Remaining elements after the predicate stops matching.
|
|
1436
|
+
*/
|
|
1437
|
+
async skipWhile(predicate)
|
|
1438
|
+
{
|
|
1439
|
+
const results = await this.exec();
|
|
1440
|
+
let i = 0;
|
|
1441
|
+
while (i < results.length && predicate(results[i], i)) i++;
|
|
1442
|
+
return results.slice(i);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// -- Post-Execution Transforms --
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Reverse the result order.
|
|
1449
|
+
* @returns {Promise<Array>} Results in reversed order.
|
|
1450
|
+
*/
|
|
1451
|
+
async reverse()
|
|
1452
|
+
{
|
|
1453
|
+
const results = await this.exec();
|
|
1454
|
+
return results.reverse();
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Append items to the end of results.
|
|
1459
|
+
* @param {...*} items - Values to add.
|
|
1460
|
+
* @returns {Promise<Array>} Results with appended items.
|
|
1461
|
+
*/
|
|
1462
|
+
async append(...items)
|
|
1463
|
+
{
|
|
1464
|
+
const results = await this.exec();
|
|
1465
|
+
return results.concat(items);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Prepend items to the beginning of results.
|
|
1470
|
+
* @param {...*} items - Values to add.
|
|
1471
|
+
* @returns {Promise<Array>} Results with prepended items.
|
|
1472
|
+
*/
|
|
1473
|
+
async prepend(...items)
|
|
1474
|
+
{
|
|
1475
|
+
const results = await this.exec();
|
|
1476
|
+
return [...items, ...results];
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* Distinct by a key selector (post-execution).
|
|
1481
|
+
* @param {Function} keyFn - (item) => key
|
|
1482
|
+
* @returns {Promise<Array>} Unique results by key.
|
|
1483
|
+
*/
|
|
1484
|
+
async distinctBy(keyFn)
|
|
1485
|
+
{
|
|
1486
|
+
const results = await this.exec();
|
|
1487
|
+
const seen = new Set();
|
|
1488
|
+
const out = [];
|
|
1489
|
+
for (const item of results)
|
|
1490
|
+
{
|
|
1491
|
+
const k = keyFn(item);
|
|
1492
|
+
if (!seen.has(k)) { seen.add(k); out.push(item); }
|
|
1493
|
+
}
|
|
1494
|
+
return out;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// -- Aggregate with Selectors --
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* Element with the minimum value from a selector.
|
|
1501
|
+
* @param {Function} fn - (item) => number
|
|
1502
|
+
* @returns {Promise<object|null>} Matching row or null.
|
|
1503
|
+
*/
|
|
1504
|
+
async minBy(fn)
|
|
1505
|
+
{
|
|
1506
|
+
const results = await this.exec();
|
|
1507
|
+
if (!results.length) return null;
|
|
1508
|
+
let min = results[0], minVal = fn(results[0]);
|
|
1509
|
+
for (let i = 1; i < results.length; i++)
|
|
1510
|
+
{
|
|
1511
|
+
const v = fn(results[i]);
|
|
1512
|
+
if (v < minVal) { minVal = v; min = results[i]; }
|
|
1513
|
+
}
|
|
1514
|
+
return min;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* Element with the maximum value from a selector.
|
|
1519
|
+
* @param {Function} fn - (item) => number
|
|
1520
|
+
* @returns {Promise<object|null>} Matching row or null.
|
|
1521
|
+
*/
|
|
1522
|
+
async maxBy(fn)
|
|
1523
|
+
{
|
|
1524
|
+
const results = await this.exec();
|
|
1525
|
+
if (!results.length) return null;
|
|
1526
|
+
let max = results[0], maxVal = fn(results[0]);
|
|
1527
|
+
for (let i = 1; i < results.length; i++)
|
|
1528
|
+
{
|
|
1529
|
+
const v = fn(results[i]);
|
|
1530
|
+
if (v > maxVal) { maxVal = v; max = results[i]; }
|
|
1531
|
+
}
|
|
1532
|
+
return max;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Sum using a value selector.
|
|
1537
|
+
* @param {Function} fn - (item) => number
|
|
1538
|
+
* @returns {Promise<number>} Sum of selected values.
|
|
1539
|
+
*/
|
|
1540
|
+
async sumBy(fn)
|
|
1541
|
+
{
|
|
1542
|
+
const results = await this.exec();
|
|
1543
|
+
let total = 0;
|
|
1544
|
+
for (const item of results) total += fn(item);
|
|
1545
|
+
return total;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/**
|
|
1549
|
+
* Average using a value selector.
|
|
1550
|
+
* @param {Function} fn - (item) => number
|
|
1551
|
+
* @returns {Promise<number>} Average of selected values.
|
|
1552
|
+
*/
|
|
1553
|
+
async averageBy(fn)
|
|
1554
|
+
{
|
|
1555
|
+
const results = await this.exec();
|
|
1556
|
+
if (!results.length) return 0;
|
|
1557
|
+
let total = 0;
|
|
1558
|
+
for (const item of results) total += fn(item);
|
|
1559
|
+
return total / results.length;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Count elements per group using a key selector.
|
|
1564
|
+
* @param {Function} keyFn - (item) => groupKey
|
|
1565
|
+
* @returns {Promise<Map>} Keyed result map.
|
|
1566
|
+
*/
|
|
1567
|
+
async countBy(keyFn)
|
|
1568
|
+
{
|
|
1569
|
+
const results = await this.exec();
|
|
1570
|
+
const map = new Map();
|
|
1571
|
+
for (const item of results)
|
|
1572
|
+
{
|
|
1573
|
+
const k = keyFn(item);
|
|
1574
|
+
map.set(k, (map.get(k) || 0) + 1);
|
|
1575
|
+
}
|
|
1576
|
+
return map;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// -- Conditional & Debugging --
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* Conditionally apply query logic.
|
|
1583
|
+
* If `condition` is truthy, calls `fn(query)`.
|
|
1584
|
+
* Perfect for optional filters.
|
|
1585
|
+
*
|
|
1586
|
+
* @param {*} condition - Evaluated for truthiness.
|
|
1587
|
+
* @param {Function} fn - Called with `this` when truthy.
|
|
1588
|
+
* @returns {Query} This query for chaining.
|
|
1589
|
+
*
|
|
1590
|
+
* @example
|
|
1591
|
+
* User.query()
|
|
1592
|
+
* .when(req.query.role, (q) => q.where('role', req.query.role))
|
|
1593
|
+
* .when(req.query.minAge, (q) => q.where('age', '>=', req.query.minAge))
|
|
1594
|
+
*/
|
|
1595
|
+
when(condition, fn)
|
|
1596
|
+
{
|
|
1597
|
+
if (condition) fn(this);
|
|
1598
|
+
return this;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Inverse of when — apply query logic when condition is falsy.
|
|
1603
|
+
*
|
|
1604
|
+
* @param {*} condition - Condition to evaluate.
|
|
1605
|
+
* @param {Function} fn - Callback function.
|
|
1606
|
+
* @returns {Query} This query for chaining.
|
|
1607
|
+
*/
|
|
1608
|
+
unless(condition, fn)
|
|
1609
|
+
{
|
|
1610
|
+
if (!condition) fn(this);
|
|
1611
|
+
return this;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Inspect the query without breaking the chain.
|
|
1616
|
+
* Calls `fn(this)` for side effects (logging, debugging).
|
|
1617
|
+
*
|
|
1618
|
+
* @param {Function} fn - Receives the query instance.
|
|
1619
|
+
* @returns {Query} This query for chaining.
|
|
1620
|
+
*
|
|
1621
|
+
* @example
|
|
1622
|
+
* User.query()
|
|
1623
|
+
* .where('role', 'admin')
|
|
1624
|
+
* .tap(q => console.log('Query:', q.build()))
|
|
1625
|
+
* .limit(10)
|
|
1626
|
+
*/
|
|
1627
|
+
tap(fn)
|
|
1628
|
+
{
|
|
1629
|
+
fn(this);
|
|
1630
|
+
return this;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Process results in batches. Calls `fn(batch, batchIndex)` for each chunk.
|
|
1635
|
+
* Useful for processing large datasets without loading everything into memory.
|
|
1636
|
+
*
|
|
1637
|
+
* @param {number} size - Number of records per batch.
|
|
1638
|
+
* @param {Function} fn - Called with (batch: Model[], index: number).
|
|
1639
|
+
* @returns {Promise<void>} Resolves when complete.
|
|
1640
|
+
*
|
|
1641
|
+
* @example
|
|
1642
|
+
* await User.query().where('active', true).chunk(100, async (users, i) => {
|
|
1643
|
+
* console.log(`Processing batch ${i} (${users.length} users)`);
|
|
1644
|
+
* for (const user of users) await user.update({ migrated: true });
|
|
1645
|
+
* });
|
|
1646
|
+
*/
|
|
1647
|
+
async chunk(size, fn)
|
|
1648
|
+
{
|
|
1649
|
+
let page = 0;
|
|
1650
|
+
while (true)
|
|
1651
|
+
{
|
|
1652
|
+
const saved = { limit: this._limitVal, offset: this._offsetVal };
|
|
1653
|
+
this._limitVal = size;
|
|
1654
|
+
this._offsetVal = page * size;
|
|
1655
|
+
const batch = await this.exec();
|
|
1656
|
+
this._limitVal = saved.limit;
|
|
1657
|
+
this._offsetVal = saved.offset;
|
|
1658
|
+
if (batch.length === 0) break;
|
|
1659
|
+
await fn(batch, page);
|
|
1660
|
+
if (batch.length < size) break;
|
|
1661
|
+
page++;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Execute and iterate each result with a callback.
|
|
1667
|
+
*
|
|
1668
|
+
* @param {Function} fn - Called with (item, index).
|
|
1669
|
+
* @returns {Promise<void>} Resolves when complete.
|
|
1670
|
+
*/
|
|
1671
|
+
async each(fn)
|
|
1672
|
+
{
|
|
1673
|
+
const results = await this.exec();
|
|
1674
|
+
for (let i = 0; i < results.length; i++)
|
|
1675
|
+
{
|
|
1676
|
+
await fn(results[i], i);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* Execute, transform results with a mapper, and return the mapped array.
|
|
1682
|
+
*
|
|
1683
|
+
* @param {Function} fn - Called with (item, index). Return the mapped value.
|
|
1684
|
+
* @returns {Promise<Array>} Mapped results.
|
|
1685
|
+
*
|
|
1686
|
+
* @example
|
|
1687
|
+
* const names = await User.query().map(u => u.name);
|
|
1688
|
+
*/
|
|
1689
|
+
async map(fn)
|
|
1690
|
+
{
|
|
1691
|
+
const results = await this.exec();
|
|
1692
|
+
return results.map(fn);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* Execute, filter results with a predicate, and return matches.
|
|
1697
|
+
*
|
|
1698
|
+
* @param {Function} fn - Called with (item, index). Return truthy to keep.
|
|
1699
|
+
* @returns {Promise<Array>} Filtered results.
|
|
1700
|
+
*/
|
|
1701
|
+
async filter(fn)
|
|
1702
|
+
{
|
|
1703
|
+
const results = await this.exec();
|
|
1704
|
+
return results.filter(fn);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/**
|
|
1708
|
+
* Execute and reduce results to a single value.
|
|
1709
|
+
*
|
|
1710
|
+
* @param {Function} fn - Reducer: (acc, item, index).
|
|
1711
|
+
* @param {*} initial - Initial accumulator value.
|
|
1712
|
+
* @returns {Promise<*>} Accumulated value.
|
|
1713
|
+
*/
|
|
1714
|
+
async reduce(fn, initial)
|
|
1715
|
+
{
|
|
1716
|
+
const results = await this.exec();
|
|
1717
|
+
return results.reduce(fn, initial);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Rich pagination with metadata.
|
|
1722
|
+
* Returns `{ data, total, page, perPage, pages, hasNext, hasPrev }`.
|
|
1723
|
+
*
|
|
1724
|
+
* @param {number} pg - 1-indexed page number.
|
|
1725
|
+
* @param {number} [perPage=20] - Items per page.
|
|
1726
|
+
* @returns {Promise<object>} Pagination result with data, total, page, perPage, pages, hasNext, hasPrev.
|
|
1727
|
+
*
|
|
1728
|
+
* @example
|
|
1729
|
+
* const result = await User.query()
|
|
1730
|
+
* .where('active', true)
|
|
1731
|
+
* .paginate(2, 10);
|
|
1732
|
+
* // { data: [...], total: 53, page: 2, perPage: 10,
|
|
1733
|
+
* // pages: 6, hasNext: true, hasPrev: true }
|
|
1734
|
+
*/
|
|
1735
|
+
async paginate(pg, perPage = 20)
|
|
1736
|
+
{
|
|
1737
|
+
pg = Math.max(1, pg);
|
|
1738
|
+
const total = await this.count();
|
|
1739
|
+
const pages = Math.ceil(total / perPage);
|
|
1740
|
+
this._limitVal = perPage;
|
|
1741
|
+
this._offsetVal = (pg - 1) * perPage;
|
|
1742
|
+
const data = await this.exec();
|
|
1743
|
+
return {
|
|
1744
|
+
data,
|
|
1745
|
+
total,
|
|
1746
|
+
page: pg,
|
|
1747
|
+
perPage,
|
|
1748
|
+
pages,
|
|
1749
|
+
hasNext: pg < pages,
|
|
1750
|
+
hasPrev: pg > 1,
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/**
|
|
1755
|
+
* Inject a raw WHERE clause for SQL adapters.
|
|
1756
|
+
* Ignored by non-SQL adapters (memory, json, mongo).
|
|
1757
|
+
*
|
|
1758
|
+
* @param {string} sql - Raw SQL expression (e.g. 'age > ? AND role = ?').
|
|
1759
|
+
* @param {...*} [params] - Parameter values.
|
|
1760
|
+
* @returns {Query} This query for chaining.
|
|
1761
|
+
*
|
|
1762
|
+
* @example
|
|
1763
|
+
* User.query().whereRaw('LOWER(email) = ?', 'alice@example.com')
|
|
1764
|
+
*/
|
|
1765
|
+
whereRaw(sql, ...params)
|
|
1766
|
+
{
|
|
1767
|
+
this._where.push({ raw: sql, params, logic: 'AND' });
|
|
1768
|
+
return this;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
module.exports = Query;
|