@zerotal/orm 1.0.0

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 (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +170 -0
  4. package/package.json +58 -0
  5. package/src/casts/Cast.ts +200 -0
  6. package/src/commands/DbSeedCommand.ts +71 -0
  7. package/src/commands/MakeFactoryCommand.ts +59 -0
  8. package/src/commands/MakeMigrationCommand.ts +109 -0
  9. package/src/commands/MakeModelCommand.ts +83 -0
  10. package/src/commands/MakeSeederCommand.ts +50 -0
  11. package/src/commands/MigrateCommand.ts +60 -0
  12. package/src/commands/MigrateFreshCommand.ts +41 -0
  13. package/src/commands/MigrateGenerateCommand.ts +110 -0
  14. package/src/commands/MigrateRollbackCommand.ts +43 -0
  15. package/src/commands/MigrateStatusCommand.ts +49 -0
  16. package/src/commands/_loadMigrations.ts +34 -0
  17. package/src/commands/index.ts +30 -0
  18. package/src/config.ts +182 -0
  19. package/src/conventions.ts +67 -0
  20. package/src/db/DB.ts +486 -0
  21. package/src/db/NPlusOneDetector.ts +176 -0
  22. package/src/db/QueryBuilder.ts +2458 -0
  23. package/src/db/ReadWriteRouter.ts +96 -0
  24. package/src/db/TransactionContext.ts +13 -0
  25. package/src/db/dialects/MysqlDialect.ts +57 -0
  26. package/src/db/dialects/PostgresDialect.ts +55 -0
  27. package/src/db/dialects/SqliteDialect.ts +54 -0
  28. package/src/db/dialects/index.ts +25 -0
  29. package/src/db/dialects/types.ts +67 -0
  30. package/src/db/resolver.ts +30 -0
  31. package/src/db/sql-types.ts +12 -0
  32. package/src/db/types.ts +296 -0
  33. package/src/errors/MassAssignmentError.ts +25 -0
  34. package/src/errors/MigrationError.ts +18 -0
  35. package/src/errors/ModelNotFoundError.ts +21 -0
  36. package/src/errors/NPlusOneError.ts +6 -0
  37. package/src/errors/RelationNotLoadedError.ts +19 -0
  38. package/src/errors/StateError.ts +18 -0
  39. package/src/errors/TransactionError.ts +13 -0
  40. package/src/errors/UnsupportedDialectError.ts +18 -0
  41. package/src/errors/index.ts +7 -0
  42. package/src/events.ts +112 -0
  43. package/src/global.d.ts +17 -0
  44. package/src/implicitBinding.ts +73 -0
  45. package/src/index.ts +255 -0
  46. package/src/model/BaseModel.ts +2499 -0
  47. package/src/model/ModelQueryBuilder.ts +1808 -0
  48. package/src/model/Observer.ts +73 -0
  49. package/src/model/OrmContext.ts +71 -0
  50. package/src/model/ReactiveProxy.ts +53 -0
  51. package/src/model/SoftDeletes.ts +108 -0
  52. package/src/model/State.ts +290 -0
  53. package/src/model/decorators/_metadata.ts +211 -0
  54. package/src/model/decorators/_registerRelation.ts +20 -0
  55. package/src/model/decorators/belongsTo.ts +38 -0
  56. package/src/model/decorators/column.ts +278 -0
  57. package/src/model/decorators/hasMany.ts +34 -0
  58. package/src/model/decorators/hasManyThrough.ts +50 -0
  59. package/src/model/decorators/hasOne.ts +34 -0
  60. package/src/model/decorators/hasOneThrough.ts +40 -0
  61. package/src/model/decorators/manyToMany.ts +55 -0
  62. package/src/model/decorators/morphMany.ts +38 -0
  63. package/src/model/decorators/morphOne.ts +38 -0
  64. package/src/model/decorators/morphTo.ts +51 -0
  65. package/src/model/decorators/morphToMany.ts +49 -0
  66. package/src/model/decorators/morphedByMany.ts +46 -0
  67. package/src/model/decorators/table.ts +124 -0
  68. package/src/model/hooks/HookRegistry.ts +110 -0
  69. package/src/model/mixins.ts +536 -0
  70. package/src/model/payload.ts +114 -0
  71. package/src/model/relations/RelationRegistry.ts +184 -0
  72. package/src/observability.ts +210 -0
  73. package/src/provider/DatabaseProvider.ts +266 -0
  74. package/src/schema/Blueprint.ts +900 -0
  75. package/src/schema/ColumnDefinition.ts +517 -0
  76. package/src/schema/Migration.ts +34 -0
  77. package/src/schema/MigrationCodegen.ts +108 -0
  78. package/src/schema/MigrationRunner.ts +351 -0
  79. package/src/schema/ModelInspector.ts +133 -0
  80. package/src/schema/Schema.ts +140 -0
  81. package/src/schema/SchemaDiffer.ts +137 -0
  82. package/src/schema/SchemaInspector.ts +164 -0
  83. package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
  84. package/src/schema/autoMigrate.ts +154 -0
  85. package/src/schema/index.ts +28 -0
  86. package/src/seeding/Seeder.ts +46 -0
  87. package/src/support/identifiers.ts +62 -0
@@ -0,0 +1,2458 @@
1
+ import type { SQLInstance } from "./sql-types.ts";
2
+ import { RequestContext, rescueSync, FrameworkEvents, currentPage } from "@zerotal/core";
3
+ import { Carbon } from "@zerotal/core/carbon";
4
+ import { toCamelKey } from "../support/identifiers.ts";
5
+ import { QueryExecuted } from "../events.ts";
6
+ import { trackQuery } from "./NPlusOneDetector.ts";
7
+ import { TransactionContext } from "./TransactionContext.ts";
8
+ import { getDialect } from "./dialects/index.ts";
9
+ import type {
10
+ WhereOperator,
11
+ OrderDirection,
12
+ WhereClause,
13
+ HavingClause,
14
+ QueryState,
15
+ JoinType,
16
+ PaginateResult,
17
+ CursorPaginateResult,
18
+ KeysetOptions,
19
+ KeysetPaginateResult,
20
+ SimplePaginateResult,
21
+ } from "./types.ts";
22
+ import { withPaginationHelpers, withSimplePaginationHelpers } from "./types.ts";
23
+
24
+ /**
25
+ * The comparison operators accepted in a value-comparison slot. Exported so
26
+ * {@link ModelQueryBuilder} shares the exact same set — both classes use
27
+ * membership here as the 2-arg-vs-3-arg dispatch heuristic in `where()`, and
28
+ * two drifting copies would make base and subclass disagree about whether
29
+ * argument two is an operator or a value.
30
+ */
31
+ export const OPERATORS = new Set<string>([
32
+ "=",
33
+ "!=",
34
+ ">",
35
+ ">=",
36
+ "<",
37
+ "<=",
38
+ "like",
39
+ "not like",
40
+ "in",
41
+ "not in",
42
+ ]);
43
+ const COLUMN_OPERATORS = new Set<string>(["=", "!=", ">", ">=", "<", "<=", "<>"]);
44
+
45
+ /**
46
+ * Reject an operator that is not on the allowlist.
47
+ *
48
+ * An operator slot is interpolated into SQL, not bound, so it is an identifier-class input
49
+ * and gets identifier-class treatment: allowlist, never escaping. A slot that skips this is
50
+ * a full injection point even though the binding count stays correct — the 2026-07 audit
51
+ * found `whereDate('created_at', req.query.op, …)` accepting
52
+ * `"IS NOT NULL OR 1=1 OR date(created_at) ="`, which escapes the AND chain and defeats
53
+ * tenant scoping, ownership filters and the soft-delete scope in one string.
54
+ *
55
+ * @param op - Candidate operator.
56
+ * @param fn - Calling method name, for the error message.
57
+ * @throws {Error} When `op` is not a known comparison operator.
58
+ */
59
+ function _assertOperator(op: string, fn: string): void {
60
+ if (!OPERATORS.has(op) && !COLUMN_OPERATORS.has(op)) {
61
+ throw new Error(`[Zerotal ORM] ${fn}: unsupported operator "${op}".`);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Sort directions that may be interpolated into `ORDER BY`.
67
+ * The compile-time `"asc" | "desc"` type is not a runtime check — a direction arriving from
68
+ * a query string is just a string, and `orderBy("name", "desc; DROP TABLE users --")`
69
+ * compiled to exactly that.
70
+ */
71
+ const SORT_DIRECTIONS = new Set<string>(["asc", "desc"]);
72
+
73
+ /**
74
+ * Normalise and validate a sort direction.
75
+ *
76
+ * @param direction - Candidate direction, any casing.
77
+ * @param fn - Calling method name, for the error message.
78
+ * @returns The lowercased direction.
79
+ * @throws {Error} When `direction` is not `asc` or `desc`.
80
+ */
81
+ function _assertDirection(direction: string, fn: string): "asc" | "desc" {
82
+ const normalised = direction.toLowerCase();
83
+ if (!SORT_DIRECTIONS.has(normalised)) {
84
+ throw new Error(
85
+ `[Zerotal ORM] ${fn}: direction must be "asc" or "desc", received "${direction}".`,
86
+ );
87
+ }
88
+ return normalised as "asc" | "desc";
89
+ }
90
+
91
+ /**
92
+ * A compiled query fragment: either a literal SQL string, or `{ val }` marking
93
+ * a value to be sent through a parameterised binding (`?` placeholder).
94
+ */
95
+ export type Segment = string | { val: unknown };
96
+
97
+ // ── Dialect awareness ─────────────────────────────────────────────────────────
98
+ // QueryBuilder is dialect-light, but a few features differ across engines:
99
+ // - pessimistic row locks (FOR UPDATE / FOR SHARE) are unsupported on SQLite
100
+ // - random ordering is RAND() on MySQL, RANDOM() elsewhere
101
+ // The active dialect is set once at boot (DatabaseProvider / _setBaseModelDialect).
102
+
103
+ /** The database engines the builder can target. */
104
+ export type Dialect = "sqlite" | "postgres" | "mysql";
105
+
106
+ /** Global default dialect — used for connections not explicitly registered. */
107
+ let _dialect: Dialect = "sqlite";
108
+
109
+ /** @internal Set the global default dialect (lock + random-order SQL). */
110
+ export function _setQueryBuilderDialect(d: Dialect): void {
111
+ _dialect = d;
112
+ }
113
+
114
+ const _connectionDialects = new WeakMap<object, Dialect>();
115
+
116
+ /**
117
+ * Associate a specific connection object with a dialect, overriding the global
118
+ * default for queries run on that connection.
119
+ */
120
+ export function registerConnectionDialect(conn: object, d: Dialect): void {
121
+ _connectionDialects.set(conn, d);
122
+ }
123
+
124
+ /**
125
+ * Resolve the dialect for a connection — its registered dialect if any,
126
+ * otherwise the global default.
127
+ */
128
+ export function dialectFor(conn: object | undefined | null): Dialect {
129
+ return (conn ? _connectionDialects.get(conn) : undefined) ?? _dialect;
130
+ }
131
+
132
+ // ── Prepared-statement cache ──────────────────────────────────────────────────
133
+ //
134
+ // Bun.sql identifies prepared statements by the *object identity* of the
135
+ // TemplateStringsArray passed to the tagged-template call. Generating a fresh
136
+ // array on every query (via Object.assign) defeats that cache entirely.
137
+ //
138
+ // This Map interns arrays by their joined SQL fragments so identical query
139
+ // shapes reuse the same object reference, giving Bun.sql a 100% cache-hit rate
140
+ // for any query shape seen more than once.
141
+ const _tplCache = new Map<string, TemplateStringsArray>();
142
+
143
+ // Upper bound on interned template shapes. Most apps produce a small, fixed
144
+ // set of query shapes, but `whereIn()` with varying list lengths mints a new
145
+ // shape per cardinality — without a cap the map grows for the process
146
+ // lifetime. On overflow the oldest entry is evicted (Map preserves insertion
147
+ // order), keeping the hot path a single Map lookup.
148
+ const _TPL_CACHE_MAX = 500;
149
+
150
+ const _SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
151
+ // having() additionally accepts a simple aggregate wrapper, e.g. SUM(score) or COUNT(*).
152
+ const _SAFE_AGGREGATE = /^[a-zA-Z_][a-zA-Z0-9_]*\(\s*(?:\*|[a-zA-Z_][a-zA-Z0-9_.]*)\s*\)$/;
153
+
154
+ function _isSafeIdentifier(s: string): boolean {
155
+ return _SAFE_IDENTIFIER.test(s);
156
+ }
157
+
158
+ /**
159
+ * @internal Throw unless `s` is a safe (optionally dotted) SQL identifier.
160
+ * Every identifier interpolated into SQL — column names, table names — must
161
+ * pass through here; values always flow through tagged-template bindings.
162
+ * Raw expressions belong in the documented escape hatches (selectRaw,
163
+ * whereRaw, orderByRaw).
164
+ */
165
+ export function _assertIdentifier(s: string, where: string): void {
166
+ if (!_isSafeIdentifier(s)) {
167
+ throw new Error(
168
+ `[Zerotal ORM] ${where}: unsafe identifier "${s}". Must match /^[a-zA-Z_][a-zA-Z0-9_.]*$/.`,
169
+ );
170
+ }
171
+ }
172
+
173
+ /** Like _assertIdentifier, but also allows a simple aggregate such as SUM(score). */
174
+ function _assertIdentifierOrAggregate(s: string, where: string): void {
175
+ if (_SAFE_IDENTIFIER.test(s) || _SAFE_AGGREGATE.test(s)) return;
176
+ throw new Error(
177
+ `[Zerotal ORM] ${where}: unsafe column expression "${s}". ` +
178
+ `Expected an identifier or a simple aggregate like SUM(score).`,
179
+ );
180
+ }
181
+
182
+ // A single SELECT-list entry: a column (`name`, `users.id`), a star (`*`,
183
+ // `users.*`), or a simple aggregate (`COUNT(*)`, `SUM(score)`) — each with an
184
+ // optional `[AS] alias`. Deliberately rejects subqueries, commas, and operators
185
+ // so nothing but selectRaw() can smuggle raw SQL into the projection.
186
+ const _SAFE_SELECT_EXPRESSION =
187
+ /^(?:\*|[a-zA-Z_][a-zA-Z0-9_]*\.\*|[a-zA-Z_][a-zA-Z0-9_]*\(\s*(?:\*|[a-zA-Z_][a-zA-Z0-9_.]*)\s*\)|[a-zA-Z_][a-zA-Z0-9_.]*)(?:\s+(?:as\s+)?[a-zA-Z_][a-zA-Z0-9_]*)?$/i;
188
+
189
+ /**
190
+ * @internal Throw unless `s` is a safe SELECT-list entry. Guards the projection
191
+ * (select/pluck/value) the same way {@link _assertIdentifier} guards where/order
192
+ * columns. Raw projection SQL belongs in the `selectRaw()` escape hatch.
193
+ */
194
+ function _assertSelectExpression(s: string, where: string): void {
195
+ if (!_SAFE_SELECT_EXPRESSION.test(s)) {
196
+ throw new Error(
197
+ `[Zerotal ORM] ${where}: unsafe select expression "${s}". ` +
198
+ `Expected a column, table.*, or a simple aggregate; use selectRaw() for raw SQL.`,
199
+ );
200
+ }
201
+ }
202
+
203
+ function _getCachedTemplate(strings: string[]): TemplateStringsArray {
204
+ const key = strings.join("\x00");
205
+ let tpl = _tplCache.get(key);
206
+ if (!tpl) {
207
+ const arr = [...strings];
208
+ tpl = Object.assign(arr, { raw: arr }) as TemplateStringsArray;
209
+ if (_tplCache.size >= _TPL_CACHE_MAX) {
210
+ // FIFO eviction: drop the oldest interned shape.
211
+ const oldest = _tplCache.keys().next().value;
212
+ if (oldest !== undefined) _tplCache.delete(oldest);
213
+ }
214
+ _tplCache.set(key, tpl);
215
+ }
216
+ return tpl;
217
+ }
218
+
219
+ /**
220
+ * @internal Execute compiled segments on `conn` with prepared-template
221
+ * interning and QueryExecuted telemetry. Shared by `QueryBuilder._run` and
222
+ * the BaseModel write paths so every query — builder reads and model writes
223
+ * alike — emits the same events.
224
+ */
225
+ export async function _runSegments<T = Record<string, unknown>>(
226
+ conn: SQLInstance,
227
+ segs: Segment[],
228
+ trackNPlusOne = false,
229
+ ): Promise<T[]> {
230
+ const strings: string[] = [];
231
+ const values: unknown[] = [];
232
+ let current = "";
233
+
234
+ for (const seg of segs) {
235
+ if (typeof seg === "string") {
236
+ current += seg;
237
+ } else {
238
+ strings.push(current);
239
+ current = "";
240
+ values.push(seg.val);
241
+ }
242
+ }
243
+ strings.push(current);
244
+
245
+ const cacheKey = strings.join("\x00");
246
+ const tpl = _getCachedTemplate(strings);
247
+ const ctx = RequestContext.tryGet();
248
+ if (trackNPlusOne) trackQuery(ctx, cacheKey);
249
+
250
+ const startMs = Date.now();
251
+ const rows = await conn<T>(tpl, ...values);
252
+ const durationMs = Date.now() - startMs;
253
+ FrameworkEvents.emit(
254
+ new QueryExecuted(
255
+ cacheKey.replace(/\x00/g, "?"),
256
+ values,
257
+ startMs,
258
+ durationMs,
259
+ Array.isArray(rows) ? rows.length : 0,
260
+ ctx,
261
+ ),
262
+ );
263
+ return rows;
264
+ }
265
+
266
+ /**
267
+ * Fluent, low-level SQL query builder over a {@link SQLInstance} (Bun.sql).
268
+ *
269
+ * Chain methods to describe a query, then call a terminal ({@link get},
270
+ * {@link first}, {@link count}, {@link insert}, {@link update}, {@link delete}, …)
271
+ * to compile and execute it. Every builder method returns `this`, so calls
272
+ * chain. This is the engine beneath `DB.table()` and the model query builder;
273
+ * most application code reaches it through those, but it can be used directly.
274
+ *
275
+ * @remarks
276
+ * **Safety model.** User-supplied *values* always flow through Bun.sql
277
+ * tagged-template bindings (`?` placeholders) — they are never string-concatenated
278
+ * into SQL. User-supplied *identifiers* (column, table, alias, group/order
279
+ * columns) are interpolated into the SQL text and are therefore forced through an
280
+ * identifier-assertion allowlist (`/^[a-zA-Z_][a-zA-Z0-9_.]*$/`, plus a simple
281
+ * aggregate form for {@link having}); anything else throws.
282
+ *
283
+ * Not every method asserts, and the differences are load-bearing:
284
+ * - {@link where}, {@link whereIn}, {@link orderBy}, {@link groupBy},
285
+ * {@link having}, {@link join}, the aggregates and the write methods DO assert
286
+ * their identifiers.
287
+ * - {@link select} asserts each entry as a safe SELECT expression;
288
+ * {@link selectRaw} does NOT — it interpolates verbatim, so never pass user
289
+ * input to it.
290
+ * - The raw escape hatches {@link selectRaw}, {@link whereRaw}, {@link orderByRaw}
291
+ * inject their SQL verbatim and are **trusted-input only**; pass dynamic values
292
+ * through their bindings argument, never by concatenation.
293
+ *
294
+ * A few features are dialect-aware (row locks are no-ops on SQLite; random
295
+ * ordering is `RAND()` on MySQL and `RANDOM()` elsewhere; date-part extraction
296
+ * differs per engine).
297
+ *
298
+ * @example
299
+ * ```ts
300
+ * // Read: where → order → limit → fetch
301
+ * const users = await DB.table('users')
302
+ * .where('active', true)
303
+ * .where('age', '>=', 18)
304
+ * .orderBy('created_at', 'desc')
305
+ * .limit(10)
306
+ * .get();
307
+ *
308
+ * // Write
309
+ * await DB.table('users').insert({ email: 'a@example.com', active: true });
310
+ * await DB.table('users').where('id', 1).update({ active: false });
311
+ * ```
312
+ *
313
+ * @category Select
314
+ */
315
+ export class QueryBuilder {
316
+ protected _state: QueryState;
317
+ protected _sql: SQLInstance;
318
+
319
+ /**
320
+ * Index into `_state.wheres` at which caller-supplied predicates begin.
321
+ *
322
+ * Anything before it was injected by the framework (currently the soft-delete predicate
323
+ * seeded in `BaseModel.query()`) and must stay outside the group built by
324
+ * {@link _groupUserWheres}. Defaults to 0 for a plain `DB.table()` builder, which has no
325
+ * framework predicates.
326
+ */
327
+ protected _userWhereStart = 0;
328
+
329
+ /**
330
+ * Identifier-ingress hook: every caller-supplied column name passes through
331
+ * here exactly once, at the point it enters builder state. The base builder
332
+ * is identity; {@link ModelQueryBuilder} overrides it to resolve camelCase
333
+ * model properties to snake_case columns. Centralising the seam is what
334
+ * guarantees *every* column-taking method resolves — the previous
335
+ * per-method overrides had drifted (`whereIn` resolved, `whereNotIn` did
336
+ * not; `orWhereNotLike` was the only LIKE variant left out; the pagination
337
+ * option columns were never touched).
338
+ * @internal
339
+ */
340
+ protected _column(column: string): string {
341
+ return column;
342
+ }
343
+
344
+ /**
345
+ * Value-ingress hook: a value bound alongside a column passes through here.
346
+ * The base builder is identity; {@link ModelQueryBuilder} overrides it to
347
+ * coerce through the column's cast metadata (Carbon → DB string, boolean →
348
+ * 0/1, …), so `whereBetween('createdAt', [a, b])` coerces the same way
349
+ * `where('createdAt', '>=', a)` always has.
350
+ * @internal
351
+ */
352
+ protected _bind(column: string, value: unknown, operator?: WhereOperator): unknown {
353
+ void column;
354
+ void operator;
355
+ return value;
356
+ }
357
+
358
+ constructor(table: string, sql: SQLInstance) {
359
+ // Subquery builders start with an empty table (set later via from()).
360
+ if (table) _assertIdentifier(table, "table name");
361
+ this._state = {
362
+ table,
363
+ selects: [],
364
+ distinct: false,
365
+ joins: [],
366
+ wheres: [],
367
+ orders: [],
368
+ groupBys: [],
369
+ havings: [],
370
+ unions: [],
371
+ limit: undefined,
372
+ offset: undefined,
373
+ lock: undefined,
374
+ };
375
+ this._sql = sql;
376
+ }
377
+
378
+ // ── Builder ───────────────────────────────────────────────────────────
379
+
380
+ /**
381
+ * Change the table this query targets. Useful for subquery builders that
382
+ * start with an empty table.
383
+ *
384
+ * @param table - Table name; asserted as a safe identifier.
385
+ * @throws {Error} When `table` is not a safe SQL identifier.
386
+ * @category Select
387
+ */
388
+ from(table: string): this {
389
+ _assertIdentifier(table, "from()");
390
+ this._state.table = table;
391
+ return this;
392
+ }
393
+
394
+ /**
395
+ * Add columns to the SELECT list. Called with no columns the query selects
396
+ * `*`. Repeated calls accumulate.
397
+ *
398
+ * @remarks
399
+ * Each column is asserted as a safe SELECT entry (a column, `table.*`, or a
400
+ * simple aggregate, with an optional `[AS] alias`) — the same identifier guard
401
+ * the where/order builders use. For raw projection SQL, use {@link selectRaw}.
402
+ *
403
+ * @throws {Error} When a column is not a safe select expression.
404
+ * @category Select
405
+ */
406
+ select(...columns: string[]): this {
407
+ const cols = columns.map((c) => this._column(c));
408
+ for (const c of cols) _assertSelectExpression(c, "select()");
409
+ this._state.selects.push(...cols);
410
+ return this;
411
+ }
412
+
413
+ /**
414
+ * Emit `SELECT DISTINCT`.
415
+ * @category Select
416
+ */
417
+ distinct(): this {
418
+ this._state.distinct = true;
419
+ return this;
420
+ }
421
+
422
+ /**
423
+ * Add an `AND` WHERE clause. Two-arg form defaults the operator to `=`; the
424
+ * three-arg form takes an explicit operator (`=`, `!=`, `>`, `>=`, `<`, `<=`,
425
+ * `like`, `not like`, `in`, `not in`). The column is asserted; the value is
426
+ * always bound.
427
+ *
428
+ * @throws {Error} When `column` is not a safe SQL identifier.
429
+ * @category Where clauses
430
+ * @example
431
+ * ```ts
432
+ * DB.table('users').where('active', true).where('age', '>=', 18);
433
+ * ```
434
+ */
435
+ where(column: string, value: unknown): this;
436
+ where(column: string, operator: WhereOperator, value: unknown): this;
437
+ where(group: (query: this) => void): this;
438
+ where(
439
+ columnOrGroup: string | ((query: this) => void),
440
+ operatorOrValue?: unknown,
441
+ value?: unknown,
442
+ ): this {
443
+ if (typeof columnOrGroup === "function") {
444
+ return this._addWhereGroup(columnOrGroup, "and");
445
+ }
446
+ if (typeof operatorOrValue === "string" && OPERATORS.has(operatorOrValue)) {
447
+ this._addWhere(columnOrGroup, operatorOrValue as WhereOperator, value, "and");
448
+ } else {
449
+ this._addWhere(columnOrGroup, "=", operatorOrValue, "and");
450
+ }
451
+ return this;
452
+ }
453
+
454
+ /**
455
+ * Add an `OR` WHERE clause. Same operator/value semantics as {@link where}.
456
+ * @throws {Error} When `column` is not a safe SQL identifier.
457
+ * @category Where clauses
458
+ */
459
+ orWhere(column: string, value: unknown): this;
460
+ orWhere(column: string, operator: WhereOperator, value: unknown): this;
461
+ orWhere(group: (query: this) => void): this;
462
+ orWhere(
463
+ columnOrGroup: string | ((query: this) => void),
464
+ operatorOrValue?: unknown,
465
+ value?: unknown,
466
+ ): this {
467
+ if (typeof columnOrGroup === "function") {
468
+ return this._addWhereGroup(columnOrGroup, "or");
469
+ }
470
+ const column = columnOrGroup;
471
+ if (typeof operatorOrValue === "string" && OPERATORS.has(operatorOrValue)) {
472
+ this._addWhere(column, operatorOrValue as WhereOperator, value, "or");
473
+ } else {
474
+ this._addWhere(column, "=", operatorOrValue, "or");
475
+ }
476
+ return this;
477
+ }
478
+
479
+ /**
480
+ * `AND column IN (…)`. Each value is bound. An empty list compiles to a
481
+ * constant-false predicate (`1 = 0`) so no rows match.
482
+ * @throws {Error} When `column` is not a safe SQL identifier.
483
+ * @category Where clauses
484
+ */
485
+ whereIn(column: string, values: unknown[]): this {
486
+ const col = this._column(column);
487
+ _assertIdentifier(col, "whereIn()");
488
+ const vals = values.map((v) => this._bind(col, v));
489
+ this._state.wheres.push({ column: col, operator: "in", value: vals, boolean: "and" });
490
+ return this;
491
+ }
492
+
493
+ /**
494
+ * `OR column IN (…)`. See {@link whereIn}.
495
+ * @throws {Error} When `column` is not a safe SQL identifier.
496
+ * @category Where clauses
497
+ */
498
+ orWhereIn(column: string, values: unknown[]): this {
499
+ const col = this._column(column);
500
+ _assertIdentifier(col, "orWhereIn()");
501
+ const vals = values.map((v) => this._bind(col, v));
502
+ this._state.wheres.push({ column: col, operator: "in", value: vals, boolean: "or" });
503
+ return this;
504
+ }
505
+
506
+ /**
507
+ * `AND column NOT IN (…)`. An empty list compiles to a constant-true
508
+ * predicate (`1 = 1`) so all rows match.
509
+ * @throws {Error} When `column` is not a safe SQL identifier.
510
+ * @category Where clauses
511
+ */
512
+ whereNotIn(column: string, values: unknown[]): this {
513
+ const col = this._column(column);
514
+ _assertIdentifier(col, "whereNotIn()");
515
+ const vals = values.map((v) => this._bind(col, v));
516
+ this._state.wheres.push({ column: col, operator: "not in", value: vals, boolean: "and" });
517
+ return this;
518
+ }
519
+
520
+ /**
521
+ * `OR column NOT IN (…)`. See {@link whereNotIn}.
522
+ * @throws {Error} When `column` is not a safe SQL identifier.
523
+ * @category Where clauses
524
+ */
525
+ orWhereNotIn(column: string, values: unknown[]): this {
526
+ const col = this._column(column);
527
+ _assertIdentifier(col, "orWhereNotIn()");
528
+ const vals = values.map((v) => this._bind(col, v));
529
+ this._state.wheres.push({ column: col, operator: "not in", value: vals, boolean: "or" });
530
+ return this;
531
+ }
532
+
533
+ /**
534
+ * `AND column IS NULL`.
535
+ * @throws {Error} When `column` is not a safe SQL identifier.
536
+ * @category Where clauses
537
+ */
538
+ whereNull(column: string): this {
539
+ const col = this._column(column);
540
+ _assertIdentifier(col, "whereNull()");
541
+ this._state.wheres.push({ column: col, operator: "is null", value: null, boolean: "and" });
542
+ return this;
543
+ }
544
+
545
+ /**
546
+ * `OR column IS NULL`.
547
+ * @throws {Error} When `column` is not a safe SQL identifier.
548
+ * @category Where clauses
549
+ */
550
+ orWhereNull(column: string): this {
551
+ const col = this._column(column);
552
+ _assertIdentifier(col, "orWhereNull()");
553
+ this._state.wheres.push({ column: col, operator: "is null", value: null, boolean: "or" });
554
+ return this;
555
+ }
556
+
557
+ /**
558
+ * `AND column IS NOT NULL`.
559
+ * @throws {Error} When `column` is not a safe SQL identifier.
560
+ * @category Where clauses
561
+ */
562
+ whereNotNull(column: string): this {
563
+ const col = this._column(column);
564
+ _assertIdentifier(col, "whereNotNull()");
565
+ this._state.wheres.push({ column: col, operator: "is not null", value: null, boolean: "and" });
566
+ return this;
567
+ }
568
+
569
+ /**
570
+ * `OR column IS NOT NULL`.
571
+ * @throws {Error} When `column` is not a safe SQL identifier.
572
+ * @category Where clauses
573
+ */
574
+ orWhereNotNull(column: string): this {
575
+ const col = this._column(column);
576
+ _assertIdentifier(col, "orWhereNotNull()");
577
+ this._state.wheres.push({ column: col, operator: "is not null", value: null, boolean: "or" });
578
+ return this;
579
+ }
580
+
581
+ // ── Range / column / date / pattern filters ───────────────────────────
582
+
583
+ /**
584
+ * `AND column BETWEEN ? AND ?` — both bounds bound as values.
585
+ * @throws {Error} When `column` is not a safe SQL identifier.
586
+ * @category Where clauses
587
+ */
588
+ whereBetween(column: string, range: [unknown, unknown]): this {
589
+ return this._between(column, range, false, "and");
590
+ }
591
+
592
+ /**
593
+ * `OR column BETWEEN ? AND ?`.
594
+ * @throws {Error} When `column` is not a safe SQL identifier.
595
+ * @category Where clauses
596
+ */
597
+ orWhereBetween(column: string, range: [unknown, unknown]): this {
598
+ return this._between(column, range, false, "or");
599
+ }
600
+
601
+ /**
602
+ * `AND column NOT BETWEEN ? AND ?`.
603
+ * @throws {Error} When `column` is not a safe SQL identifier.
604
+ * @category Where clauses
605
+ */
606
+ whereNotBetween(column: string, range: [unknown, unknown]): this {
607
+ return this._between(column, range, true, "and");
608
+ }
609
+
610
+ /**
611
+ * `OR column NOT BETWEEN ? AND ?`.
612
+ * @throws {Error} When `column` is not a safe SQL identifier.
613
+ * @category Where clauses
614
+ */
615
+ orWhereNotBetween(column: string, range: [unknown, unknown]): this {
616
+ return this._between(column, range, true, "or");
617
+ }
618
+
619
+ /** Shared BETWEEN builder: one resolution + coercion + assertion site for the four variants. */
620
+ private _between(
621
+ column: string,
622
+ range: [unknown, unknown],
623
+ negate: boolean,
624
+ boolean: "and" | "or",
625
+ ): this {
626
+ const col = this._column(column);
627
+ _assertIdentifier(col, negate ? "whereNotBetween()" : "whereBetween()");
628
+ const bounds = [this._bind(col, range[0]), this._bind(col, range[1])];
629
+ return this._pushRaw(`${col} ${negate ? "NOT " : ""}BETWEEN ? AND ?`, bounds, boolean);
630
+ }
631
+
632
+ /**
633
+ * Compare two columns instead of a column to a value:
634
+ * `whereColumn('updated_at', '>', 'created_at')`. With two arguments the
635
+ * operator defaults to `=`. Both column names are asserted.
636
+ * @throws {Error} When either column is unsafe or the operator is unsupported.
637
+ * @category Where clauses
638
+ */
639
+ whereColumn(first: string, operatorOrSecond: string, second?: string): this {
640
+ return this._whereColumn(first, operatorOrSecond, second, "and");
641
+ }
642
+
643
+ /**
644
+ * `OR` form of {@link whereColumn}.
645
+ * @throws {Error} When either column is unsafe or the operator is unsupported.
646
+ * @category Where clauses
647
+ */
648
+ orWhereColumn(first: string, operatorOrSecond: string, second?: string): this {
649
+ return this._whereColumn(first, operatorOrSecond, second, "or");
650
+ }
651
+
652
+ private _whereColumn(
653
+ first: string,
654
+ operatorOrSecond: string,
655
+ second: string | undefined,
656
+ boolean: "and" | "or",
657
+ ): this {
658
+ const op = second === undefined ? "=" : operatorOrSecond;
659
+ const col1 = this._column(first);
660
+ const col2 = this._column(second === undefined ? operatorOrSecond : second);
661
+ _assertIdentifier(col1, "whereColumn()");
662
+ _assertIdentifier(col2, "whereColumn()");
663
+ if (!COLUMN_OPERATORS.has(op))
664
+ throw new Error(`[Zerotal ORM] whereColumn(): unsupported operator "${op}".`);
665
+ return this._pushRaw(`${col1} ${op} ${col2}`, [], boolean);
666
+ }
667
+
668
+ /**
669
+ * Filter on the date portion of a timestamp column. Two-arg form defaults the
670
+ * operator to `=`. The date-extraction SQL is dialect-specific.
671
+ * @throws {Error} When `column` is not a safe SQL identifier.
672
+ * @category Where clauses
673
+ */
674
+ whereDate(column: string, operatorOrValue: unknown, value?: unknown): this {
675
+ return this._dateFn("date", column, operatorOrValue, value, "and");
676
+ }
677
+ /**
678
+ * Filter on the time portion of a timestamp column. See {@link whereDate}.
679
+ * @throws {Error} When `column` is not a safe SQL identifier.
680
+ * @category Where clauses
681
+ */
682
+ whereTime(column: string, operatorOrValue: unknown, value?: unknown): this {
683
+ return this._dateFn("time", column, operatorOrValue, value, "and");
684
+ }
685
+ /**
686
+ * Filter on the day-of-month component of a timestamp column.
687
+ * @throws {Error} When `column` is not a safe SQL identifier.
688
+ * @category Where clauses
689
+ */
690
+ whereDay(column: string, operatorOrValue: unknown, value?: unknown): this {
691
+ return this._dateFn("day", column, operatorOrValue, value, "and");
692
+ }
693
+ /**
694
+ * Filter on the month component of a timestamp column.
695
+ * @throws {Error} When `column` is not a safe SQL identifier.
696
+ * @category Where clauses
697
+ */
698
+ whereMonth(column: string, operatorOrValue: unknown, value?: unknown): this {
699
+ return this._dateFn("month", column, operatorOrValue, value, "and");
700
+ }
701
+ /**
702
+ * Filter on the year component of a timestamp column.
703
+ * @throws {Error} When `column` is not a safe SQL identifier.
704
+ * @category Where clauses
705
+ */
706
+ whereYear(column: string, operatorOrValue: unknown, value?: unknown): this {
707
+ return this._dateFn("year", column, operatorOrValue, value, "and");
708
+ }
709
+
710
+ private _dateFn(
711
+ kind: "date" | "time" | "day" | "month" | "year",
712
+ column: string,
713
+ operatorOrValue: unknown,
714
+ value: unknown,
715
+ boolean: "and" | "or",
716
+ ): this {
717
+ const col = this._column(column);
718
+ _assertIdentifier(col, `where${kind}()`);
719
+ const hasOp = value !== undefined && typeof operatorOrValue === "string";
720
+ const op = hasOp ? (operatorOrValue as string) : "=";
721
+ if (hasOp) _assertOperator(op, `where${kind}()`);
722
+ // The value is a date *fragment* ('2026-01-01', 14, 'May') compared against
723
+ // the extracted part — deliberately NOT passed through _bind, whose cast
724
+ // coercion would turn a Carbon into a full timestamp and break equality.
725
+ const val = hasOp ? value : operatorOrValue;
726
+ // Date-part extraction differs per engine (strftime vs EXTRACT vs DAY()).
727
+ const expr = getDialect(dialectFor(this._sql)).dateExpr(kind, col);
728
+ return this._pushRaw(`${expr} ${op} ?`, [val], boolean);
729
+ }
730
+
731
+ /**
732
+ * `AND column LIKE ?` — the pattern is bound as a value.
733
+ * @throws {Error} When `column` is not a safe SQL identifier.
734
+ * @category Where clauses
735
+ */
736
+ whereLike(column: string, value: string): this {
737
+ const col = this._column(column);
738
+ _assertIdentifier(col, "whereLike()");
739
+ return this._pushRaw(`${col} LIKE ?`, [value], "and");
740
+ }
741
+ /**
742
+ * `OR column LIKE ?`.
743
+ * @throws {Error} When `column` is not a safe SQL identifier.
744
+ * @category Where clauses
745
+ */
746
+ orWhereLike(column: string, value: string): this {
747
+ const col = this._column(column);
748
+ _assertIdentifier(col, "orWhereLike()");
749
+ return this._pushRaw(`${col} LIKE ?`, [value], "or");
750
+ }
751
+ /**
752
+ * `AND column NOT LIKE ?`.
753
+ * @throws {Error} When `column` is not a safe SQL identifier.
754
+ * @category Where clauses
755
+ */
756
+ whereNotLike(column: string, value: string): this {
757
+ const col = this._column(column);
758
+ _assertIdentifier(col, "whereNotLike()");
759
+ return this._pushRaw(`${col} NOT LIKE ?`, [value], "and");
760
+ }
761
+ /**
762
+ * `OR column NOT LIKE ?`.
763
+ * @throws {Error} When `column` is not a safe SQL identifier.
764
+ * @category Where clauses
765
+ */
766
+ orWhereNotLike(column: string, value: string): this {
767
+ const col = this._column(column);
768
+ _assertIdentifier(col, "orWhereNotLike()");
769
+ return this._pushRaw(`${col} NOT LIKE ?`, [value], "or");
770
+ }
771
+
772
+ /**
773
+ * Match when ANY of the columns satisfies the operator/value, as a
774
+ * parenthesised `OR` group. Each column is asserted and the value is bound
775
+ * once per column.
776
+ * @throws {Error} When a column is unsafe or the operator is unsupported.
777
+ * @category Where clauses
778
+ */
779
+ whereAny(columns: string[], operator: string, value: unknown): this {
780
+ return this._whereMany(columns, operator, value, "OR", "and");
781
+ }
782
+ /**
783
+ * Match when ALL of the columns satisfy the operator/value, as a
784
+ * parenthesised `AND` group.
785
+ * @throws {Error} When a column is unsafe or the operator is unsupported.
786
+ * @category Where clauses
787
+ */
788
+ whereAll(columns: string[], operator: string, value: unknown): this {
789
+ return this._whereMany(columns, operator, value, "AND", "and");
790
+ }
791
+
792
+ private _whereMany(
793
+ columns: string[],
794
+ operator: string,
795
+ value: unknown,
796
+ glue: "OR" | "AND",
797
+ boolean: "and" | "or",
798
+ ): this {
799
+ if (!OPERATORS.has(operator))
800
+ throw new Error(`[Zerotal ORM] whereAny/whereAll(): unsupported operator "${operator}".`);
801
+ // One shared value across N columns whose casts may differ — resolved per
802
+ // column, but deliberately not cast-coerced (coercion is per-column and
803
+ // there is only one value).
804
+ const cols = columns.map((c) => this._column(c));
805
+ for (const c of cols) _assertIdentifier(c, "whereAny/whereAll()");
806
+ const frag = cols.map((c) => `${c} ${operator} ?`).join(` ${glue} `);
807
+ return this._pushRaw(
808
+ `(${frag})`,
809
+ cols.map(() => value),
810
+ boolean,
811
+ );
812
+ }
813
+
814
+ /**
815
+ * `AND EXISTS (subquery)`. The callback receives a fresh {@link QueryBuilder}
816
+ * to build the correlated subquery; its bindings are merged into the parent.
817
+ * @category Where clauses
818
+ * @example
819
+ * ```ts
820
+ * DB.table('users').whereExists((q) =>
821
+ * q.from('orders').whereColumn('orders.user_id', 'users.id'));
822
+ * ```
823
+ */
824
+ whereExists(callback: (q: QueryBuilder) => void): this {
825
+ return this._whereExists(callback, "EXISTS", "and");
826
+ }
827
+ /**
828
+ * `OR EXISTS (subquery)`. See {@link whereExists}.
829
+ * @category Where clauses
830
+ */
831
+ orWhereExists(callback: (q: QueryBuilder) => void): this {
832
+ return this._whereExists(callback, "EXISTS", "or");
833
+ }
834
+ /**
835
+ * `AND NOT EXISTS (subquery)`. See {@link whereExists}.
836
+ * @category Where clauses
837
+ */
838
+ whereNotExists(callback: (q: QueryBuilder) => void): this {
839
+ return this._whereExists(callback, "NOT EXISTS", "and");
840
+ }
841
+ /**
842
+ * `OR NOT EXISTS (subquery)`. See {@link whereExists}.
843
+ * @category Where clauses
844
+ */
845
+ orWhereNotExists(callback: (q: QueryBuilder) => void): this {
846
+ return this._whereExists(callback, "NOT EXISTS", "or");
847
+ }
848
+
849
+ private _whereExists(
850
+ callback: (q: QueryBuilder) => void,
851
+ kw: string,
852
+ boolean: "and" | "or",
853
+ ): this {
854
+ const sub = new QueryBuilder("", this._sql);
855
+ callback(sub);
856
+ const { sql, bindings } = sub._compileSelect();
857
+ return this._pushRaw(`${kw} (${sql})`, bindings, boolean);
858
+ }
859
+
860
+ /**
861
+ * Order by a column. The column must be a safe identifier — for raw SQL
862
+ * expressions (e.g. `RANDOM()`) use {@link orderByRaw}.
863
+ *
864
+ * @param direction - `'asc'` (default) or `'desc'`, any casing.
865
+ * @throws {Error} When `column` is not a safe SQL identifier, or `direction` is neither
866
+ * `asc` nor `desc` — the `OrderDirection` type is a compile-time hint, and a direction
867
+ * read off a query string arrives as an unchecked string.
868
+ * @category Ordering & grouping
869
+ */
870
+ orderBy(column: string, direction: OrderDirection = "asc"): this {
871
+ const col = this._column(column);
872
+ _assertIdentifier(col, "orderBy()");
873
+ this._state.orders.push({ column: col, direction: _assertDirection(direction, "orderBy()") });
874
+ return this;
875
+ }
876
+
877
+ /**
878
+ * Order descending by a column. Shorthand for `orderBy(column, 'desc')`.
879
+ * @throws {Error} When `column` is not a safe SQL identifier.
880
+ * @category Ordering & grouping
881
+ */
882
+ orderByDesc(column: string): this {
883
+ return this.orderBy(column, "desc");
884
+ }
885
+
886
+ /**
887
+ * Order descending by a column (default the primary key `id`).
888
+ * @throws {Error} When `column` is not a safe SQL identifier.
889
+ * @category Ordering & grouping
890
+ */
891
+ desc(column = "id"): this {
892
+ return this.orderBy(column, "desc");
893
+ }
894
+
895
+ /**
896
+ * Order ascending by a column (default the primary key `id`).
897
+ * @throws {Error} When `column` is not a safe SQL identifier.
898
+ * @category Ordering & grouping
899
+ */
900
+ asc(column = "id"): this {
901
+ return this.orderBy(column, "asc");
902
+ }
903
+
904
+ /**
905
+ * Order newest-first by a timestamp column (default `created_at`).
906
+ * @throws {Error} When `column` is not a safe SQL identifier.
907
+ * @category Ordering & grouping
908
+ */
909
+ latest(column = "created_at"): this {
910
+ return this.orderBy(column, "desc");
911
+ }
912
+
913
+ /**
914
+ * Order oldest-first by a timestamp column (default `created_at`).
915
+ * @throws {Error} When `column` is not a safe SQL identifier.
916
+ * @category Ordering & grouping
917
+ */
918
+ oldest(column = "created_at"): this {
919
+ return this.orderBy(column, "asc");
920
+ }
921
+
922
+ /**
923
+ * Randomise row order — `RAND()` on MySQL, `RANDOM()` on SQLite/Postgres.
924
+ * @category Ordering & grouping
925
+ */
926
+ inRandomOrder(): this {
927
+ return this.orderByRaw(dialectFor(this._sql) === "mysql" ? "RAND()" : "RANDOM()");
928
+ }
929
+
930
+ /**
931
+ * Clear all ORDER BY clauses; optionally apply a new one.
932
+ * @throws {Error} When a replacement `column` is given and is unsafe.
933
+ * @category Ordering & grouping
934
+ */
935
+ reorder(column?: string, direction: OrderDirection = "asc"): this {
936
+ this._state.orders = [];
937
+ if (column) this.orderBy(column, direction);
938
+ return this;
939
+ }
940
+
941
+ /**
942
+ * Add columns to the `GROUP BY` clause. Each column is asserted.
943
+ * @throws {Error} When any column is not a safe SQL identifier.
944
+ * @category Ordering & grouping
945
+ */
946
+ groupBy(...columns: string[]): this {
947
+ const cols = columns.map((c) => this._column(c));
948
+ for (const c of cols) _assertIdentifier(c, "groupBy()");
949
+ this._state.groupBys.push(...cols);
950
+ return this;
951
+ }
952
+
953
+ /**
954
+ * Add a HAVING clause. The column may be a plain identifier or a simple
955
+ * aggregate such as `SUM(score)`; the value is always bound. Two-arg form
956
+ * defaults the operator to `=`. Multiple HAVING clauses are joined with `AND`.
957
+ *
958
+ * @throws {Error} When `column` is neither a safe identifier nor a simple
959
+ * aggregate, or when the operator is unsupported.
960
+ * @category Ordering & grouping
961
+ * @example
962
+ * ```ts
963
+ * DB.table('orders').groupBy('user_id').having('SUM(total)', '>', 100);
964
+ * ```
965
+ */
966
+ having(column: string, operatorOrValue: unknown, value?: unknown): this {
967
+ // Aggregate forms like SUM(score) pass through _column unchanged (the
968
+ // resolver's raw-expression guard skips anything beyond identifier chars).
969
+ const col = this._column(column);
970
+ _assertIdentifierOrAggregate(col, "having()");
971
+ const op = value !== undefined ? String(operatorOrValue) : "=";
972
+ const val = value !== undefined ? value : operatorOrValue;
973
+ if (!OPERATORS.has(op) && !COLUMN_OPERATORS.has(op))
974
+ throw new Error(`[Zerotal ORM] having(): unsupported operator "${op}".`);
975
+ this._state.havings.push({ column: col, operator: op, value: val } satisfies HavingClause);
976
+ return this;
977
+ }
978
+
979
+ // ── Joins ─────────────────────────────────────────────────────────────
980
+
981
+ /**
982
+ * `INNER JOIN table ON first <operator> second`. The table and both columns
983
+ * are asserted; the operator is checked against the column-comparison set.
984
+ * @throws {Error} When an identifier is unsafe or the operator is unsupported.
985
+ * @category Joins
986
+ * @example
987
+ * ```ts
988
+ * DB.table('users').join('orders', 'users.id', '=', 'orders.user_id');
989
+ * ```
990
+ */
991
+ join(table: string, first: string, operator: string, second: string): this {
992
+ return this._addJoin("inner", table, first, operator, second);
993
+ }
994
+ /**
995
+ * `LEFT JOIN`. See {@link join}.
996
+ * @throws {Error} When an identifier is unsafe or the operator is unsupported.
997
+ * @category Joins
998
+ */
999
+ leftJoin(table: string, first: string, operator: string, second: string): this {
1000
+ return this._addJoin("left", table, first, operator, second);
1001
+ }
1002
+ /**
1003
+ * `RIGHT JOIN`. See {@link join}.
1004
+ * @throws {Error} When an identifier is unsafe or the operator is unsupported.
1005
+ * @category Joins
1006
+ */
1007
+ rightJoin(table: string, first: string, operator: string, second: string): this {
1008
+ return this._addJoin("right", table, first, operator, second);
1009
+ }
1010
+ /**
1011
+ * `CROSS JOIN table`. Only the table name is asserted.
1012
+ * @throws {Error} When `table` is not a safe SQL identifier.
1013
+ * @category Joins
1014
+ */
1015
+ crossJoin(table: string): this {
1016
+ _assertIdentifier(table, "crossJoin()");
1017
+ this._state.joins.push({ type: "cross", table });
1018
+ return this;
1019
+ }
1020
+
1021
+ /**
1022
+ * Join a subquery, aliased. Pass a builder (or a callback that populates one)
1023
+ * plus an alias and the join condition. The subquery's bindings are merged
1024
+ * into the parent query.
1025
+ *
1026
+ * @remarks
1027
+ * The `alias`, `first` and `second` are asserted as safe identifiers and
1028
+ * `operator` against the allowed column-comparison set — the same guards
1029
+ * {@link join} applies. Only the subquery's own SQL comes from the passed
1030
+ * builder.
1031
+ * @category Joins
1032
+ * @example
1033
+ * ```ts
1034
+ * qb.joinSub(
1035
+ * DB.table('orders').selectRaw('user_id, COUNT(*) c').groupBy('user_id'),
1036
+ * 'o', 'users.id', '=', 'o.user_id',
1037
+ * );
1038
+ * ```
1039
+ */
1040
+ joinSub(
1041
+ sub: QueryBuilder | ((q: QueryBuilder) => void),
1042
+ alias: string,
1043
+ first: string,
1044
+ operator: string,
1045
+ second: string,
1046
+ type: JoinType = "inner",
1047
+ ): this {
1048
+ _assertIdentifier(alias, "joinSub()");
1049
+ first = this._column(first);
1050
+ second = this._column(second);
1051
+ _assertIdentifier(first, "joinSub()");
1052
+ _assertIdentifier(second, "joinSub()");
1053
+ if (!COLUMN_OPERATORS.has(operator))
1054
+ throw new Error(`[Zerotal ORM] joinSub(): unsupported operator "${operator}".`);
1055
+ let builder: QueryBuilder;
1056
+ if (typeof sub === "function") {
1057
+ builder = new QueryBuilder("", this._sql);
1058
+ sub(builder);
1059
+ } else builder = sub;
1060
+ const { sql, bindings } = builder._compileSelect();
1061
+ this._state.joins.push({
1062
+ type,
1063
+ table: `(${sql}) AS ${alias}`,
1064
+ first,
1065
+ operator,
1066
+ second,
1067
+ bindings,
1068
+ });
1069
+ return this;
1070
+ }
1071
+
1072
+ private _addJoin(
1073
+ type: JoinType,
1074
+ table: string,
1075
+ first: string,
1076
+ operator: string,
1077
+ second: string,
1078
+ ): this {
1079
+ _assertIdentifier(table, "join()");
1080
+ first = this._column(first);
1081
+ second = this._column(second);
1082
+ _assertIdentifier(first, "join()");
1083
+ _assertIdentifier(second, "join()");
1084
+ if (!COLUMN_OPERATORS.has(operator))
1085
+ throw new Error(`[Zerotal ORM] join(): unsupported operator "${operator}".`);
1086
+ this._state.joins.push({ type, table, first, operator, second });
1087
+ return this;
1088
+ }
1089
+
1090
+ // ── Unions ────────────────────────────────────────────────────────────
1091
+
1092
+ /**
1093
+ * Append `UNION` (or `UNION ALL` when `all` is true) with another builder's
1094
+ * compiled SELECT. The other query's bindings are merged.
1095
+ * @category Ordering & grouping
1096
+ */
1097
+ union(other: QueryBuilder, all = false): this {
1098
+ const { sql, bindings } = other._compileSelect();
1099
+ this._state.unions.push({ sql, bindings, all });
1100
+ return this;
1101
+ }
1102
+ /**
1103
+ * Append `UNION ALL` with another builder's SELECT. Shorthand for
1104
+ * `union(other, true)`.
1105
+ * @category Ordering & grouping
1106
+ */
1107
+ unionAll(other: QueryBuilder): this {
1108
+ return this.union(other, true);
1109
+ }
1110
+
1111
+ // ── Pessimistic locking ───────────────────────────────────────────────
1112
+
1113
+ /**
1114
+ * `SELECT … FOR UPDATE` — take an exclusive row lock. No-op on SQLite, which
1115
+ * lacks row locks (the suffix is omitted from the compiled SQL).
1116
+ * @category Select
1117
+ */
1118
+ lockForUpdate(): this {
1119
+ this._state.lock = "FOR UPDATE";
1120
+ return this;
1121
+ }
1122
+
1123
+ /**
1124
+ * Take a shared row lock — `LOCK IN SHARE MODE` on MySQL, `FOR SHARE`
1125
+ * elsewhere. No-op on SQLite.
1126
+ * @category Select
1127
+ */
1128
+ sharedLock(): this {
1129
+ this._state.lock = dialectFor(this._sql) === "mysql" ? "LOCK IN SHARE MODE" : "FOR SHARE";
1130
+ return this;
1131
+ }
1132
+
1133
+ /**
1134
+ * Add a raw SQL expression to the SELECT list.
1135
+ *
1136
+ * **Security:** The expression is injected verbatim into the query — never
1137
+ * interpolate user-controlled values directly. Build the expression from
1138
+ * trusted constants only, and pass dynamic values via `whereRaw()` bindings.
1139
+ *
1140
+ * @example
1141
+ * qb.selectRaw('COUNT(*) as total, MAX(score) as top')
1142
+ * qb.selectRaw('price * quantity as revenue')
1143
+ * @category Raw
1144
+ */
1145
+ selectRaw(expression: string): this {
1146
+ this._state.selects.push(expression);
1147
+ return this;
1148
+ }
1149
+
1150
+ /**
1151
+ * Add a raw SQL WHERE clause.
1152
+ * Bindings are passed as the second argument to avoid SQL injection.
1153
+ *
1154
+ * @example
1155
+ * qb.whereRaw('LOWER(email) = ?', ['alice@example.com'])
1156
+ * qb.whereRaw('score BETWEEN ? AND ?', [10, 50])
1157
+ * @category Raw
1158
+ */
1159
+ whereRaw(sql: string, bindings: unknown[] = []): this {
1160
+ return this._pushRaw(sql, bindings, "and");
1161
+ }
1162
+
1163
+ /**
1164
+ * `OR` form of {@link whereRaw}. The SQL fragment is trusted-input only;
1165
+ * pass dynamic values via `bindings`.
1166
+ * @category Raw
1167
+ */
1168
+ orWhereRaw(sql: string, bindings: unknown[] = []): this {
1169
+ return this._pushRaw(sql, bindings, "or");
1170
+ }
1171
+
1172
+ private _pushRaw(sql: string, bindings: unknown[], boolean: "and" | "or"): this {
1173
+ this._state.wheres.push({ column: sql, operator: "__raw__", value: bindings, boolean });
1174
+ return this;
1175
+ }
1176
+
1177
+ /**
1178
+ * Filter by a JSONB/JSON column path value using the `->>` text-extraction operator.
1179
+ *
1180
+ * Accepts `'column->key'` notation. Works natively on PostgreSQL (JSONB columns)
1181
+ * and MySQL (JSON columns). For SQLite use `whereRaw('json_extract(col, ?) = ?', …)`.
1182
+ *
1183
+ * The column name and key must be safe SQL identifiers (letters, digits, `_`, `.`).
1184
+ * Throws if either part fails validation — never pass user-controlled strings here.
1185
+ *
1186
+ * @throws {Error} When the column or path fails identifier validation.
1187
+ * @example
1188
+ * DB.table('users').whereJson('preferences->theme', 'dark')
1189
+ * // WHERE preferences->>'theme' = ?
1190
+ * @category Where clauses
1191
+ */
1192
+ whereJson(column: string, value: unknown): this {
1193
+ const arrowIdx = column.indexOf("->");
1194
+ if (arrowIdx === -1) return this.where(column, value);
1195
+ const col = this._column(column.slice(0, arrowIdx));
1196
+ const path = column.slice(arrowIdx + 2);
1197
+ if (!_isSafeIdentifier(col) || !_isSafeIdentifier(path)) {
1198
+ throw new Error(
1199
+ `[Zerotal ORM] whereJson(): unsafe identifier detected in "${column}". ` +
1200
+ `Column and path must match /^[a-zA-Z_][a-zA-Z0-9_.]*$/.`,
1201
+ );
1202
+ }
1203
+ return this.whereRaw(`${col}->>'${path}' = ?`, [value]);
1204
+ }
1205
+
1206
+ /**
1207
+ * Add a raw SQL ORDER BY expression.
1208
+ *
1209
+ * @remarks Injected verbatim — trusted-input only. Prefer {@link orderBy} for
1210
+ * plain column ordering.
1211
+ * @example
1212
+ * qb.orderByRaw('RAND()')
1213
+ * qb.orderByRaw('created_at DESC, id ASC')
1214
+ * @category Raw
1215
+ */
1216
+ orderByRaw(expression: string): this {
1217
+ this._state.orders.push({ column: expression, direction: "__raw__" });
1218
+ return this;
1219
+ }
1220
+
1221
+ /**
1222
+ * Cap the number of rows returned (`LIMIT`). The value is bound.
1223
+ * @category Pagination
1224
+ */
1225
+ limit(n: number): this {
1226
+ this._state.limit = n;
1227
+ return this;
1228
+ }
1229
+
1230
+ /**
1231
+ * Skip a number of leading rows (`OFFSET`). The value is bound.
1232
+ * @category Pagination
1233
+ */
1234
+ offset(n: number): this {
1235
+ this._state.offset = n;
1236
+ return this;
1237
+ }
1238
+
1239
+ /**
1240
+ * Conditionally apply builder mutations: when `condition` is truthy, invoke
1241
+ * `callback(this, condition)` and continue chaining. No `otherwise` branch.
1242
+ * @category Select
1243
+ * @example
1244
+ * ```ts
1245
+ * DB.table('users').when(search, (q, s) => q.whereLike('name', `%${s}%`));
1246
+ * ```
1247
+ */
1248
+ when(condition: unknown, callback: (q: QueryBuilder, value: unknown) => void): this {
1249
+ if (condition) callback(this, condition);
1250
+ return this;
1251
+ }
1252
+
1253
+ /**
1254
+ * Deep-copy this builder so repeated paginated reads (chunk, cursor, …) do not
1255
+ * mutate the original. Subclasses override `_newInstance()` to preserve their
1256
+ * own state.
1257
+ * @category Execution
1258
+ */
1259
+ clone(): this {
1260
+ const c = this._newInstance();
1261
+ c._state = {
1262
+ ...this._state,
1263
+ selects: [...this._state.selects],
1264
+ joins: this._state.joins.map((j) => ({ ...j })),
1265
+ wheres: this._state.wheres.map((w) => ({ ...w })),
1266
+ orders: this._state.orders.map((o) => ({ ...o })),
1267
+ groupBys: [...this._state.groupBys],
1268
+ havings: this._state.havings.map((h) => ({ ...h })),
1269
+ unions: this._state.unions.map((u) => ({ ...u })),
1270
+ };
1271
+ return c as this;
1272
+ }
1273
+
1274
+ protected _newInstance(): QueryBuilder {
1275
+ return new QueryBuilder(this._state.table, this._sql);
1276
+ }
1277
+
1278
+ // ── Terminals ─────────────────────────────────────────────────────────
1279
+
1280
+ /**
1281
+ * Compile and execute the SELECT, returning all matching rows.
1282
+ * @returns The result rows (plain records, or model instances under the
1283
+ * model query builder).
1284
+ * @category Execution
1285
+ */
1286
+ async get<T = Record<string, unknown>>(): Promise<T[]> {
1287
+ this._beforeTerminal();
1288
+ return this._runSelect<T>();
1289
+ }
1290
+
1291
+ /**
1292
+ * Execute the SELECT with `LIMIT 1` and return the first row, or `null` when
1293
+ * none match. Restores any previously-set limit afterward.
1294
+ * @category Execution
1295
+ */
1296
+ async first<T = Record<string, unknown>>(): Promise<T | null> {
1297
+ this._beforeTerminal();
1298
+ const prev = this._state.limit;
1299
+ this._state.limit = 1;
1300
+ const rows = await this._runSelect<T>();
1301
+ this._state.limit = prev;
1302
+ return rows[0] ?? null;
1303
+ }
1304
+
1305
+ /**
1306
+ * Return an array of a single column's values.
1307
+ * Pass `key` to return an object keyed by that column instead.
1308
+ *
1309
+ * @example
1310
+ * await DB.table('users').pluck('email'); // ['a@x', 'b@y']
1311
+ * await DB.table('users').pluck('name', 'id'); // { 1: 'Al', 2: 'Bo' }
1312
+ * @category Execution
1313
+ */
1314
+ async pluck<V = unknown>(column: string, key?: string): Promise<V[] | Record<string, V>> {
1315
+ this._beforeTerminal();
1316
+ const col = this._column(column);
1317
+ const keyCol = key === undefined ? undefined : this._column(key);
1318
+ _assertSelectExpression(col, "pluck()");
1319
+ if (keyCol) _assertSelectExpression(keyCol, "pluck()");
1320
+ const c = this.clone();
1321
+ c._state.selects = keyCol ? [col, keyCol] : [col];
1322
+ const rows = await c.get<Record<string, unknown>>();
1323
+ // Readback goes through _keysetValue: the model builder hydrates rows into
1324
+ // instances whose properties are camelCase, so a snake_case column name
1325
+ // alone would miss the value.
1326
+ const colKey = col.split(".").pop()!.split(" ").pop()!;
1327
+ if (keyCol) {
1328
+ const keyKey = keyCol.split(".").pop()!;
1329
+ const out: Record<string, V> = {};
1330
+ for (const r of rows)
1331
+ out[String(this._keysetValue(r as Record<string, unknown>, keyKey))] = this._keysetValue(
1332
+ r as Record<string, unknown>,
1333
+ colKey,
1334
+ ) as V;
1335
+ return out;
1336
+ }
1337
+ return rows.map((r) => this._keysetValue(r as Record<string, unknown>, colKey) as V);
1338
+ }
1339
+
1340
+ /**
1341
+ * Return a single column's value from the first matching row, or `null`.
1342
+ * @category Execution
1343
+ */
1344
+ async value<V = unknown>(column: string): Promise<V | null> {
1345
+ this._beforeTerminal();
1346
+ const col = this._column(column);
1347
+ _assertSelectExpression(col, "value()");
1348
+ const c = this.clone();
1349
+ c._state.selects = [col];
1350
+ const row = await c.first<Record<string, unknown>>();
1351
+ if (!row) return null;
1352
+ const colKey = col.split(".").pop()!.split(" ").pop()!;
1353
+ return (this._keysetValue(row, colKey) as V) ?? null;
1354
+ }
1355
+
1356
+ /**
1357
+ * `COUNT(*)` over the current query, ignoring any select list. Returns 0 when
1358
+ * there are no rows.
1359
+ *
1360
+ * Counts what the query *returns*. A grouped query returns one row per group, so
1361
+ * `groupBy("country").count()` is the number of countries, not the number of rows — the
1362
+ * previous implementation dropped the grouping and answered 1, which also made
1363
+ * `paginate()` report `total: 1` beside two rows of data. `DISTINCT` is likewise honoured
1364
+ * rather than forced off. Those cases count through a subquery, which is the only form
1365
+ * that gets both right.
1366
+ *
1367
+ * `ORDER BY` is always dropped: it cannot change a count, and retaining it makes the
1368
+ * count query illegal under PostgreSQL and MySQL's `ONLY_FULL_GROUP_BY` — so
1369
+ * `orderBy(...).paginate()`, the most common call in the framework, could not run there
1370
+ * at all.
1371
+ *
1372
+ * @category Aggregates
1373
+ */
1374
+ async count(): Promise<number> {
1375
+ this._beforeTerminal();
1376
+ const segs = this._countSegments();
1377
+ const rows = await this._run<{ _zerotal_count: number | bigint }>(segs);
1378
+ return Number(rows[0]?._zerotal_count ?? 0);
1379
+ }
1380
+
1381
+ /**
1382
+ * `SUM(column)`, coerced to a number (0 when the sum is NULL/no rows).
1383
+ * @throws {Error} When `column` is not a safe SQL identifier.
1384
+ * @category Aggregates
1385
+ */
1386
+ async sum(column: string): Promise<number> {
1387
+ this._beforeTerminal();
1388
+ const col = this._column(column);
1389
+ _assertIdentifier(col, "sum()");
1390
+ const segs = this._selectSegments(`SUM(${col}) as _zerotal_sum`, false);
1391
+ const rows = await this._run<{ _zerotal_sum: number | bigint | null }>(segs);
1392
+ return Number(rows[0]?._zerotal_sum ?? 0);
1393
+ }
1394
+
1395
+ /**
1396
+ * `AVG(column)`, coerced to a number (0 when NULL/no rows).
1397
+ * @throws {Error} When `column` is not a safe SQL identifier.
1398
+ * @category Aggregates
1399
+ */
1400
+ async avg(column: string): Promise<number> {
1401
+ this._beforeTerminal();
1402
+ const col = this._column(column);
1403
+ _assertIdentifier(col, "avg()");
1404
+ const segs = this._selectSegments(`AVG(${col}) as _zerotal_avg`, false);
1405
+ const rows = await this._run<{ _zerotal_avg: number | null }>(segs);
1406
+ return Number(rows[0]?._zerotal_avg ?? 0);
1407
+ }
1408
+
1409
+ /**
1410
+ * `MIN(column)`, coerced to a number (0 when NULL/no rows).
1411
+ * @throws {Error} When `column` is not a safe SQL identifier.
1412
+ * @category Aggregates
1413
+ */
1414
+ async min(column: string): Promise<number> {
1415
+ this._beforeTerminal();
1416
+ const col = this._column(column);
1417
+ _assertIdentifier(col, "min()");
1418
+ const segs = this._selectSegments(`MIN(${col}) as _zerotal_min`, false);
1419
+ const rows = await this._run<{ _zerotal_min: number | null }>(segs);
1420
+ return Number(rows[0]?._zerotal_min ?? 0);
1421
+ }
1422
+
1423
+ /**
1424
+ * `MAX(column)`, coerced to a number (0 when NULL/no rows).
1425
+ * @throws {Error} When `column` is not a safe SQL identifier.
1426
+ * @category Aggregates
1427
+ */
1428
+ async max(column: string): Promise<number> {
1429
+ this._beforeTerminal();
1430
+ const col = this._column(column);
1431
+ _assertIdentifier(col, "max()");
1432
+ const segs = this._selectSegments(`MAX(${col}) as _zerotal_max`, false);
1433
+ const rows = await this._run<{ _zerotal_max: number | null }>(segs);
1434
+ return Number(rows[0]?._zerotal_max ?? 0);
1435
+ }
1436
+
1437
+ /**
1438
+ * Whether at least one row matches. Runs `SELECT 1 … LIMIT 1` and restores the
1439
+ * previous limit/select state afterward.
1440
+ * @category Aggregates
1441
+ */
1442
+ async exists(): Promise<boolean> {
1443
+ this._beforeTerminal();
1444
+ const prev = { limit: this._state.limit, selects: this._state.selects };
1445
+ this._state.limit = 1;
1446
+ this._state.selects = ["1 as _zerotal_exists"];
1447
+ const rows = await this._run(this._selectSegments("1 as _zerotal_exists", false));
1448
+ this._state.limit = prev.limit;
1449
+ this._state.selects = prev.selects;
1450
+ return rows.length > 0;
1451
+ }
1452
+
1453
+ /**
1454
+ * Inverse of {@link exists} — true when no rows match.
1455
+ * @category Aggregates
1456
+ */
1457
+ async doesntExist(): Promise<boolean> {
1458
+ return !(await this.exists());
1459
+ }
1460
+
1461
+ /**
1462
+ * Return the single matching row, asserting uniqueness.
1463
+ * @throws {Error} When zero rows match, or when more than one row matches.
1464
+ * @category Execution
1465
+ */
1466
+ async sole<T = Record<string, unknown>>(): Promise<T> {
1467
+ const prev = this._state.limit;
1468
+ this._state.limit = 2;
1469
+ const rows = await this.get<T>();
1470
+ this._state.limit = prev;
1471
+ if (rows.length === 0) throw new Error("[Zerotal ORM] sole(): no records found.");
1472
+ if (rows.length > 1)
1473
+ throw new Error(`[Zerotal ORM] sole(): ${rows.length} records found, expected exactly one.`);
1474
+ return rows[0]!;
1475
+ }
1476
+
1477
+ /**
1478
+ * Insert a single row. Object keys become columns (each asserted); values are
1479
+ * bound. An empty object is a no-op. Ignores any WHERE clauses on the builder.
1480
+ * @throws {Error} When any column key is not a safe SQL identifier.
1481
+ * @category Insert / update / delete
1482
+ * @example
1483
+ * ```ts
1484
+ * await DB.table('users').insert({ email: 'a@example.com', active: true });
1485
+ * ```
1486
+ */
1487
+ async insert(data: Record<string, unknown>): Promise<void> {
1488
+ const cols = Object.keys(data).map((k) => this._column(k));
1489
+ const vals = Object.values(data);
1490
+ if (cols.length === 0) return;
1491
+ for (const c of cols) _assertIdentifier(c, "insert()");
1492
+
1493
+ const segs: Segment[] = [`INSERT INTO ${this._state.table} (${cols.join(", ")}) VALUES (`];
1494
+ vals.forEach((v, i) => {
1495
+ if (i > 0) segs.push(", ");
1496
+ segs.push({ val: v });
1497
+ });
1498
+ segs.push(")");
1499
+ await this._run(segs);
1500
+ }
1501
+
1502
+ /**
1503
+ * `UPDATE … SET …` for rows matching the current WHERE clauses. Object keys
1504
+ * become assigned columns (each asserted); values are bound. An empty object
1505
+ * is a no-op.
1506
+ * @throws {Error} When any column key is not a safe SQL identifier.
1507
+ * @category Insert / update / delete
1508
+ * @example
1509
+ * ```ts
1510
+ * await DB.table('users').where('id', 1).update({ active: false });
1511
+ * ```
1512
+ */
1513
+ async update(data: Record<string, unknown>): Promise<void> {
1514
+ this._beforeTerminal();
1515
+ const entries = Object.entries(data);
1516
+ if (entries.length === 0) return;
1517
+
1518
+ const segs: Segment[] = [`UPDATE ${this._state.table} SET `];
1519
+ entries.forEach(([key, val], i) => {
1520
+ const col = this._column(key);
1521
+ _assertIdentifier(col, "update()");
1522
+ if (i > 0) segs.push(", ");
1523
+ segs.push(`${col} = `);
1524
+ segs.push({ val });
1525
+ });
1526
+ this._appendWhere(segs);
1527
+ await this._run(segs);
1528
+ }
1529
+
1530
+ /**
1531
+ * Update rows matching `attributes`; insert a merged `{ ...attributes,
1532
+ * ...values }` row when none exist.
1533
+ * @returns `true` when a row was inserted, `false` when an existing row was
1534
+ * updated.
1535
+ * @category Insert / update / delete
1536
+ */
1537
+ async updateOrInsert(
1538
+ attributes: Record<string, unknown>,
1539
+ values: Record<string, unknown> = {},
1540
+ ): Promise<boolean> {
1541
+ const probe = this.clone();
1542
+ for (const [k, v] of Object.entries(attributes)) probe.where(k, v);
1543
+ const existing = await probe.clone().first();
1544
+ if (existing) {
1545
+ if (Object.keys(values).length > 0) await probe.update(values);
1546
+ return false;
1547
+ }
1548
+ await this.clone().insert({ ...attributes, ...values });
1549
+ return true;
1550
+ }
1551
+
1552
+ /**
1553
+ * `DELETE FROM …` for rows matching the current WHERE clauses.
1554
+ *
1555
+ * @remarks With no WHERE clauses this deletes every row in the table.
1556
+ * @category Insert / update / delete
1557
+ */
1558
+ async delete(): Promise<void> {
1559
+ this._beforeTerminal();
1560
+ const segs: Segment[] = [`DELETE FROM ${this._state.table}`];
1561
+ this._appendWhere(segs);
1562
+ await this._run(segs);
1563
+ }
1564
+
1565
+ /**
1566
+ * Atomically add `amount` (default 1) to `column` for matching rows
1567
+ * (`SET column = column + ?`).
1568
+ * @throws {Error} When `column` is not a safe SQL identifier.
1569
+ * @category Insert / update / delete
1570
+ */
1571
+ async increment(column: string, amount = 1): Promise<void> {
1572
+ this._beforeTerminal();
1573
+ const col = this._column(column);
1574
+ _assertIdentifier(col, "increment()");
1575
+ const segs: Segment[] = [`UPDATE ${this._state.table} SET ${col} = ${col} + `, { val: amount }];
1576
+ this._appendWhere(segs);
1577
+ await this._run(segs);
1578
+ }
1579
+
1580
+ /**
1581
+ * Atomically subtract `amount` (default 1) from `column` for matching rows
1582
+ * (`SET column = column - ?`).
1583
+ * @throws {Error} When `column` is not a safe SQL identifier.
1584
+ * @category Insert / update / delete
1585
+ */
1586
+ async decrement(column: string, amount = 1): Promise<void> {
1587
+ this._beforeTerminal();
1588
+ const col = this._column(column);
1589
+ _assertIdentifier(col, "decrement()");
1590
+ const segs: Segment[] = [`UPDATE ${this._state.table} SET ${col} = ${col} - `, { val: amount }];
1591
+ this._appendWhere(segs);
1592
+ await this._run(segs);
1593
+ }
1594
+
1595
+ // ── Chunking / streaming ──────────────────────────────────────────────
1596
+
1597
+ /**
1598
+ * Process results in fixed-size pages (offset-based). Return `false` from the
1599
+ * callback to stop early. Memory-safe for large tables.
1600
+ * @category Execution
1601
+ */
1602
+ async chunk<T = Record<string, unknown>>(
1603
+ size: number,
1604
+ callback: (rows: T[], page: number) => unknown | Promise<unknown>,
1605
+ ): Promise<void> {
1606
+ size = Math.max(1, size);
1607
+ let page = 1;
1608
+ for (;;) {
1609
+ const rows = await this.clone()
1610
+ .limit(size)
1611
+ .offset((page - 1) * size)
1612
+ .get<T>();
1613
+ if (rows.length === 0) break;
1614
+ const cont = await callback(rows, page);
1615
+ if (cont === false) break;
1616
+ if (rows.length < size) break;
1617
+ page++;
1618
+ }
1619
+ }
1620
+
1621
+ /**
1622
+ * Like `chunk()` but pages by an incrementing key (keyset). Stable when rows
1623
+ * are inserted/deleted during iteration. `column` defaults to `id`.
1624
+ * @category Execution
1625
+ */
1626
+ async chunkById<T = Record<string, unknown>>(
1627
+ size: number,
1628
+ callback: (rows: T[]) => unknown | Promise<unknown>,
1629
+ column = "id",
1630
+ ): Promise<void> {
1631
+ size = Math.max(1, size);
1632
+ const col = this._column(column);
1633
+ let lastId: unknown = 0;
1634
+ for (;;) {
1635
+ const rows = await this.clone()
1636
+ .where(col, ">", lastId)
1637
+ .reorder(col, "asc")
1638
+ .limit(size)
1639
+ .get<T>();
1640
+ if (rows.length === 0) break;
1641
+ const cont = await callback(rows);
1642
+ if (cont === false) break;
1643
+ lastId = this._keysetValue(rows[rows.length - 1] as Record<string, unknown>, col);
1644
+ if (rows.length < size) break;
1645
+ }
1646
+ }
1647
+
1648
+ /**
1649
+ * Async generator yielding one row at a time (offset-paged internally).
1650
+ * @category Execution
1651
+ */
1652
+ async *lazy<T = Record<string, unknown>>(size = 1000): AsyncGenerator<T> {
1653
+ size = Math.max(1, size);
1654
+ let page = 1;
1655
+ for (;;) {
1656
+ const rows = await this.clone()
1657
+ .limit(size)
1658
+ .offset((page - 1) * size)
1659
+ .get<T>();
1660
+ if (rows.length === 0) return;
1661
+ for (const r of rows) yield r;
1662
+ if (rows.length < size) return;
1663
+ page++;
1664
+ }
1665
+ }
1666
+
1667
+ /**
1668
+ * Async generator yielding one row at a time (keyset-paged on `column`,
1669
+ * default `id`).
1670
+ * @category Execution
1671
+ */
1672
+ async *lazyById<T = Record<string, unknown>>(size = 1000, column = "id"): AsyncGenerator<T> {
1673
+ size = Math.max(1, size);
1674
+ const col = this._column(column);
1675
+ let lastId: unknown = 0;
1676
+ for (;;) {
1677
+ const rows = await this.clone()
1678
+ .where(col, ">", lastId)
1679
+ .reorder(col, "asc")
1680
+ .limit(size)
1681
+ .get<T>();
1682
+ if (rows.length === 0) return;
1683
+ for (const r of rows) yield r;
1684
+ lastId = this._keysetValue(rows[rows.length - 1] as Record<string, unknown>, col);
1685
+ if (rows.length < size) return;
1686
+ }
1687
+ }
1688
+
1689
+ /**
1690
+ * Alias of {@link lazy} — stream rows one at a time.
1691
+ * @category Execution
1692
+ */
1693
+ cursor<T = Record<string, unknown>>(size = 1000): AsyncGenerator<T> {
1694
+ return this.lazy<T>(size);
1695
+ }
1696
+
1697
+ /**
1698
+ * Invoke `callback` for each row, streaming in pages. Return `false` from the
1699
+ * callback to stop early.
1700
+ * @category Execution
1701
+ */
1702
+ async each<T = Record<string, unknown>>(
1703
+ callback: (row: T, index: number) => unknown | Promise<unknown>,
1704
+ size = 1000,
1705
+ ): Promise<void> {
1706
+ let i = 0;
1707
+ for await (const row of this.lazy<T>(size)) {
1708
+ const cont = await callback(row, i++);
1709
+ if (cont === false) break;
1710
+ }
1711
+ }
1712
+
1713
+ // ── Debugging ─────────────────────────────────────────────────────────
1714
+
1715
+ /**
1716
+ * Compiled SELECT SQL with `?` placeholders. Does not execute.
1717
+ * @category Execution
1718
+ */
1719
+ toSql(): string {
1720
+ return this._compileSelect().sql;
1721
+ }
1722
+
1723
+ /**
1724
+ * `{ sql, bindings }` for the current SELECT. Does not execute.
1725
+ * @category Execution
1726
+ */
1727
+ toSqlWithBindings(): { sql: string; bindings: unknown[] } {
1728
+ return this._compileSelect();
1729
+ }
1730
+
1731
+ /**
1732
+ * SQL with bindings inlined as literals — for logging only. The result is
1733
+ * **not safe to execute** (values are not re-escaped for a driver).
1734
+ * @category Execution
1735
+ */
1736
+ toRawSql(): string {
1737
+ const { sql, bindings } = this._compileSelect();
1738
+ let i = 0;
1739
+ return sql.replace(/\?/g, () => _inlineValue(bindings[i++]));
1740
+ }
1741
+
1742
+ /**
1743
+ * Log the compiled SQL and bindings to the console, then return the builder
1744
+ * for continued chaining.
1745
+ * @category Execution
1746
+ */
1747
+ dump(): this {
1748
+ const { sql, bindings } = this._compileSelect();
1749
+
1750
+ console.log("[Zerotal ORM] SQL:", sql, "\nbindings:", bindings);
1751
+ return this;
1752
+ }
1753
+
1754
+ /**
1755
+ * Alias of {@link dump}. Note: despite the conventional `dd()` name, this does
1756
+ * NOT dump-and-die — it logs and returns for chaining.
1757
+ * @category Execution
1758
+ */
1759
+ dd(): this {
1760
+ return this.dump();
1761
+ }
1762
+
1763
+ /**
1764
+ * Run the query plan for the current SELECT and return the plan rows —
1765
+ * `EXPLAIN QUERY PLAN` on SQLite, `EXPLAIN` elsewhere.
1766
+ * @category Execution
1767
+ */
1768
+ async explain<T = Record<string, unknown>>(): Promise<T[]> {
1769
+ const cols = this._state.selects.length > 0 ? this._state.selects.join(", ") : "*";
1770
+ const segs = this._selectSegments(cols);
1771
+ const prefix = dialectFor(this._sql) === "sqlite" ? "EXPLAIN QUERY PLAN " : "EXPLAIN ";
1772
+ segs[0] = prefix + (segs[0] as string);
1773
+ return this._run<T>(segs);
1774
+ }
1775
+
1776
+ // ── Pagination ────────────────────────────────────────────────────────
1777
+
1778
+ /**
1779
+ * Offset-based pagination.
1780
+ *
1781
+ * Runs COUNT then SELECT — count ignores LIMIT/OFFSET by temporarily clearing
1782
+ * them; SELECT uses `this.get()` so subclass overrides (ModelQueryBuilder)
1783
+ * get model instances and eager-load relations automatically.
1784
+ *
1785
+ * @param perPage - Rows per page (clamped to ≥ 1).
1786
+ * @param page - 1-based page number. Omit it to use the request's current page
1787
+ * (the `?page=` query string, or a resolver a server-driven view registered).
1788
+ * @param pageName - Which paginator to read when `page` is omitted, so one request can
1789
+ * drive several independently. Defaults to `"page"`.
1790
+ * @returns A {@link PaginateResult} with `data`, `total`, `lastPage` and URL helpers.
1791
+ * @category Pagination
1792
+ */
1793
+ async paginate<T = Record<string, unknown>>(
1794
+ perPage = 15,
1795
+ page?: number,
1796
+ pageName = "page",
1797
+ ): Promise<PaginateResult<T>> {
1798
+ this._beforeTerminal();
1799
+ // No page given: read the one belonging to the request in flight — the `?page=` query
1800
+ // string, or whatever a server-driven view registered instead. Outside a request it is 1.
1801
+ page = Math.max(1, page ?? currentPage(pageName));
1802
+ perPage = Math.max(1, perPage);
1803
+
1804
+ // Save pagination state so we can restore after the two queries
1805
+ const savedLimit = this._state.limit;
1806
+ const savedOffset = this._state.offset;
1807
+
1808
+ // Count without any LIMIT / OFFSET applied
1809
+ this._state.limit = undefined;
1810
+ this._state.offset = undefined;
1811
+ const total = await this.count();
1812
+
1813
+ // Fetch the requested page — this.get() is polymorphic:
1814
+ // ModelQueryBuilder.get() maps rows → model instances + eager loads.
1815
+ this._state.limit = perPage;
1816
+ this._state.offset = (page - 1) * perPage;
1817
+ const data = await this.get<T>();
1818
+
1819
+ // Restore
1820
+ this._state.limit = savedLimit;
1821
+ this._state.offset = savedOffset;
1822
+
1823
+ return withPaginationHelpers({
1824
+ data,
1825
+ total,
1826
+ page,
1827
+ perPage,
1828
+ lastPage: Math.max(1, Math.ceil(total / perPage)),
1829
+ });
1830
+ }
1831
+
1832
+ /**
1833
+ * Cursor-based pagination using `WHERE <column> > cursor ORDER BY <column> ASC`
1834
+ * (`column` defaults to `id`).
1835
+ *
1836
+ * Avoids a `COUNT(*)` entirely — ideal for very large tables and
1837
+ * infinite-scroll UIs. Fetches `limit + 1` rows to detect whether a next page
1838
+ * exists, trims the extra row, and sets `nextCursor` to the last returned id.
1839
+ *
1840
+ * Results flow through `this.get()`, so a `ModelQueryBuilder` returns model
1841
+ * instances (with eager-loaded relations), while a raw `QueryBuilder` returns
1842
+ * plain rows.
1843
+ *
1844
+ * Returns `{ data, nextCursor, prevCursor, hasMore }`:
1845
+ * - `nextCursor` — pass to the next call's `cursor`; null on the last page.
1846
+ * - `prevCursor` — the cursor that produced the page before this one
1847
+ * (the incoming `cursor`), or null on the first page.
1848
+ * - `hasMore` — true when another page follows.
1849
+ *
1850
+ * Defaults: `{ cursor: 0, limit: 15, column: 'id' }`.
1851
+ *
1852
+ * @remarks Like {@link paginate}, the builder's clause state is snapshotted and
1853
+ * restored, so the query can be safely reused. `column` defaults to `id`; for
1854
+ * non-numeric sort keys and opaque cursors, prefer {@link keysetPaginate}.
1855
+ * @throws {Error} When `column` is not a safe SQL identifier.
1856
+ * @category Pagination
1857
+ */
1858
+ async cursorPaginate<T = Record<string, unknown>>(options?: {
1859
+ cursor?: number;
1860
+ limit?: number;
1861
+ column?: string;
1862
+ }): Promise<CursorPaginateResult<T>> {
1863
+ this._beforeTerminal();
1864
+ const limit = Math.max(1, options?.limit ?? 15);
1865
+ const cursor = options?.cursor ?? 0;
1866
+ const column = this._column(options?.column ?? "id");
1867
+ _assertIdentifier(column, "cursorPaginate()");
1868
+
1869
+ // Snapshot clause state so the builder is left untouched — reusing it must
1870
+ // not compound the cursor WHERE/ORDER clauses across calls.
1871
+ const savedWheres = this._state.wheres.length;
1872
+ const savedOrders = this._state.orders.length;
1873
+ const savedLimit = this._state.limit;
1874
+
1875
+ // Apply cursor filter when advancing past the first page
1876
+ if (cursor > 0) {
1877
+ this._state.wheres.push({ column, operator: ">", value: cursor, boolean: "and" });
1878
+ }
1879
+
1880
+ // Always order by the cursor column ascending so the cursor is predictable
1881
+ this._state.orders.push({ column, direction: "asc" });
1882
+
1883
+ // Fetch one extra row to know whether another page follows.
1884
+ // this.get() is polymorphic: ModelQueryBuilder maps rows → model instances.
1885
+ this._state.limit = limit + 1;
1886
+ const rows = await this.get<T>();
1887
+
1888
+ // Restore the snapshotted clause state.
1889
+ this._state.wheres.length = savedWheres;
1890
+ this._state.orders.length = savedOrders;
1891
+ this._state.limit = savedLimit;
1892
+
1893
+ const hasMore = rows.length > limit;
1894
+ const data = (hasMore ? rows.slice(0, limit) : rows) as T[];
1895
+ const lastRow = data[data.length - 1] as Record<string, unknown> | undefined;
1896
+ const nextCursor: number | null =
1897
+ hasMore && lastRow
1898
+ ? ((this._keysetValue(lastRow, column) as number | undefined) ?? null)
1899
+ : null;
1900
+ const prevCursor: number | null = cursor > 0 ? cursor : null;
1901
+
1902
+ return { data, nextCursor, prevCursor, hasMore };
1903
+ }
1904
+
1905
+ /**
1906
+ * "Simple" offset pagination — next/prev only, **no `COUNT(*)`**.
1907
+ *
1908
+ * Fetches `perPage + 1` rows to detect whether another page follows, then
1909
+ * trims the probe row. Use this instead of `paginate()` when you don't need
1910
+ * a total row count or numbered page links (cheaper on large tables).
1911
+ *
1912
+ * Results flow through `this.get()`, so a `ModelQueryBuilder` returns model
1913
+ * instances. Returns a `SimplePaginateResult` with `hasMorePages`, `page`,
1914
+ * and URL helpers — but no `total` or `lastPage`. Omit `page` to use the
1915
+ * request's current page, exactly like `paginate()`.
1916
+ *
1917
+ * @example
1918
+ * const page = await Post.query().orderBy('id').simplePaginate(15);
1919
+ * page.hasMorePages; // boolean
1920
+ * page.nextPageUrl(); // '?page=2' | null
1921
+ * @category Pagination
1922
+ */
1923
+ async simplePaginate<T = Record<string, unknown>>(
1924
+ perPage = 15,
1925
+ page?: number,
1926
+ pageName = "page",
1927
+ ): Promise<SimplePaginateResult<T>> {
1928
+ this._beforeTerminal();
1929
+ page = Math.max(1, page ?? currentPage(pageName));
1930
+ perPage = Math.max(1, perPage);
1931
+
1932
+ const savedLimit = this._state.limit;
1933
+ const savedOffset = this._state.offset;
1934
+
1935
+ // Fetch one extra row to know whether another page follows.
1936
+ this._state.limit = perPage + 1;
1937
+ this._state.offset = (page - 1) * perPage;
1938
+ const rows = await this.get<T>();
1939
+
1940
+ this._state.limit = savedLimit;
1941
+ this._state.offset = savedOffset;
1942
+
1943
+ const hasMore = rows.length > perPage;
1944
+ const data = (hasMore ? rows.slice(0, perPage) : rows) as T[];
1945
+
1946
+ return withSimplePaginationHelpers<T>({
1947
+ data,
1948
+ perPage,
1949
+ page,
1950
+ hasMore,
1951
+ });
1952
+ }
1953
+
1954
+ /**
1955
+ * Keyset (cursor) pagination — scales to any table size with no offset cost.
1956
+ *
1957
+ * Unlike `cursorPaginate()` this method:
1958
+ * - Accepts **any sort column** (not just `id`).
1959
+ * - Supports `'asc'` and `'desc'` ordering.
1960
+ * - Returns an **opaque base64 cursor** that encodes the sort value of the last
1961
+ * row, so clients cannot interpret or tamper with it.
1962
+ * - Adds a secondary tiebreaker on the primary key when the sort column is not unique,
1963
+ * ordered in the **same direction** as the primary sort, so page boundaries are stable.
1964
+ *
1965
+ * The tiebreaker's direction is not cosmetic. The cursor predicate compares the
1966
+ * tiebreaker with the primary sort's operator (`>` ascending, `<` descending), so an
1967
+ * `id ASC` tiebreaker under a `desc` sort asked for rows *before* the ones just
1968
+ * returned: within a tie group, page 1 emitted the lowest ids and page 2 then re-emitted
1969
+ * them while the rest of the group became unreachable.
1970
+ *
1971
+ * @example
1972
+ * // First page
1973
+ * const p1 = await db('posts').keysetPaginate({ column: 'created_at', direction: 'desc' });
1974
+ *
1975
+ * // Next page — pass the opaque cursor directly
1976
+ * const p2 = await db('posts').keysetPaginate({
1977
+ * column: 'created_at', direction: 'desc', cursor: p1.nextCursor,
1978
+ * });
1979
+ * @throws {Error} When `options.column` is not a safe SQL identifier.
1980
+ * @category Pagination
1981
+ */
1982
+ async keysetPaginate<T = Record<string, unknown>>(
1983
+ options?: KeysetOptions,
1984
+ ): Promise<KeysetPaginateResult<T>> {
1985
+ const limit = Math.max(1, options?.limit ?? 15);
1986
+ const column = this._column(options?.column ?? "id");
1987
+ const direction = options?.direction ?? "asc";
1988
+
1989
+ if (!_isSafeIdentifier(column)) {
1990
+ throw new Error(`keysetPaginate: unsafe column name "${column}"`);
1991
+ }
1992
+
1993
+ // The unique column that breaks ties in the sort column. `id` for a plain builder;
1994
+ // ModelQueryBuilder overrides this with the model's actual primary key, since a model
1995
+ // keyed on something else had its ties broken by a column that may not exist.
1996
+ const tiebreaker = this._keysetTiebreaker();
1997
+ if (!_isSafeIdentifier(tiebreaker)) {
1998
+ throw new Error(`keysetPaginate: unsafe tiebreaker column "${tiebreaker}"`);
1999
+ }
2000
+
2001
+ const cursor = options?.cursor ? _decodeCursor(options.cursor) : null;
2002
+
2003
+ // Snapshot clause state so the builder is left untouched — reusing it (or
2004
+ // fetching the next page from the same query) must not stack keyset clauses.
2005
+ const savedWheres = this._state.wheres.length;
2006
+ const savedOrders = this._state.orders.length;
2007
+ const savedLimit = this._state.limit;
2008
+
2009
+ if (cursor !== null) {
2010
+ const op = direction === "asc" ? ">" : "<";
2011
+ if (column === tiebreaker || cursor.id === undefined) {
2012
+ // Simple single-column keyset — the sort column is already unique.
2013
+ this._state.wheres.push({ column, operator: op, value: cursor.val, boolean: "and" });
2014
+ } else {
2015
+ // Compound: (col op val) OR (col = val AND tiebreaker op tie_val)
2016
+ // Uses whereRaw so the condition is parenthesised as a unit.
2017
+ this.whereRaw(`(${column} ${op} ? OR (${column} = ? AND ${tiebreaker} ${op} ?))`, [
2018
+ cursor.val,
2019
+ cursor.val,
2020
+ cursor.id,
2021
+ ]);
2022
+ }
2023
+ }
2024
+
2025
+ // Primary sort, then the tiebreaker in the SAME direction — the cursor predicate above
2026
+ // compares it with the primary operator, so the two have to agree.
2027
+ this._state.orders.push({ column, direction });
2028
+ if (column !== tiebreaker) {
2029
+ this._state.orders.push({ column: tiebreaker, direction });
2030
+ }
2031
+
2032
+ // Fetch one extra row to know whether another page follows.
2033
+ // this.get() is polymorphic: ModelQueryBuilder maps rows → model instances, applies
2034
+ // casts, strips `hidden` and runs eager loads. Calling _runSelect() directly skipped
2035
+ // all of that *and* the _beforeTerminal() hook that applies global scopes — so a
2036
+ // tenant- or soft-delete-scoped model came back unscoped, as raw rows, while typed as
2037
+ // KeysetPaginateResult<M>.
2038
+ this._state.limit = limit + 1;
2039
+ const rows = await this.get<Record<string, unknown>>();
2040
+
2041
+ // Restore the snapshotted clause state.
2042
+ this._state.wheres.length = savedWheres;
2043
+ this._state.orders.length = savedOrders;
2044
+ this._state.limit = savedLimit;
2045
+
2046
+ const hasMore = rows.length > limit;
2047
+ const data = (hasMore ? rows.slice(0, limit) : rows) as T[];
2048
+ const lastRow = data[data.length - 1] as Record<string, unknown> | undefined;
2049
+
2050
+ const nextCursor =
2051
+ hasMore && lastRow
2052
+ ? _encodeCursor({
2053
+ col: column,
2054
+ val: this._keysetValue(lastRow, column),
2055
+ id: column !== tiebreaker ? this._keysetValue(lastRow, tiebreaker) : undefined,
2056
+ })
2057
+ : null;
2058
+
2059
+ return { data, nextCursor };
2060
+ }
2061
+
2062
+ /**
2063
+ * The unique column that breaks ties in a keyset sort. `id` here; the model builder
2064
+ * overrides it with the model's declared primary key.
2065
+ * @internal
2066
+ */
2067
+ protected _keysetTiebreaker(): string {
2068
+ return "id";
2069
+ }
2070
+
2071
+ /**
2072
+ * Read a keyset column off a result row.
2073
+ *
2074
+ * Rows come back as model instances under the model builder, where a `created_at` column
2075
+ * is exposed as `createdAt` — so the DB column name alone does not find the value, and a
2076
+ * cursor built from `undefined` restarts pagination from the top.
2077
+ * @internal
2078
+ */
2079
+ protected _keysetValue(row: Record<string, unknown>, column: string): unknown {
2080
+ if (column in row) return row[column];
2081
+ // Shared camel helper — its regex matches hydration exactly, so the lookup
2082
+ // always finds what fromRow() actually named the property.
2083
+ return row[toCamelKey(column)];
2084
+ }
2085
+
2086
+ // ── Private helpers ───────────────────────────────────────────────────
2087
+
2088
+ private _addWhere(
2089
+ column: string,
2090
+ op: WhereClause["operator"],
2091
+ value: unknown,
2092
+ boolean: "and" | "or",
2093
+ ): void {
2094
+ column = this._column(column);
2095
+ _assertIdentifier(column, "where()");
2096
+ value = this._bind(column, value, op as WhereOperator);
2097
+ // Normalize null comparisons: `col = NULL` / `col != NULL` never match in SQL,
2098
+ // so a null value with an (in)equality operator becomes IS [NOT] NULL.
2099
+ if (value === null && (op === "=" || op === "!=" || op === "<>")) {
2100
+ this._state.wheres.push({
2101
+ column,
2102
+ operator: op === "=" ? "is null" : "is not null",
2103
+ value: null,
2104
+ boolean,
2105
+ });
2106
+ return;
2107
+ }
2108
+ this._state.wheres.push({ column, operator: op, value, boolean });
2109
+ }
2110
+
2111
+ private _runSelect<T = Record<string, unknown>>(): Promise<T[]> {
2112
+ const cols = this._state.selects.length > 0 ? this._state.selects.join(", ") : "*";
2113
+ return this._run<T>(this._selectSegments(cols));
2114
+ }
2115
+
2116
+ private _compileSelect(): { sql: string; bindings: unknown[] } {
2117
+ const cols = this._state.selects.length > 0 ? this._state.selects.join(", ") : "*";
2118
+ const segs = this._selectSegments(cols);
2119
+ let sql = "";
2120
+ const bindings: unknown[] = [];
2121
+ for (const seg of segs) {
2122
+ if (typeof seg === "string") sql += seg;
2123
+ else {
2124
+ sql += "?";
2125
+ bindings.push(seg.val);
2126
+ }
2127
+ }
2128
+ return { sql, bindings };
2129
+ }
2130
+
2131
+ /**
2132
+ * Build the segments for `count()`.
2133
+ *
2134
+ * Grouped, distinct and unioned queries return a different number of rows than a bare
2135
+ * `COUNT(*)` over the same WHERE clause, so those are counted by wrapping the query as a
2136
+ * subquery. Everything else takes the direct form, which avoids the extra nesting on the
2137
+ * overwhelmingly common case.
2138
+ */
2139
+ private _countSegments(): Segment[] {
2140
+ const needsSubquery =
2141
+ this._state.groupBys.length > 0 ||
2142
+ this._state.distinct ||
2143
+ this._state.unions.length > 0 ||
2144
+ this._state.havings.length > 0;
2145
+
2146
+ // ORDER BY never affects a count and is invalid inside an aggregate over a grouped
2147
+ // query on Postgres/MySQL, so it is dropped in both forms.
2148
+ const inner = this.clone();
2149
+ inner._state.orders = [];
2150
+
2151
+ if (!needsSubquery) {
2152
+ return inner._selectSegments("COUNT(*) as _zerotal_count", false);
2153
+ }
2154
+
2155
+ // The inner query keeps its own select list: with DISTINCT, *what* is being made
2156
+ // distinct is the whole question, and replacing it with `*` counts raw rows again.
2157
+ const cols = inner._state.selects.length > 0 ? inner._state.selects.join(", ") : "*";
2158
+ const segs: Segment[] = ["SELECT COUNT(*) as _zerotal_count FROM ("];
2159
+ segs.push(...inner._selectSegments(cols));
2160
+ segs.push(") as _zerotal_count_sub");
2161
+ return segs;
2162
+ }
2163
+
2164
+ private _selectSegments(cols: string, applyDistinct = true): Segment[] {
2165
+ const distinct = applyDistinct && this._state.distinct ? "DISTINCT " : "";
2166
+ const segs: Segment[] = [`SELECT ${distinct}${cols} FROM ${this._state.table}`];
2167
+
2168
+ this._appendJoins(segs);
2169
+ this._appendWhere(segs);
2170
+
2171
+ if (this._state.groupBys.length > 0) {
2172
+ segs.push(` GROUP BY ${this._state.groupBys.join(", ")}`);
2173
+ }
2174
+
2175
+ if (this._state.havings.length > 0) {
2176
+ this._state.havings.forEach((h, i) => {
2177
+ segs.push(i === 0 ? " HAVING " : " AND ");
2178
+ segs.push(`${h.column} ${h.operator} `);
2179
+ segs.push({ val: h.value });
2180
+ });
2181
+ }
2182
+
2183
+ for (const u of this._state.unions) {
2184
+ segs.push(` UNION ${u.all ? "ALL " : ""}`);
2185
+ _pushSqlWithBindings(segs, u.sql, u.bindings);
2186
+ }
2187
+
2188
+ if (this._state.orders.length > 0) {
2189
+ const ords = this._state.orders
2190
+ .map((o) =>
2191
+ o.direction === "__raw__" ? o.column : `${o.column} ${o.direction.toUpperCase()}`,
2192
+ )
2193
+ .join(", ");
2194
+ segs.push(` ORDER BY ${ords}`);
2195
+ }
2196
+
2197
+ if (this._state.limit !== undefined) {
2198
+ segs.push(" LIMIT ");
2199
+ segs.push({ val: this._state.limit });
2200
+ }
2201
+
2202
+ if (this._state.offset !== undefined) {
2203
+ segs.push(" OFFSET ");
2204
+ segs.push({ val: this._state.offset });
2205
+ }
2206
+
2207
+ if (this._state.lock && dialectFor(this._sql) !== "sqlite") {
2208
+ segs.push(` ${this._state.lock}`);
2209
+ }
2210
+
2211
+ return segs;
2212
+ }
2213
+
2214
+ private _appendJoins(segs: Segment[]): void {
2215
+ for (const j of this._state.joins) {
2216
+ if (j.type === "cross") {
2217
+ segs.push(` CROSS JOIN ${j.table}`);
2218
+ continue;
2219
+ }
2220
+ const kw = j.type === "left" ? "LEFT JOIN" : j.type === "right" ? "RIGHT JOIN" : "INNER JOIN";
2221
+ segs.push(` ${kw} `);
2222
+ _pushSqlWithBindings(segs, j.table, j.bindings ?? []);
2223
+ segs.push(` ON ${j.first} ${j.operator} ${j.second}`);
2224
+ }
2225
+ }
2226
+
2227
+ /**
2228
+ * Build a parenthesised group of predicates from a callback.
2229
+ *
2230
+ * The callback receives a scratch builder; whatever it accumulates is appended as one
2231
+ * `__group__` clause. This is what lets an `OR` chain be contained:
2232
+ * `.where("a", 1).where(q => q.where("b", 2).orWhere("c", 3))` compiles to
2233
+ * `a = ? AND (b = ? OR c = ?)` rather than `a = ? AND b = ? OR c = ?`.
2234
+ *
2235
+ * @category Where clauses
2236
+ * @internal
2237
+ */
2238
+ private _addWhereGroup(build: (query: this) => void, boolean: "and" | "or"): this {
2239
+ const scratch = this.clone() as this;
2240
+ scratch._state.wheres = [];
2241
+ build(scratch);
2242
+ const inner = scratch._state.wheres;
2243
+ if (inner.length === 0) return this; // nothing to add — do not emit an empty ()
2244
+ this._state.wheres.push({
2245
+ column: "",
2246
+ operator: "__group__",
2247
+ value: undefined,
2248
+ boolean,
2249
+ group: inner,
2250
+ });
2251
+ return this;
2252
+ }
2253
+
2254
+ /**
2255
+ * Wrap every predicate added since {@link _userWhereStart} in a single group.
2256
+ *
2257
+ * Framework-injected predicates — the soft-delete `deleted_at IS NULL` seeded by
2258
+ * `BaseModel.query()`, and global scopes appended at terminal time — must AND with the
2259
+ * caller's predicates as a whole, not join their chain. They did not:
2260
+ *
2261
+ * User.query().where("role","admin").orWhere("role","owner")
2262
+ * -> WHERE deleted_at IS NULL AND role = ? OR role = ? AND tenant_id = ?
2263
+ *
2264
+ * The bare `OR` splits the chain, so the second arm carried neither the soft-delete
2265
+ * predicate nor the tenant scope — returning trashed rows and other tenants' rows. Grouping
2266
+ * produces `deleted_at IS NULL AND (role = ? OR role = ?) AND tenant_id = ?`.
2267
+ *
2268
+ * Only groups when the caller's predicates actually contain an `OR`; a pure `AND` chain is
2269
+ * unaffected by grouping, and skipping it keeps the emitted SQL unchanged in the common case.
2270
+ *
2271
+ * Idempotent: a second call finds a single already-grouped clause and does nothing.
2272
+ *
2273
+ * @category Where clauses
2274
+ * @internal
2275
+ */
2276
+ /**
2277
+ * Record that all subsequent predicates are caller-supplied.
2278
+ *
2279
+ * Called by `BaseModel.query()` once framework predicates (soft deletes) are seeded.
2280
+ *
2281
+ * @category Where clauses
2282
+ * @internal
2283
+ */
2284
+ _markUserWhereStart(): void {
2285
+ this._userWhereStart = this._state.wheres.length;
2286
+ }
2287
+
2288
+ protected _groupUserWheres(): void {
2289
+ const start = this._userWhereStart;
2290
+ const wheres = this._state.wheres;
2291
+ if (start >= wheres.length) return;
2292
+
2293
+ const userWheres = wheres.slice(start);
2294
+ if (userWheres.length < 2) return;
2295
+ if (!userWheres.some((w) => w.boolean === "or")) return;
2296
+
2297
+ this._state.wheres = [
2298
+ ...wheres.slice(0, start),
2299
+ {
2300
+ column: "",
2301
+ operator: "__group__",
2302
+ value: undefined,
2303
+ // The group as a whole joins with AND. Its first member's own boolean is irrelevant,
2304
+ // since the renderer skips the connective for index 0.
2305
+ boolean: "and",
2306
+ group: userWheres,
2307
+ },
2308
+ ];
2309
+ }
2310
+
2311
+ private _appendWhere(segs: Segment[]): void {
2312
+ this._renderWheres(segs, this._state.wheres, true);
2313
+ }
2314
+
2315
+ /**
2316
+ * Render a list of WHERE predicates, recursing into `__group__` clauses.
2317
+ *
2318
+ * @param top - True for the outermost list, which emits the ` WHERE ` keyword. Nested groups
2319
+ * emit parentheses instead.
2320
+ */
2321
+ private _renderWheres(segs: Segment[], wheres: WhereClause[], top: boolean): void {
2322
+ wheres.forEach((w, i) => {
2323
+ if (i === 0) segs.push(top ? " WHERE " : "");
2324
+ else segs.push(` ${w.boolean.toUpperCase()} `);
2325
+
2326
+ if (w.operator === "__group__") {
2327
+ const inner = w.group ?? [];
2328
+ if (inner.length === 0) {
2329
+ // An empty group must not emit `()`, which is a syntax error. `1 = 1` is the
2330
+ // identity for the AND it sits in.
2331
+ segs.push("1 = 1");
2332
+ return;
2333
+ }
2334
+ segs.push("(");
2335
+ this._renderWheres(segs, inner, false);
2336
+ segs.push(")");
2337
+ return;
2338
+ }
2339
+
2340
+ if (w.operator === "__raw__") {
2341
+ // whereRaw: column holds the SQL fragment, value holds the bindings array.
2342
+ // One shared splicer — this used to be a second copy that dropped an unbound `?`.
2343
+ _pushSqlWithBindings(segs, w.column, w.value as unknown[]);
2344
+ } else if (w.operator === "is null") {
2345
+ segs.push(`${w.column} IS NULL`);
2346
+ } else if (w.operator === "is not null") {
2347
+ segs.push(`${w.column} IS NOT NULL`);
2348
+ } else if (w.operator === "in" || w.operator === "not in") {
2349
+ const inVals = w.value as unknown[];
2350
+ const kw = w.operator === "in" ? "IN" : "NOT IN";
2351
+ if (inVals.length === 0) {
2352
+ segs.push(w.operator === "in" ? `1 = 0` : `1 = 1`); // empty IN() false; empty NOT IN() true
2353
+ } else {
2354
+ segs.push(`${w.column} ${kw} (`);
2355
+ inVals.forEach((v, j) => {
2356
+ if (j > 0) segs.push(", ");
2357
+ segs.push({ val: v });
2358
+ });
2359
+ segs.push(")");
2360
+ }
2361
+ } else {
2362
+ segs.push(`${w.column} ${w.operator} `);
2363
+ segs.push({ val: w.value });
2364
+ }
2365
+ });
2366
+ }
2367
+
2368
+ /**
2369
+ * Hook invoked at the top of every terminal method — the last point at which a subclass may
2370
+ * still mutate builder state before SQL is compiled.
2371
+ *
2372
+ * The base builder has nothing to do here. {@link ModelQueryBuilder} overrides it to apply
2373
+ * global scopes (tenancy, soft deletes, and any `addGlobalScope` registration). Scopes were
2374
+ * previously applied only in `get()` and `first()`, which left `update()`, `delete()`,
2375
+ * `count()` and every aggregate running **unscoped** — a tenant-scoped mass update crossed
2376
+ * the tenant boundary and soft-deleted rows were counted. Routing every terminal through one
2377
+ * hook is what makes the scope contract in `Tenantable`'s docblock actually true.
2378
+ *
2379
+ * Implementations must be idempotent: `clone()`-based terminals can reach it more than once.
2380
+ *
2381
+ * @category Execution
2382
+ * @internal
2383
+ */
2384
+ protected _beforeTerminal(): void {}
2385
+
2386
+ private async _run<T = Record<string, unknown>>(segs: Segment[]): Promise<T[]> {
2387
+ // Read the transaction from AsyncLocalStorage, never from RequestContext._transaction.
2388
+ //
2389
+ // `ctx._transaction` is a single slot on the per-request context, so it cannot represent
2390
+ // two transactions at once. This method used to prefer it over `this._sql`, which meant
2391
+ // that when two transactions overlapped within one request, statements from one landed on
2392
+ // the other's connection — a transfer's debit and credit could end up in different
2393
+ // transactions, so rolling one back debited without crediting. DB.transaction()'s `finally`
2394
+ // clearing the same slot made it worse, since the inner transaction's cleanup blanked the
2395
+ // outer one's entry.
2396
+ //
2397
+ // TransactionContext is an ALS store, so it follows the async call stack and is correct
2398
+ // under concurrency. Priority matches _resolveConn's documented contract: an active ALS
2399
+ // transaction wins, otherwise the connection this builder was constructed with — which
2400
+ // _resolveConn has already resolved (including the legacy ctx._transaction fallback) at
2401
+ // build time.
2402
+ return _runSegments<T>(TransactionContext.getStore() ?? this._sql, segs, true);
2403
+ }
2404
+ }
2405
+
2406
+ // ── Module-private helpers ────────────────────────────────────────────────────
2407
+
2408
+ /** Split a SQL fragment on `?` and interleave binding segments. */
2409
+ /**
2410
+ * Split raw SQL on `?` and interleave the supplied bindings.
2411
+ *
2412
+ * A `?` with no binding left to consume is emitted back as a literal `?`. Dropping it — the
2413
+ * previous behaviour — silently rewrote the SQL: `whereRaw("name LIKE 'Who?%'")` became
2414
+ * `LIKE 'Who%'` and returned the wrong rows with no error, and PostgreSQL's jsonb
2415
+ * key-existence operator (`data ? 'key'`) was destroyed outright.
2416
+ */
2417
+ function _pushSqlWithBindings(segs: Segment[], sql: string, bindings: unknown[]): void {
2418
+ const parts = sql.split("?");
2419
+ parts.forEach((part, idx) => {
2420
+ segs.push(part);
2421
+ if (idx === parts.length - 1) return; // trailing fragment — no `?` followed it
2422
+ if (idx < bindings.length) segs.push({ val: bindings[idx] });
2423
+ else segs.push("?");
2424
+ });
2425
+ }
2426
+
2427
+ /**
2428
+ * Inline a binding value as a SQL literal. Used for display SQL (`toRawSql`)
2429
+ * and by the model builder's relation-aggregate sub-selects, whose constrained
2430
+ * form inlines its bindings — so Carbon/Date values must render as their DB
2431
+ * representation, not `String(new Date())`. Quote-doubling is the only
2432
+ * escaping; treat output as executable only in the aggregate-subquery path,
2433
+ * whose inputs already flowed through the builder's identifier/binding guards.
2434
+ */
2435
+ export function _inlineValue(v: unknown): string {
2436
+ if (v === null || v === undefined) return "NULL";
2437
+ if (typeof v === "number" || typeof v === "bigint") return String(v);
2438
+ if (typeof v === "boolean") return v ? "1" : "0";
2439
+ if (v instanceof Carbon) return `'${v.toDatabase()}'`;
2440
+ if (v instanceof Date) return `'${v.toISOString()}'`;
2441
+ return `'${String(v).replace(/'/g, "''")}'`;
2442
+ }
2443
+
2444
+ // ── Keyset cursor helpers (module-private) ────────────────────────────────────
2445
+
2446
+ interface _CursorPayload {
2447
+ col: string;
2448
+ val: unknown;
2449
+ id?: unknown;
2450
+ }
2451
+
2452
+ function _encodeCursor(payload: _CursorPayload): string {
2453
+ return btoa(JSON.stringify(payload));
2454
+ }
2455
+
2456
+ function _decodeCursor(cursor: string): _CursorPayload | null {
2457
+ return rescueSync(() => JSON.parse(atob(cursor)) as _CursorPayload, null);
2458
+ }