laravel-workers 0.4.0 → 0.4.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/kernel/db.mjs CHANGED
@@ -1,703 +1,703 @@
1
- // kernel/db.mjs — D1 query builder
2
- // SQL is generated with bound parameters; identifiers are quoted.
3
- // Raw fragments (selectRaw/whereRaw/havingRaw) accept explicit bindings — the
4
- // caller owns their safety (documented trust boundary).
5
-
6
- // The operators Laravel accepts (Query/Builder.php:249-253 - `$operators`):
7
- // '<=>' and 'IS'/'IS NOT' are in that list, so a rule with them is a legal
8
- // query there. The 2-ARGUMENT form is different: Laravel's
9
- // prepareValueAndOperator() makes the operator '=' and the given operator the
10
- // VALUE (`where('c', 'IS')` is `c = 'IS'`).
11
- const ALLOWED_OPS = new Set([
12
- '=', '!=', '<>', '<', '>', '<=', '>=', '<=>', 'LIKE', 'NOT LIKE', 'IS', 'IS NOT',
13
- ]);
14
- const ALLOWED_JOIN_OPS = new Set(['=', '!=', '<>', '<', '>', '<=', '>=']);
15
-
16
- // D1/SQLite only bind null | number | string | ArrayBuffer. Normalize the JS
17
- // values the builder accepts so callers can pass idiomatic JS: booleans become
18
- // 1/0 (a raw JS boolean throws in node:sqlite and is rejected by D1), Dates
19
- // become ISO strings, undefined becomes null.
20
- function bindValue(value) {
21
- if (typeof value === 'boolean') return value ? 1 : 0;
22
- if (value instanceof Date) {
23
- // Laravel's Connection::prepareBindings(): a DateTimeInterface is bound
24
- // with the grammar's date format ('Y-m-d H:i:s') - an ISO string
25
- // ('2024-01-02T03:04:05.000Z') never matches the stored TEXT column.
26
- return value.toISOString().slice(0, 19).replace('T', ' ');
27
- }
28
- if (value === undefined) return null;
29
- return value;
30
- }
31
-
32
- function quoteIdent(name) {
33
- // Supports `table.column` dot notation → `"table"."column"`.
34
- return String(name)
35
- .split('.')
36
- .map((part) => '"' + part.replace(/"/g, '""') + '"')
37
- .join('.');
38
- }
39
-
40
- function isPlainObject(v) {
41
- return v != null && typeof v === 'object' && !Array.isArray(v);
42
- }
43
-
44
- // Normalize the (column, operatorOrValue, value) trio used by where-like APIs.
45
- function normalizeOp(operatorOrValue, value) {
46
- let operator = '=';
47
- let val = operatorOrValue;
48
- if (value !== undefined) {
49
- operator = String(operatorOrValue).toUpperCase();
50
- val = value;
51
- if (!ALLOWED_OPS.has(operator)) throw new Error('unsupported operator: ' + operator);
52
- }
53
- return { operator, val };
54
- }
55
-
56
- export class QueryBuilder {
57
- constructor(db, table) {
58
- this.db = db;
59
- this.table = String(table);
60
- this.columns = ['*'];
61
- this.wheres = [];
62
- this.joins = [];
63
- this.groups = [];
64
- this.havings = [];
65
- this.order = [];
66
- this.distinctValue = false;
67
- this.limitValue = null;
68
- this.offsetValue = null;
69
- this.model = null;
70
- this._hasSelect = false;
71
- this._unions = [];
72
- }
73
-
74
- select(...columns) {
75
- this.columns = columns.length ? columns.map((c) => String(c)) : ['*'];
76
- this._hasSelect = true;
77
- return this;
78
- }
79
-
80
- selectRaw(sql, params = []) {
81
- if (!this._hasSelect && this.columns.length === 1 && this.columns[0] === '*') this.columns = [];
82
- this.columns.push({ raw: String(sql), params: [...params] });
83
- this._hasSelect = true;
84
- return this;
85
- }
86
-
87
- from(table) {
88
- this.table = String(table);
89
- return this;
90
- }
91
-
92
- distinct() {
93
- this.distinctValue = true;
94
- return this;
95
- }
96
-
97
- where(column, operatorOrValue, value) {
98
- // where(fn) → nested group, mirroring Laravel's closure grouping.
99
- if (typeof column === 'function') return this.whereGroup(column, 'and');
100
- return this._whereBasic('and', column, operatorOrValue, value);
101
- }
102
- orWhere(column, operatorOrValue, value) {
103
- if (typeof column === 'function') return this.whereGroup(column, 'or');
104
- return this._whereBasic('or', column, operatorOrValue, value);
105
- }
106
- _whereBasic(boolean, column, operatorOrValue, value) {
107
- let operator = '=';
108
- let val = operatorOrValue;
109
- if (value !== undefined) {
110
- operator = String(operatorOrValue).toUpperCase();
111
- val = value;
112
- if (!ALLOWED_OPS.has(operator)) throw new Error('unsupported where operator: ' + operator);
113
- }
114
- // Laravel's where() (Query/Builder.php:988-991): a NULL value is not a
115
- // comparison - `col = NULL` never matches a row - it is a where-null
116
- // clause, and '!=' / '<>' make it `IS NOT NULL`. An operator that cannot
117
- // compare with NULL at all is an illegal combination
118
- // (InvalidArgumentException).
119
- if (val === null) {
120
- // Laravel's invalidOperatorAndValue() (Query/Builder.php:1083-1087): a
121
- // NULL value with an operator outside ['=', '<=>', '<>', '!='] is an
122
- // illegal combination (Laravel throws).
123
- if (operator !== '=' && operator !== '<=>' && operator !== '!=' && operator !== '<>') {
124
- throw new Error('Illegal operator and value combination.');
125
- }
126
- return this._whereNull(boolean, operator === '!=' || operator === '<>' ? 'notNull' : 'null', column);
127
- }
128
- this.wheres.push({ type: 'basic', boolean, column: String(column), operator, value: val });
129
- return this;
130
- }
131
-
132
- whereIn(column, values) {
133
- return this._whereIn('and', column, values);
134
- }
135
- orWhereIn(column, values) {
136
- return this._whereIn('or', column, values);
137
- }
138
- whereNotIn(column, values) {
139
- return this._whereNotIn('and', column, values);
140
- }
141
- orWhereNotIn(column, values) {
142
- return this._whereNotIn('or', column, values);
143
- }
144
- _whereIn(boolean, column, values) {
145
- if (!Array.isArray(values)) throw new Error('whereIn expects an array');
146
- this.wheres.push({ type: 'in', boolean, column: String(column), values: [...values] });
147
- return this;
148
- }
149
- _whereNotIn(boolean, column, values) {
150
- if (!Array.isArray(values)) throw new Error('whereNotIn expects an array');
151
- this.wheres.push({ type: 'notIn', boolean, column: String(column), values: [...values] });
152
- return this;
153
- }
154
-
155
- whereNull(column) {
156
- return this._whereNull('and', 'null', column);
157
- }
158
- orWhereNull(column) {
159
- return this._whereNull('or', 'null', column);
160
- }
161
- whereNotNull(column) {
162
- return this._whereNull('and', 'notNull', column);
163
- }
164
- orWhereNotNull(column) {
165
- return this._whereNull('or', 'notNull', column);
166
- }
167
- _whereNull(boolean, type, column) {
168
- this.wheres.push({ type, boolean, column: String(column) });
169
- return this;
170
- }
171
-
172
- whereLike(column, value) {
173
- return this._whereBasic('and', column, 'LIKE', value);
174
- }
175
- whereNotLike(column, value) {
176
- return this._whereBasic('and', column, 'NOT LIKE', value);
177
- }
178
-
179
- // Date-part filters. SQLite has no YEAR()/MONTH(); use date()/strftime().
180
- // date()/time()/strftime() return TEXT, so a bound JS number must be bound as
181
- // a string: drivers may bind numbers as REAL/INTEGER and `'2024' = 2024.0`
182
- // never matches (verified against node:sqlite/D1 semantics).
183
- whereDate(column, operatorOrValue, value) {
184
- return this._whereDatePart('and', (c) => `date(${quoteIdent(c)})`, column, operatorOrValue, value);
185
- }
186
- orWhereDate(column, operatorOrValue, value) {
187
- return this._whereDatePart('or', (c) => `date(${quoteIdent(c)})`, column, operatorOrValue, value);
188
- }
189
- whereYear(column, operatorOrValue, value) {
190
- return this._whereDatePart('and', (c) => `strftime('%Y', ${quoteIdent(c)})`, column, operatorOrValue, value, 4);
191
- }
192
- whereMonth(column, operatorOrValue, value) {
193
- return this._whereDatePart('and', (c) => `strftime('%m', ${quoteIdent(c)})`, column, operatorOrValue, value, 2);
194
- }
195
- whereDay(column, operatorOrValue, value) {
196
- return this._whereDatePart('and', (c) => `strftime('%d', ${quoteIdent(c)})`, column, operatorOrValue, value, 2);
197
- }
198
- whereTime(column, operatorOrValue, value) {
199
- return this._whereDatePart('and', (c) => `time(${quoteIdent(c)})`, column, operatorOrValue, value);
200
- }
201
- _whereDatePart(boolean, fn, column, operatorOrValue, value, pad = 0) {
202
- const { operator, val } = normalizeOp(operatorOrValue, value);
203
- // Stringify + zero-pad the bound value: date()/strftime()/time() return
204
- // TEXT (`%m`/`%d` are zero-padded), and some drivers (node:sqlite; D1
205
- // historically) bind JS numbers as REAL, which would make CAST(? AS TEXT)
206
- // yield '2024.0'/'2' and never match '2024'/'02'.
207
- const bound = pad > 0 && val != null && val !== '' ? String(val).padStart(pad, '0') : val;
208
- this.wheres.push({ type: 'raw', boolean, sql: `${fn(column)} ${operator} CAST(? AS TEXT)`, params: [bound] });
209
- return this;
210
- }
211
-
212
- whereBetween(column, [min, max]) {
213
- this.wheres.push({ type: 'between', boolean: 'and', column: String(column), min, max });
214
- return this;
215
- }
216
- whereNotBetween(column, [min, max]) {
217
- this.wheres.push({ type: 'notBetween', boolean: 'and', column: String(column), min, max });
218
- return this;
219
- }
220
-
221
- whereColumn(first, operator = '=', second) {
222
- if (second === undefined) { second = operator; operator = '='; }
223
- const op = String(operator).toUpperCase();
224
- if (!ALLOWED_JOIN_OPS.has(op)) throw new Error('unsupported whereColumn operator: ' + op);
225
- this.wheres.push({ type: 'column', boolean: 'and', first: String(first), operator: op, second: String(second) });
226
- return this;
227
- }
228
-
229
- whereRaw(sql, params = []) {
230
- this.wheres.push({ type: 'raw', boolean: 'and', sql: String(sql), params: [...params] });
231
- return this;
232
- }
233
- orWhereRaw(sql, params = []) {
234
- this.wheres.push({ type: 'raw', boolean: 'or', sql: String(sql), params: [...params] });
235
- return this;
236
- }
237
-
238
- whereExists(term, boolean = 'and') {
239
- if (typeof term !== 'function') throw new Error('whereExists expects a function');
240
- const sub = new QueryBuilder(this.db, this.table);
241
- term(sub);
242
- this.wheres.push({ type: 'exists', boolean, query: sub, not: false });
243
- return this;
244
- }
245
- whereNotExists(term, boolean = 'and') {
246
- if (typeof term !== 'function') throw new Error('whereNotExists expects a function');
247
- const sub = new QueryBuilder(this.db, this.table);
248
- term(sub);
249
- this.wheres.push({ type: 'exists', boolean, query: sub, not: true });
250
- return this;
251
- }
252
- orWhereExists(term) {
253
- return this.whereExists(term, 'or');
254
- }
255
- orWhereNotExists(term) {
256
- return this.whereNotExists(term, 'or');
257
- }
258
-
259
- union(other) {
260
- if (!(other instanceof QueryBuilder)) throw new Error('union expects a QueryBuilder');
261
- this._unions.push({ query: other, all: false });
262
- return this;
263
- }
264
- unionAll(other) {
265
- if (!(other instanceof QueryBuilder)) throw new Error('unionAll expects a QueryBuilder');
266
- this._unions.push({ query: other, all: true });
267
- return this;
268
- }
269
-
270
- whereGroup(cb, boolean = 'and') {
271
- const sub = new QueryBuilder(this.db, this.table);
272
- cb(sub);
273
- this.wheres.push({ type: 'group', boolean, wheres: sub.wheres });
274
- return this;
275
- }
276
- orWhereGroup(cb) {
277
- return this.whereGroup(cb, 'or');
278
- }
279
-
280
- join(table, first, operator, second) {
281
- return this._join('INNER', table, first, operator, second);
282
- }
283
- innerJoin(table, first, operator, second) {
284
- return this._join('INNER', table, first, operator, second);
285
- }
286
- leftJoin(table, first, operator, second) {
287
- return this._join('LEFT', table, first, operator, second);
288
- }
289
- rightJoin(table, first, operator, second) {
290
- return this._join('RIGHT', table, first, operator, second);
291
- }
292
- _join(kind, table, first, operator, second) {
293
- const op = String(operator).toUpperCase();
294
- if (!ALLOWED_JOIN_OPS.has(op)) throw new Error('unsupported join operator: ' + op);
295
- this.joins.push({ kind, table: String(table), first: String(first), operator: op, second: String(second) });
296
- return this;
297
- }
298
-
299
- groupBy(...columns) {
300
- for (const c of columns) {
301
- if (Array.isArray(c)) this.groups.push(...c.map(String));
302
- else this.groups.push(String(c));
303
- }
304
- return this;
305
- }
306
-
307
- having(column, operatorOrValue, value) {
308
- let operator = '=';
309
- let val = operatorOrValue;
310
- if (value !== undefined) {
311
- operator = String(operatorOrValue).toUpperCase();
312
- val = value;
313
- if (!ALLOWED_OPS.has(operator)) throw new Error('unsupported having operator: ' + operator);
314
- }
315
- this.havings.push({ type: 'basic', boolean: 'and', column: String(column), operator, value: val });
316
- return this;
317
- }
318
- havingRaw(sql, params = []) {
319
- this.havings.push({ type: 'raw', boolean: 'and', sql: String(sql), params: [...params] });
320
- return this;
321
- }
322
-
323
- orderBy(column, direction = 'asc') {
324
- // Laravel's orderBy (Query/Builder.php:3042-3062): the direction must be
325
- // 'asc' or 'desc' - anything else is an InvalidArgumentException (a 500
326
- // there). The edge used to answer ANY direction as ASC: a different row
327
- // order, silently.
328
- const dir = String(direction).toLowerCase();
329
- if (dir !== 'asc' && dir !== 'desc') {
330
- throw new Error('Order direction must be "asc" or "desc".');
331
- }
332
- this.order.push({ column: String(column), direction: dir.toUpperCase() });
333
- return this;
334
- }
335
- orderByDesc(column) { return this.orderBy(column, 'desc'); }
336
- latest(column = 'created_at') { return this.orderByDesc(column); }
337
- oldest(column = 'created_at') { return this.orderBy(column, 'asc'); }
338
-
339
- limit(n) {
340
- this.limitValue = Number(n);
341
- return this;
342
- }
343
- offset(n) {
344
- this.offsetValue = Number(n);
345
- return this;
346
- }
347
-
348
- asModel(model) {
349
- this.model = model;
350
- return this;
351
- }
352
-
353
- clone() {
354
- const c = new QueryBuilder(this.db, this.table);
355
- c.columns = this.columns.map((col) => (isPlainObject(col) ? { raw: col.raw, params: [...col.params] } : col));
356
- c.wheres = cloneWheres(this.wheres);
357
- c.joins = this.joins.map((j) => ({ ...j }));
358
- c.groups = [...this.groups];
359
- c.havings = cloneWheres(this.havings);
360
- c.order = this.order.map((o) => ({ ...o }));
361
- c.distinctValue = this.distinctValue;
362
- c.limitValue = this.limitValue;
363
- c.offsetValue = this.offsetValue;
364
- c.model = this.model;
365
- c._hasSelect = this._hasSelect;
366
- c._unions = (this._unions || []).map((u) => ({ query: u.query.clone(), all: u.all }));
367
- return c;
368
- }
369
-
370
- _whereSql(params) {
371
- return renderClauses(this.wheres, params);
372
- }
373
-
374
- _havingSql(params) {
375
- return renderClauses(this.havings, params, 'HAVING');
376
- }
377
-
378
- _joinSql() {
379
- if (!this.joins.length) return '';
380
- return this.joins
381
- .map((j) => ` ${j.kind} JOIN ${quoteIdent(j.table)} ON ${quoteIdent(j.first)} ${j.operator} ${quoteIdent(j.second)}`)
382
- .join('');
383
- }
384
-
385
- _columnsSql(params) {
386
- const cols = [];
387
- for (const c of this.columns) {
388
- if (isPlainObject(c)) {
389
- for (const p of c.params) params.push(p);
390
- cols.push(c.raw);
391
- } else if (c === '*') {
392
- cols.push('*');
393
- } else {
394
- cols.push(quoteIdent(c));
395
- }
396
- }
397
- return cols.join(', ');
398
- }
399
-
400
- _groupSql() {
401
- if (!this.groups.length) return '';
402
- return ' GROUP BY ' + this.groups.map(quoteIdent).join(', ');
403
- }
404
-
405
- _tailSql(params) {
406
- let sql = '';
407
- if (this.order.length) {
408
- sql += ' ORDER BY ' + this.order.map((o) => `${quoteIdent(o.column)} ${o.direction}`).join(', ');
409
- }
410
- if (this.limitValue != null) sql += ' LIMIT ?';
411
- if (this.offsetValue != null) sql += ' OFFSET ?';
412
- if (this.limitValue != null) params.push(this.limitValue);
413
- if (this.offsetValue != null) params.push(this.offsetValue);
414
- return sql;
415
- }
416
-
417
- toSql() {
418
- const params = [];
419
- const cols = this._columnsSql(params);
420
- let sql = `SELECT ${this.distinctValue ? 'DISTINCT ' : ''}${cols} FROM ${quoteIdent(this.table)}`;
421
- sql += this._joinSql();
422
- sql += renderClauses(this.wheres, params);
423
- sql += this._groupSql();
424
- sql += renderClauses(this.havings, params, 'HAVING');
425
- sql += this._tailSql(params);
426
- if (this._unions && this._unions.length) {
427
- const parts = ['(' + sql + ')'];
428
- for (const u of this._unions) {
429
- const sub = u.query.toSql();
430
- params.push(...sub.params);
431
- parts.push((u.all ? ' UNION ALL ' : ' UNION ') + '(' + sub.sql + ')');
432
- }
433
- sql = parts.join('');
434
- }
435
- return { sql, params };
436
- }
437
-
438
- _prepare(sql, params) {
439
- const stmt = this.db.prepare(sql);
440
- const bound = params.map(bindValue);
441
- return bound.length ? stmt.bind(...bound) : stmt;
442
- }
443
-
444
- async get() {
445
- const { sql, params } = this.toSql();
446
- const stmt = this._prepare(sql, params);
447
- const res = await stmt.all();
448
- const rows = (res && Array.isArray(res.results)) ? res.results : [];
449
- if (this.model) return rows.map((row) => new this.model(row));
450
- return rows;
451
- }
452
-
453
- async first() {
454
- const rows = await this.clone().limit(1).get();
455
- return rows.length ? rows[0] : null;
456
- }
457
-
458
- async count(column = '*') {
459
- const params = [];
460
- const agg = column === '*' ? '*' : quoteIdent(column);
461
- let sql = `SELECT COUNT(${agg}) AS "aggregate" FROM ${quoteIdent(this.table)}`;
462
- sql += this._joinSql();
463
- sql += renderClauses(this.wheres, params);
464
- sql += this._groupSql();
465
- sql += renderClauses(this.havings, params, 'HAVING');
466
- const stmt = this._prepare(sql, params);
467
- const res = await stmt.first();
468
- const row = res || {};
469
- return Number(row.aggregate ?? row['COUNT(*)'] ?? 0);
470
- }
471
-
472
- async _aggregate(fn, column) {
473
- const params = [];
474
- const agg = column === undefined || column === '*' ? '*' : quoteIdent(column);
475
- let sql = `SELECT ${fn}(${agg}) AS "aggregate" FROM ${quoteIdent(this.table)}`;
476
- sql += this._joinSql();
477
- sql += renderClauses(this.wheres, params);
478
- sql += this._groupSql();
479
- sql += renderClauses(this.havings, params, 'HAVING');
480
- const stmt = this._prepare(sql, params);
481
- const res = await stmt.first();
482
- const row = res || {};
483
- return row.aggregate == null ? null : Number(row.aggregate);
484
- }
485
-
486
- async sum(column) {
487
- // Laravel's sum() (Query/Builder.php:4099-4104): `$result = $this->aggregate(
488
- // __FUNCTION__, [$column]); return $result ?: 0;` - an empty set (or an
489
- // all-NULL column) answers 0, not null (the JSON body differed).
490
- const value = await this._aggregate('SUM', column);
491
- return value == null ? 0 : value;
492
- }
493
- async avg(column) { return this._aggregate('AVG', column); }
494
- async min(column) { return this._aggregate('MIN', column); }
495
- async max(column) { return this._aggregate('MAX', column); }
496
-
497
- async exists() {
498
- const params = [];
499
- let sql = `SELECT 1 AS "e" FROM ${quoteIdent(this.table)}`;
500
- sql += this._joinSql();
501
- sql += renderClauses(this.wheres, params);
502
- sql += this._groupSql();
503
- sql += renderClauses(this.havings, params, 'HAVING');
504
- sql += ' LIMIT 1';
505
- const stmt = this._prepare(sql, params);
506
- const res = await stmt.first();
507
- return !!res;
508
- }
509
-
510
- async value(column) {
511
- const b = this.clone().select(String(column));
512
- const { sql, params } = b.limit(1).toSql();
513
- const stmt = this._prepare(sql, params);
514
- const row = await stmt.first();
515
- if (!row) return null;
516
- if (this.model && typeof row.get === 'function') return row.get(column);
517
- return row[column];
518
- }
519
-
520
- async pluck(column) {
521
- const rows = await this.clone().select(String(column)).get();
522
- return rows.map((r) => (this.model && typeof r.get === 'function' ? r.get(column) : r[column]));
523
- }
524
-
525
- async insert(data) {
526
- if (!isPlainObject(data) || !Object.keys(data).length) throw new Error('insert expects a non-empty object');
527
- const columns = Object.keys(data);
528
- const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.map(quoteIdent).join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
529
- const stmt = this._prepare(sql, Object.values(data));
530
- const res = await stmt.run();
531
- return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, lastInsertRowid: res?.meta?.last_row_id ?? null, meta: res?.meta ?? {} };
532
- }
533
-
534
- async update(data) {
535
- if (!isPlainObject(data) || !Object.keys(data).length) throw new Error('update expects a non-empty object');
536
- const columns = Object.keys(data);
537
- const params = Object.values(data);
538
- let sql = `UPDATE ${quoteIdent(this.table)} SET ${columns.map((c) => `${quoteIdent(c)} = ?`).join(', ')}`;
539
- sql += renderClauses(this.wheres, params);
540
- const stmt = this._prepare(sql, params);
541
- const res = await stmt.run();
542
- return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, meta: res?.meta ?? {} };
543
- }
544
-
545
- async delete() {
546
- const params = [];
547
- let sql = `DELETE FROM ${quoteIdent(this.table)}`;
548
- sql += renderClauses(this.wheres, params);
549
- const stmt = this._prepare(sql, params);
550
- const res = await stmt.run();
551
- return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, meta: res?.meta ?? {} };
552
- }
553
-
554
- async truncate() {
555
- const stmt = this.db.prepare(`DELETE FROM ${quoteIdent(this.table)}`);
556
- const res = await stmt.run();
557
- return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, meta: res?.meta ?? {} };
558
- }
559
-
560
- async insertGetId(data) {
561
- const res = await this.insert(data);
562
- return res.lastInsertRowid;
563
- }
564
-
565
- // SQLite/D1 upsert: multi-row INSERT ... ON CONFLICT (unique) DO UPDATE|NOTHING.
566
- async upsert(rows, uniqueBy, updateColumns) {
567
- const list = Array.isArray(rows) ? rows : [rows];
568
- if (!list.length) throw new Error('upsert expects at least one row');
569
- const columns = Object.keys(list[0]);
570
- const unique = Array.isArray(uniqueBy) ? uniqueBy.map(String) : [String(uniqueBy)];
571
- const update = (updateColumns == null ? [] : (Array.isArray(updateColumns) ? updateColumns : [updateColumns]))
572
- .map(String)
573
- .filter((c) => !unique.includes(c));
574
- const params = [];
575
- const tuples = list.map((row) => '(' + columns.map((c) => { params.push(row[c] ?? null); return '?'; }).join(', ') + ')');
576
- let sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.map(quoteIdent).join(', ')}) VALUES ${tuples.join(', ')}`;
577
- sql += ` ON CONFLICT (${unique.map(quoteIdent).join(', ')}) `;
578
- if (update.length) sql += `DO UPDATE SET ${update.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`).join(', ')}`;
579
- else sql += 'DO NOTHING';
580
- const stmt = this._prepare(sql, params);
581
- const res = await stmt.run();
582
- return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, meta: res?.meta ?? {} };
583
- }
584
-
585
- async increment(column, amount = 1) {
586
- const params = [Number(amount)];
587
- let sql = `UPDATE ${quoteIdent(this.table)} SET ${quoteIdent(column)} = ${quoteIdent(column)} + ?`;
588
- sql += renderClauses(this.wheres, params);
589
- const stmt = this._prepare(sql, params);
590
- const res = await stmt.run();
591
- return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, meta: res?.meta ?? {} };
592
- }
593
-
594
- async decrement(column, amount = 1) {
595
- return this.increment(column, -Number(amount));
596
- }
597
-
598
- async updateOrInsert(where, values) {
599
- const q = new QueryBuilder(this.db, this.table);
600
- for (const [k, v] of Object.entries(where || {})) q.where(k, v);
601
- const existing = await q.clone().first();
602
- if (existing) return q.update(values);
603
- return this.insert({ ...(where || {}), ...(values || {}) });
604
- }
605
-
606
- async chunk(size, cb) {
607
- const n = Math.max(1, Number(size) || 1);
608
- for (let page = 1; ; page++) {
609
- const rows = await this.clone().limit(n).offset((page - 1) * n).get();
610
- if (!rows.length) return;
611
- const stop = await cb(rows, page);
612
- if (rows.length < n || stop === false) return;
613
- }
614
- }
615
-
616
- async paginate(perPage = 15, page = 1) {
617
- const p = Math.max(1, Number(page) || 1);
618
- const size = Math.max(1, Number(perPage) || 15);
619
- const total = await this.count();
620
- const data = await this.clone().limit(size).offset((p - 1) * size).get();
621
- return {
622
- data,
623
- total,
624
- per_page: size,
625
- current_page: p,
626
- last_page: Math.max(1, Math.ceil(total / size)),
627
- };
628
- }
629
- }
630
-
631
- function cloneWheres(wheres) {
632
- return wheres.map((w) => {
633
- const copy = { ...w };
634
- if (w.values) copy.values = [...w.values];
635
- if (w.params) copy.params = [...w.params];
636
- if (w.wheres) copy.wheres = cloneWheres(w.wheres);
637
- if (w.type === 'exists' && w.query && typeof w.query.clone === 'function') copy.query = w.query.clone();
638
- return copy;
639
- });
640
- }
641
-
642
- // Render a list of where-like clauses with AND/OR booleans. Flat list → the
643
- // first clause has no connector, the rest are prefixed by their boolean.
644
- function renderClauses(clauses, params, keyword = 'WHERE') {
645
- if (!clauses.length) return '';
646
- const parts = clauses.map((w) => {
647
- let chunk;
648
- if (w.type === 'basic') {
649
- params.push(w.value);
650
- chunk = `${quoteIdent(w.column)} ${w.operator} ?`;
651
- } else if (w.type === 'in') {
652
- if (!w.values.length) chunk = '1 = 0';
653
- else {
654
- for (const v of w.values) params.push(v);
655
- chunk = `${quoteIdent(w.column)} IN (${w.values.map(() => '?').join(', ')})`;
656
- }
657
- } else if (w.type === 'notIn') {
658
- if (!w.values.length) chunk = '1 = 1';
659
- else {
660
- for (const v of w.values) params.push(v);
661
- chunk = `${quoteIdent(w.column)} NOT IN (${w.values.map(() => '?').join(', ')})`;
662
- }
663
- } else if (w.type === 'null') {
664
- chunk = `${quoteIdent(w.column)} IS NULL`;
665
- } else if (w.type === 'notNull') {
666
- chunk = `${quoteIdent(w.column)} IS NOT NULL`;
667
- } else if (w.type === 'between') {
668
- params.push(w.min, w.max);
669
- chunk = `${quoteIdent(w.column)} BETWEEN ? AND ?`;
670
- } else if (w.type === 'notBetween') {
671
- params.push(w.min, w.max);
672
- chunk = `${quoteIdent(w.column)} NOT BETWEEN ? AND ?`;
673
- } else if (w.type === 'column') {
674
- chunk = `${quoteIdent(w.first)} ${w.operator} ${quoteIdent(w.second)}`;
675
- } else if (w.type === 'raw') {
676
- for (const p of w.params) params.push(p);
677
- chunk = w.sql;
678
- } else if (w.type === 'group') {
679
- const inner = renderClauses(w.wheres, params);
680
- chunk = '(' + inner.replace(/^ (WHERE|HAVING) /, '').replace(/^ (AND|OR) /, '') + ')';
681
- } else if (w.type === 'exists') {
682
- const sub = w.query.toSql();
683
- for (const p2 of sub.params) params.push(p2);
684
- chunk = (w.not ? 'NOT EXISTS (' : 'EXISTS (') + sub.sql + ')';
685
- } else {
686
- throw new Error('unknown where type: ' + w.type);
687
- }
688
- return { boolean: w.boolean || 'and', chunk };
689
- });
690
- let sql = '';
691
- parts.forEach((p, i) => {
692
- if (i === 0) sql += p.chunk;
693
- else sql += ' ' + p.boolean.toUpperCase() + ' ' + p.chunk;
694
- });
695
- return ' ' + keyword + ' ' + sql;
696
- }
697
-
698
- export function table(db, name) {
699
- return new QueryBuilder(db, name);
700
- }
701
-
702
- export const DB = { table };
703
- export default { QueryBuilder, table, DB };
1
+ // kernel/db.mjs — D1 query builder
2
+ // SQL is generated with bound parameters; identifiers are quoted.
3
+ // Raw fragments (selectRaw/whereRaw/havingRaw) accept explicit bindings — the
4
+ // caller owns their safety (documented trust boundary).
5
+
6
+ // The operators Laravel accepts (Query/Builder.php:249-253 - `$operators`):
7
+ // '<=>' and 'IS'/'IS NOT' are in that list, so a rule with them is a legal
8
+ // query there. The 2-ARGUMENT form is different: Laravel's
9
+ // prepareValueAndOperator() makes the operator '=' and the given operator the
10
+ // VALUE (`where('c', 'IS')` is `c = 'IS'`).
11
+ const ALLOWED_OPS = new Set([
12
+ '=', '!=', '<>', '<', '>', '<=', '>=', '<=>', 'LIKE', 'NOT LIKE', 'IS', 'IS NOT',
13
+ ]);
14
+ const ALLOWED_JOIN_OPS = new Set(['=', '!=', '<>', '<', '>', '<=', '>=']);
15
+
16
+ // D1/SQLite only bind null | number | string | ArrayBuffer. Normalize the JS
17
+ // values the builder accepts so callers can pass idiomatic JS: booleans become
18
+ // 1/0 (a raw JS boolean throws in node:sqlite and is rejected by D1), Dates
19
+ // become ISO strings, undefined becomes null.
20
+ function bindValue(value) {
21
+ if (typeof value === 'boolean') return value ? 1 : 0;
22
+ if (value instanceof Date) {
23
+ // Laravel's Connection::prepareBindings(): a DateTimeInterface is bound
24
+ // with the grammar's date format ('Y-m-d H:i:s') - an ISO string
25
+ // ('2024-01-02T03:04:05.000Z') never matches the stored TEXT column.
26
+ return value.toISOString().slice(0, 19).replace('T', ' ');
27
+ }
28
+ if (value === undefined) return null;
29
+ return value;
30
+ }
31
+
32
+ function quoteIdent(name) {
33
+ // Supports `table.column` dot notation → `"table"."column"`.
34
+ return String(name)
35
+ .split('.')
36
+ .map((part) => '"' + part.replace(/"/g, '""') + '"')
37
+ .join('.');
38
+ }
39
+
40
+ function isPlainObject(v) {
41
+ return v != null && typeof v === 'object' && !Array.isArray(v);
42
+ }
43
+
44
+ // Normalize the (column, operatorOrValue, value) trio used by where-like APIs.
45
+ function normalizeOp(operatorOrValue, value) {
46
+ let operator = '=';
47
+ let val = operatorOrValue;
48
+ if (value !== undefined) {
49
+ operator = String(operatorOrValue).toUpperCase();
50
+ val = value;
51
+ if (!ALLOWED_OPS.has(operator)) throw new Error('unsupported operator: ' + operator);
52
+ }
53
+ return { operator, val };
54
+ }
55
+
56
+ export class QueryBuilder {
57
+ constructor(db, table) {
58
+ this.db = db;
59
+ this.table = String(table);
60
+ this.columns = ['*'];
61
+ this.wheres = [];
62
+ this.joins = [];
63
+ this.groups = [];
64
+ this.havings = [];
65
+ this.order = [];
66
+ this.distinctValue = false;
67
+ this.limitValue = null;
68
+ this.offsetValue = null;
69
+ this.model = null;
70
+ this._hasSelect = false;
71
+ this._unions = [];
72
+ }
73
+
74
+ select(...columns) {
75
+ this.columns = columns.length ? columns.map((c) => String(c)) : ['*'];
76
+ this._hasSelect = true;
77
+ return this;
78
+ }
79
+
80
+ selectRaw(sql, params = []) {
81
+ if (!this._hasSelect && this.columns.length === 1 && this.columns[0] === '*') this.columns = [];
82
+ this.columns.push({ raw: String(sql), params: [...params] });
83
+ this._hasSelect = true;
84
+ return this;
85
+ }
86
+
87
+ from(table) {
88
+ this.table = String(table);
89
+ return this;
90
+ }
91
+
92
+ distinct() {
93
+ this.distinctValue = true;
94
+ return this;
95
+ }
96
+
97
+ where(column, operatorOrValue, value) {
98
+ // where(fn) → nested group, mirroring Laravel's closure grouping.
99
+ if (typeof column === 'function') return this.whereGroup(column, 'and');
100
+ return this._whereBasic('and', column, operatorOrValue, value);
101
+ }
102
+ orWhere(column, operatorOrValue, value) {
103
+ if (typeof column === 'function') return this.whereGroup(column, 'or');
104
+ return this._whereBasic('or', column, operatorOrValue, value);
105
+ }
106
+ _whereBasic(boolean, column, operatorOrValue, value) {
107
+ let operator = '=';
108
+ let val = operatorOrValue;
109
+ if (value !== undefined) {
110
+ operator = String(operatorOrValue).toUpperCase();
111
+ val = value;
112
+ if (!ALLOWED_OPS.has(operator)) throw new Error('unsupported where operator: ' + operator);
113
+ }
114
+ // Laravel's where() (Query/Builder.php:988-991): a NULL value is not a
115
+ // comparison - `col = NULL` never matches a row - it is a where-null
116
+ // clause, and '!=' / '<>' make it `IS NOT NULL`. An operator that cannot
117
+ // compare with NULL at all is an illegal combination
118
+ // (InvalidArgumentException).
119
+ if (val === null) {
120
+ // Laravel's invalidOperatorAndValue() (Query/Builder.php:1083-1087): a
121
+ // NULL value with an operator outside ['=', '<=>', '<>', '!='] is an
122
+ // illegal combination (Laravel throws).
123
+ if (operator !== '=' && operator !== '<=>' && operator !== '!=' && operator !== '<>') {
124
+ throw new Error('Illegal operator and value combination.');
125
+ }
126
+ return this._whereNull(boolean, operator === '!=' || operator === '<>' ? 'notNull' : 'null', column);
127
+ }
128
+ this.wheres.push({ type: 'basic', boolean, column: String(column), operator, value: val });
129
+ return this;
130
+ }
131
+
132
+ whereIn(column, values) {
133
+ return this._whereIn('and', column, values);
134
+ }
135
+ orWhereIn(column, values) {
136
+ return this._whereIn('or', column, values);
137
+ }
138
+ whereNotIn(column, values) {
139
+ return this._whereNotIn('and', column, values);
140
+ }
141
+ orWhereNotIn(column, values) {
142
+ return this._whereNotIn('or', column, values);
143
+ }
144
+ _whereIn(boolean, column, values) {
145
+ if (!Array.isArray(values)) throw new Error('whereIn expects an array');
146
+ this.wheres.push({ type: 'in', boolean, column: String(column), values: [...values] });
147
+ return this;
148
+ }
149
+ _whereNotIn(boolean, column, values) {
150
+ if (!Array.isArray(values)) throw new Error('whereNotIn expects an array');
151
+ this.wheres.push({ type: 'notIn', boolean, column: String(column), values: [...values] });
152
+ return this;
153
+ }
154
+
155
+ whereNull(column) {
156
+ return this._whereNull('and', 'null', column);
157
+ }
158
+ orWhereNull(column) {
159
+ return this._whereNull('or', 'null', column);
160
+ }
161
+ whereNotNull(column) {
162
+ return this._whereNull('and', 'notNull', column);
163
+ }
164
+ orWhereNotNull(column) {
165
+ return this._whereNull('or', 'notNull', column);
166
+ }
167
+ _whereNull(boolean, type, column) {
168
+ this.wheres.push({ type, boolean, column: String(column) });
169
+ return this;
170
+ }
171
+
172
+ whereLike(column, value) {
173
+ return this._whereBasic('and', column, 'LIKE', value);
174
+ }
175
+ whereNotLike(column, value) {
176
+ return this._whereBasic('and', column, 'NOT LIKE', value);
177
+ }
178
+
179
+ // Date-part filters. SQLite has no YEAR()/MONTH(); use date()/strftime().
180
+ // date()/time()/strftime() return TEXT, so a bound JS number must be bound as
181
+ // a string: drivers may bind numbers as REAL/INTEGER and `'2024' = 2024.0`
182
+ // never matches (verified against node:sqlite/D1 semantics).
183
+ whereDate(column, operatorOrValue, value) {
184
+ return this._whereDatePart('and', (c) => `date(${quoteIdent(c)})`, column, operatorOrValue, value);
185
+ }
186
+ orWhereDate(column, operatorOrValue, value) {
187
+ return this._whereDatePart('or', (c) => `date(${quoteIdent(c)})`, column, operatorOrValue, value);
188
+ }
189
+ whereYear(column, operatorOrValue, value) {
190
+ return this._whereDatePart('and', (c) => `strftime('%Y', ${quoteIdent(c)})`, column, operatorOrValue, value, 4);
191
+ }
192
+ whereMonth(column, operatorOrValue, value) {
193
+ return this._whereDatePart('and', (c) => `strftime('%m', ${quoteIdent(c)})`, column, operatorOrValue, value, 2);
194
+ }
195
+ whereDay(column, operatorOrValue, value) {
196
+ return this._whereDatePart('and', (c) => `strftime('%d', ${quoteIdent(c)})`, column, operatorOrValue, value, 2);
197
+ }
198
+ whereTime(column, operatorOrValue, value) {
199
+ return this._whereDatePart('and', (c) => `time(${quoteIdent(c)})`, column, operatorOrValue, value);
200
+ }
201
+ _whereDatePart(boolean, fn, column, operatorOrValue, value, pad = 0) {
202
+ const { operator, val } = normalizeOp(operatorOrValue, value);
203
+ // Stringify + zero-pad the bound value: date()/strftime()/time() return
204
+ // TEXT (`%m`/`%d` are zero-padded), and some drivers (node:sqlite; D1
205
+ // historically) bind JS numbers as REAL, which would make CAST(? AS TEXT)
206
+ // yield '2024.0'/'2' and never match '2024'/'02'.
207
+ const bound = pad > 0 && val != null && val !== '' ? String(val).padStart(pad, '0') : val;
208
+ this.wheres.push({ type: 'raw', boolean, sql: `${fn(column)} ${operator} CAST(? AS TEXT)`, params: [bound] });
209
+ return this;
210
+ }
211
+
212
+ whereBetween(column, [min, max]) {
213
+ this.wheres.push({ type: 'between', boolean: 'and', column: String(column), min, max });
214
+ return this;
215
+ }
216
+ whereNotBetween(column, [min, max]) {
217
+ this.wheres.push({ type: 'notBetween', boolean: 'and', column: String(column), min, max });
218
+ return this;
219
+ }
220
+
221
+ whereColumn(first, operator = '=', second) {
222
+ if (second === undefined) { second = operator; operator = '='; }
223
+ const op = String(operator).toUpperCase();
224
+ if (!ALLOWED_JOIN_OPS.has(op)) throw new Error('unsupported whereColumn operator: ' + op);
225
+ this.wheres.push({ type: 'column', boolean: 'and', first: String(first), operator: op, second: String(second) });
226
+ return this;
227
+ }
228
+
229
+ whereRaw(sql, params = []) {
230
+ this.wheres.push({ type: 'raw', boolean: 'and', sql: String(sql), params: [...params] });
231
+ return this;
232
+ }
233
+ orWhereRaw(sql, params = []) {
234
+ this.wheres.push({ type: 'raw', boolean: 'or', sql: String(sql), params: [...params] });
235
+ return this;
236
+ }
237
+
238
+ whereExists(term, boolean = 'and') {
239
+ if (typeof term !== 'function') throw new Error('whereExists expects a function');
240
+ const sub = new QueryBuilder(this.db, this.table);
241
+ term(sub);
242
+ this.wheres.push({ type: 'exists', boolean, query: sub, not: false });
243
+ return this;
244
+ }
245
+ whereNotExists(term, boolean = 'and') {
246
+ if (typeof term !== 'function') throw new Error('whereNotExists expects a function');
247
+ const sub = new QueryBuilder(this.db, this.table);
248
+ term(sub);
249
+ this.wheres.push({ type: 'exists', boolean, query: sub, not: true });
250
+ return this;
251
+ }
252
+ orWhereExists(term) {
253
+ return this.whereExists(term, 'or');
254
+ }
255
+ orWhereNotExists(term) {
256
+ return this.whereNotExists(term, 'or');
257
+ }
258
+
259
+ union(other) {
260
+ if (!(other instanceof QueryBuilder)) throw new Error('union expects a QueryBuilder');
261
+ this._unions.push({ query: other, all: false });
262
+ return this;
263
+ }
264
+ unionAll(other) {
265
+ if (!(other instanceof QueryBuilder)) throw new Error('unionAll expects a QueryBuilder');
266
+ this._unions.push({ query: other, all: true });
267
+ return this;
268
+ }
269
+
270
+ whereGroup(cb, boolean = 'and') {
271
+ const sub = new QueryBuilder(this.db, this.table);
272
+ cb(sub);
273
+ this.wheres.push({ type: 'group', boolean, wheres: sub.wheres });
274
+ return this;
275
+ }
276
+ orWhereGroup(cb) {
277
+ return this.whereGroup(cb, 'or');
278
+ }
279
+
280
+ join(table, first, operator, second) {
281
+ return this._join('INNER', table, first, operator, second);
282
+ }
283
+ innerJoin(table, first, operator, second) {
284
+ return this._join('INNER', table, first, operator, second);
285
+ }
286
+ leftJoin(table, first, operator, second) {
287
+ return this._join('LEFT', table, first, operator, second);
288
+ }
289
+ rightJoin(table, first, operator, second) {
290
+ return this._join('RIGHT', table, first, operator, second);
291
+ }
292
+ _join(kind, table, first, operator, second) {
293
+ const op = String(operator).toUpperCase();
294
+ if (!ALLOWED_JOIN_OPS.has(op)) throw new Error('unsupported join operator: ' + op);
295
+ this.joins.push({ kind, table: String(table), first: String(first), operator: op, second: String(second) });
296
+ return this;
297
+ }
298
+
299
+ groupBy(...columns) {
300
+ for (const c of columns) {
301
+ if (Array.isArray(c)) this.groups.push(...c.map(String));
302
+ else this.groups.push(String(c));
303
+ }
304
+ return this;
305
+ }
306
+
307
+ having(column, operatorOrValue, value) {
308
+ let operator = '=';
309
+ let val = operatorOrValue;
310
+ if (value !== undefined) {
311
+ operator = String(operatorOrValue).toUpperCase();
312
+ val = value;
313
+ if (!ALLOWED_OPS.has(operator)) throw new Error('unsupported having operator: ' + operator);
314
+ }
315
+ this.havings.push({ type: 'basic', boolean: 'and', column: String(column), operator, value: val });
316
+ return this;
317
+ }
318
+ havingRaw(sql, params = []) {
319
+ this.havings.push({ type: 'raw', boolean: 'and', sql: String(sql), params: [...params] });
320
+ return this;
321
+ }
322
+
323
+ orderBy(column, direction = 'asc') {
324
+ // Laravel's orderBy (Query/Builder.php:3042-3062): the direction must be
325
+ // 'asc' or 'desc' - anything else is an InvalidArgumentException (a 500
326
+ // there). The edge used to answer ANY direction as ASC: a different row
327
+ // order, silently.
328
+ const dir = String(direction).toLowerCase();
329
+ if (dir !== 'asc' && dir !== 'desc') {
330
+ throw new Error('Order direction must be "asc" or "desc".');
331
+ }
332
+ this.order.push({ column: String(column), direction: dir.toUpperCase() });
333
+ return this;
334
+ }
335
+ orderByDesc(column) { return this.orderBy(column, 'desc'); }
336
+ latest(column = 'created_at') { return this.orderByDesc(column); }
337
+ oldest(column = 'created_at') { return this.orderBy(column, 'asc'); }
338
+
339
+ limit(n) {
340
+ this.limitValue = Number(n);
341
+ return this;
342
+ }
343
+ offset(n) {
344
+ this.offsetValue = Number(n);
345
+ return this;
346
+ }
347
+
348
+ asModel(model) {
349
+ this.model = model;
350
+ return this;
351
+ }
352
+
353
+ clone() {
354
+ const c = new QueryBuilder(this.db, this.table);
355
+ c.columns = this.columns.map((col) => (isPlainObject(col) ? { raw: col.raw, params: [...col.params] } : col));
356
+ c.wheres = cloneWheres(this.wheres);
357
+ c.joins = this.joins.map((j) => ({ ...j }));
358
+ c.groups = [...this.groups];
359
+ c.havings = cloneWheres(this.havings);
360
+ c.order = this.order.map((o) => ({ ...o }));
361
+ c.distinctValue = this.distinctValue;
362
+ c.limitValue = this.limitValue;
363
+ c.offsetValue = this.offsetValue;
364
+ c.model = this.model;
365
+ c._hasSelect = this._hasSelect;
366
+ c._unions = (this._unions || []).map((u) => ({ query: u.query.clone(), all: u.all }));
367
+ return c;
368
+ }
369
+
370
+ _whereSql(params) {
371
+ return renderClauses(this.wheres, params);
372
+ }
373
+
374
+ _havingSql(params) {
375
+ return renderClauses(this.havings, params, 'HAVING');
376
+ }
377
+
378
+ _joinSql() {
379
+ if (!this.joins.length) return '';
380
+ return this.joins
381
+ .map((j) => ` ${j.kind} JOIN ${quoteIdent(j.table)} ON ${quoteIdent(j.first)} ${j.operator} ${quoteIdent(j.second)}`)
382
+ .join('');
383
+ }
384
+
385
+ _columnsSql(params) {
386
+ const cols = [];
387
+ for (const c of this.columns) {
388
+ if (isPlainObject(c)) {
389
+ for (const p of c.params) params.push(p);
390
+ cols.push(c.raw);
391
+ } else if (c === '*') {
392
+ cols.push('*');
393
+ } else {
394
+ cols.push(quoteIdent(c));
395
+ }
396
+ }
397
+ return cols.join(', ');
398
+ }
399
+
400
+ _groupSql() {
401
+ if (!this.groups.length) return '';
402
+ return ' GROUP BY ' + this.groups.map(quoteIdent).join(', ');
403
+ }
404
+
405
+ _tailSql(params) {
406
+ let sql = '';
407
+ if (this.order.length) {
408
+ sql += ' ORDER BY ' + this.order.map((o) => `${quoteIdent(o.column)} ${o.direction}`).join(', ');
409
+ }
410
+ if (this.limitValue != null) sql += ' LIMIT ?';
411
+ if (this.offsetValue != null) sql += ' OFFSET ?';
412
+ if (this.limitValue != null) params.push(this.limitValue);
413
+ if (this.offsetValue != null) params.push(this.offsetValue);
414
+ return sql;
415
+ }
416
+
417
+ toSql() {
418
+ const params = [];
419
+ const cols = this._columnsSql(params);
420
+ let sql = `SELECT ${this.distinctValue ? 'DISTINCT ' : ''}${cols} FROM ${quoteIdent(this.table)}`;
421
+ sql += this._joinSql();
422
+ sql += renderClauses(this.wheres, params);
423
+ sql += this._groupSql();
424
+ sql += renderClauses(this.havings, params, 'HAVING');
425
+ sql += this._tailSql(params);
426
+ if (this._unions && this._unions.length) {
427
+ const parts = ['(' + sql + ')'];
428
+ for (const u of this._unions) {
429
+ const sub = u.query.toSql();
430
+ params.push(...sub.params);
431
+ parts.push((u.all ? ' UNION ALL ' : ' UNION ') + '(' + sub.sql + ')');
432
+ }
433
+ sql = parts.join('');
434
+ }
435
+ return { sql, params };
436
+ }
437
+
438
+ _prepare(sql, params) {
439
+ const stmt = this.db.prepare(sql);
440
+ const bound = params.map(bindValue);
441
+ return bound.length ? stmt.bind(...bound) : stmt;
442
+ }
443
+
444
+ async get() {
445
+ const { sql, params } = this.toSql();
446
+ const stmt = this._prepare(sql, params);
447
+ const res = await stmt.all();
448
+ const rows = (res && Array.isArray(res.results)) ? res.results : [];
449
+ if (this.model) return rows.map((row) => new this.model(row));
450
+ return rows;
451
+ }
452
+
453
+ async first() {
454
+ const rows = await this.clone().limit(1).get();
455
+ return rows.length ? rows[0] : null;
456
+ }
457
+
458
+ async count(column = '*') {
459
+ const params = [];
460
+ const agg = column === '*' ? '*' : quoteIdent(column);
461
+ let sql = `SELECT COUNT(${agg}) AS "aggregate" FROM ${quoteIdent(this.table)}`;
462
+ sql += this._joinSql();
463
+ sql += renderClauses(this.wheres, params);
464
+ sql += this._groupSql();
465
+ sql += renderClauses(this.havings, params, 'HAVING');
466
+ const stmt = this._prepare(sql, params);
467
+ const res = await stmt.first();
468
+ const row = res || {};
469
+ return Number(row.aggregate ?? row['COUNT(*)'] ?? 0);
470
+ }
471
+
472
+ async _aggregate(fn, column) {
473
+ const params = [];
474
+ const agg = column === undefined || column === '*' ? '*' : quoteIdent(column);
475
+ let sql = `SELECT ${fn}(${agg}) AS "aggregate" FROM ${quoteIdent(this.table)}`;
476
+ sql += this._joinSql();
477
+ sql += renderClauses(this.wheres, params);
478
+ sql += this._groupSql();
479
+ sql += renderClauses(this.havings, params, 'HAVING');
480
+ const stmt = this._prepare(sql, params);
481
+ const res = await stmt.first();
482
+ const row = res || {};
483
+ return row.aggregate == null ? null : Number(row.aggregate);
484
+ }
485
+
486
+ async sum(column) {
487
+ // Laravel's sum() (Query/Builder.php:4099-4104): `$result = $this->aggregate(
488
+ // __FUNCTION__, [$column]); return $result ?: 0;` - an empty set (or an
489
+ // all-NULL column) answers 0, not null (the JSON body differed).
490
+ const value = await this._aggregate('SUM', column);
491
+ return value == null ? 0 : value;
492
+ }
493
+ async avg(column) { return this._aggregate('AVG', column); }
494
+ async min(column) { return this._aggregate('MIN', column); }
495
+ async max(column) { return this._aggregate('MAX', column); }
496
+
497
+ async exists() {
498
+ const params = [];
499
+ let sql = `SELECT 1 AS "e" FROM ${quoteIdent(this.table)}`;
500
+ sql += this._joinSql();
501
+ sql += renderClauses(this.wheres, params);
502
+ sql += this._groupSql();
503
+ sql += renderClauses(this.havings, params, 'HAVING');
504
+ sql += ' LIMIT 1';
505
+ const stmt = this._prepare(sql, params);
506
+ const res = await stmt.first();
507
+ return !!res;
508
+ }
509
+
510
+ async value(column) {
511
+ const b = this.clone().select(String(column));
512
+ const { sql, params } = b.limit(1).toSql();
513
+ const stmt = this._prepare(sql, params);
514
+ const row = await stmt.first();
515
+ if (!row) return null;
516
+ if (this.model && typeof row.get === 'function') return row.get(column);
517
+ return row[column];
518
+ }
519
+
520
+ async pluck(column) {
521
+ const rows = await this.clone().select(String(column)).get();
522
+ return rows.map((r) => (this.model && typeof r.get === 'function' ? r.get(column) : r[column]));
523
+ }
524
+
525
+ async insert(data) {
526
+ if (!isPlainObject(data) || !Object.keys(data).length) throw new Error('insert expects a non-empty object');
527
+ const columns = Object.keys(data);
528
+ const sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.map(quoteIdent).join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
529
+ const stmt = this._prepare(sql, Object.values(data));
530
+ const res = await stmt.run();
531
+ return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, lastInsertRowid: res?.meta?.last_row_id ?? null, meta: res?.meta ?? {} };
532
+ }
533
+
534
+ async update(data) {
535
+ if (!isPlainObject(data) || !Object.keys(data).length) throw new Error('update expects a non-empty object');
536
+ const columns = Object.keys(data);
537
+ const params = Object.values(data);
538
+ let sql = `UPDATE ${quoteIdent(this.table)} SET ${columns.map((c) => `${quoteIdent(c)} = ?`).join(', ')}`;
539
+ sql += renderClauses(this.wheres, params);
540
+ const stmt = this._prepare(sql, params);
541
+ const res = await stmt.run();
542
+ return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, meta: res?.meta ?? {} };
543
+ }
544
+
545
+ async delete() {
546
+ const params = [];
547
+ let sql = `DELETE FROM ${quoteIdent(this.table)}`;
548
+ sql += renderClauses(this.wheres, params);
549
+ const stmt = this._prepare(sql, params);
550
+ const res = await stmt.run();
551
+ return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, meta: res?.meta ?? {} };
552
+ }
553
+
554
+ async truncate() {
555
+ const stmt = this.db.prepare(`DELETE FROM ${quoteIdent(this.table)}`);
556
+ const res = await stmt.run();
557
+ return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, meta: res?.meta ?? {} };
558
+ }
559
+
560
+ async insertGetId(data) {
561
+ const res = await this.insert(data);
562
+ return res.lastInsertRowid;
563
+ }
564
+
565
+ // SQLite/D1 upsert: multi-row INSERT ... ON CONFLICT (unique) DO UPDATE|NOTHING.
566
+ async upsert(rows, uniqueBy, updateColumns) {
567
+ const list = Array.isArray(rows) ? rows : [rows];
568
+ if (!list.length) throw new Error('upsert expects at least one row');
569
+ const columns = Object.keys(list[0]);
570
+ const unique = Array.isArray(uniqueBy) ? uniqueBy.map(String) : [String(uniqueBy)];
571
+ const update = (updateColumns == null ? [] : (Array.isArray(updateColumns) ? updateColumns : [updateColumns]))
572
+ .map(String)
573
+ .filter((c) => !unique.includes(c));
574
+ const params = [];
575
+ const tuples = list.map((row) => '(' + columns.map((c) => { params.push(row[c] ?? null); return '?'; }).join(', ') + ')');
576
+ let sql = `INSERT INTO ${quoteIdent(this.table)} (${columns.map(quoteIdent).join(', ')}) VALUES ${tuples.join(', ')}`;
577
+ sql += ` ON CONFLICT (${unique.map(quoteIdent).join(', ')}) `;
578
+ if (update.length) sql += `DO UPDATE SET ${update.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`).join(', ')}`;
579
+ else sql += 'DO NOTHING';
580
+ const stmt = this._prepare(sql, params);
581
+ const res = await stmt.run();
582
+ return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, meta: res?.meta ?? {} };
583
+ }
584
+
585
+ async increment(column, amount = 1) {
586
+ const params = [Number(amount)];
587
+ let sql = `UPDATE ${quoteIdent(this.table)} SET ${quoteIdent(column)} = ${quoteIdent(column)} + ?`;
588
+ sql += renderClauses(this.wheres, params);
589
+ const stmt = this._prepare(sql, params);
590
+ const res = await stmt.run();
591
+ return { success: !!(res && res.success), changes: res?.meta?.changes ?? 0, meta: res?.meta ?? {} };
592
+ }
593
+
594
+ async decrement(column, amount = 1) {
595
+ return this.increment(column, -Number(amount));
596
+ }
597
+
598
+ async updateOrInsert(where, values) {
599
+ const q = new QueryBuilder(this.db, this.table);
600
+ for (const [k, v] of Object.entries(where || {})) q.where(k, v);
601
+ const existing = await q.clone().first();
602
+ if (existing) return q.update(values);
603
+ return this.insert({ ...(where || {}), ...(values || {}) });
604
+ }
605
+
606
+ async chunk(size, cb) {
607
+ const n = Math.max(1, Number(size) || 1);
608
+ for (let page = 1; ; page++) {
609
+ const rows = await this.clone().limit(n).offset((page - 1) * n).get();
610
+ if (!rows.length) return;
611
+ const stop = await cb(rows, page);
612
+ if (rows.length < n || stop === false) return;
613
+ }
614
+ }
615
+
616
+ async paginate(perPage = 15, page = 1) {
617
+ const p = Math.max(1, Number(page) || 1);
618
+ const size = Math.max(1, Number(perPage) || 15);
619
+ const total = await this.count();
620
+ const data = await this.clone().limit(size).offset((p - 1) * size).get();
621
+ return {
622
+ data,
623
+ total,
624
+ per_page: size,
625
+ current_page: p,
626
+ last_page: Math.max(1, Math.ceil(total / size)),
627
+ };
628
+ }
629
+ }
630
+
631
+ function cloneWheres(wheres) {
632
+ return wheres.map((w) => {
633
+ const copy = { ...w };
634
+ if (w.values) copy.values = [...w.values];
635
+ if (w.params) copy.params = [...w.params];
636
+ if (w.wheres) copy.wheres = cloneWheres(w.wheres);
637
+ if (w.type === 'exists' && w.query && typeof w.query.clone === 'function') copy.query = w.query.clone();
638
+ return copy;
639
+ });
640
+ }
641
+
642
+ // Render a list of where-like clauses with AND/OR booleans. Flat list → the
643
+ // first clause has no connector, the rest are prefixed by their boolean.
644
+ function renderClauses(clauses, params, keyword = 'WHERE') {
645
+ if (!clauses.length) return '';
646
+ const parts = clauses.map((w) => {
647
+ let chunk;
648
+ if (w.type === 'basic') {
649
+ params.push(w.value);
650
+ chunk = `${quoteIdent(w.column)} ${w.operator} ?`;
651
+ } else if (w.type === 'in') {
652
+ if (!w.values.length) chunk = '1 = 0';
653
+ else {
654
+ for (const v of w.values) params.push(v);
655
+ chunk = `${quoteIdent(w.column)} IN (${w.values.map(() => '?').join(', ')})`;
656
+ }
657
+ } else if (w.type === 'notIn') {
658
+ if (!w.values.length) chunk = '1 = 1';
659
+ else {
660
+ for (const v of w.values) params.push(v);
661
+ chunk = `${quoteIdent(w.column)} NOT IN (${w.values.map(() => '?').join(', ')})`;
662
+ }
663
+ } else if (w.type === 'null') {
664
+ chunk = `${quoteIdent(w.column)} IS NULL`;
665
+ } else if (w.type === 'notNull') {
666
+ chunk = `${quoteIdent(w.column)} IS NOT NULL`;
667
+ } else if (w.type === 'between') {
668
+ params.push(w.min, w.max);
669
+ chunk = `${quoteIdent(w.column)} BETWEEN ? AND ?`;
670
+ } else if (w.type === 'notBetween') {
671
+ params.push(w.min, w.max);
672
+ chunk = `${quoteIdent(w.column)} NOT BETWEEN ? AND ?`;
673
+ } else if (w.type === 'column') {
674
+ chunk = `${quoteIdent(w.first)} ${w.operator} ${quoteIdent(w.second)}`;
675
+ } else if (w.type === 'raw') {
676
+ for (const p of w.params) params.push(p);
677
+ chunk = w.sql;
678
+ } else if (w.type === 'group') {
679
+ const inner = renderClauses(w.wheres, params);
680
+ chunk = '(' + inner.replace(/^ (WHERE|HAVING) /, '').replace(/^ (AND|OR) /, '') + ')';
681
+ } else if (w.type === 'exists') {
682
+ const sub = w.query.toSql();
683
+ for (const p2 of sub.params) params.push(p2);
684
+ chunk = (w.not ? 'NOT EXISTS (' : 'EXISTS (') + sub.sql + ')';
685
+ } else {
686
+ throw new Error('unknown where type: ' + w.type);
687
+ }
688
+ return { boolean: w.boolean || 'and', chunk };
689
+ });
690
+ let sql = '';
691
+ parts.forEach((p, i) => {
692
+ if (i === 0) sql += p.chunk;
693
+ else sql += ' ' + p.boolean.toUpperCase() + ' ' + p.chunk;
694
+ });
695
+ return ' ' + keyword + ' ' + sql;
696
+ }
697
+
698
+ export function table(db, name) {
699
+ return new QueryBuilder(db, name);
700
+ }
701
+
702
+ export const DB = { table };
703
+ export default { QueryBuilder, table, DB };