@bakery-framework/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.
@@ -0,0 +1,1680 @@
1
+ import { Case } from '@bakery-framework/core/utils'
2
+ import { throws } from '@bakery-framework/core/utils/common'
3
+ import { getActiveDb, txStorage } from '../connection'
4
+ import type { AppDBSchema as DBSchema } from '../schema-registry'
5
+ import {
6
+ evalOperands,
7
+ isSafeIdentifier,
8
+ qId,
9
+ qRaw,
10
+ SQL_FUNCTIONS,
11
+ col as schemaCol,
12
+ ColumnRef as schemaColumnRef,
13
+ OperatorRef as schemaOperatorRef,
14
+ SQLFunctionRef as schemaSQLFunctionRef,
15
+ WindowRef as schemaWindowRef,
16
+ } from '../schema-util'
17
+ import { Mutation } from './mutation'
18
+
19
+ export namespace DB {
20
+ export type MapOf<T> = Record<string, T>
21
+
22
+ /**
23
+ * Bound a query that only needs its first row. `.get()` otherwise runs the
24
+ * full query and materializes every row into JS objects before discarding all
25
+ * but one — `first()` on an unfiltered table scanned the whole table.
26
+ */
27
+ function singleRow(sql: string): string {
28
+ return /\bLIMIT\s+\d+/i.test(sql) ? sql : `${sql} LIMIT 1`
29
+ }
30
+
31
+ /**
32
+ * Join keywords the builder may emit. `FULL` renders as `FULL JOIN`, which
33
+ * both dialects that have it accept as a synonym for `FULL OUTER JOIN`.
34
+ */
35
+ const JOIN_TYPES = new Set(['INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS'])
36
+
37
+ /** LIMIT/OFFSET are interpolated, so they must be real non-negative integers. */
38
+ function toRowCount(value: unknown, label: string): number {
39
+ const n = Math.trunc(Number(value))
40
+ if (!Number.isFinite(n) || n < 0) {
41
+ throws(`Invalid ${label}: ${String(value)}`)
42
+ }
43
+ return n
44
+ }
45
+
46
+ export function safeColumn(colStr: string): string {
47
+ if (colStr === '*') return colStr
48
+
49
+ // A matched pair of parens used to return the input completely unchecked,
50
+ // so `orderBy('(1) UNION SELECT password FROM users --')` sailed straight
51
+ // into the query. Parse the FN(col) form and validate both halves instead.
52
+ const fnCall = colStr.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)\s*$/s)
53
+ if (fnCall) {
54
+ const fnName = fnCall[1]!.toUpperCase()
55
+ if (!SQL_FUNCTIONS.has(fnName)) {
56
+ throw new Error(`Unsupported SQL function: ${fnCall[1]}`)
57
+ }
58
+ const inner = fnCall[2]!.trim()
59
+ return `${fnName}(${inner === '*' ? '*' : safeColumn(inner)})`
60
+ }
61
+
62
+ if (colStr.includes('(') || colStr.includes(')')) {
63
+ throw new Error(`Invalid or unsafe column/table name: ${colStr}`)
64
+ }
65
+
66
+ return colStr
67
+ .split('.')
68
+ .map(part => {
69
+ if (part === '*') return part
70
+ if (!isSafeIdentifier(part)) {
71
+ throw new Error(`Invalid or unsafe column/table name: ${part}`)
72
+ }
73
+ return qId(part)
74
+ })
75
+ .join('.')
76
+ }
77
+
78
+ export type Tables = keyof DBSchema
79
+ export type TableSchemas = DBSchema & { [key: string]: any }
80
+ export type ValidAlias<A> = A extends string
81
+ ? A extends ''
82
+ ? never
83
+ : A
84
+ : never
85
+ export type ExtractTableFromDot<T extends string> =
86
+ T extends `${infer Table}.${infer _Col}` ? Table : T
87
+
88
+ export type ResolveTableSchema<S, T extends string> =
89
+ ExtractTableFromDot<T> extends keyof DBSchema
90
+ ? DBSchema[ExtractTableFromDot<T>]
91
+ : ExtractTableFromDot<T> extends keyof S
92
+ ? S[ExtractTableFromDot<T>]
93
+ : any
94
+
95
+ export type NewJoinedTable<
96
+ S extends TableSchemas,
97
+ A extends string | undefined,
98
+ T extends string,
99
+ > =
100
+ ValidAlias<A> extends never
101
+ ? Record<ExtractTableFromDot<T>, ResolveTableSchema<S, T>>
102
+ : Record<ValidAlias<A>, ResolveTableSchema<S, T>>
103
+
104
+ export type NewJoinedScope<
105
+ J extends string,
106
+ A extends string | undefined,
107
+ T extends string,
108
+ > =
109
+ ValidAlias<A> extends never ? J | ExtractTableFromDot<T> : J | ValidAlias<A>
110
+
111
+ export type NewTable<
112
+ S extends TableSchemas,
113
+ A extends string | undefined,
114
+ T extends string,
115
+ > = S & NewJoinedTable<S, A, T>
116
+
117
+ export type ColumnString<S, J extends string> =
118
+ | {
119
+ [T in J]: T extends keyof S
120
+ ? `${T}.${Extract<keyof S[T] & string, string>}`
121
+ : T extends keyof DBSchema
122
+ ? `${T}.${Extract<keyof DBSchema[T] & string, string>}`
123
+ : never
124
+ }[J]
125
+ | (J extends keyof S
126
+ ? Extract<keyof S[J] & string, string>
127
+ : J extends keyof DBSchema
128
+ ? Extract<keyof DBSchema[J] & string, string>
129
+ : never)
130
+
131
+ /**
132
+ * Every `table.column` of every table the app declared.
133
+ *
134
+ * Deliberately takes no type parameter, though it used to take an unused
135
+ * `S`. That read as "columns of the schema in scope" and is not what it
136
+ * means: the right-hand side of a join names a table you are *adding*, so
137
+ * it cannot already be in scope. The parameter was ignored in the body,
138
+ * so every call site got this answer anyway — it only misled the reader.
139
+ */
140
+ export type AllTableColumns = {
141
+ [T in keyof DBSchema]: `${T & string}.${Extract<keyof DBSchema[T] & string, string>}`
142
+ }[keyof DBSchema]
143
+
144
+ export type WhereValue<C extends string> =
145
+ | schemaOperatorRef<any>
146
+ | schemaColumnRef<C>
147
+ | QBRaw
148
+ | QBObject
149
+ | string
150
+ | number
151
+ | boolean
152
+ | null
153
+
154
+ export type WhereColumn<S extends TableSchemas, J extends string> =
155
+ | ColumnString<S, J>
156
+ | SQLFunctionRef<ColumnString<S, J>>
157
+ | QBRaw
158
+ | QBObject
159
+
160
+ export type ResolveColumnString<
161
+ S,
162
+ T extends string,
163
+ > = T extends `${infer Table}.${infer Col}`
164
+ ? Table extends keyof S
165
+ ? Col extends keyof S[Table]
166
+ ? S[Table][Col]
167
+ : never
168
+ : never
169
+ : { [K in keyof S]: T extends keyof S[K] ? S[K][T] : never }[keyof S]
170
+
171
+ // Defined in schema-util so `evalOperands` can use a real `instanceof`.
172
+ export const SQLFunctionRef = schemaSQLFunctionRef
173
+ export type SQLFunctionRef<C extends string = string> =
174
+ schemaSQLFunctionRef<C>
175
+
176
+ /**
177
+ * `COUNT`, `SUM` and `AVG` also come in a `.distinct` form —
178
+ * `DB.count.distinct('users.city')` emits `COUNT(DISTINCT "users"."city")`.
179
+ *
180
+ * Only these three. `DISTINCT` is legal inside `MIN`/`MAX` on every dialect
181
+ * and cannot change their result, so offering it there would imply an effect
182
+ * that does not exist — the same reason the builder has no per-column
183
+ * `distinct('col')`.
184
+ */
185
+ const aggregate = <N extends string>(fnName: N) =>
186
+ Object.assign(
187
+ <C extends string = string>(col: C): SQLFunctionRef<C> =>
188
+ new SQLFunctionRef(fnName, col),
189
+ {
190
+ distinct: <C extends string = string>(col: C): SQLFunctionRef<C> =>
191
+ new SQLFunctionRef(fnName, col, [], true),
192
+ },
193
+ )
194
+
195
+ export const count = aggregate('COUNT')
196
+ export const sum = aggregate('SUM')
197
+ export const avg = aggregate('AVG')
198
+ export function min<C extends string = string>(col: C): SQLFunctionRef<C> {
199
+ return new SQLFunctionRef('MIN', col)
200
+ }
201
+ export function max<C extends string = string>(col: C): SQLFunctionRef<C> {
202
+ return new SQLFunctionRef('MAX', col)
203
+ }
204
+ export function lower<C extends string = string>(col: C): SQLFunctionRef<C> {
205
+ return new SQLFunctionRef('LOWER', col)
206
+ }
207
+ export function upper<C extends string = string>(col: C): SQLFunctionRef<C> {
208
+ return new SQLFunctionRef('UPPER', col)
209
+ }
210
+ export function length<C extends string = string>(col: C): SQLFunctionRef<C> {
211
+ return new SQLFunctionRef('LENGTH', col)
212
+ }
213
+ export function coalesce<C extends string = string>(
214
+ col: C,
215
+ defaultVal: any,
216
+ ): SQLFunctionRef<C> {
217
+ return new SQLFunctionRef('COALESCE', col, [defaultVal])
218
+ }
219
+
220
+ // Defined in schema-util for the same reason as SQLFunctionRef: `evalOperands`
221
+ // needs a real `instanceof`.
222
+ export const WindowRef = schemaWindowRef
223
+ export type WindowRef = schemaWindowRef
224
+
225
+ /** `PARTITION BY … ORDER BY …`, for a window. Every field is optional. */
226
+ export interface WindowSpec {
227
+ partitionBy?: string | string[]
228
+ /**
229
+ * `'users.score'` or `'users.score DESC'`. The direction is optional and
230
+ * per column, which is why it is spelled inside the string rather than as
231
+ * a separate field — a window frequently orders by two columns in opposite
232
+ * directions, and one shared flag could not express that.
233
+ */
234
+ orderBy?: string | string[]
235
+ }
236
+
237
+ /** Build the inside of `OVER (…)`, with every identifier through safeColumn. */
238
+ function windowSpec(spec: WindowSpec = {}): string {
239
+ const parts: string[] = []
240
+ const list = (v: string | string[] | undefined) =>
241
+ v === undefined ? [] : Array.isArray(v) ? v : [v]
242
+
243
+ const partition = list(spec.partitionBy).map(c => safeColumn(c))
244
+ if (partition.length) parts.push(`PARTITION BY ${partition.join(', ')}`)
245
+
246
+ const order = list(spec.orderBy).map(entry => {
247
+ // Split the direction off the tail rather than taking a separate
248
+ // argument — `safeColumn` would reject 'score DESC' as an identifier, so
249
+ // the two halves have to be validated apart from each other anyway.
250
+ const m = /^(.*?)\s+(ASC|DESC)$/i.exec(String(entry).trim())
251
+ if (!m) return `${safeColumn(String(entry).trim())} ASC`
252
+ return `${safeColumn(m[1]!.trim())} ${m[2]!.toUpperCase()}`
253
+ })
254
+ if (order.length) parts.push(`ORDER BY ${order.join(', ')}`)
255
+
256
+ return parts.join(' ')
257
+ }
258
+
259
+ /**
260
+ * An aggregate over a window: `SUM("total") OVER (PARTITION BY "user_id")`.
261
+ *
262
+ * ```ts no-check — illustrative
263
+ * DB.from('orders').select({
264
+ * runningTotal: DB.over(DB.sum('orders.total'), {
265
+ * partitionBy: 'orders.userId',
266
+ * orderBy: 'orders.createdAt',
267
+ * }),
268
+ * })
269
+ * ```
270
+ *
271
+ * Takes an existing function ref rather than a column, so every aggregate the
272
+ * builder already has — including `DB.count.distinct(…)` — composes with a
273
+ * window without a second set of wrappers.
274
+ */
275
+ export function over(
276
+ fn: SQLFunctionRef<string>,
277
+ spec: WindowSpec = {},
278
+ ): WindowRef {
279
+ return new WindowRef(fn, windowSpec(spec))
280
+ }
281
+
282
+ /**
283
+ * The ranking functions, which take no column — the window *is* the argument.
284
+ *
285
+ * Only these three are wrapped by name. The rest of `WINDOW_FUNCTIONS`
286
+ * (`LAG`, `NTILE`, `FIRST_VALUE`, …) take arguments whose meaning differs per
287
+ * function, so they go through `DB.window(name, args, spec)` where the caller
288
+ * says what they mean rather than through eleven near-identical helpers.
289
+ */
290
+ const ranking =
291
+ (fnName: string) =>
292
+ (spec: WindowSpec = {}): WindowRef =>
293
+ new WindowRef(fnName, windowSpec(spec))
294
+
295
+ export const rowNumber = ranking('ROW_NUMBER')
296
+ export const rank = ranking('RANK')
297
+ export const denseRank = ranking('DENSE_RANK')
298
+
299
+ /**
300
+ * Any window function by name, with its own arguments.
301
+ *
302
+ * ```ts no-check — illustrative
303
+ * DB.window('LAG', ['orders.total', 1], { orderBy: 'orders.createdAt' })
304
+ * ```
305
+ *
306
+ * The name is checked against `WINDOW_FUNCTIONS` (plus the aggregates) at
307
+ * parse time — it is interpolated, not bound. Arguments go through
308
+ * `evalOperands`, so a bare string binds as a parameter and `DB.col('x')`
309
+ * references a column, exactly as everywhere else in the builder.
310
+ */
311
+ export function window(
312
+ fnName: string,
313
+ args: unknown[] = [],
314
+ spec: WindowSpec = {},
315
+ ): WindowRef {
316
+ return new WindowRef(fnName, windowSpec(spec), args)
317
+ }
318
+ export function abs<C extends string = string>(col: C): SQLFunctionRef<C> {
319
+ return new SQLFunctionRef('ABS', col)
320
+ }
321
+ export function concat<C extends string = string>(
322
+ col: C,
323
+ ...rest: any[]
324
+ ): SQLFunctionRef<C> {
325
+ return new SQLFunctionRef('CONCAT', col, rest)
326
+ }
327
+
328
+ export function equals<R>(val: R): schemaOperatorRef<R> {
329
+ const isCol = val instanceof schemaColumnRef
330
+ return new schemaOperatorRef('=', val, isCol)
331
+ }
332
+ export function eq<R>(val: R): schemaOperatorRef<R> {
333
+ return equals(val)
334
+ }
335
+ export function notEquals<R>(val: R): schemaOperatorRef<R> {
336
+ const isCol = val instanceof schemaColumnRef
337
+ return new schemaOperatorRef('<>', val, isCol)
338
+ }
339
+ export function neq<R>(val: R): schemaOperatorRef<R> {
340
+ return notEquals(val)
341
+ }
342
+ export function gt<R>(val: R): schemaOperatorRef<R> {
343
+ const isCol = val instanceof schemaColumnRef
344
+ return new schemaOperatorRef('>', val, isCol)
345
+ }
346
+ export function gte<R>(val: R): schemaOperatorRef<R> {
347
+ const isCol = val instanceof schemaColumnRef
348
+ return new schemaOperatorRef('>=', val, isCol)
349
+ }
350
+ export function lt<R>(val: R): schemaOperatorRef<R> {
351
+ const isCol = val instanceof schemaColumnRef
352
+ return new schemaOperatorRef('<', val, isCol)
353
+ }
354
+ export function lte<R>(val: R): schemaOperatorRef<R> {
355
+ const isCol = val instanceof schemaColumnRef
356
+ return new schemaOperatorRef('<=', val, isCol)
357
+ }
358
+ export function like<R>(val: R): schemaOperatorRef<R> {
359
+ return new schemaOperatorRef('LIKE', val)
360
+ }
361
+ export function ilike<R>(val: R): schemaOperatorRef<R> {
362
+ return new schemaOperatorRef('ILIKE', val)
363
+ }
364
+ export function inList<R>(
365
+ vals: R[] | QBObject,
366
+ ): schemaOperatorRef<R[] | QBObject> {
367
+ return new schemaOperatorRef('IN', vals)
368
+ }
369
+ export function notInList<R>(
370
+ vals: R[] | QBObject,
371
+ ): schemaOperatorRef<R[] | QBObject> {
372
+ return new schemaOperatorRef('NOT IN', vals)
373
+ }
374
+ export function isNull(): schemaOperatorRef<null> {
375
+ return new schemaOperatorRef('IS NULL', null)
376
+ }
377
+ export function isNotNull(): schemaOperatorRef<null> {
378
+ return new schemaOperatorRef('IS NOT NULL', null)
379
+ }
380
+ export function between<R>(minVal: R, maxVal: R): schemaOperatorRef<[R, R]> {
381
+ return new schemaOperatorRef('BETWEEN', [minVal, maxVal])
382
+ }
383
+
384
+ export type SelectValue<S extends TableSchemas, J extends string> =
385
+ | ColumnString<S, J>
386
+ | SQLFunctionRef<ColumnString<S, J> | '*'>
387
+ // Unparameterised: a window's columns are validated at construction by
388
+ // `safeColumn`, not by the select's column union. Threading `S`/`J` through
389
+ // would mean typing the spec against the same table set, which reads well
390
+ // until a window orders by a *select alias* — legal SQL, and not a column
391
+ // of any table in scope.
392
+ | WindowRef
393
+ | QBRaw
394
+
395
+ export type SelectColumns<S extends TableSchemas, J extends string> = {
396
+ [alias: string]: SelectValue<S, J>
397
+ }
398
+
399
+ export type TakeSelectValues<S, C> = {
400
+ // `WindowRef` first: it is checked before `SQLFunctionRef` because a
401
+ // windowed aggregate *contains* one, and the outer expression is what the
402
+ // column's type follows.
403
+ [A in keyof C]: C[A] extends WindowRef
404
+ ? number | null
405
+ : C[A] extends SQLFunctionRef<infer _Col>
406
+ ? C[A]['fnName'] extends 'COUNT'
407
+ ? number
408
+ : number | null
409
+ : C[A] extends QBRaw<infer R>
410
+ ? R
411
+ : C[A] extends string
412
+ ? ResolveColumnString<S, C[A]>
413
+ : any
414
+ }
415
+
416
+ /**
417
+ * Memo for `Case.camel` over result-row keys. Every row of every result set
418
+ * is re-cased key by key, so a 1000-row × 8-column query ran `Case.camel`
419
+ * 8000 times over the same eight strings — and `Case.camel` is two regex
420
+ * passes, one with a replacer callback.
421
+ *
422
+ * Capped `Map`, not `LRUCache`, for the reason given on `snakeCache` in
423
+ * schema-util: an LRU `get` re-inserts on every hit and measured far slower
424
+ * than the call it would be caching. Column names are schema-derived, but
425
+ * `DB.raw()` can alias a column to anything, so the bound holds by
426
+ * construction rather than by assumption.
427
+ */
428
+ const CAMEL_CACHE_MAX = 4096
429
+ const camelCache = new Map<string, string>()
430
+
431
+ function camelKeyOf(key: string): string {
432
+ const hit = camelCache.get(key)
433
+ if (hit !== undefined) return hit
434
+ const value = Case.camel(key)
435
+ if (camelCache.size >= CAMEL_CACHE_MAX) camelCache.clear()
436
+ camelCache.set(key, value)
437
+ return value
438
+ }
439
+
440
+ function toCamelCaseKeys<T>(obj: T): T {
441
+ if (
442
+ !obj ||
443
+ typeof obj !== 'object' ||
444
+ Array.isArray(obj) ||
445
+ obj instanceof Date
446
+ )
447
+ return obj
448
+ const res: Record<string, any> = {}
449
+ for (const key of Object.keys(obj)) {
450
+ const val = (obj as any)[key]
451
+ res[key] = val
452
+ const camelKey = camelKeyOf(key)
453
+ if (camelKey !== key) {
454
+ res[camelKey] = val
455
+ }
456
+ }
457
+ return res as T
458
+ }
459
+
460
+ export abstract class QBExecutable<P> {
461
+ abstract parse(): { sql: string; params: any[] }
462
+
463
+ /**
464
+ * `UNION` — every distinct row from this query and the next.
465
+ *
466
+ * Returns a {@link QBSet}, not `this`. That is the whole shape of the
467
+ * feature: a compound select is not a `SELECT` with an extra clause, it is
468
+ * a different kind of statement whose operands happen to be selects. So
469
+ * `.where()` and `.select()` are gone from the result — they would have to
470
+ * mean "on which branch?" — and what remains is what SQL allows after the
471
+ * last operand: `orderBy`, `limit`, `offset`.
472
+ */
473
+ union<Q>(next: QBExecutable<Q>): QBSet<P> {
474
+ return new QBSet<P>([
475
+ { op: null, query: this },
476
+ { op: 'UNION', query: next },
477
+ ])
478
+ }
479
+
480
+ /** `UNION ALL` — as `union`, keeping duplicates. Cheaper: no dedupe pass. */
481
+ unionAll<Q>(next: QBExecutable<Q>): QBSet<P> {
482
+ return new QBSet<P>([
483
+ { op: null, query: this },
484
+ { op: 'UNION ALL', query: next },
485
+ ])
486
+ }
487
+
488
+ /** `INTERSECT` — rows present in both. */
489
+ intersect<Q>(next: QBExecutable<Q>, all = false): QBSet<P> {
490
+ return new QBSet<P>([
491
+ { op: null, query: this },
492
+ { op: all ? 'INTERSECT ALL' : 'INTERSECT', query: next },
493
+ ])
494
+ }
495
+
496
+ /** `EXCEPT` — rows in this query that are not in the next. */
497
+ except<Q>(next: QBExecutable<Q>, all = false): QBSet<P> {
498
+ return new QBSet<P>([
499
+ { op: null, query: this },
500
+ { op: all ? 'EXCEPT ALL' : 'EXCEPT', query: next },
501
+ ])
502
+ }
503
+
504
+ async *iterable(): AsyncIterable<P> {
505
+ const { sql, params } = this.parse()
506
+ for await (const row of getActiveDb()
507
+ .query(sql)
508
+ .iterate(...(params as unknown[]))) {
509
+ yield toCamelCaseKeys(row as P)
510
+ }
511
+ }
512
+
513
+ async array(): Promise<P[]> {
514
+ const { sql, params } = this.parse()
515
+ const results = (await getActiveDb()
516
+ .query(sql)
517
+ .all(...(params as unknown[]))) as MapOf<unknown>[]
518
+ return (results || []).map(toCamelCaseKeys) as P[]
519
+ }
520
+
521
+ async column<C = unknown>(): Promise<C[]> {
522
+ const { sql, params } = this.parse()
523
+ const rows = (await getActiveDb()
524
+ .query(sql)
525
+ .values(...(params as unknown[]))) as unknown[][]
526
+ return rows.map((row: unknown[]) => row[0]) as C[]
527
+ }
528
+
529
+ async value<C = unknown>(): Promise<C | undefined> {
530
+ const { sql, params } = this.parse()
531
+ const rows = (await getActiveDb()
532
+ .query(singleRow(sql))
533
+ .values(...(params as unknown[]))) as unknown[][]
534
+ if (!rows || rows.length === 0 || rows[0]?.length === 0) return undefined
535
+ return rows[0]![0] as C
536
+ }
537
+
538
+ scalar = this.value
539
+
540
+ async fetch(): Promise<P | undefined> {
541
+ const { sql, params } = this.parse()
542
+ const result = await getActiveDb()
543
+ .query(singleRow(sql))
544
+ .get(...(params as unknown[]))
545
+ if (!result) return undefined
546
+ return toCamelCaseKeys(result as P)
547
+ }
548
+
549
+ first = this.fetch
550
+
551
+ async exists(): Promise<boolean> {
552
+ const { sql, params } = this.parse()
553
+ const existsSql = `SELECT 1 FROM (${sql}) AS ${qRaw('sub')} LIMIT 1`
554
+ const result = await getActiveDb()
555
+ .query(existsSql)
556
+ .get(...params)
557
+ return !!result
558
+ }
559
+
560
+ then<TR1 = P[], TR2 = never>(
561
+ onfulfilled?: ((v: P[]) => TR1 | PromiseLike<TR1>) | null,
562
+ onrejected?: ((r: any) => TR2 | PromiseLike<TR2>) | null,
563
+ ): Promise<TR1 | TR2> {
564
+ return this.array().then(onfulfilled, onrejected)
565
+ }
566
+ }
567
+
568
+ export abstract class QBObject<P = any> extends QBExecutable<P> {
569
+ abstract clone(): this
570
+ }
571
+
572
+ export type ExtractTableFromColumn<C extends string> =
573
+ C extends `${infer Table}.${infer _Col}` ? Table : C
574
+
575
+ // Stage Interfaces for Method Ordering
576
+ export interface IQBTable<S extends TableSchemas, J extends string, P = any>
577
+ extends QBObject<P> {
578
+ join<R extends AllTableColumns, A extends string | undefined = undefined>(
579
+ leftCol: ColumnString<S, J>,
580
+ rightCol: R,
581
+ as?: A,
582
+ type?: 'INNER' | 'LEFT' | 'RIGHT' | 'FULL' | 'CROSS',
583
+ ): IQBTable<
584
+ S & NewJoinedTable<S, A, ExtractTableFromColumn<R>>,
585
+ NewJoinedScope<J, A, ExtractTableFromColumn<R>>,
586
+ P
587
+ >
588
+
589
+ leftJoin<
590
+ R extends AllTableColumns,
591
+ A extends string | undefined = undefined,
592
+ >(
593
+ leftCol: ColumnString<S, J>,
594
+ rightCol: R,
595
+ as?: A,
596
+ ): IQBTable<
597
+ S & NewJoinedTable<S, A, ExtractTableFromColumn<R>>,
598
+ NewJoinedScope<J, A, ExtractTableFromColumn<R>>,
599
+ P
600
+ >
601
+
602
+ rightJoin<
603
+ R extends AllTableColumns,
604
+ A extends string | undefined = undefined,
605
+ >(
606
+ leftCol: ColumnString<S, J>,
607
+ rightCol: R,
608
+ as?: A,
609
+ ): IQBTable<
610
+ S & NewJoinedTable<S, A, ExtractTableFromColumn<R>>,
611
+ NewJoinedScope<J, A, ExtractTableFromColumn<R>>,
612
+ P
613
+ >
614
+
615
+ innerJoin<
616
+ R extends AllTableColumns,
617
+ A extends string | undefined = undefined,
618
+ >(
619
+ leftCol: ColumnString<S, J>,
620
+ rightCol: R,
621
+ as?: A,
622
+ ): IQBTable<
623
+ S & NewJoinedTable<S, A, ExtractTableFromColumn<R>>,
624
+ NewJoinedScope<J, A, ExtractTableFromColumn<R>>,
625
+ P
626
+ >
627
+
628
+ /**
629
+ * `FULL OUTER JOIN`. Typed like the others, but **MySQL has none** — the
630
+ * runtime refuses there, because a capability the compiler cannot see
631
+ * cannot be expressed in this signature.
632
+ */
633
+ fullJoin<
634
+ R extends AllTableColumns,
635
+ A extends string | undefined = undefined,
636
+ >(
637
+ leftCol: ColumnString<S, J>,
638
+ rightCol: R,
639
+ as?: A,
640
+ ): IQBTable<
641
+ S & NewJoinedTable<S, A, ExtractTableFromColumn<R>>,
642
+ NewJoinedScope<J, A, ExtractTableFromColumn<R>>,
643
+ P
644
+ >
645
+
646
+ where(
647
+ column: WhereColumn<S, J>,
648
+ valueOrRef?: WhereValue<ColumnString<S, J>>,
649
+ ): IQBWhere<S, J, P>
650
+
651
+ groupBy(groupCol: ColumnString<S, J>): IQBGroupBy<S, J, P>
652
+
653
+ select<
654
+ C extends SelectColumns<S, J>,
655
+ P2 extends TakeSelectValues<S, C> = TakeSelectValues<S, C>,
656
+ >(columns: C): IQBSelect<S, J, P2>
657
+ selectAll<A extends Extract<J, string>>(
658
+ alias?: A,
659
+ ): IQBSelect<
660
+ S,
661
+ J,
662
+ A extends keyof S ? S[A] : S[keyof S] extends infer Row ? Row : any
663
+ >
664
+
665
+ /**
666
+ * Ordering and paging are legal straight off the table — `DB.table('t')
667
+ * .limit(10)` needs no where or select — and always worked at runtime.
668
+ * They were simply missing from this stage of the interface chain.
669
+ */
670
+ orderBy(
671
+ colStr: keyof P | ColumnString<S, J>,
672
+ direction?: 'ASC' | 'DESC',
673
+ ): IQBOrderBy<S, J, P>
674
+ /** `SELECT DISTINCT` — see the runtime method for the semantics. */
675
+ distinct(): this
676
+ limit(count: number, offset?: number): IQBLimit<S, J, P>
677
+ paginate(page: number, pageSize: number): IQBLimit<S, J, P>
678
+ /** Cursor pagination — see the implementation for why it is not paginate(). */
679
+ seek(
680
+ column: ColumnString<S, J>,
681
+ cursor: unknown,
682
+ pageSize: number,
683
+ direction?: 'ASC' | 'DESC',
684
+ ): IQBLimit<S, J, P>
685
+ }
686
+
687
+ export interface IQBWhere<S extends TableSchemas, J extends string, P = any>
688
+ extends QBObject<P> {
689
+ and(
690
+ column: WhereColumn<S, J>,
691
+ valueOrRef?: WhereValue<ColumnString<S, J>>,
692
+ ): IQBWhere<S, J, P>
693
+ or(
694
+ column: WhereColumn<S, J>,
695
+ valueOrRef?: WhereValue<ColumnString<S, J>>,
696
+ ): IQBWhere<S, J, P>
697
+
698
+ groupBy(groupCol: ColumnString<S, J>): IQBGroupBy<S, J, P>
699
+ select<
700
+ C extends SelectColumns<S, J>,
701
+ P2 extends TakeSelectValues<S, C> = TakeSelectValues<S, C>,
702
+ >(columns: C): IQBSelect<S, J, P2>
703
+ selectAll<A extends Extract<J, string>>(
704
+ alias?: A,
705
+ ): IQBSelect<
706
+ S,
707
+ J,
708
+ A extends keyof S ? S[A] : S[keyof S] extends infer Row ? Row : any
709
+ >
710
+ orderBy(
711
+ colStr: keyof P | ColumnString<S, J>,
712
+ direction?: 'ASC' | 'DESC',
713
+ ): IQBOrderBy<S, J, P>
714
+ /** `SELECT DISTINCT` — see the runtime method for the semantics. */
715
+ distinct(): this
716
+ limit(count: number, offset?: number): IQBLimit<S, J, P>
717
+ paginate(page: number, pageSize: number): IQBLimit<S, J, P>
718
+ /** Cursor pagination — see the implementation for why it is not paginate(). */
719
+ seek(
720
+ column: ColumnString<S, J>,
721
+ cursor: unknown,
722
+ pageSize: number,
723
+ direction?: 'ASC' | 'DESC',
724
+ ): IQBLimit<S, J, P>
725
+ }
726
+
727
+ export interface IQBGroupBy<S extends TableSchemas, J extends string, P = any>
728
+ extends QBObject<P> {
729
+ select<
730
+ C extends SelectColumns<S, J>,
731
+ P2 extends TakeSelectValues<S, C> = TakeSelectValues<S, C>,
732
+ >(columns: C): IQBSelect<S, J, P2>
733
+ selectAll<A extends Extract<J, string>>(
734
+ alias?: A,
735
+ ): IQBSelect<
736
+ S,
737
+ J,
738
+ A extends keyof S ? S[A] : S[keyof S] extends infer Row ? Row : any
739
+ >
740
+ having(
741
+ column: WhereColumn<S, J>,
742
+ valueOrRef?: WhereValue<ColumnString<S, J>>,
743
+ ): IQBHaving<S, J, P>
744
+ /**
745
+ * `SELECT DISTINCT` — see the runtime method for the semantics.
746
+ *
747
+ * Declared here too, unlike the sibling stages, because this one has no
748
+ * `limit`: `distinct()` was added everywhere `limit` already appeared, and
749
+ * that heuristic silently skipped `groupBy`. It ran fine and stopped
750
+ * typechecking, which is precisely what `fluent.test.ts` exists to catch.
751
+ */
752
+ distinct(): this
753
+ }
754
+
755
+ export interface IQBHaving<S extends TableSchemas, J extends string, P = any>
756
+ extends QBObject<P> {
757
+ andHaving(
758
+ column: WhereColumn<S, J>,
759
+ valueOrRef?: WhereValue<ColumnString<S, J>>,
760
+ ): IQBHaving<S, J, P>
761
+ orHaving(
762
+ column: WhereColumn<S, J>,
763
+ valueOrRef?: WhereValue<ColumnString<S, J>>,
764
+ ): IQBHaving<S, J, P>
765
+ orderBy(
766
+ colStr: keyof P | ColumnString<S, J>,
767
+ direction?: 'ASC' | 'DESC',
768
+ ): IQBOrderBy<S, J, P>
769
+ /** `SELECT DISTINCT` — see the runtime method for the semantics. */
770
+ distinct(): this
771
+ limit(count: number, offset?: number): IQBLimit<S, J, P>
772
+ paginate(page: number, pageSize: number): IQBLimit<S, J, P>
773
+ /** Cursor pagination — see the implementation for why it is not paginate(). */
774
+ seek(
775
+ column: ColumnString<S, J>,
776
+ cursor: unknown,
777
+ pageSize: number,
778
+ direction?: 'ASC' | 'DESC',
779
+ ): IQBLimit<S, J, P>
780
+ }
781
+
782
+ export interface IQBSelect<S extends TableSchemas, J extends string, P = any>
783
+ extends QBObject<P> {
784
+ /**
785
+ * Grouping after selecting, which always worked at runtime — clauses are
786
+ * assembled at `parse()`, so call order is irrelevant — and was simply
787
+ * missing from this stage of the chain. The same gap `IQBTable` had for
788
+ * `orderBy`/`limit`, found by writing the query that motivates it:
789
+ * `.select({ n: DB.count.distinct(c) }).groupBy(x).having(…)`.
790
+ */
791
+ groupBy(groupCol: ColumnString<S, J>): IQBGroupBy<S, J, P>
792
+ select<
793
+ C extends SelectColumns<S, J>,
794
+ P2 extends TakeSelectValues<S, C> = TakeSelectValues<S, C>,
795
+ >(columns: C): IQBSelect<S, J, P & P2>
796
+ selectAll<A extends Extract<J, string>>(
797
+ alias?: A,
798
+ ): IQBSelect<
799
+ S,
800
+ J,
801
+ P & (A extends keyof S ? S[A] : S[keyof S] extends infer Row ? Row : any)
802
+ >
803
+ having(
804
+ column: WhereColumn<S, J>,
805
+ valueOrRef?: WhereValue<ColumnString<S, J>>,
806
+ ): IQBHaving<S, J, P>
807
+
808
+ /**
809
+ * Filtering after projection is ordinary builder usage — the clauses are
810
+ * assembled, not emitted in call order — and worked at runtime already.
811
+ * The projection `P` is preserved, so the row type survives the call.
812
+ */
813
+ where(
814
+ column: WhereColumn<S, J>,
815
+ valueOrRef?: WhereValue<ColumnString<S, J>>,
816
+ ): IQBSelect<S, J, P>
817
+ and(
818
+ column: WhereColumn<S, J>,
819
+ valueOrRef?: WhereValue<ColumnString<S, J>>,
820
+ ): IQBSelect<S, J, P>
821
+ or(
822
+ column: WhereColumn<S, J>,
823
+ valueOrRef?: WhereValue<ColumnString<S, J>>,
824
+ ): IQBSelect<S, J, P>
825
+
826
+ orderBy(
827
+ colStr: keyof P | ColumnString<S, J>,
828
+ direction?: 'ASC' | 'DESC',
829
+ ): IQBOrderBy<S, J, P>
830
+ /** `SELECT DISTINCT` — see the runtime method for the semantics. */
831
+ distinct(): this
832
+ limit(count: number, offset?: number): IQBLimit<S, J, P>
833
+ paginate(page: number, pageSize: number): IQBLimit<S, J, P>
834
+ /** Cursor pagination — see the implementation for why it is not paginate(). */
835
+ seek(
836
+ column: ColumnString<S, J>,
837
+ cursor: unknown,
838
+ pageSize: number,
839
+ direction?: 'ASC' | 'DESC',
840
+ ): IQBLimit<S, J, P>
841
+ }
842
+
843
+ export interface IQBOrderBy<S extends TableSchemas, J extends string, P = any>
844
+ extends QBObject<P> {
845
+ orderBy(
846
+ colStr: keyof P | ColumnString<S, J>,
847
+ direction?: 'ASC' | 'DESC',
848
+ ): IQBOrderBy<S, J, P>
849
+ /** `SELECT DISTINCT` — see the runtime method for the semantics. */
850
+ distinct(): this
851
+ limit(count: number, offset?: number): IQBLimit<S, J, P>
852
+ paginate(page: number, pageSize: number): IQBLimit<S, J, P>
853
+ /** Cursor pagination — see the implementation for why it is not paginate(). */
854
+ seek(
855
+ column: ColumnString<S, J>,
856
+ cursor: unknown,
857
+ pageSize: number,
858
+ direction?: 'ASC' | 'DESC',
859
+ ): IQBLimit<S, J, P>
860
+ }
861
+
862
+ export interface IQBLimit<S extends TableSchemas, J extends string, P = any>
863
+ extends QBObject<P> {
864
+ /** `SELECT DISTINCT` — see the runtime method for the semantics. */
865
+ distinct(): this
866
+ limit(count: number, offset?: number): IQBLimit<S, J, P>
867
+ paginate(page: number, pageSize: number): IQBLimit<S, J, P>
868
+ /** Cursor pagination — see the implementation for why it is not paginate(). */
869
+ seek(
870
+ column: ColumnString<S, J>,
871
+ cursor: unknown,
872
+ pageSize: number,
873
+ direction?: 'ASC' | 'DESC',
874
+ ): IQBLimit<S, J, P>
875
+ }
876
+
877
+ export type SetOperator =
878
+ | 'UNION'
879
+ | 'UNION ALL'
880
+ | 'INTERSECT'
881
+ | 'INTERSECT ALL'
882
+ | 'EXCEPT'
883
+ | 'EXCEPT ALL'
884
+
885
+ /**
886
+ * A compound select: two or more queries joined by `UNION` and friends.
887
+ *
888
+ * The emission rules below are not style choices — each one is the only form
889
+ * all three dialects accept, measured against live servers rather than read
890
+ * off a standard:
891
+ *
892
+ * - **Operands are bare, never parenthesised.** MySQL and Postgres take
893
+ * `(SELECT …) UNION (SELECT …)`; **SQLite rejects it outright** — a
894
+ * parenthesised select is not a legal operand of a compound there.
895
+ * - **A branch carrying its own `ORDER BY`/`LIMIT` is wrapped as a derived
896
+ * table** instead. `SELECT id FROM a LIMIT 2 UNION …` is a syntax error on
897
+ * all three, the parenthesised fix works on two of them, and
898
+ * `SELECT * FROM (SELECT id FROM a LIMIT 2) AS b0` works on all three. The
899
+ * alias is required by MySQL and harmless elsewhere.
900
+ * - **`ORDER BY` and `LIMIT` on the set go at the very end**, unwrapped,
901
+ * where every dialect reads them as applying to the whole compound.
902
+ * - **`INTERSECT ALL` / `EXCEPT ALL` are gated.** MySQL 8.0.31+ and Postgres
903
+ * have them; SQLite does not, and its message — `near "ALL": syntax error`
904
+ * — does not say which construct it means.
905
+ */
906
+ export class QBSet<P = any> extends QBExecutable<P> {
907
+ private _orderBy: string[] = []
908
+ private _limit?: number
909
+ private _offset?: number
910
+
911
+ constructor(
912
+ private branches: { op: SetOperator | null; query: QBExecutable<any> }[],
913
+ ) {
914
+ super()
915
+ }
916
+
917
+ private add(op: SetOperator, next: QBExecutable<any>): QBSet<P> {
918
+ this.branches.push({ op, query: next })
919
+ return this
920
+ }
921
+
922
+ // Chaining a third operand extends this set rather than nesting one inside
923
+ // another: `a UNION b UNION c` is flat in SQL and mixed operators are
924
+ // evaluated left to right, which is what a flat list already means.
925
+ override union<Q>(next: QBExecutable<Q>): QBSet<P> {
926
+ return this.add('UNION', next)
927
+ }
928
+ override unionAll<Q>(next: QBExecutable<Q>): QBSet<P> {
929
+ return this.add('UNION ALL', next)
930
+ }
931
+ override intersect<Q>(next: QBExecutable<Q>, all = false): QBSet<P> {
932
+ return this.add(all ? 'INTERSECT ALL' : 'INTERSECT', next)
933
+ }
934
+ override except<Q>(next: QBExecutable<Q>, all = false): QBSet<P> {
935
+ return this.add(all ? 'EXCEPT ALL' : 'EXCEPT', next)
936
+ }
937
+
938
+ /**
939
+ * Order the whole compound. The column names an *output* column of the
940
+ * set, so it is the select alias rather than `table.column`.
941
+ */
942
+ orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
943
+ const dir = String(direction).toUpperCase()
944
+ if (dir !== 'ASC' && dir !== 'DESC') {
945
+ throws(`Invalid sort direction: ${direction}`)
946
+ }
947
+ this._orderBy.push(`${safeColumn(column)} ${dir}`)
948
+ return this
949
+ }
950
+
951
+ limit(count: number, offset?: number): this {
952
+ this._limit = toRowCount(count, 'limit')
953
+ this._offset =
954
+ offset === undefined ? undefined : toRowCount(offset, 'offset')
955
+ return this
956
+ }
957
+
958
+ paginate(page: number, pageSize: number): this {
959
+ return this.limit(
960
+ Math.max(1, pageSize),
961
+ (Math.max(1, page) - 1) * Math.max(1, pageSize),
962
+ )
963
+ }
964
+
965
+ parse(): { sql: string; params: any[] } {
966
+ if (this.branches.length < 2) {
967
+ throws('A set operation needs at least two queries')
968
+ }
969
+ const db = getActiveDb()
970
+ const params: any[] = []
971
+ const parts: string[] = []
972
+
973
+ for (let i = 0; i < this.branches.length; i++) {
974
+ const { op, query } = this.branches[i]!
975
+ if (op) {
976
+ if (
977
+ op.endsWith(' ALL') &&
978
+ op !== 'UNION ALL' &&
979
+ !db?.supportsSetOperationAll
980
+ ) {
981
+ throws(
982
+ `${op} is not supported by this database. ` +
983
+ `Use ${op.replace(' ALL', '')} instead — it removes duplicates.`,
984
+ )
985
+ }
986
+ parts.push(op)
987
+ }
988
+ const parsed = query.parse()
989
+ params.push(...parsed.params)
990
+ // Only a branch that orders or limits itself needs the wrapper; a plain
991
+ // SELECT is emitted as written, which keeps the common case readable.
992
+ parts.push(
993
+ /\b(ORDER\s+BY|LIMIT)\b/i.test(parsed.sql)
994
+ ? `SELECT * FROM (${parsed.sql}) AS ${qRaw(`bakery_set_${i}`)}`
995
+ : parsed.sql,
996
+ )
997
+ }
998
+
999
+ const orderSql =
1000
+ this._orderBy.length > 0 ? ` ORDER BY ${this._orderBy.join(', ')}` : ''
1001
+ const limitSql =
1002
+ this._limit !== undefined
1003
+ ? ` LIMIT ${this._limit}${this._offset !== undefined ? ` OFFSET ${this._offset}` : ''}`
1004
+ : ''
1005
+
1006
+ return { sql: `${parts.join(' ')}${orderSql}${limitSql}`, params }
1007
+ }
1008
+ }
1009
+
1010
+ export class QBRaw<T = any> extends QBObject<T> {
1011
+ constructor(
1012
+ private _sql: string,
1013
+ private _params: any[] = [],
1014
+ ) {
1015
+ super()
1016
+ }
1017
+ parse(): { sql: string; params: any[] } {
1018
+ return { sql: this._sql, params: this._params }
1019
+ }
1020
+ clone(): this {
1021
+ return new QBRaw<T>(this._sql, [...this._params]) as any
1022
+ }
1023
+ }
1024
+
1025
+ export function parseWhereArgs(left: any, valueOrRef?: any) {
1026
+ if (
1027
+ left &&
1028
+ typeof left === 'object' &&
1029
+ typeof left.parse === 'function' &&
1030
+ valueOrRef === undefined
1031
+ ) {
1032
+ return {
1033
+ left,
1034
+ operator: '',
1035
+ right: undefined,
1036
+ isRightColumn: false,
1037
+ }
1038
+ }
1039
+ if (valueOrRef instanceof schemaOperatorRef) {
1040
+ return {
1041
+ left,
1042
+ operator: valueOrRef.operator,
1043
+ right: valueOrRef.right,
1044
+ isRightColumn: Boolean(valueOrRef.isRightColumn),
1045
+ }
1046
+ }
1047
+ return {
1048
+ left,
1049
+ operator: '=',
1050
+ right: valueOrRef,
1051
+ isRightColumn: false,
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * Emit one WHERE/HAVING clause. Captures nothing from the builder — it takes
1057
+ * `params` as an argument — so it lives here rather than being re-created as
1058
+ * a closure on every `parse()`, which is once per executed query.
1059
+ */
1060
+ function formatClause(
1061
+ leftArg: any,
1062
+ operator: string,
1063
+ rightArg: any,
1064
+ isRightColumn: boolean | undefined,
1065
+ params: any[],
1066
+ ): string {
1067
+ const left = evalOperands(leftArg, params, true)
1068
+ if (!operator) {
1069
+ return left
1070
+ }
1071
+ const op = operator.toUpperCase()
1072
+ if (op === 'IS NULL' || op === 'IS NOT NULL') {
1073
+ return `${left} ${op}`
1074
+ }
1075
+ if (op === 'BETWEEN' && Array.isArray(rightArg)) {
1076
+ const min = evalOperands(rightArg[0], params, false)
1077
+ const max = evalOperands(rightArg[1], params, false)
1078
+ return `${left} BETWEEN ${min} AND ${max}`
1079
+ }
1080
+ const right = evalOperands(rightArg, params, isRightColumn)
1081
+ return `${left} ${operator} ${right}`
1082
+ }
1083
+
1084
+ export class QB<
1085
+ S extends TableSchemas = DBSchema,
1086
+ J extends string = never,
1087
+ P = any,
1088
+ >
1089
+ extends QBObject<P>
1090
+ implements
1091
+ IQBTable<S, J, P>,
1092
+ IQBWhere<S, J, P>,
1093
+ IQBGroupBy<S, J, P>,
1094
+ IQBHaving<S, J, P>,
1095
+ IQBSelect<S, J, P>,
1096
+ IQBOrderBy<S, J, P>,
1097
+ IQBLimit<S, J, P>
1098
+ {
1099
+ private _table = ''
1100
+ private _alias = ''
1101
+ private _with: Record<string, any> = {}
1102
+ private _joins: Array<{
1103
+ table: string
1104
+ alias: string
1105
+ /** Already validated and quoted by `join()`. */
1106
+ on: string
1107
+ type: string
1108
+ }> = []
1109
+ private _where: Array<{
1110
+ connector: 'WHERE' | 'AND' | 'OR'
1111
+ left: any
1112
+ operator: string
1113
+ right: any
1114
+ isRightColumn?: boolean
1115
+ }> = []
1116
+ private _groupBy: string[] = []
1117
+ private _having: Array<{
1118
+ connector: 'HAVING' | 'AND' | 'OR'
1119
+ left: any
1120
+ operator: string
1121
+ right: any
1122
+ isRightColumn?: boolean
1123
+ }> = []
1124
+ private _distinct = false
1125
+ private _select: Record<string, any> = {}
1126
+ private _selectAllAlias?: string
1127
+ private _orderBy: string[] = []
1128
+ private _limit?: number
1129
+ private _offset?: number
1130
+
1131
+ private constructor(table: string) {
1132
+ super()
1133
+ this._table = table
1134
+ }
1135
+
1136
+ clone(): this {
1137
+ const qb = new QB(this._table)
1138
+ qb._alias = this._alias
1139
+ qb._with = { ...this._with }
1140
+ qb._joins = this._joins.map(j => ({ ...j }))
1141
+ qb._where = this._where.map(w => ({ ...w }))
1142
+ qb._groupBy = [...this._groupBy]
1143
+ qb._having = this._having.map(h => ({ ...h }))
1144
+ qb._distinct = this._distinct
1145
+ qb._select = { ...this._select }
1146
+ qb._selectAllAlias = this._selectAllAlias
1147
+ qb._orderBy = [...this._orderBy]
1148
+ qb._limit = this._limit
1149
+ qb._offset = this._offset
1150
+ return qb as any
1151
+ }
1152
+
1153
+ static table<T extends Tables, A extends string | undefined = undefined>(
1154
+ name: T,
1155
+ as?: A,
1156
+ ): IQBTable<
1157
+ NewTable<TableSchemas, A, Extract<T, string>>,
1158
+ Extract<T, string> | ValidAlias<A>,
1159
+ any
1160
+ > {
1161
+ const qb = new QB(name as string)
1162
+ qb._alias = as || (name as string)
1163
+ return qb as any
1164
+ }
1165
+
1166
+ static from = QB.table
1167
+
1168
+ static with<P, N extends string>(
1169
+ qb: QBObject<P>,
1170
+ name: N,
1171
+ ): WithQB<TableSchemas & Record<N, P>, N> {
1172
+ const withQB = new (WithQB as any)()
1173
+ return withQB.with(qb, name)
1174
+ }
1175
+
1176
+ static count = count
1177
+ static sum = sum
1178
+ static avg = avg
1179
+ static min = min
1180
+ static max = max
1181
+ static lower = lower
1182
+ static upper = upper
1183
+ static length = length
1184
+ static coalesce = coalesce
1185
+ static abs = abs
1186
+ static concat = concat
1187
+
1188
+ static equals = equals
1189
+ static eq = eq
1190
+ static notEquals = notEquals
1191
+ static neq = neq
1192
+ static gt = gt
1193
+ static gte = gte
1194
+ static lt = lt
1195
+ static lte = lte
1196
+ static like = like
1197
+ static ilike = ilike
1198
+ static inList = inList
1199
+ static in = inList
1200
+ static notInList = notInList
1201
+ static notIn = notInList
1202
+ static isNull = isNull
1203
+ static isNotNull = isNotNull
1204
+ static between = between
1205
+
1206
+ /**
1207
+ * Both columns, the joined table and the alias go through the convention-8
1208
+ * guards here, at the call site — the same thing `orderBy` and `groupBy`
1209
+ * do, and for the same reason: the `ColumnString` union is compile-time
1210
+ * only, so a value taken off a request reaches this method as a plain
1211
+ * string.
1212
+ *
1213
+ * It previously concatenated the raw arguments into an ON clause and left
1214
+ * `parse()` to run a `word.word` regex over the result, quoting what
1215
+ * matched and passing everything else through untouched — so
1216
+ * `join("users.id = 1 OR 1=1 UNION SELECT password FROM secrets --", …)`
1217
+ * was emitted verbatim.
1218
+ */
1219
+ join(leftCol: any, rightCol: any, as?: string, type = 'INNER'): any {
1220
+ const strLeft = String(leftCol)
1221
+ const strRight = String(rightCol)
1222
+
1223
+ const targetTable = strRight.includes('.')
1224
+ ? strRight.split('.')[0]!
1225
+ : strRight
1226
+ const aliasKey = as || targetTable
1227
+
1228
+ const rightColName = strRight.includes('.')
1229
+ ? strRight.split('.')[1]!
1230
+ : 'id'
1231
+
1232
+ // `parse()` quotes these with `qId`, which only strips the dialect's
1233
+ // quote character; the allow-list is what makes them identifiers.
1234
+ if (!isSafeIdentifier(targetTable)) {
1235
+ throws(`Invalid or unsafe join table: ${targetTable}`)
1236
+ }
1237
+ if (!isSafeIdentifier(aliasKey)) {
1238
+ throws(`Invalid or unsafe join alias: ${aliasKey}`)
1239
+ }
1240
+
1241
+ // Always qualified, never the raw argument. An undotted right column
1242
+ // means "that table's `id`" — which is what the aliased form already
1243
+ // emitted — but the unaliased path passed `strRight` straight through, so
1244
+ // `join('teachers.campusId', 'campuses')` produced
1245
+ // `ON "teachers"."campus_id" = campuses`: a bare table name in a value
1246
+ // position, invalid on every dialect and only discovered at execution.
1247
+ // For a dotted argument this rebuilds the identical string.
1248
+ const rightSideOn = `${aliasKey}.${rightColName}`
1249
+
1250
+ const onClause = `${safeColumn(strLeft)} = ${safeColumn(rightSideOn)}`
1251
+
1252
+ // The union type is compile-time only and this string is interpolated
1253
+ // straight into `${j.type} JOIN`, so it needs the same runtime allow-list
1254
+ // `orderBy` gives its direction. Without it, a join type taken off a
1255
+ // request was emitted verbatim.
1256
+ const joinType = String(type).toUpperCase()
1257
+ if (!JOIN_TYPES.has(joinType)) {
1258
+ throws(`Invalid join type: ${type}`)
1259
+ }
1260
+ // Refused at the call site rather than at the server, because MySQL's
1261
+ // message for it — "You have an error in your SQL syntax" pointing at the
1262
+ // whole statement — says nothing about which construct is unsupported.
1263
+ if (joinType === 'FULL' && !getActiveDb()?.supportsFullOuterJoin) {
1264
+ throws(
1265
+ 'FULL OUTER JOIN is not supported by this database (MySQL has no ' +
1266
+ 'FULL JOIN at all). Express it as a LEFT JOIN unioned with a ' +
1267
+ 'RIGHT JOIN, or query the two sides separately.',
1268
+ )
1269
+ }
1270
+
1271
+ this._joins.push({
1272
+ table: targetTable,
1273
+ alias: aliasKey,
1274
+ on: onClause,
1275
+ type: joinType,
1276
+ })
1277
+ return this as any
1278
+ }
1279
+
1280
+ leftJoin(leftCol: any, rightCol: any, as?: string): any {
1281
+ return this.join(leftCol, rightCol, as, 'LEFT')
1282
+ }
1283
+
1284
+ rightJoin(leftCol: any, rightCol: any, as?: string): any {
1285
+ return this.join(leftCol, rightCol, as, 'RIGHT')
1286
+ }
1287
+
1288
+ innerJoin(leftCol: any, rightCol: any, as?: string): any {
1289
+ return this.join(leftCol, rightCol, as, 'INNER')
1290
+ }
1291
+
1292
+ /**
1293
+ * `FULL OUTER JOIN` — every row from both sides, matched where possible.
1294
+ *
1295
+ * SQLite (3.39+) and Postgres have it; **MySQL does not**, at any version,
1296
+ * so this throws there rather than emitting SQL the server will reject.
1297
+ */
1298
+ fullJoin(leftCol: any, rightCol: any, as?: string): any {
1299
+ return this.join(leftCol, rightCol, as, 'FULL')
1300
+ }
1301
+
1302
+ where(left: any, valueOrRef?: any): any {
1303
+ const parsed = parseWhereArgs(left, valueOrRef)
1304
+ this._where.push({
1305
+ connector: this._where.length === 0 ? 'WHERE' : 'AND',
1306
+ ...parsed,
1307
+ })
1308
+ return this as any
1309
+ }
1310
+
1311
+ and(left: any, valueOrRef?: any): any {
1312
+ const parsed = parseWhereArgs(left, valueOrRef)
1313
+ this._where.push({
1314
+ connector: 'AND',
1315
+ ...parsed,
1316
+ })
1317
+ return this as any
1318
+ }
1319
+
1320
+ or(left: any, valueOrRef?: any): any {
1321
+ const parsed = parseWhereArgs(left, valueOrRef)
1322
+ this._where.push({
1323
+ connector: 'OR',
1324
+ ...parsed,
1325
+ })
1326
+ return this as any
1327
+ }
1328
+
1329
+ select(columns: Record<string, any>): any {
1330
+ Object.assign(this._select, columns)
1331
+ return this as any
1332
+ }
1333
+
1334
+ selectAll(alias?: string): any {
1335
+ this._selectAllAlias = alias || this._alias || this._table
1336
+ return this as any
1337
+ }
1338
+
1339
+ groupBy(groupCol: string): any {
1340
+ this._groupBy.push(safeColumn(groupCol))
1341
+ return this as any
1342
+ }
1343
+
1344
+ having(left: any, valueOrRef?: any): any {
1345
+ const parsed = parseWhereArgs(left, valueOrRef)
1346
+ this._having.push({
1347
+ connector: this._having.length === 0 ? 'HAVING' : 'AND',
1348
+ ...parsed,
1349
+ })
1350
+ return this as any
1351
+ }
1352
+
1353
+ andHaving(left: any, valueOrRef?: any): any {
1354
+ const parsed = parseWhereArgs(left, valueOrRef)
1355
+ this._having.push({
1356
+ connector: 'AND',
1357
+ ...parsed,
1358
+ })
1359
+ return this as any
1360
+ }
1361
+
1362
+ orHaving(left: any, valueOrRef?: any): any {
1363
+ const parsed = parseWhereArgs(left, valueOrRef)
1364
+ this._having.push({
1365
+ connector: 'OR',
1366
+ ...parsed,
1367
+ })
1368
+ return this as any
1369
+ }
1370
+
1371
+ orderBy(colStr: any, direction: 'ASC' | 'DESC' = 'ASC'): any {
1372
+ // The union type is compile-time only; a value off a request would
1373
+ // otherwise be interpolated straight into ORDER BY.
1374
+ const dir = String(direction).toUpperCase()
1375
+ if (dir !== 'ASC' && dir !== 'DESC') {
1376
+ throws(`Invalid sort direction: ${direction}`)
1377
+ }
1378
+ this._orderBy.push(`${safeColumn(colStr)} ${dir}`)
1379
+ return this as any
1380
+ }
1381
+
1382
+ /**
1383
+ * `SELECT DISTINCT`. Applies to the whole select list, not one column —
1384
+ * SQL has no per-column distinct, and offering one would imply otherwise.
1385
+ *
1386
+ * Idempotent, so `.distinct().distinct()` is one keyword rather than two.
1387
+ */
1388
+ distinct(): any {
1389
+ this._distinct = true
1390
+ return this as any
1391
+ }
1392
+
1393
+ limit(count: number, offset?: number): any {
1394
+ // Coerced here rather than at parse time so a bad value fails at the
1395
+ // call site instead of emitting `LIMIT NaN`.
1396
+ this._limit = toRowCount(count, 'limit')
1397
+ this._offset =
1398
+ offset === undefined ? undefined : toRowCount(offset, 'offset')
1399
+ return this as any
1400
+ }
1401
+
1402
+ paginate(page: number, pageSize: number): any {
1403
+ const p = Math.max(1, page)
1404
+ const ps = Math.max(1, pageSize)
1405
+ return this.limit(ps, (p - 1) * ps)
1406
+ }
1407
+
1408
+ /**
1409
+ * Cursor (keyset) pagination — the next `pageSize` rows *after* `cursor`.
1410
+ *
1411
+ * const first = await DB.from('posts').seek('id', null, 20).all()
1412
+ * const next = await DB.from('posts')
1413
+ * .seek('id', first.at(-1)!.id, 20).all()
1414
+ *
1415
+ * `paginate()` is offset-based, and an offset is not free: `LIMIT 20 OFFSET
1416
+ * 200000` makes the server walk and discard 200,000 rows, so page 10,000
1417
+ * costs far more than page 1. This walks nothing — it seeks straight into
1418
+ * the index — so every page costs the same.
1419
+ *
1420
+ * It also does not skip or repeat rows when the table is written to
1421
+ * mid-scan, which offset paging does by construction: delete one row on
1422
+ * page 1 and every later page shifts by one.
1423
+ *
1424
+ * The trade is that pages are only reachable in order — there is no "jump
1425
+ * to page 500" — and the column must be **unique and ordered**, which in
1426
+ * practice means a primary key or something monotonic. A non-unique cursor
1427
+ * column silently drops the rows that tie on the boundary value, which is
1428
+ * why this takes one column rather than pretending to sort by several.
1429
+ *
1430
+ * `null` or `undefined` means the first page, so the same call site works
1431
+ * for both without a branch.
1432
+ */
1433
+ seek(
1434
+ column: string,
1435
+ cursor: unknown,
1436
+ pageSize: number,
1437
+ direction: 'ASC' | 'DESC' = 'ASC',
1438
+ ): any {
1439
+ const dir = String(direction).toUpperCase()
1440
+ if (dir !== 'ASC' && dir !== 'DESC') {
1441
+ throws(`Invalid seek direction: ${direction}`)
1442
+ }
1443
+ // Only after a first page. `seek(col, null, n)` is the opening call and
1444
+ // must not become `WHERE col > NULL`, which matches nothing at all.
1445
+ if (cursor !== null && cursor !== undefined) {
1446
+ // `gt`/`lt`, not a `'id >'` string: the operator belongs in an operand
1447
+ // ref, and folding it into the column name puts `id >` through
1448
+ // `safeColumn`, which rejects it — correctly, since that is the guard
1449
+ // stopping an operator from being smuggled into an identifier.
1450
+ this._where.push({
1451
+ connector: this._where.length === 0 ? 'WHERE' : 'AND',
1452
+ ...parseWhereArgs(column, dir === 'ASC' ? gt(cursor) : lt(cursor)),
1453
+ })
1454
+ }
1455
+ // Ordering is not optional here the way it is for `paginate` — a cursor
1456
+ // is meaningless without the order it is a position in. Prepended so an
1457
+ // explicit `.orderBy()` still breaks ties after it.
1458
+ this._orderBy.unshift(`${safeColumn(column)} ${dir}`)
1459
+ return this.limit(pageSize)
1460
+ }
1461
+
1462
+ // The numbered sections below are the statement grammar. Parameter push order
1463
+ // is load-bearing (Postgres renumbers `?` to `$n` left to right), so the
1464
+ // sequence is the correctness condition, not incidental.
1465
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SQL assembler — one section per clause, in emission order
1466
+ parse(): { sql: string; params: any[] } {
1467
+ const params: any[] = []
1468
+
1469
+ // 1. WITH clause
1470
+ let withSql = ''
1471
+ if (Object.keys(this._with).length > 0) {
1472
+ const withParts: string[] = []
1473
+ for (const [alias, qb] of Object.entries(this._with)) {
1474
+ const parsed = (qb as any).parse()
1475
+ withParts.push(`${qRaw(alias)} AS (${parsed.sql})`)
1476
+ params.push(...parsed.params)
1477
+ }
1478
+ withSql = `WITH ${withParts.join(', ')} `
1479
+ }
1480
+
1481
+ // 2. SELECT clause
1482
+ const selectParts: string[] = []
1483
+ if (this._selectAllAlias) {
1484
+ selectParts.push(`${qId(this._selectAllAlias)}.*`)
1485
+ }
1486
+
1487
+ if (Object.keys(this._select).length > 0) {
1488
+ for (const [alias, colRef] of Object.entries(this._select)) {
1489
+ if (colRef instanceof WindowRef || colRef instanceof SQLFunctionRef) {
1490
+ // `evalOperands` is the single writer for a function call — the
1491
+ // same one WHERE and HAVING go through, allow-list included.
1492
+ //
1493
+ // This used to re-implement it, and the copies had drifted: the
1494
+ // select branch never read `extraArgs`, so `COALESCE(col, 'n/a')`
1495
+ // emitted `COALESCE("col")` and quietly returned NULL instead of
1496
+ // the fallback, while the identical call inside a WHERE was
1497
+ // correct. `CONCAT` lost its arguments the same way, and
1498
+ // `COUNT(DISTINCT …)` would have been the third.
1499
+ selectParts.push(
1500
+ `${evalOperands(colRef, params, true)} AS ${qRaw(alias)}`,
1501
+ )
1502
+ } else if (colRef instanceof QBRaw) {
1503
+ const parsedRaw = colRef.parse()
1504
+ selectParts.push(`(${parsedRaw.sql}) AS ${qRaw(alias)}`)
1505
+ params.push(...parsedRaw.params)
1506
+ } else if (typeof colRef === 'string' && colRef.includes('.')) {
1507
+ const [tbl, colName] = colRef.split('.')
1508
+ selectParts.push(`${qId(tbl!)}.${qId(colName!)} AS ${qRaw(alias)}`)
1509
+ } else {
1510
+ selectParts.push(`${qId(colRef)} AS ${qRaw(alias)}`)
1511
+ }
1512
+ }
1513
+ }
1514
+
1515
+ const selectSql = selectParts.length > 0 ? selectParts.join(', ') : '*'
1516
+
1517
+ // 3. FROM clause
1518
+ const dbTable = Case.snake(this._table)
1519
+ const aliasName = this._alias || this._table
1520
+ const fromSql = `FROM ${qRaw(dbTable)}${aliasName !== dbTable ? ` AS ${qId(aliasName)}` : ''}`
1521
+
1522
+ // 4. JOIN clause
1523
+ const joinParts: string[] = []
1524
+ for (const j of this._joins) {
1525
+ // `j.on` is built by `join()` out of `safeColumn` output, so it is
1526
+ // already validated and quoted. This used to re-derive it here with a
1527
+ // regex, which is where the unguarded write lived.
1528
+ joinParts.push(
1529
+ `${j.type} JOIN ${qId(j.table)} AS ${qId(j.alias)} ON ${j.on}`,
1530
+ )
1531
+ }
1532
+ const joinSql = joinParts.length > 0 ? ` ${joinParts.join(' ')}` : ''
1533
+
1534
+ // 5. WHERE clause
1535
+ let whereSql = ''
1536
+ if (this._where.length > 0) {
1537
+ const whereParts: string[] = []
1538
+ for (let i = 0; i < this._where.length; i++) {
1539
+ const w = this._where[i]!
1540
+ const clauseStr = formatClause(
1541
+ w.left,
1542
+ w.operator,
1543
+ w.right,
1544
+ w.isRightColumn,
1545
+ params,
1546
+ )
1547
+ whereParts.push(i === 0 ? clauseStr : `${w.connector} ${clauseStr}`)
1548
+ }
1549
+ whereSql = ` WHERE ${whereParts.join(' ')}`
1550
+ }
1551
+
1552
+ // 6. GROUP BY clause
1553
+ const groupSql =
1554
+ this._groupBy.length > 0 ? ` GROUP BY ${this._groupBy.join(', ')}` : ''
1555
+
1556
+ // 7. HAVING clause
1557
+ let havingSql = ''
1558
+ if (this._having.length > 0) {
1559
+ const havingParts: string[] = []
1560
+ for (let i = 0; i < this._having.length; i++) {
1561
+ const h = this._having[i]!
1562
+ const clauseStr = formatClause(
1563
+ h.left,
1564
+ h.operator,
1565
+ h.right,
1566
+ h.isRightColumn,
1567
+ params,
1568
+ )
1569
+ havingParts.push(i === 0 ? clauseStr : `${h.connector} ${clauseStr}`)
1570
+ }
1571
+ havingSql = ` HAVING ${havingParts.join(' ')}`
1572
+ }
1573
+
1574
+ // 8. ORDER BY clause
1575
+ const orderSql =
1576
+ this._orderBy.length > 0 ? ` ORDER BY ${this._orderBy.join(', ')}` : ''
1577
+
1578
+ // 9. LIMIT / OFFSET clause
1579
+ const limitSql =
1580
+ this._limit !== undefined
1581
+ ? ` LIMIT ${this._limit}${this._offset !== undefined ? ` OFFSET ${this._offset}` : ''}`
1582
+ : ''
1583
+
1584
+ const distinctSql = this._distinct ? 'DISTINCT ' : ''
1585
+ const sql = `${withSql}SELECT ${distinctSql}${selectSql} ${fromSql}${joinSql}${whereSql}${groupSql}${havingSql}${orderSql}${limitSql}`
1586
+ return { sql, params }
1587
+ }
1588
+ }
1589
+
1590
+ export class WithQB<S extends TableSchemas, J extends string> {
1591
+ private _with: Record<string, any> = {}
1592
+ private constructor() {}
1593
+
1594
+ with<P, A extends string>(
1595
+ qb: QBObject<P>,
1596
+ alias: A,
1597
+ ): WithQB<S & Record<A, P>, J | A> {
1598
+ if (!alias) throws('Name is required')
1599
+ this._with[alias] = qb
1600
+ return this as any
1601
+ }
1602
+
1603
+ table<
1604
+ T extends Tables | Extract<keyof S, string>,
1605
+ A extends string | undefined = undefined,
1606
+ >(
1607
+ name: T,
1608
+ as?: A,
1609
+ ): IQBTable<
1610
+ NewTable<S, A, Extract<T, string>>,
1611
+ J | Extract<T, string> | ValidAlias<A>,
1612
+ any
1613
+ > {
1614
+ const qb = new (QB as any)(name)
1615
+ qb._alias = as || name
1616
+ qb._with = this._with
1617
+ return qb as any
1618
+ }
1619
+
1620
+ from = this.table
1621
+ }
1622
+
1623
+ export const table = QB.table
1624
+ export const from = QB.from
1625
+ export const include = QB.with
1626
+ export const col: <C extends string>(name: C) => schemaColumnRef<C> =
1627
+ schemaCol
1628
+ export const cols: <C extends string>(name: C) => schemaColumnRef<C> =
1629
+ schemaCol
1630
+
1631
+ export const raw = <T = any>(
1632
+ stringsOrSql: string | TemplateStringsArray,
1633
+ ...values: any[]
1634
+ ) => {
1635
+ if (typeof stringsOrSql === 'string') {
1636
+ const params =
1637
+ values.length > 0 && Array.isArray(values[0]) ? values[0] : values
1638
+ return new QBRaw<T>(stringsOrSql, params)
1639
+ }
1640
+ const strings = stringsOrSql
1641
+ let sql = ''
1642
+ const params: any[] = []
1643
+ for (let i = 0; i < strings.length; i++) {
1644
+ sql += strings[i]
1645
+ if (i < values.length) {
1646
+ const val = values[i]
1647
+ if (val instanceof schemaColumnRef) {
1648
+ sql += evalOperands(val, params, true)
1649
+ } else if (
1650
+ typeof val === 'object' &&
1651
+ val !== null &&
1652
+ typeof (val as any).parse === 'function'
1653
+ ) {
1654
+ const sub = (val as any).parse()
1655
+ sql += sub.sql
1656
+ if (sub.params?.length) params.push(...sub.params)
1657
+ } else {
1658
+ sql += '?'
1659
+ params.push(val)
1660
+ }
1661
+ }
1662
+ }
1663
+ return new QBRaw<T>(sql, params)
1664
+ }
1665
+
1666
+ export const Insert = Mutation.Insert
1667
+ export const Update = Mutation.Update
1668
+ export const Delete = Mutation.Delete
1669
+
1670
+ export function transaction<T>(
1671
+ callback: (tx: import('../adapters').SQLAdapter) => Promise<T> | T,
1672
+ ): Promise<T> {
1673
+ const activeConn = getActiveDb()
1674
+ return activeConn.transaction(
1675
+ async (tx: import('../adapters').SQLAdapter) => {
1676
+ return await txStorage.run(tx, () => callback(tx))
1677
+ },
1678
+ )
1679
+ }
1680
+ }