zero-http 0.2.5 → 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.
- package/README.md +1250 -283
- package/documentation/config/db.js +25 -0
- package/documentation/config/middleware.js +44 -0
- package/documentation/config/tls.js +12 -0
- package/documentation/controllers/cookies.js +34 -0
- package/documentation/controllers/tasks.js +108 -0
- package/documentation/full-server.js +25 -184
- package/documentation/models/Task.js +21 -0
- package/documentation/public/data/api.json +404 -24
- package/documentation/public/data/docs.json +1139 -0
- package/documentation/public/data/examples.json +80 -2
- package/documentation/public/data/options.json +23 -8
- package/documentation/public/index.html +138 -99
- package/documentation/public/scripts/app.js +1 -3
- package/documentation/public/scripts/custom-select.js +189 -0
- package/documentation/public/scripts/data-sections.js +233 -250
- package/documentation/public/scripts/playground.js +270 -0
- package/documentation/public/scripts/ui.js +4 -3
- package/documentation/public/styles.css +56 -5
- package/documentation/public/vendor/icons/compress.svg +17 -17
- package/documentation/public/vendor/icons/database.svg +21 -0
- package/documentation/public/vendor/icons/env.svg +21 -0
- package/documentation/public/vendor/icons/fetch.svg +11 -14
- package/documentation/public/vendor/icons/security.svg +15 -0
- package/documentation/public/vendor/icons/sse.svg +12 -13
- package/documentation/public/vendor/icons/static.svg +12 -26
- package/documentation/public/vendor/icons/stream.svg +7 -13
- package/documentation/public/vendor/icons/validate.svg +17 -0
- package/documentation/routes/api.js +41 -0
- package/documentation/routes/core.js +20 -0
- package/documentation/routes/playground.js +29 -0
- package/documentation/routes/realtime.js +49 -0
- package/documentation/routes/uploads.js +71 -0
- package/index.js +62 -1
- package/lib/app.js +200 -8
- package/lib/body/json.js +28 -5
- package/lib/body/multipart.js +29 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/sendError.js +1 -0
- package/lib/body/text.js +1 -1
- package/lib/body/typeMatch.js +6 -2
- package/lib/body/urlencoded.js +5 -2
- package/lib/debug.js +345 -0
- package/lib/env/index.js +440 -0
- package/lib/errors.js +231 -0
- package/lib/http/request.js +219 -1
- package/lib/http/response.js +410 -6
- package/lib/middleware/compress.js +39 -6
- package/lib/middleware/cookieParser.js +237 -0
- package/lib/middleware/cors.js +13 -2
- package/lib/middleware/csrf.js +135 -0
- package/lib/middleware/errorHandler.js +90 -0
- package/lib/middleware/helmet.js +176 -0
- package/lib/middleware/index.js +7 -2
- package/lib/middleware/rateLimit.js +12 -1
- package/lib/middleware/requestId.js +54 -0
- package/lib/middleware/static.js +95 -11
- package/lib/middleware/timeout.js +72 -0
- package/lib/middleware/validator.js +257 -0
- package/lib/orm/adapters/json.js +215 -0
- package/lib/orm/adapters/memory.js +383 -0
- package/lib/orm/adapters/mongo.js +444 -0
- package/lib/orm/adapters/mysql.js +272 -0
- package/lib/orm/adapters/postgres.js +394 -0
- package/lib/orm/adapters/sql-base.js +142 -0
- package/lib/orm/adapters/sqlite.js +311 -0
- package/lib/orm/index.js +276 -0
- package/lib/orm/model.js +895 -0
- package/lib/orm/query.js +807 -0
- package/lib/orm/schema.js +172 -0
- package/lib/router/index.js +136 -47
- package/lib/sse/stream.js +15 -3
- package/lib/ws/connection.js +19 -3
- package/lib/ws/handshake.js +3 -0
- package/lib/ws/index.js +3 -1
- package/lib/ws/room.js +222 -0
- package/package.json +15 -5
- package/types/app.d.ts +120 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +147 -0
- package/types/fetch.d.ts +43 -0
- package/types/index.d.ts +135 -0
- package/types/middleware.d.ts +292 -0
- package/types/orm.d.ts +610 -0
- package/types/request.d.ts +99 -0
- package/types/response.d.ts +142 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/websocket.d.ts +119 -0
package/lib/orm/query.js
ADDED
|
@@ -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;
|