@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +58 -0
- package/src/casts/Cast.ts +200 -0
- package/src/commands/DbSeedCommand.ts +71 -0
- package/src/commands/MakeFactoryCommand.ts +59 -0
- package/src/commands/MakeMigrationCommand.ts +109 -0
- package/src/commands/MakeModelCommand.ts +83 -0
- package/src/commands/MakeSeederCommand.ts +50 -0
- package/src/commands/MigrateCommand.ts +60 -0
- package/src/commands/MigrateFreshCommand.ts +41 -0
- package/src/commands/MigrateGenerateCommand.ts +110 -0
- package/src/commands/MigrateRollbackCommand.ts +43 -0
- package/src/commands/MigrateStatusCommand.ts +49 -0
- package/src/commands/_loadMigrations.ts +34 -0
- package/src/commands/index.ts +30 -0
- package/src/config.ts +182 -0
- package/src/conventions.ts +67 -0
- package/src/db/DB.ts +486 -0
- package/src/db/NPlusOneDetector.ts +176 -0
- package/src/db/QueryBuilder.ts +2458 -0
- package/src/db/ReadWriteRouter.ts +96 -0
- package/src/db/TransactionContext.ts +13 -0
- package/src/db/dialects/MysqlDialect.ts +57 -0
- package/src/db/dialects/PostgresDialect.ts +55 -0
- package/src/db/dialects/SqliteDialect.ts +54 -0
- package/src/db/dialects/index.ts +25 -0
- package/src/db/dialects/types.ts +67 -0
- package/src/db/resolver.ts +30 -0
- package/src/db/sql-types.ts +12 -0
- package/src/db/types.ts +296 -0
- package/src/errors/MassAssignmentError.ts +25 -0
- package/src/errors/MigrationError.ts +18 -0
- package/src/errors/ModelNotFoundError.ts +21 -0
- package/src/errors/NPlusOneError.ts +6 -0
- package/src/errors/RelationNotLoadedError.ts +19 -0
- package/src/errors/StateError.ts +18 -0
- package/src/errors/TransactionError.ts +13 -0
- package/src/errors/UnsupportedDialectError.ts +18 -0
- package/src/errors/index.ts +7 -0
- package/src/events.ts +112 -0
- package/src/global.d.ts +17 -0
- package/src/implicitBinding.ts +73 -0
- package/src/index.ts +255 -0
- package/src/model/BaseModel.ts +2499 -0
- package/src/model/ModelQueryBuilder.ts +1808 -0
- package/src/model/Observer.ts +73 -0
- package/src/model/OrmContext.ts +71 -0
- package/src/model/ReactiveProxy.ts +53 -0
- package/src/model/SoftDeletes.ts +108 -0
- package/src/model/State.ts +290 -0
- package/src/model/decorators/_metadata.ts +211 -0
- package/src/model/decorators/_registerRelation.ts +20 -0
- package/src/model/decorators/belongsTo.ts +38 -0
- package/src/model/decorators/column.ts +278 -0
- package/src/model/decorators/hasMany.ts +34 -0
- package/src/model/decorators/hasManyThrough.ts +50 -0
- package/src/model/decorators/hasOne.ts +34 -0
- package/src/model/decorators/hasOneThrough.ts +40 -0
- package/src/model/decorators/manyToMany.ts +55 -0
- package/src/model/decorators/morphMany.ts +38 -0
- package/src/model/decorators/morphOne.ts +38 -0
- package/src/model/decorators/morphTo.ts +51 -0
- package/src/model/decorators/morphToMany.ts +49 -0
- package/src/model/decorators/morphedByMany.ts +46 -0
- package/src/model/decorators/table.ts +124 -0
- package/src/model/hooks/HookRegistry.ts +110 -0
- package/src/model/mixins.ts +536 -0
- package/src/model/payload.ts +114 -0
- package/src/model/relations/RelationRegistry.ts +184 -0
- package/src/observability.ts +210 -0
- package/src/provider/DatabaseProvider.ts +266 -0
- package/src/schema/Blueprint.ts +900 -0
- package/src/schema/ColumnDefinition.ts +517 -0
- package/src/schema/Migration.ts +34 -0
- package/src/schema/MigrationCodegen.ts +108 -0
- package/src/schema/MigrationRunner.ts +351 -0
- package/src/schema/ModelInspector.ts +133 -0
- package/src/schema/Schema.ts +140 -0
- package/src/schema/SchemaDiffer.ts +137 -0
- package/src/schema/SchemaInspector.ts +164 -0
- package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
- package/src/schema/autoMigrate.ts +154 -0
- package/src/schema/index.ts +28 -0
- package/src/seeding/Seeder.ts +46 -0
- 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
|
+
}
|