@zero-server/sdk 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.
Files changed (128) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +460 -443
  3. package/index.js +414 -412
  4. package/lib/app.js +1172 -1172
  5. package/lib/auth/authorize.js +399 -399
  6. package/lib/auth/enrollment.js +367 -367
  7. package/lib/auth/index.js +57 -57
  8. package/lib/auth/jwt.js +731 -731
  9. package/lib/auth/oauth.js +362 -362
  10. package/lib/auth/session.js +588 -588
  11. package/lib/auth/trustedDevice.js +409 -409
  12. package/lib/auth/twoFactor.js +1150 -1150
  13. package/lib/auth/webauthn.js +946 -946
  14. package/lib/body/index.js +14 -14
  15. package/lib/body/json.js +109 -109
  16. package/lib/body/multipart.js +440 -440
  17. package/lib/body/raw.js +71 -71
  18. package/lib/body/rawBuffer.js +160 -160
  19. package/lib/body/sendError.js +25 -25
  20. package/lib/body/text.js +75 -75
  21. package/lib/body/typeMatch.js +41 -41
  22. package/lib/body/urlencoded.js +235 -235
  23. package/lib/cli.js +845 -845
  24. package/lib/cluster.js +666 -666
  25. package/lib/debug.js +372 -372
  26. package/lib/env/index.js +465 -465
  27. package/lib/errors.js +683 -683
  28. package/lib/fetch/index.js +256 -256
  29. package/lib/grpc/balancer.js +378 -378
  30. package/lib/grpc/call.js +708 -708
  31. package/lib/grpc/client.js +764 -764
  32. package/lib/grpc/codec.js +1221 -1221
  33. package/lib/grpc/credentials.js +398 -398
  34. package/lib/grpc/frame.js +262 -262
  35. package/lib/grpc/health.js +287 -287
  36. package/lib/grpc/index.js +121 -121
  37. package/lib/grpc/metadata.js +461 -461
  38. package/lib/grpc/proto.js +821 -821
  39. package/lib/grpc/reflection.js +590 -590
  40. package/lib/grpc/server.js +445 -445
  41. package/lib/grpc/status.js +118 -118
  42. package/lib/grpc/watch.js +173 -173
  43. package/lib/http/index.js +10 -10
  44. package/lib/http/request.js +727 -727
  45. package/lib/http/response.js +799 -799
  46. package/lib/lifecycle.js +557 -557
  47. package/lib/middleware/compress.js +230 -230
  48. package/lib/middleware/cookieParser.js +237 -237
  49. package/lib/middleware/cors.js +93 -93
  50. package/lib/middleware/csrf.js +137 -137
  51. package/lib/middleware/errorHandler.js +101 -101
  52. package/lib/middleware/helmet.js +175 -175
  53. package/lib/middleware/index.js +19 -17
  54. package/lib/middleware/logger.js +74 -74
  55. package/lib/middleware/rateLimit.js +88 -88
  56. package/lib/middleware/requestId.js +53 -53
  57. package/lib/middleware/static.js +326 -326
  58. package/lib/middleware/timeout.js +71 -71
  59. package/lib/middleware/validator.js +255 -255
  60. package/lib/observe/health.js +326 -326
  61. package/lib/observe/index.js +50 -50
  62. package/lib/observe/logger.js +359 -359
  63. package/lib/observe/metrics.js +805 -805
  64. package/lib/observe/tracing.js +592 -592
  65. package/lib/orm/adapters/json.js +290 -290
  66. package/lib/orm/adapters/memory.js +764 -764
  67. package/lib/orm/adapters/mongo.js +764 -764
  68. package/lib/orm/adapters/mysql.js +933 -933
  69. package/lib/orm/adapters/postgres.js +1144 -1144
  70. package/lib/orm/adapters/redis.js +1534 -1534
  71. package/lib/orm/adapters/sql-base.js +212 -212
  72. package/lib/orm/adapters/sqlite.js +858 -858
  73. package/lib/orm/audit.js +649 -649
  74. package/lib/orm/cache.js +394 -394
  75. package/lib/orm/geo.js +387 -387
  76. package/lib/orm/index.js +784 -784
  77. package/lib/orm/migrate.js +432 -432
  78. package/lib/orm/model.js +1706 -1706
  79. package/lib/orm/plugin.js +375 -375
  80. package/lib/orm/procedures.js +836 -836
  81. package/lib/orm/profiler.js +233 -233
  82. package/lib/orm/query.js +1772 -1772
  83. package/lib/orm/replicas.js +241 -241
  84. package/lib/orm/schema.js +307 -307
  85. package/lib/orm/search.js +380 -380
  86. package/lib/orm/seed/data/commerce.js +136 -136
  87. package/lib/orm/seed/data/internet.js +111 -111
  88. package/lib/orm/seed/data/locations.js +204 -204
  89. package/lib/orm/seed/data/names.js +338 -338
  90. package/lib/orm/seed/data/person.js +128 -128
  91. package/lib/orm/seed/data/phone.js +211 -211
  92. package/lib/orm/seed/data/words.js +134 -134
  93. package/lib/orm/seed/factory.js +178 -178
  94. package/lib/orm/seed/fake.js +1186 -1186
  95. package/lib/orm/seed/index.js +18 -18
  96. package/lib/orm/seed/rng.js +70 -70
  97. package/lib/orm/seed/seeder.js +124 -124
  98. package/lib/orm/seed/unique.js +68 -68
  99. package/lib/orm/snapshot.js +366 -366
  100. package/lib/orm/tenancy.js +605 -605
  101. package/lib/orm/views.js +350 -350
  102. package/lib/router/index.js +436 -436
  103. package/lib/sse/index.js +8 -8
  104. package/lib/sse/stream.js +349 -349
  105. package/lib/ws/connection.js +451 -451
  106. package/lib/ws/handshake.js +125 -125
  107. package/lib/ws/index.js +14 -14
  108. package/lib/ws/room.js +223 -223
  109. package/package.json +73 -73
  110. package/types/app.d.ts +223 -223
  111. package/types/auth.d.ts +520 -520
  112. package/types/body.d.ts +14 -0
  113. package/types/cli.d.ts +2 -0
  114. package/types/cluster.d.ts +75 -75
  115. package/types/env.d.ts +80 -80
  116. package/types/errors.d.ts +316 -316
  117. package/types/fetch.d.ts +43 -43
  118. package/types/grpc.d.ts +432 -432
  119. package/types/index.d.ts +384 -384
  120. package/types/lifecycle.d.ts +60 -60
  121. package/types/middleware.d.ts +320 -320
  122. package/types/observe.d.ts +304 -304
  123. package/types/orm.d.ts +1887 -1887
  124. package/types/request.d.ts +109 -109
  125. package/types/response.d.ts +157 -157
  126. package/types/router.d.ts +78 -78
  127. package/types/sse.d.ts +78 -78
  128. package/types/websocket.d.ts +126 -126
package/lib/orm/query.js CHANGED
@@ -1,1772 +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;
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;