@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.
- package/LICENSE +19 -0
- package/README.md +88 -0
- package/package.json +59 -0
- package/src/adapters/base.ts +1128 -0
- package/src/adapters/mysql.ts +619 -0
- package/src/adapters/observe.ts +261 -0
- package/src/adapters/pgsql.ts +611 -0
- package/src/adapters/registry.ts +204 -0
- package/src/adapters/sqlite.ts +588 -0
- package/src/adapters.ts +72 -0
- package/src/backup.ts +37 -0
- package/src/connection.ts +69 -0
- package/src/define.ts +380 -0
- package/src/field.ts +595 -0
- package/src/globals.d.ts +22 -0
- package/src/index.ts +63 -0
- package/src/orm/index.ts +24 -0
- package/src/orm/mutation.ts +692 -0
- package/src/orm/query.ts +1680 -0
- package/src/pool.ts +83 -0
- package/src/schema-registry.ts +75 -0
- package/src/schema-util.ts +467 -0
- package/src/sync/builder.ts +618 -0
- package/src/sync/engine.ts +399 -0
- package/src/sync/helpers.ts +1119 -0
- package/src/sync/history.ts +169 -0
- package/src/sync/index.ts +113 -0
- package/src/sync/ledger.ts +335 -0
- package/src/sync/load.ts +368 -0
- package/src/sync/rollback.ts +200 -0
- package/src/sync/types.ts +101 -0
- package/src/sync/view-sql.ts +160 -0
- package/templates/schema.example.ts +94 -0
package/src/orm/query.ts
ADDED
|
@@ -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
|
+
}
|