zero-http 0.2.4 → 0.3.1

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 (89) hide show
  1. package/README.md +1250 -283
  2. package/documentation/config/db.js +25 -0
  3. package/documentation/config/middleware.js +44 -0
  4. package/documentation/config/tls.js +12 -0
  5. package/documentation/controllers/cookies.js +34 -0
  6. package/documentation/controllers/tasks.js +108 -0
  7. package/documentation/full-server.js +28 -177
  8. package/documentation/models/Task.js +21 -0
  9. package/documentation/public/data/api.json +404 -24
  10. package/documentation/public/data/docs.json +1139 -0
  11. package/documentation/public/data/examples.json +80 -2
  12. package/documentation/public/data/options.json +23 -8
  13. package/documentation/public/index.html +138 -99
  14. package/documentation/public/scripts/app.js +1 -3
  15. package/documentation/public/scripts/custom-select.js +189 -0
  16. package/documentation/public/scripts/data-sections.js +233 -250
  17. package/documentation/public/scripts/playground.js +270 -0
  18. package/documentation/public/scripts/ui.js +4 -3
  19. package/documentation/public/styles.css +56 -5
  20. package/documentation/public/vendor/icons/compress.svg +17 -17
  21. package/documentation/public/vendor/icons/database.svg +21 -0
  22. package/documentation/public/vendor/icons/env.svg +21 -0
  23. package/documentation/public/vendor/icons/fetch.svg +11 -14
  24. package/documentation/public/vendor/icons/security.svg +15 -0
  25. package/documentation/public/vendor/icons/sse.svg +12 -13
  26. package/documentation/public/vendor/icons/static.svg +12 -26
  27. package/documentation/public/vendor/icons/stream.svg +7 -13
  28. package/documentation/public/vendor/icons/validate.svg +17 -0
  29. package/documentation/routes/api.js +41 -0
  30. package/documentation/routes/core.js +20 -0
  31. package/documentation/routes/playground.js +29 -0
  32. package/documentation/routes/realtime.js +49 -0
  33. package/documentation/routes/uploads.js +71 -0
  34. package/index.js +62 -1
  35. package/lib/app.js +200 -8
  36. package/lib/body/json.js +28 -5
  37. package/lib/body/multipart.js +29 -1
  38. package/lib/body/raw.js +1 -1
  39. package/lib/body/sendError.js +1 -0
  40. package/lib/body/text.js +1 -1
  41. package/lib/body/typeMatch.js +6 -2
  42. package/lib/body/urlencoded.js +5 -2
  43. package/lib/debug.js +345 -0
  44. package/lib/env/index.js +440 -0
  45. package/lib/errors.js +231 -0
  46. package/lib/http/request.js +219 -1
  47. package/lib/http/response.js +410 -6
  48. package/lib/middleware/compress.js +39 -6
  49. package/lib/middleware/cookieParser.js +237 -0
  50. package/lib/middleware/cors.js +13 -2
  51. package/lib/middleware/csrf.js +135 -0
  52. package/lib/middleware/errorHandler.js +90 -0
  53. package/lib/middleware/helmet.js +176 -0
  54. package/lib/middleware/index.js +7 -2
  55. package/lib/middleware/rateLimit.js +12 -1
  56. package/lib/middleware/requestId.js +54 -0
  57. package/lib/middleware/static.js +95 -11
  58. package/lib/middleware/timeout.js +72 -0
  59. package/lib/middleware/validator.js +257 -0
  60. package/lib/orm/adapters/json.js +215 -0
  61. package/lib/orm/adapters/memory.js +383 -0
  62. package/lib/orm/adapters/mongo.js +444 -0
  63. package/lib/orm/adapters/mysql.js +272 -0
  64. package/lib/orm/adapters/postgres.js +394 -0
  65. package/lib/orm/adapters/sql-base.js +142 -0
  66. package/lib/orm/adapters/sqlite.js +311 -0
  67. package/lib/orm/index.js +276 -0
  68. package/lib/orm/model.js +895 -0
  69. package/lib/orm/query.js +807 -0
  70. package/lib/orm/schema.js +172 -0
  71. package/lib/router/index.js +136 -47
  72. package/lib/sse/stream.js +15 -3
  73. package/lib/ws/connection.js +19 -3
  74. package/lib/ws/handshake.js +3 -0
  75. package/lib/ws/index.js +3 -1
  76. package/lib/ws/room.js +222 -0
  77. package/package.json +15 -5
  78. package/types/app.d.ts +120 -0
  79. package/types/env.d.ts +80 -0
  80. package/types/errors.d.ts +147 -0
  81. package/types/fetch.d.ts +43 -0
  82. package/types/index.d.ts +135 -0
  83. package/types/middleware.d.ts +292 -0
  84. package/types/orm.d.ts +610 -0
  85. package/types/request.d.ts +99 -0
  86. package/types/response.d.ts +142 -0
  87. package/types/router.d.ts +78 -0
  88. package/types/sse.d.ts +78 -0
  89. package/types/websocket.d.ts +119 -0
