@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/pool.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection pool settings for the MySQL and Postgres adapters.
|
|
3
|
+
*
|
|
4
|
+
* Nothing exposed these before: every deployment ran on Bun's defaults, which
|
|
5
|
+
* is fine until an app with more workers than the server has connection slots
|
|
6
|
+
* meets `FATAL: sorry, too many clients already`. `--threads N` makes that a
|
|
7
|
+
* realistic shape rather than a theoretical one — each worker opens its own
|
|
8
|
+
* pool, so the number that matters is `max x threads`.
|
|
9
|
+
*
|
|
10
|
+
* **SQLite ignores all of it**, and that is not an omission: a SQLite adapter
|
|
11
|
+
* is one file handle, and there is no pool to size.
|
|
12
|
+
*/
|
|
13
|
+
export interface PoolOptions {
|
|
14
|
+
/** Maximum concurrent connections. Bun's default is 10. */
|
|
15
|
+
max?: number
|
|
16
|
+
/** Seconds an idle connection is kept before being closed. */
|
|
17
|
+
idleTimeout?: number
|
|
18
|
+
/** Seconds to wait for a connection before giving up. */
|
|
19
|
+
connectionTimeout?: number
|
|
20
|
+
/** Seconds after which a connection is retired and replaced. */
|
|
21
|
+
maxLifetime?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The env var behind each option.
|
|
26
|
+
*
|
|
27
|
+
* Environment rather than `server.config.ts` because pool size is a property
|
|
28
|
+
* of the *deployment*, not of the app: the same build runs with one connection
|
|
29
|
+
* on a laptop and forty in production, and `--threads N` multiplies whatever
|
|
30
|
+
* is set here per worker.
|
|
31
|
+
*/
|
|
32
|
+
const ENV_KEYS: Record<keyof PoolOptions, string> = {
|
|
33
|
+
max: 'DB_POOL_MAX',
|
|
34
|
+
idleTimeout: 'DB_POOL_IDLE_TIMEOUT',
|
|
35
|
+
connectionTimeout: 'DB_POOL_CONNECTION_TIMEOUT',
|
|
36
|
+
maxLifetime: 'DB_POOL_MAX_LIFETIME',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read pool options from the environment, dropping anything unusable.
|
|
41
|
+
*
|
|
42
|
+
* Unset stays unset — an option Bakery does not pass is one Bun defaults,
|
|
43
|
+
* which is different from passing Bun a zero. A non-numeric or negative value
|
|
44
|
+
* is dropped for the same reason: `DB_POOL_MAX=lots` must not become
|
|
45
|
+
* `max: NaN`, which Bun would take and then behave unpredictably around.
|
|
46
|
+
*
|
|
47
|
+
* **Only known keys are ever forwarded**, and that matters more than it looks:
|
|
48
|
+
* Bun accepts an unrecognised option silently — verified, `new SQL(url, {
|
|
49
|
+
* totallyNotAnOption: 1 })` constructs and queries fine — so a typo'd key
|
|
50
|
+
* would configure nothing and report nothing. Passing a fixed set means the
|
|
51
|
+
* typo lands in an env var name, where it is at least visible in one place.
|
|
52
|
+
*/
|
|
53
|
+
export function poolOptionsFromEnv(
|
|
54
|
+
env: Record<string, string | undefined> = process.env,
|
|
55
|
+
): PoolOptions {
|
|
56
|
+
const out: PoolOptions = {}
|
|
57
|
+
for (const [key, envKey] of Object.entries(ENV_KEYS) as [
|
|
58
|
+
keyof PoolOptions,
|
|
59
|
+
string,
|
|
60
|
+
][]) {
|
|
61
|
+
const raw = env[envKey]
|
|
62
|
+
if (raw === undefined || raw === '') continue
|
|
63
|
+
const n = Number(raw)
|
|
64
|
+
if (!Number.isFinite(n) || n <= 0) continue
|
|
65
|
+
out[key] = n
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Merge pool options into the object handed to `new SQL()`.
|
|
72
|
+
*
|
|
73
|
+
* The timeouts are **seconds here and milliseconds inside Bun** — it multiplies
|
|
74
|
+
* by 1000 on the way in, verified by reading `sql.options` back: `idleTimeout:
|
|
75
|
+
* 5` is stored as `5000`. Seconds is what Bun's own documented unit is, so this
|
|
76
|
+
* passes them straight through rather than converting and doubling the factor.
|
|
77
|
+
*/
|
|
78
|
+
export function withPoolOptions<T extends object>(
|
|
79
|
+
base: T,
|
|
80
|
+
pool: PoolOptions = poolOptionsFromEnv(),
|
|
81
|
+
): T & PoolOptions {
|
|
82
|
+
return { ...base, ...pool }
|
|
83
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level extension point connecting the framework to the app's schema.
|
|
3
|
+
*
|
|
4
|
+
* The dependency must point one way: the app depends on the framework, never
|
|
5
|
+
* the reverse. Yet the ORM's types are derived from the app's `schema.ts` —
|
|
6
|
+
* previously via a direct `import ... from '~/schema'`, which meant the
|
|
7
|
+
* framework's own types imported an app-owned, gitignored file, and a fresh
|
|
8
|
+
* clone could not typecheck its database layer.
|
|
9
|
+
*
|
|
10
|
+
* Instead, the app *registers* its schema here through declaration merging
|
|
11
|
+
* (the same pattern TanStack Router and vue-router use). At the bottom of
|
|
12
|
+
* `schema.ts`:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* declare module '@bakery-framework/orm/schema-registry' {
|
|
16
|
+
* interface SchemaRegistry {
|
|
17
|
+
* schema: {
|
|
18
|
+
* DBSchema: MyDBSchema
|
|
19
|
+
* DBOptionals: MyDBOptionals
|
|
20
|
+
* Views: DBInfo.Views
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* With no registration, every table and column falls back to permissive
|
|
27
|
+
* `any`-shaped records — the ORM stays fully usable, just untyped. The
|
|
28
|
+
* runtime needs no registration at all: schema *values* are loaded by path
|
|
29
|
+
* (see `sync/load.ts`), from `schema` in `server.config.ts` when the app sets
|
|
30
|
+
* one and otherwise from `<cwd>/orm/index.ts` or `<cwd>/schema.ts`, whichever
|
|
31
|
+
* exists. Absence is tolerated; only a *configured* path that does not exist
|
|
32
|
+
* is an error.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { MapOf } from '@bakery-framework/core/types'
|
|
36
|
+
|
|
37
|
+
// Augmented by the app; empty until then.
|
|
38
|
+
//
|
|
39
|
+
// **It has to be an `interface`, and the empty body is the point.** Biome's
|
|
40
|
+
// **`interface`, not `type`, and the empty body is the point.** A type alias
|
|
41
|
+
// cannot be declaration-merged, so rewriting this to `type X = {}` turns every
|
|
42
|
+
// app's `declare module '@bakery-framework/orm/schema-registry'` into
|
|
43
|
+
// `TS2300: Duplicate identifier` and the whole schema registry stops working.
|
|
44
|
+
//
|
|
45
|
+
// Biome's `noBannedTypes` proposed exactly that rewrite and called it a safe
|
|
46
|
+
// fix; the 2026-08-09 sweep applied it and `bun run typecheck` was the only
|
|
47
|
+
// thing that caught it. That rule is off repo-wide now — it had no true
|
|
48
|
+
// positives here — but `noEmptyInterface` reaches the same construct and
|
|
49
|
+
// offers the same fix, so the suppression stays. The compiler is the real
|
|
50
|
+
// guard: change this line and `apps/starter` fails to typecheck.
|
|
51
|
+
// biome-ignore lint/suspicious/noEmptyInterface: must stay mergeable — above
|
|
52
|
+
export interface SchemaRegistry {}
|
|
53
|
+
|
|
54
|
+
type Registered = SchemaRegistry extends { schema: infer S } ? S : never
|
|
55
|
+
|
|
56
|
+
/** Table map: `{ tableName: { column: type } }`. Permissive when unregistered. */
|
|
57
|
+
export type AppDBSchema = [Registered] extends [never]
|
|
58
|
+
? MapOf<MapOf<any>>
|
|
59
|
+
: Registered extends { DBSchema: infer T }
|
|
60
|
+
? T
|
|
61
|
+
: MapOf<MapOf<any>>
|
|
62
|
+
|
|
63
|
+
/** Per-table union of column names that have defaults (optional on insert). */
|
|
64
|
+
export type AppDBOptionals = [Registered] extends [never]
|
|
65
|
+
? MapOf<any>
|
|
66
|
+
: Registered extends { DBOptionals: infer T }
|
|
67
|
+
? T
|
|
68
|
+
: MapOf<any>
|
|
69
|
+
|
|
70
|
+
/** Union of table names that are views (excluded from mutation targets). */
|
|
71
|
+
export type AppViews = [Registered] extends [never]
|
|
72
|
+
? never
|
|
73
|
+
: Registered extends { Views: infer T }
|
|
74
|
+
? T
|
|
75
|
+
: never
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { Case } from '@bakery-framework/core/utils'
|
|
2
|
+
import { is, throws } from '@bakery-framework/core/utils/common'
|
|
3
|
+
import { quoteIdentifier } from './adapters/base'
|
|
4
|
+
import { getActiveDb } from './connection'
|
|
5
|
+
import type * as SyncTypes from './sync/types'
|
|
6
|
+
|
|
7
|
+
export type TypeMap = {
|
|
8
|
+
integer: number
|
|
9
|
+
number: number
|
|
10
|
+
string: string & {}
|
|
11
|
+
boolean: boolean
|
|
12
|
+
buffer: Buffer
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type DataTypes = keyof TypeMap
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A column, described by what it *is* rather than by how it is spelled.
|
|
19
|
+
*
|
|
20
|
+
* TableDef<number> // NOT NULL, required on insert
|
|
21
|
+
* TableDef<string | null, true> // nullable
|
|
22
|
+
* TableDef<Status, false, true> // an enum, optional on insert
|
|
23
|
+
*
|
|
24
|
+
* Three parameters, where there were five (`type, default, nullable,
|
|
25
|
+
* autoIncrement, primary`). The two that went were positional booleans nothing
|
|
26
|
+
* outside `Field.Primary()` ever set, and `default` was carried only so
|
|
27
|
+
* nullability and optionality could be *derived* from it — which is why
|
|
28
|
+
* "defaulted but not nullable" and "nullable" were indistinguishable to
|
|
29
|
+
* anything downstream.
|
|
30
|
+
*
|
|
31
|
+
* **`T` is the row type, not a dialect name.** `number`, `string | null`, or an
|
|
32
|
+
* enum's own union — so `ExtractTableTypes` is a lookup rather than a mapping
|
|
33
|
+
* through `TypeMap`, and a type `TypeMap` cannot express (an enum, a branded
|
|
34
|
+
* id) needs no special case.
|
|
35
|
+
*
|
|
36
|
+
* At runtime this key holds the dialect discriminator (`'integer'`, `'string'`)
|
|
37
|
+
* that the adapters switch on. The two never meet: every adapter takes
|
|
38
|
+
* `def: unknown` and casts, and the sync engine has its own `ColumnConstraint`,
|
|
39
|
+
* bridged by the cast in `sync/load.ts`. That separation already existed and is
|
|
40
|
+
* what lets this change the schema-facing type without touching a single
|
|
41
|
+
* adapter, generated file, or stored ledger payload.
|
|
42
|
+
*/
|
|
43
|
+
export type TableDef<
|
|
44
|
+
T,
|
|
45
|
+
N extends boolean = false,
|
|
46
|
+
O extends boolean = false,
|
|
47
|
+
> = {
|
|
48
|
+
type: T
|
|
49
|
+
nullable: N
|
|
50
|
+
optional: O
|
|
51
|
+
default?: unknown
|
|
52
|
+
length?: number
|
|
53
|
+
autoIncrement?: boolean
|
|
54
|
+
primary?: boolean
|
|
55
|
+
_enum?: readonly string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const dateNow = '%dateNow%'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The row type of a column descriptor.
|
|
62
|
+
*
|
|
63
|
+
* Two forms reach here. A `Field.*` builder returns `TableDef<T, …>`, whose
|
|
64
|
+
* `type` **is** the row type, so this is a lookup. A hand-written literal —
|
|
65
|
+
* `{ type: 'integer', default: 0, nullable: true }`, the one shape `Field`
|
|
66
|
+
* does not spell — carries a dialect name there instead, which still goes
|
|
67
|
+
* through `TypeMap`.
|
|
68
|
+
*
|
|
69
|
+
* Distinguished by whether `type` is one of `TypeMap`'s keys. That is
|
|
70
|
+
* unambiguous because those keys are string *literals* and no row type is one:
|
|
71
|
+
* a column of literal type `'integer'` would be `Field.Enum(['integer'])`,
|
|
72
|
+
* whose `type` is the union, not the key.
|
|
73
|
+
*/
|
|
74
|
+
type RowTypeOf<Col> = Col extends { type: infer T }
|
|
75
|
+
? T extends keyof TypeMap
|
|
76
|
+
? TypeMap[T] | (Col extends { nullable: true } ? null : never)
|
|
77
|
+
: T
|
|
78
|
+
: any
|
|
79
|
+
|
|
80
|
+
export type ExtractTableTypes<C, K extends keyof C> = {
|
|
81
|
+
[P in keyof C[K] as P extends '_view' ? never : P]: RowTypeOf<C[K][P]>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Which columns may be omitted from an `INSERT`.
|
|
86
|
+
*
|
|
87
|
+
* `optional: true` when a `Field.*` builder said so, which is the whole check
|
|
88
|
+
* for anything `Field` produces — it states optionality rather than leaving it
|
|
89
|
+
* to be reconstructed. The three derived conditions below it are the fallback
|
|
90
|
+
* for a hand-written literal, which has no `optional` key.
|
|
91
|
+
*
|
|
92
|
+
* Stating it also makes a shape expressible that derivation could not:
|
|
93
|
+
* **optional but neither nullable nor defaulted** — a column the database fills
|
|
94
|
+
* in, such as a generated key.
|
|
95
|
+
*/
|
|
96
|
+
export type ExtractOptionals<C, T extends keyof C> = {
|
|
97
|
+
[K in keyof C[T]]: K extends '_view'
|
|
98
|
+
? never
|
|
99
|
+
: C[T][K] extends { optional: true }
|
|
100
|
+
? K
|
|
101
|
+
: C[T][K] extends { optional: false }
|
|
102
|
+
? never
|
|
103
|
+
: C[T][K] extends { nullable: true }
|
|
104
|
+
? K
|
|
105
|
+
: C[T][K] extends { default: string | number | boolean | null }
|
|
106
|
+
? K
|
|
107
|
+
: C[T][K] extends { autoIncrement: true }
|
|
108
|
+
? K
|
|
109
|
+
: never
|
|
110
|
+
}[keyof C[T]]
|
|
111
|
+
|
|
112
|
+
export type ExtractViews<C> = {
|
|
113
|
+
[K in keyof C]: C[K] extends { _view: string } ? K : never
|
|
114
|
+
}[keyof C]
|
|
115
|
+
|
|
116
|
+
export class ColumnRef<C extends string = string> {
|
|
117
|
+
constructor(public col: C) {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function col<C extends string>(name: C): ColumnRef<C> {
|
|
121
|
+
return new ColumnRef(name)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The only SQL function names that may be emitted into a query. `fnName` is
|
|
126
|
+
* interpolated rather than bound, so it must never come from request data.
|
|
127
|
+
*/
|
|
128
|
+
export const SQL_FUNCTIONS = new Set([
|
|
129
|
+
'ABS',
|
|
130
|
+
'AVG',
|
|
131
|
+
'COALESCE',
|
|
132
|
+
'CONCAT',
|
|
133
|
+
'COUNT',
|
|
134
|
+
'LENGTH',
|
|
135
|
+
'LOWER',
|
|
136
|
+
'MAX',
|
|
137
|
+
'MIN',
|
|
138
|
+
'REPLACE',
|
|
139
|
+
'SUBSTR',
|
|
140
|
+
'SUM',
|
|
141
|
+
'TRIM',
|
|
142
|
+
'UPPER',
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Functions that are only meaningful over a window.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately a second list rather than more entries in `SQL_FUNCTIONS`:
|
|
149
|
+
* `ROW_NUMBER()` outside an `OVER` clause is a syntax error on all three
|
|
150
|
+
* dialects, so putting it in the general list would let the builder emit SQL
|
|
151
|
+
* that cannot run. The aggregates in `SQL_FUNCTIONS` work in *both* positions
|
|
152
|
+
* and stay where they are — `evalOperands` accepts either list inside a window.
|
|
153
|
+
*
|
|
154
|
+
* Verified present on all three servers: SQLite has had window functions since
|
|
155
|
+
* 3.25, MySQL since 8.0, Postgres throughout.
|
|
156
|
+
*/
|
|
157
|
+
export const WINDOW_FUNCTIONS = new Set([
|
|
158
|
+
'CUME_DIST',
|
|
159
|
+
'DENSE_RANK',
|
|
160
|
+
'FIRST_VALUE',
|
|
161
|
+
'LAG',
|
|
162
|
+
'LAST_VALUE',
|
|
163
|
+
'LEAD',
|
|
164
|
+
'NTH_VALUE',
|
|
165
|
+
'NTILE',
|
|
166
|
+
'PERCENT_RANK',
|
|
167
|
+
'RANK',
|
|
168
|
+
'ROW_NUMBER',
|
|
169
|
+
])
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A function call with an `OVER (…)` clause.
|
|
173
|
+
*
|
|
174
|
+
* `spec` is the inside of the parentheses — `PARTITION BY "a" ORDER BY "b" ASC`
|
|
175
|
+
* — and arrives **already validated and quoted**, exactly like `_joins[].on`.
|
|
176
|
+
* That is deliberate: `safeColumn` lives in the query builder, and duplicating
|
|
177
|
+
* the identifier rules here would make two writers of the same SQL, which is
|
|
178
|
+
* the thing convention 8 exists to prevent. This class stores; the builder
|
|
179
|
+
* validates.
|
|
180
|
+
*
|
|
181
|
+
* No frame clause (`ROWS BETWEEN …`). It is legal everywhere and would fit, but
|
|
182
|
+
* it has its own grammar and nobody has asked — an empty frame is the SQL
|
|
183
|
+
* default and is what every call here gets.
|
|
184
|
+
*/
|
|
185
|
+
export class WindowRef {
|
|
186
|
+
constructor(
|
|
187
|
+
/** Either an aggregate call, or the name of a window-only function. */
|
|
188
|
+
public fn: SQLFunctionRef | string,
|
|
189
|
+
public spec: string,
|
|
190
|
+
/** Arguments for a window-only function, e.g. `LAG(col, 1)`. */
|
|
191
|
+
public args: unknown[] = [],
|
|
192
|
+
) {}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const RX_SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
|
196
|
+
|
|
197
|
+
/** True for a plain identifier safe to interpolate into SQL after quoting. */
|
|
198
|
+
export function isSafeIdentifier(name: unknown): name is string {
|
|
199
|
+
return typeof name === 'string' && RX_SAFE_IDENTIFIER.test(name)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function activeQuoteChar(): string {
|
|
203
|
+
return getActiveDb()?.quoteChar || '"'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Memo for `Case.snake`, which is the single most expensive step in quoting an
|
|
208
|
+
* identifier — measurably more than the quoting itself — and is re-run for the
|
|
209
|
+
* same handful of table and column names on every clause of every query.
|
|
210
|
+
*
|
|
211
|
+
* A capped `Map` rather than `LRUCache`: an LRU `get` deletes and re-inserts on
|
|
212
|
+
* every hit to maintain recency, which measured ~38x slower than `Map.get` and
|
|
213
|
+
* would cost more than the `Case.snake` call it is meant to replace. Convention
|
|
214
|
+
* 6 asks for bounded, and this is bounded by construction — identifiers are
|
|
215
|
+
* schema-derived in practice, but `DB.col()` takes a caller-supplied string, so
|
|
216
|
+
* the bound cannot rest on that. Dropping the whole map on overflow is correct
|
|
217
|
+
* because every entry is recomputable: it is a memo, not state.
|
|
218
|
+
*/
|
|
219
|
+
const SNAKE_CACHE_MAX = 4096
|
|
220
|
+
const snakeCache = new Map<string, string>()
|
|
221
|
+
|
|
222
|
+
function snake(name: string): string {
|
|
223
|
+
const hit = snakeCache.get(name)
|
|
224
|
+
if (hit !== undefined) return hit
|
|
225
|
+
const value = Case.snake(name)
|
|
226
|
+
if (snakeCache.size >= SNAKE_CACHE_MAX) snakeCache.clear()
|
|
227
|
+
snakeCache.set(name, value)
|
|
228
|
+
return value
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Quote an identifier for the active dialect. Everything that interpolates a
|
|
233
|
+
* table/column/alias name goes through here rather than inlining the quote
|
|
234
|
+
* character, so the quote-stripping in `quoteIdentifier` can never be skipped.
|
|
235
|
+
*/
|
|
236
|
+
export function qRaw(name: string): string {
|
|
237
|
+
return quoteIdentifier(name, activeQuoteChar())
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Quote a single identifier, snake-casing it first. */
|
|
241
|
+
export function qId(name: string): string {
|
|
242
|
+
return qRaw(snake(name))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Quote `table.column`, a bare column, or pass `*` through untouched. */
|
|
246
|
+
export function qRef(ref: string): string {
|
|
247
|
+
if (ref === '*') return ref
|
|
248
|
+
const dot = ref.indexOf('.')
|
|
249
|
+
if (dot === -1) return qId(ref)
|
|
250
|
+
return `${qId(ref.slice(0, dot))}.${qId(ref.slice(dot + 1))}`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Lives here rather than in `orm/query.ts` so `evalOperands` can identify it
|
|
255
|
+
* with a real `instanceof`. It used to be duck-typed on the presence of
|
|
256
|
+
* `fnName`/`col`, which let any JSON request body reach the interpolation below.
|
|
257
|
+
*/
|
|
258
|
+
export class SQLFunctionRef<C extends string = string> {
|
|
259
|
+
constructor(
|
|
260
|
+
public fnName: string,
|
|
261
|
+
public col: C,
|
|
262
|
+
public extraArgs: any[] = [],
|
|
263
|
+
/**
|
|
264
|
+
* `COUNT(DISTINCT col)` rather than `COUNT(col)`.
|
|
265
|
+
*
|
|
266
|
+
* A property of the call, not of the query: the builder-level `.distinct()`
|
|
267
|
+
* is `SELECT DISTINCT` over the whole row, which is a different thing and
|
|
268
|
+
* composes independently of this one.
|
|
269
|
+
*/
|
|
270
|
+
public distinct = false,
|
|
271
|
+
) {}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export class OperatorRef<R = any> {
|
|
275
|
+
constructor(
|
|
276
|
+
public operator: string,
|
|
277
|
+
public right: R,
|
|
278
|
+
public isRightColumn?: boolean,
|
|
279
|
+
) {}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Convention 8 makes this the single writer of SQL identifiers, so the arms
|
|
283
|
+
// belong together: splitting by operand kind spreads that responsibility.
|
|
284
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: recursive operand evaluator — one arm per operand shape
|
|
285
|
+
export function evalOperands(
|
|
286
|
+
where: unknown,
|
|
287
|
+
params: unknown[],
|
|
288
|
+
isColumn?: boolean,
|
|
289
|
+
): string {
|
|
290
|
+
if (where instanceof ColumnRef) {
|
|
291
|
+
return qRef(where.col)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (where instanceof OperatorRef) {
|
|
295
|
+
const op = where.operator.toUpperCase()
|
|
296
|
+
if (op === 'IS NULL' || op === 'IS NOT NULL') {
|
|
297
|
+
return op
|
|
298
|
+
}
|
|
299
|
+
if (op === 'BETWEEN' && Array.isArray(where.right)) {
|
|
300
|
+
const min = evalOperands(where.right[0], params, false)
|
|
301
|
+
const max = evalOperands(where.right[1], params, false)
|
|
302
|
+
return `BETWEEN ${min} AND ${max}`
|
|
303
|
+
}
|
|
304
|
+
if ((op === 'IN' || op === 'NOT IN') && Array.isArray(where.right)) {
|
|
305
|
+
const items = where.right
|
|
306
|
+
.map(v => evalOperands(v, params, false))
|
|
307
|
+
.join(', ')
|
|
308
|
+
return `${op} (${items})`
|
|
309
|
+
}
|
|
310
|
+
const rightSql = evalOperands(
|
|
311
|
+
where.right,
|
|
312
|
+
params,
|
|
313
|
+
where.isRightColumn || where.right instanceof ColumnRef,
|
|
314
|
+
)
|
|
315
|
+
return `${op} ${rightSql}`
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (Array.isArray(where)) {
|
|
319
|
+
return `(${where.map(v => evalOperands(v, params, isColumn)).join(', ')})`
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (where === null) return 'NULL'
|
|
323
|
+
|
|
324
|
+
if (typeof where === 'object' && where !== null) {
|
|
325
|
+
if (where instanceof WindowRef) {
|
|
326
|
+
// The function half only. `spec` was built by the query builder out of
|
|
327
|
+
// `safeColumn` output and is inserted as-is — see the class comment.
|
|
328
|
+
let call: string
|
|
329
|
+
if (where.fn instanceof SQLFunctionRef) {
|
|
330
|
+
call = evalOperands(where.fn, params, true)
|
|
331
|
+
} else {
|
|
332
|
+
const fnName = String(where.fn).toUpperCase()
|
|
333
|
+
// Interpolated, not bound, so it passes the same kind of allow-list
|
|
334
|
+
// `SQL_FUNCTIONS` applies to an ordinary call.
|
|
335
|
+
if (!WINDOW_FUNCTIONS.has(fnName) && !SQL_FUNCTIONS.has(fnName)) {
|
|
336
|
+
throws(`Unsupported window function: ${where.fn}`)
|
|
337
|
+
}
|
|
338
|
+
const args = where.args
|
|
339
|
+
.map(a => evalOperands(a, params, false))
|
|
340
|
+
.join(', ')
|
|
341
|
+
call = `${fnName}(${args})`
|
|
342
|
+
}
|
|
343
|
+
return `${call} OVER (${where.spec})`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (where instanceof SQLFunctionRef) {
|
|
347
|
+
const fnName = String(where.fnName).toUpperCase()
|
|
348
|
+
const colArg = where.col
|
|
349
|
+
const extraArgs = where.extraArgs || []
|
|
350
|
+
|
|
351
|
+
// fnName is interpolated, not bound — only known functions may pass.
|
|
352
|
+
if (!SQL_FUNCTIONS.has(fnName)) {
|
|
353
|
+
throws(`Unsupported SQL function: ${where.fnName}`)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let evalCol = ''
|
|
357
|
+
if (colArg === '*') {
|
|
358
|
+
evalCol = '*'
|
|
359
|
+
} else if (colArg instanceof ColumnRef) {
|
|
360
|
+
evalCol = evalOperands(colArg, params, true)
|
|
361
|
+
} else if (typeof colArg === 'string') {
|
|
362
|
+
evalCol = qRef(colArg)
|
|
363
|
+
} else {
|
|
364
|
+
evalCol = evalOperands(colArg, params, true)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (extraArgs.length > 0) {
|
|
368
|
+
const evalExtras = extraArgs
|
|
369
|
+
.map((a: any) => evalOperands(a, params, false))
|
|
370
|
+
.join(', ')
|
|
371
|
+
return `${fnName}(${evalCol}, ${evalExtras})`
|
|
372
|
+
}
|
|
373
|
+
return `${fnName}(${where.distinct ? 'DISTINCT ' : ''}${evalCol})`
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (typeof (where as any).parse === 'function') {
|
|
377
|
+
const { sql, params: subParams } = (where as any).parse()
|
|
378
|
+
if (subParams && subParams.length > 0) {
|
|
379
|
+
params.push(...subParams)
|
|
380
|
+
}
|
|
381
|
+
return `(${sql})`
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const entries = Object.entries(where)
|
|
385
|
+
if (entries.length === 0) throws('Empty operands object')
|
|
386
|
+
const [key, val] = entries[0]!
|
|
387
|
+
|
|
388
|
+
if (SQL_FUNCTIONS.has(key.toUpperCase())) {
|
|
389
|
+
const args = Array.isArray(val) ? val : [val]
|
|
390
|
+
return `${key.toUpperCase()}(${args.map(arg => evalOperands(arg, params, true)).join(', ')})`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// `{table: 'column'}` becomes a bare identifier reference, so both halves are
|
|
394
|
+
// interpolated. Case.snake does not strip quote characters, so an unchecked
|
|
395
|
+
// value here can break out of the identifier.
|
|
396
|
+
if (!isSafeIdentifier(key) || !isSafeIdentifier(val)) {
|
|
397
|
+
throws(
|
|
398
|
+
`Unsafe identifier in operands object: ${key}.${String(val)}. ` +
|
|
399
|
+
'Pass a scalar to bind it as a parameter.',
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return `${qId(key)}.${qId(val as string)}`
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (typeof where === 'boolean') return where ? 'TRUE' : 'FALSE'
|
|
407
|
+
|
|
408
|
+
if (typeof where === 'string') {
|
|
409
|
+
if (isColumn === true) return qRef(where)
|
|
410
|
+
|
|
411
|
+
if (
|
|
412
|
+
isColumn === undefined &&
|
|
413
|
+
/^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/.test(where)
|
|
414
|
+
) {
|
|
415
|
+
return qRef(where)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
params.push(where)
|
|
419
|
+
return '?'
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
params.push(where)
|
|
423
|
+
return '?'
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function old<TSchema extends SyncTypes.DBConstraints>(
|
|
427
|
+
oldTableName: string,
|
|
428
|
+
schema: TSchema,
|
|
429
|
+
transform?: (oldRow: Record<string, unknown>) => unknown,
|
|
430
|
+
): TSchema
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Constrained on `{ type: unknown }`, not on `ColumnConstraint`.
|
|
434
|
+
*
|
|
435
|
+
* This is the one function that takes a column descriptor in a *typed*
|
|
436
|
+
* position, so it is where the two vocabularies meet: `ColumnConstraint.type`
|
|
437
|
+
* is the dialect name the sync engine reads, while a `Field.*` builder declares
|
|
438
|
+
* `type` as the **row type**. Requiring the sync-side shape here rejected every
|
|
439
|
+
* `Field` value — `old('title', Field.Varchar(255))` stopped compiling.
|
|
440
|
+
*
|
|
441
|
+
* `old()` only copies the object and adds two keys, so it needs no more than
|
|
442
|
+
* "something with a `type`", and returning `T` unchanged means the row type and
|
|
443
|
+
* optionality survive the wrapper.
|
|
444
|
+
*/
|
|
445
|
+
export function old<T extends { type: unknown }>(
|
|
446
|
+
oldColumnName: string,
|
|
447
|
+
columnDef: T,
|
|
448
|
+
transform?: (oldValue: unknown, oldRow: Record<string, unknown>) => unknown,
|
|
449
|
+
): T
|
|
450
|
+
|
|
451
|
+
export function old(
|
|
452
|
+
oldName: string,
|
|
453
|
+
target: unknown,
|
|
454
|
+
transform?: unknown,
|
|
455
|
+
): unknown {
|
|
456
|
+
if (target && is.object(target) && 'type' in target) {
|
|
457
|
+
return Object.assign({}, target, {
|
|
458
|
+
_oldColumn: oldName,
|
|
459
|
+
_transform: transform,
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return Object.assign({}, target, {
|
|
464
|
+
_oldTable: oldName,
|
|
465
|
+
_transform: transform,
|
|
466
|
+
})
|
|
467
|
+
}
|