@@ -0,0 +1,807 @@
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
+ class Query
25
+ {
26
+ /**
27
+ * @param {object} model - The Model class to query.
28
+ * @param {object} adapter - The database adapter instance.
29
+ */
30
+ constructor(model, adapter)
31
+ {
32
+ /** @private */ this._model = model;
33
+ /** @private */ this._adapter = adapter;
34
+ /** @private */ this._action = 'select';
35
+ /** @private */ this._fields = null; // null = all
36
+ /** @private */ this._where = [];
37
+ /** @private */ this._orderBy = [];
38
+ /** @private */ this._limitVal = null;
39
+ /** @private */ this._offsetVal = null;
40
+ /** @private */ this._data = null;
41
+ /** @private */ this._joins = [];
42
+ /** @private */ this._groupBy = [];
43
+ /** @private */ this._having = [];
44
+ /** @private */ this._distinct = false;
45
+ /** @private */ this._includeDeleted = false;
46
+ }
47
+
48
+ // -- Selection --------------------------------------
49
+
50
+ /**
51
+ * Select specific columns.
52
+ *
53
+ * @param {...string} fields - Column names to select.
54
+ * @returns {Query} `this` for chaining.
55
+ */
56
+ select(...fields)
57
+ {
58
+ this._fields = fields.flat();
59
+ return this;
60
+ }
61
+
62
+ /**
63
+ * Select distinct rows.
64
+ * @returns {Query} `this` for chaining.
65
+ */
66
+ distinct()
67
+ {
68
+ this._distinct = true;
69
+ return this;
70
+ }
71
+
72
+ // -- Filtering --------------------------------------
73
+
74
+ /**
75
+ * Add a WHERE condition.
76
+ *
77
+ * Accepts multiple forms:
78
+ * - `where('age', 18)` → `age = 18`
79
+ * - `where('age', '>', 18)` → `age > 18`
80
+ * - `where({ role: 'admin', active: true })` → `role = 'admin' AND active = true`
81
+ *
82
+ * @param {string|object} field - Column name or condition object.
83
+ * @param {string} [op] - Operator (=, !=, >, <, >=, <=, LIKE, IN, NOT IN, BETWEEN, IS NULL, IS NOT NULL).
84
+ * @param {*} [value] - Value to compare against.
85
+ * @returns {Query} `this` for chaining.
86
+ */
87
+ where(field, op, value)
88
+ {
89
+ if (typeof field === 'object' && field !== null)
90
+ {
91
+ for (const [k, v] of Object.entries(field))
92
+ {
93
+ this._where.push({ field: k, op: '=', value: v, logic: 'AND' });
94
+ }
95
+ return this;
96
+ }
97
+
98
+ if (value === undefined) { value = op; op = '='; }
99
+ this._where.push({ field, op: op.toUpperCase(), value, logic: 'AND' });
100
+ return this;
101
+ }
102
+
103
+ /**
104
+ * Add an OR WHERE condition.
105
+ *
106
+ * @param {string} field - Column name.
107
+ * @param {string} [op] - Operator.
108
+ * @param {*} [value] - Value.
109
+ * @returns {Query} `this` for chaining.
110
+ */
111
+ orWhere(field, op, value)
112
+ {
113
+ if (value === undefined) { value = op; op = '='; }
114
+ this._where.push({ field, op: op.toUpperCase(), value, logic: 'OR' });
115
+ return this;
116
+ }
117
+
118
+ /**
119
+ * WHERE column IS NULL.
120
+ * @param {string} field
121
+ * @returns {Query}
122
+ */
123
+ whereNull(field)
124
+ {
125
+ this._where.push({ field, op: 'IS NULL', value: null, logic: 'AND' });
126
+ return this;
127
+ }
128
+
129
+ /**
130
+ * WHERE column IS NOT NULL.
131
+ * @param {string} field
132
+ * @returns {Query}
133
+ */
134
+ whereNotNull(field)
135
+ {
136
+ this._where.push({ field, op: 'IS NOT NULL', value: null, logic: 'AND' });
137
+ return this;
138
+ }
139
+
140
+ /**
141
+ * WHERE column IN (...values).
142
+ * @param {string} field
143
+ * @param {Array} values
144
+ * @returns {Query}
145
+ */
146
+ whereIn(field, values)
147
+ {
148
+ this._where.push({ field, op: 'IN', value: values, logic: 'AND' });
149
+ return this;
150
+ }
151
+
152
+ /**
153
+ * WHERE column NOT IN (...values).
154
+ * @param {string} field
155
+ * @param {Array} values
156
+ * @returns {Query}
157
+ */
158
+ whereNotIn(field, values)
159
+ {
160
+ this._where.push({ field, op: 'NOT IN', value: values, logic: 'AND' });
161
+ return this;
162
+ }
163
+
164
+ /**
165
+ * WHERE column BETWEEN low AND high.
166
+ * @param {string} field
167
+ * @param {*} low
168
+ * @param {*} high
169
+ * @returns {Query}
170
+ */
171
+ whereBetween(field, low, high)
172
+ {
173
+ this._where.push({ field, op: 'BETWEEN', value: [low, high], logic: 'AND' });
174
+ return this;
175
+ }
176
+
177
+ /**
178
+ * WHERE column NOT BETWEEN low AND high.
179
+ * @param {string} field
180
+ * @param {*} low
181
+ * @param {*} high
182
+ * @returns {Query}
183
+ */
184
+ whereNotBetween(field, low, high)
185
+ {
186
+ this._where.push({ field, op: 'NOT BETWEEN', value: [low, high], logic: 'AND' });
187
+ return this;
188
+ }
189
+
190
+ /**
191
+ * WHERE column LIKE pattern.
192
+ * @param {string} field
193
+ * @param {string} pattern - SQL LIKE pattern (% and _ wildcards).
194
+ * @returns {Query}
195
+ */
196
+ whereLike(field, pattern)
197
+ {
198
+ this._where.push({ field, op: 'LIKE', value: pattern, logic: 'AND' });
199
+ return this;
200
+ }
201
+
202
+ // -- Ordering ---------------------------------------
203
+
204
+ /**
205
+ * ORDER BY a column.
206
+ * @param {string} field - Column name.
207
+ * @param {string} [dir='asc'] - Direction: 'asc' or 'desc'.
208
+ * @returns {Query}
209
+ */
210
+ orderBy(field, dir = 'asc')
211
+ {
212
+ this._orderBy.push({ field, dir: dir.toUpperCase() });
213
+ return this;
214
+ }
215
+
216
+ // -- Pagination -------------------------------------
217
+
218
+ /**
219
+ * LIMIT results.
220
+ * @param {number} n
221
+ * @returns {Query}
222
+ */
223
+ limit(n)
224
+ {
225
+ this._limitVal = n;
226
+ return this;
227
+ }
228
+
229
+ /**
230
+ * OFFSET results.
231
+ * @param {number} n
232
+ * @returns {Query}
233
+ */
234
+ offset(n)
235
+ {
236
+ this._offsetVal = n;
237
+ return this;
238
+ }
239
+
240
+ /**
241
+ * Convenience: page(pageNum, perPage).
242
+ * @param {number} page - 1-indexed page number.
243
+ * @param {number} perPage - Items per page.
244
+ * @returns {Query}
245
+ */
246
+ page(page, perPage = 20)
247
+ {
248
+ this._limitVal = perPage;
249
+ this._offsetVal = (Math.max(1, page) - 1) * perPage;
250
+ return this;
251
+ }
252
+
253
+ // -- Grouping ---------------------------------------
254
+
255
+ /**
256
+ * GROUP BY column(s).
257
+ * @param {...string} fields
258
+ * @returns {Query}
259
+ */
260
+ groupBy(...fields)
261
+ {
262
+ this._groupBy.push(...fields.flat());
263
+ return this;
264
+ }
265
+
266
+ /**
267
+ * HAVING (used with GROUP BY).
268
+ * @param {string} field
269
+ * @param {string} [op]
270
+ * @param {*} [value]
271
+ * @returns {Query}
272
+ */
273
+ having(field, op, value)
274
+ {
275
+ if (value === undefined) { value = op; op = '='; }
276
+ this._having.push({ field, op: op.toUpperCase(), value });
277
+ return this;
278
+ }
279
+
280
+ // -- Joins ------------------------------------------
281
+
282
+ /**
283
+ * INNER JOIN.
284
+ * @param {string} table - Table to join.
285
+ * @param {string} localKey - Local column.
286
+ * @param {string} foreignKey - Foreign column.
287
+ * @returns {Query}
288
+ */
289
+ join(table, localKey, foreignKey)
290
+ {
291
+ this._joins.push({ type: 'INNER', table, localKey, foreignKey });
292
+ return this;
293
+ }
294
+
295
+ /**
296
+ * LEFT JOIN.
297
+ * @param {string} table
298
+ * @param {string} localKey
299
+ * @param {string} foreignKey
300
+ * @returns {Query}
301
+ */
302
+ leftJoin(table, localKey, foreignKey)
303
+ {
304
+ this._joins.push({ type: 'LEFT', table, localKey, foreignKey });
305
+ return this;
306
+ }
307
+
308
+ /**
309
+ * RIGHT JOIN.
310
+ * @param {string} table
311
+ * @param {string} localKey
312
+ * @param {string} foreignKey
313
+ * @returns {Query}
314
+ */
315
+ rightJoin(table, localKey, foreignKey)
316
+ {
317
+ this._joins.push({ type: 'RIGHT', table, localKey, foreignKey });
318
+ return this;
319
+ }
320
+
321
+ // -- Soft Delete ------------------------------------
322
+
323
+ /**
324
+ * Include soft-deleted records in results.
325
+ * @returns {Query}
326
+ */
327
+ withDeleted()
328
+ {
329
+ this._includeDeleted = true;
330
+ return this;
331
+ }
332
+
333
+ /**
334
+ * Apply a named scope from the model.
335
+ * Allows chaining multiple scopes on a single query.
336
+ *
337
+ * @param {string} name - Scope name.
338
+ * @param {...*} [args] - Additional arguments for the scope function.
339
+ * @returns {Query}
340
+ *
341
+ * @example
342
+ * await User.query().scope('active').scope('olderThan', 21).limit(5);
343
+ */
344
+ scope(name, ...args)
345
+ {
346
+ const scopes = this._model.scopes;
347
+ if (!scopes || typeof scopes[name] !== 'function')
348
+ {
349
+ throw new Error(`Unknown scope "${name}" on ${this._model.name}`);
350
+ }
351
+ scopes[name](this, ...args);
352
+ return this;
353
+ }
354
+
355
+ // -- Execution --------------------------------------
356
+
357
+ /**
358
+ * Build the abstract query descriptor.
359
+ * @returns {object} Adapter-agnostic query object.
360
+ */
361
+ build()
362
+ {
363
+ // If withDeleted() was called, remove soft-delete filters
364
+ let where = this._where;
365
+ if (this._includeDeleted)
366
+ {
367
+ where = where.filter(w => !(w.field === 'deletedAt' && w.op === 'IS NULL'));
368
+ }
369
+
370
+ return {
371
+ action: this._action,
372
+ table: this._model.table,
373
+ fields: this._fields,
374
+ where,
375
+ orderBy: this._orderBy,
376
+ limit: this._limitVal,
377
+ offset: this._offsetVal,
378
+ data: this._data,
379
+ joins: this._joins,
380
+ groupBy: this._groupBy,
381
+ having: this._having,
382
+ distinct: this._distinct,
383
+ includeDeleted: this._includeDeleted,
384
+ schema: this._model.schema,
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Execute the query and return results.
390
+ * @returns {Promise<Array<object>>}
391
+ */
392
+ async exec()
393
+ {
394
+ const descriptor = this.build();
395
+ log.debug('%s %s', descriptor.action, descriptor.table);
396
+ let rows;
397
+ try { rows = await this._adapter.execute(descriptor); }
398
+ catch (e) { log.error('%s %s failed: %s', descriptor.action, descriptor.table, e.message); throw e; }
399
+
400
+ // Wrap results in model instances
401
+ if (this._action === 'select')
402
+ {
403
+ return rows.map(row => this._model._fromRow(row));
404
+ }
405
+ return rows;
406
+ }
407
+
408
+ /**
409
+ * Execute and return the first result.
410
+ * @returns {Promise<object|null>}
411
+ */
412
+ async first()
413
+ {
414
+ this._limitVal = 1;
415
+ const results = await this.exec();
416
+ return results[0] || null;
417
+ }
418
+
419
+ /**
420
+ * Count matching records.
421
+ * @returns {Promise<number>}
422
+ */
423
+ async count()
424
+ {
425
+ const descriptor = this.build();
426
+ descriptor.action = 'count';
427
+ try { return await this._adapter.execute(descriptor); }
428
+ catch (e) { log.error('count %s failed: %s', descriptor.table, e.message); throw e; }
429
+ }
430
+
431
+ /**
432
+ * Check whether any matching records exist.
433
+ * @returns {Promise<boolean>}
434
+ */
435
+ async exists()
436
+ {
437
+ const c = await this.count();
438
+ return c > 0;
439
+ }
440
+
441
+ /**
442
+ * Get an array of values for a single column.
443
+ *
444
+ * @param {string} field - Column name to extract.
445
+ * @returns {Promise<Array<*>>}
446
+ *
447
+ * @example
448
+ * const emails = await User.query().pluck('email');
449
+ * // => ['alice@a.com', 'bob@b.com']
450
+ */
451
+ async pluck(field)
452
+ {
453
+ this._fields = [field];
454
+ const rows = await this.exec();
455
+ return rows.map(r => r[field]);
456
+ }
457
+
458
+ /**
459
+ * SUM of a numeric column.
460
+ * @param {string} field - Column name.
461
+ * @returns {Promise<number>}
462
+ */
463
+ async sum(field)
464
+ {
465
+ const descriptor = this.build();
466
+ descriptor.action = 'aggregate';
467
+ descriptor.aggregateFn = 'sum';
468
+ descriptor.aggregateField = field;
469
+ // Fallback for adapters without native aggregate
470
+ if (typeof this._adapter.aggregate === 'function')
471
+ {
472
+ return this._adapter.aggregate(descriptor);
473
+ }
474
+ const rows = await this.exec();
475
+ return rows.reduce((acc, r) => acc + (Number(r[field]) || 0), 0);
476
+ }
477
+
478
+ /**
479
+ * AVG of a numeric column.
480
+ * @param {string} field - Column name.
481
+ * @returns {Promise<number>}
482
+ */
483
+ async avg(field)
484
+ {
485
+ const descriptor = this.build();
486
+ descriptor.action = 'aggregate';
487
+ descriptor.aggregateFn = 'avg';
488
+ descriptor.aggregateField = field;
489
+ if (typeof this._adapter.aggregate === 'function')
490
+ {
491
+ return this._adapter.aggregate(descriptor);
492
+ }
493
+ const rows = await this.exec();
494
+ if (!rows.length) return 0;
495
+ return rows.reduce((acc, r) => acc + (Number(r[field]) || 0), 0) / rows.length;
496
+ }
497
+
498
+ /**
499
+ * MIN of a column.
500
+ * @param {string} field - Column name.
501
+ * @returns {Promise<*>}
502
+ */
503
+ async min(field)
504
+ {
505
+ const descriptor = this.build();
506
+ descriptor.action = 'aggregate';
507
+ descriptor.aggregateFn = 'min';
508
+ descriptor.aggregateField = field;
509
+ if (typeof this._adapter.aggregate === 'function')
510
+ {
511
+ return this._adapter.aggregate(descriptor);
512
+ }
513
+ const rows = await this.exec();
514
+ if (!rows.length) return null;
515
+ return rows.reduce((m, r) => (r[field] < m ? r[field] : m), rows[0][field]);
516
+ }
517
+
518
+ /**
519
+ * MAX of a column.
520
+ * @param {string} field - Column name.
521
+ * @returns {Promise<*>}
522
+ */
523
+ async max(field)
524
+ {
525
+ const descriptor = this.build();
526
+ descriptor.action = 'aggregate';
527
+ descriptor.aggregateFn = 'max';
528
+ descriptor.aggregateField = field;
529
+ if (typeof this._adapter.aggregate === 'function')
530
+ {
531
+ return this._adapter.aggregate(descriptor);
532
+ }
533
+ const rows = await this.exec();
534
+ if (!rows.length) return null;
535
+ return rows.reduce((m, r) => (r[field] > m ? r[field] : m), rows[0][field]);
536
+ }
537
+
538
+ /**
539
+ * Make Query thenable — allows `await query`.
540
+ */
541
+ then(resolve, reject)
542
+ {
543
+ return this.exec().then(resolve, reject);
544
+ }
545
+
546
+ catch(reject)
547
+ {
548
+ return this.exec().catch(reject);
549
+ }
550
+
551
+ // -- LINQ-Inspired Utilities ------------------------
552
+
553
+ /**
554
+ * Alias for limit (LINQ naming).
555
+ * @param {number} n
556
+ * @returns {Query}
557
+ */
558
+ take(n)
559
+ {
560
+ return this.limit(n);
561
+ }
562
+
563
+ /**
564
+ * Alias for offset (LINQ naming).
565
+ * @param {number} n
566
+ * @returns {Query}
567
+ */
568
+ skip(n)
569
+ {
570
+ return this.offset(n);
571
+ }
572
+
573
+ /**
574
+ * Alias for exec — explicitly convert to array.
575
+ * @returns {Promise<Array>}
576
+ */
577
+ toArray()
578
+ {
579
+ return this.exec();
580
+ }
581
+
582
+ /**
583
+ * Shorthand for orderBy(field, 'desc').
584
+ * @param {string} field
585
+ * @returns {Query}
586
+ */
587
+ orderByDesc(field)
588
+ {
589
+ return this.orderBy(field, 'desc');
590
+ }
591
+
592
+ /**
593
+ * Execute and return the last result.
594
+ * Reverses the first orderBy or defaults to primary key DESC.
595
+ * @returns {Promise<object|null>}
596
+ */
597
+ async last()
598
+ {
599
+ // If we have order, reverse the first order direction
600
+ if (this._orderBy.length)
601
+ {
602
+ const first = this._orderBy[0];
603
+ first.dir = first.dir === 'ASC' ? 'DESC' : 'ASC';
604
+ }
605
+ else
606
+ {
607
+ // Default: order by primary key descending
608
+ const pk = this._model._primaryKey ? this._model._primaryKey() : 'id';
609
+ this._orderBy.push({ field: pk, dir: 'DESC' });
610
+ }
611
+ this._limitVal = 1;
612
+ const results = await this.exec();
613
+ return results[0] || null;
614
+ }
615
+
616
+ /**
617
+ * Conditionally apply query logic.
618
+ * If `condition` is truthy, calls `fn(query)`.
619
+ * Perfect for optional filters.
620
+ *
621
+ * @param {*} condition - Evaluated for truthiness.
622
+ * @param {Function} fn - Called with `this` when truthy.
623
+ * @returns {Query}
624
+ *
625
+ * @example
626
+ * User.query()
627
+ * .when(req.query.role, (q) => q.where('role', req.query.role))
628
+ * .when(req.query.minAge, (q) => q.where('age', '>=', req.query.minAge))
629
+ */
630
+ when(condition, fn)
631
+ {
632
+ if (condition) fn(this);
633
+ return this;
634
+ }
635
+
636
+ /**
637
+ * Inverse of when — apply query logic when condition is falsy.
638
+ *
639
+ * @param {*} condition
640
+ * @param {Function} fn
641
+ * @returns {Query}
642
+ */
643
+ unless(condition, fn)
644
+ {
645
+ if (!condition) fn(this);
646
+ return this;
647
+ }
648
+
649
+ /**
650
+ * Inspect the query without breaking the chain.
651
+ * Calls `fn(this)` for side effects (logging, debugging).
652
+ *
653
+ * @param {Function} fn - Receives the query instance.
654
+ * @returns {Query}
655
+ *
656
+ * @example
657
+ * User.query()
658
+ * .where('role', 'admin')
659
+ * .tap(q => console.log('Query:', q.build()))
660
+ * .limit(10)
661
+ */
662
+ tap(fn)
663
+ {
664
+ fn(this);
665
+ return this;
666
+ }
667
+
668
+ /**
669
+ * Process results in batches. Calls `fn(batch, batchIndex)` for each chunk.
670
+ * Useful for processing large datasets without loading everything into memory.
671
+ *
672
+ * @param {number} size - Number of records per batch.
673
+ * @param {Function} fn - Called with (batch: Model[], index: number).
674
+ * @returns {Promise<void>}
675
+ *
676
+ * @example
677
+ * await User.query().where('active', true).chunk(100, async (users, i) => {
678
+ * console.log(`Processing batch ${i} (${users.length} users)`);
679
+ * for (const user of users) await user.update({ migrated: true });
680
+ * });
681
+ */
682
+ async chunk(size, fn)
683
+ {
684
+ let page = 0;
685
+ while (true)
686
+ {
687
+ const saved = { limit: this._limitVal, offset: this._offsetVal };
688
+ this._limitVal = size;
689
+ this._offsetVal = page * size;
690
+ const batch = await this.exec();
691
+ this._limitVal = saved.limit;
692
+ this._offsetVal = saved.offset;
693
+ if (batch.length === 0) break;
694
+ await fn(batch, page);
695
+ if (batch.length < size) break;
696
+ page++;
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Execute and iterate each result with a callback.
702
+ *
703
+ * @param {Function} fn - Called with (item, index).
704
+ * @returns {Promise<void>}
705
+ */
706
+ async each(fn)
707
+ {
708
+ const results = await this.exec();
709
+ for (let i = 0; i < results.length; i++)
710
+ {
711
+ await fn(results[i], i);
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Execute, transform results with a mapper, and return the mapped array.
717
+ *
718
+ * @param {Function} fn - Called with (item, index). Return the mapped value.
719
+ * @returns {Promise<Array>}
720
+ *
721
+ * @example
722
+ * const names = await User.query().map(u => u.name);
723
+ */
724
+ async map(fn)
725
+ {
726
+ const results = await this.exec();
727
+ return results.map(fn);
728
+ }
729
+
730
+ /**
731
+ * Execute, filter results with a predicate, and return matches.
732
+ *
733
+ * @param {Function} fn - Called with (item, index). Return truthy to keep.
734
+ * @returns {Promise<Array>}
735
+ */
736
+ async filter(fn)
737
+ {
738
+ const results = await this.exec();
739
+ return results.filter(fn);
740
+ }
741
+
742
+ /**
743
+ * Execute and reduce results to a single value.
744
+ *
745
+ * @param {Function} fn - Reducer: (acc, item, index).
746
+ * @param {*} initial - Initial accumulator value.
747
+ * @returns {Promise<*>}
748
+ */
749
+ async reduce(fn, initial)
750
+ {
751
+ const results = await this.exec();
752
+ return results.reduce(fn, initial);
753
+ }
754
+
755
+ /**
756
+ * Rich pagination with metadata.
757
+ * Returns `{ data, total, page, perPage, pages, hasNext, hasPrev }`.
758
+ *
759
+ * @param {number} pg - 1-indexed page number.
760
+ * @param {number} [perPage=20] - Items per page.
761
+ * @returns {Promise<object>}
762
+ *
763
+ * @example
764
+ * const result = await User.query()
765
+ * .where('active', true)
766
+ * .paginate(2, 10);
767
+ * // { data: [...], total: 53, page: 2, perPage: 10,
768
+ * // pages: 6, hasNext: true, hasPrev: true }
769
+ */
770
+ async paginate(pg, perPage = 20)
771
+ {
772
+ pg = Math.max(1, pg);
773
+ const total = await this.count();
774
+ const pages = Math.ceil(total / perPage);
775
+ this._limitVal = perPage;
776
+ this._offsetVal = (pg - 1) * perPage;
777
+ const data = await this.exec();
778
+ return {
779
+ data,
780
+ total,
781
+ page: pg,
782
+ perPage,
783
+ pages,
784
+ hasNext: pg < pages,
785
+ hasPrev: pg > 1,
786
+ };
787
+ }
788
+
789
+ /**
790
+ * Inject a raw WHERE clause for SQL adapters.
791
+ * Ignored by non-SQL adapters (memory, json, mongo).
792
+ *
793
+ * @param {string} sql - Raw SQL expression (e.g. 'age > ? AND role = ?').
794
+ * @param {...*} [params] - Parameter values.
795
+ * @returns {Query}
796
+ *
797
+ * @example
798
+ * User.query().whereRaw('LOWER(email) = ?', 'alice@example.com')
799
+ */
800
+ whereRaw(sql, ...params)
801
+ {
802
+ this._where.push({ raw: sql, params, logic: 'AND' });
803
+ return this;
804
+ }
805
+ }
806
+
807
+ module.exports = Query;