@crombolo/data 0.9.1 → 0.10.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/package.json +2 -2
- package/src/bundle-tables/builder.ts +264 -0
- package/src/bundle-tables/capability.ts +377 -0
- package/src/bundle-tables/chain.ts +105 -0
- package/src/bundle-tables/ddl.ts +124 -0
- package/src/bundle-tables/guard.ts +116 -0
- package/src/bundle-tables/index.ts +7 -0
- package/src/bundle-tables/names.ts +58 -0
- package/src/bundle-tables/schema.ts +485 -0
- package/src/cluster.ts +1 -1
- package/src/dialect.ts +8 -0
- package/src/index.ts +3 -0
- package/src/migrations.ts +147 -31
- package/src/pending.ts +1 -1
- package/src/repositories/bundles.ts +36 -4
- package/src/repositories/documents.ts +1 -1
- package/src/repositories/form-entries.ts +1 -1
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/transfer-runs.ts +20 -2
- package/src/schema/bundle-components.ts +1 -1
- package/src/schema/bundle-package.ts +27 -0
- package/src/schema/bundles-rename.ts +1 -1
- package/src/schema/content.ts +2 -2
- package/src/schema/created-packages.ts +1 -1
- package/src/schema/form-permissions.ts +1 -1
- package/src/schema/form-workflows.ts +1 -1
- package/src/schema/forms.ts +1 -1
- package/src/schema/identity.ts +1 -1
- package/src/schema/plan.ts +4 -0
- package/src/schema/transfer-run-package.ts +27 -0
- package/src/schema/upgrade-report.ts +1 -1
- package/src/schema-state.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/data",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
31
|
+
"@crombolo/core": "0.10.0"
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declarative builder a bundle writes its tables with. `docs/bundles.md`,
|
|
3
|
+
* Part 4.
|
|
4
|
+
*
|
|
5
|
+
* It is the only way a bundle writes schema: there is no SQL in it, and no
|
|
6
|
+
* escape hatch to some. A step's `up` returns the operations it wants, as plain
|
|
7
|
+
* data, which is what makes a step checkable before it runs — linted for
|
|
8
|
+
* expand and contract, hashed for its checksum, rendered for `upgrade --plan` —
|
|
9
|
+
* and what lets the final shape of every table be worked out from the
|
|
10
|
+
* declaration's types alone.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type ColumnType =
|
|
14
|
+
| 'uuid'
|
|
15
|
+
| 'varchar'
|
|
16
|
+
| 'text'
|
|
17
|
+
| 'integer'
|
|
18
|
+
| 'bigint'
|
|
19
|
+
| 'boolean'
|
|
20
|
+
| 'timestamp'
|
|
21
|
+
| 'json'
|
|
22
|
+
|
|
23
|
+
/** A scalar a default may be: what DDL can carry as a literal. */
|
|
24
|
+
export type DefaultValue = string | number | boolean
|
|
25
|
+
|
|
26
|
+
export interface ColumnSpec {
|
|
27
|
+
type: ColumnType
|
|
28
|
+
/** For `varchar`. */
|
|
29
|
+
length?: number
|
|
30
|
+
nullable: boolean
|
|
31
|
+
default?: DefaultValue
|
|
32
|
+
primaryKey: boolean
|
|
33
|
+
indexed: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a column holds, as the capability reads and writes it. */
|
|
37
|
+
export type JsonValue =
|
|
38
|
+
| string
|
|
39
|
+
| number
|
|
40
|
+
| boolean
|
|
41
|
+
| null
|
|
42
|
+
| JsonValue[]
|
|
43
|
+
| { [key: string]: JsonValue }
|
|
44
|
+
|
|
45
|
+
interface ValueOf {
|
|
46
|
+
uuid: string
|
|
47
|
+
varchar: string
|
|
48
|
+
text: string
|
|
49
|
+
integer: number
|
|
50
|
+
bigint: number
|
|
51
|
+
boolean: boolean
|
|
52
|
+
timestamp: Date
|
|
53
|
+
json: JsonValue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A column being described. Its type parameters carry what the declaration
|
|
58
|
+
* says, so a table's row type can be read off it.
|
|
59
|
+
*/
|
|
60
|
+
export interface Column<
|
|
61
|
+
T extends ColumnType = ColumnType,
|
|
62
|
+
N extends boolean = false,
|
|
63
|
+
D extends boolean = false,
|
|
64
|
+
> {
|
|
65
|
+
readonly spec: ColumnSpec
|
|
66
|
+
/** Allows null. */
|
|
67
|
+
nullable(): Column<T, true, D>
|
|
68
|
+
/** What a row gets when an insert leaves it out. */
|
|
69
|
+
default(
|
|
70
|
+
value: T extends 'json' ? never : ValueOf[T] extends Date ? never : ValueOf[T],
|
|
71
|
+
): Column<T, N, true>
|
|
72
|
+
primaryKey(): Column<T, N, D>
|
|
73
|
+
/** Gives the column an index of its own. */
|
|
74
|
+
indexed(): Column<T, N, D>
|
|
75
|
+
/** Type-level only: what the column holds. */
|
|
76
|
+
readonly __value?: ValueOf[T]
|
|
77
|
+
readonly __nullable?: N
|
|
78
|
+
readonly __defaulted?: D
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function column<T extends ColumnType>(type: T, spec: Partial<ColumnSpec> = {}): Column<T> {
|
|
82
|
+
const make = (s: ColumnSpec): Column<T, boolean, boolean> =>
|
|
83
|
+
Object.freeze({
|
|
84
|
+
spec: Object.freeze(s),
|
|
85
|
+
nullable: () => make({ ...s, nullable: true }),
|
|
86
|
+
default: (value: DefaultValue) => make({ ...s, default: value }),
|
|
87
|
+
primaryKey: () => make({ ...s, primaryKey: true }),
|
|
88
|
+
indexed: () => make({ ...s, indexed: true }),
|
|
89
|
+
}) as Column<T, boolean, boolean>
|
|
90
|
+
return make({ type, nullable: false, primaryKey: false, indexed: false, ...spec }) as Column<T>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type Columns = Record<string, Column<ColumnType, boolean, boolean>>
|
|
94
|
+
|
|
95
|
+
export type Operation =
|
|
96
|
+
| { op: 'createTable'; table: string; columns: Record<string, ColumnSpec> }
|
|
97
|
+
| { op: 'addColumn'; table: string; column: string; spec: ColumnSpec }
|
|
98
|
+
| { op: 'addIndex'; table: string; columns: string[]; unique: boolean }
|
|
99
|
+
| { op: 'widenColumn'; table: string; column: string; spec: ColumnSpec }
|
|
100
|
+
| { op: 'dropTable'; table: string }
|
|
101
|
+
| { op: 'dropColumn'; table: string; column: string }
|
|
102
|
+
| { op: 'dropIndex'; table: string; columns: string[] }
|
|
103
|
+
|
|
104
|
+
/** An operation, carrying at the type level what it does to the schema. */
|
|
105
|
+
export type Op<Effect = never> = Operation & { readonly __effect?: Effect }
|
|
106
|
+
|
|
107
|
+
export interface CreateTable<T extends string, C extends Columns> {
|
|
108
|
+
kind: 'create'
|
|
109
|
+
table: T
|
|
110
|
+
columns: C
|
|
111
|
+
}
|
|
112
|
+
export interface AddColumn<
|
|
113
|
+
T extends string,
|
|
114
|
+
K extends string,
|
|
115
|
+
C extends Column<ColumnType, boolean, boolean>,
|
|
116
|
+
> {
|
|
117
|
+
kind: 'add'
|
|
118
|
+
table: T
|
|
119
|
+
column: K
|
|
120
|
+
spec: C
|
|
121
|
+
}
|
|
122
|
+
export interface DropTable<T extends string> {
|
|
123
|
+
kind: 'dropTable'
|
|
124
|
+
table: T
|
|
125
|
+
}
|
|
126
|
+
export interface DropColumn<T extends string, K extends string> {
|
|
127
|
+
kind: 'dropColumn'
|
|
128
|
+
table: T
|
|
129
|
+
column: K
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What a step's `up` is handed. */
|
|
133
|
+
export interface SchemaBuilder {
|
|
134
|
+
uuid(): Column<'uuid'>
|
|
135
|
+
varchar(length: number): Column<'varchar'>
|
|
136
|
+
text(): Column<'text'>
|
|
137
|
+
integer(): Column<'integer'>
|
|
138
|
+
bigint(): Column<'bigint'>
|
|
139
|
+
boolean(): Column<'boolean'>
|
|
140
|
+
timestamp(): Column<'timestamp'>
|
|
141
|
+
json(): Column<'json'>
|
|
142
|
+
|
|
143
|
+
createTable<T extends string, C extends Columns>(table: T, columns: C): Op<CreateTable<T, C>>
|
|
144
|
+
/** A new column must be nullable or have a default, so it adds under live rows. */
|
|
145
|
+
addColumn<T extends string, K extends string, C extends Column<ColumnType, boolean, boolean>>(
|
|
146
|
+
table: T,
|
|
147
|
+
column: K,
|
|
148
|
+
spec: C,
|
|
149
|
+
): Op<AddColumn<T, K, C>>
|
|
150
|
+
addIndex(table: string, columns: readonly string[], options?: { unique?: boolean }): Op
|
|
151
|
+
/** Longer text, or a wider integer; never narrower, never another kind. */
|
|
152
|
+
widenColumn(table: string, column: string, spec: Column<ColumnType, boolean, boolean>): Op
|
|
153
|
+
dropTable<T extends string>(table: T): Op<DropTable<T>>
|
|
154
|
+
dropColumn<T extends string, K extends string>(table: T, column: K): Op<DropColumn<T, K>>
|
|
155
|
+
dropIndex(table: string, columns: readonly string[]): Op
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const specs = (columns: Columns): Record<string, ColumnSpec> => {
|
|
159
|
+
const out: Record<string, ColumnSpec> = {}
|
|
160
|
+
for (const [name, col] of Object.entries(columns)) out[name] = col.spec
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The builder: every method returns plain data. */
|
|
165
|
+
export const schemaBuilder: SchemaBuilder = Object.freeze<SchemaBuilder>({
|
|
166
|
+
uuid: () => column('uuid'),
|
|
167
|
+
varchar: (length: number) => column('varchar', { length }),
|
|
168
|
+
text: () => column('text'),
|
|
169
|
+
integer: () => column('integer'),
|
|
170
|
+
bigint: () => column('bigint'),
|
|
171
|
+
boolean: () => column('boolean'),
|
|
172
|
+
timestamp: () => column('timestamp'),
|
|
173
|
+
json: () => column('json'),
|
|
174
|
+
createTable: (table, columns) => ({ op: 'createTable', table, columns: specs(columns) }) as never,
|
|
175
|
+
addColumn: (table, name, spec) =>
|
|
176
|
+
({ op: 'addColumn', table, column: name, spec: spec.spec }) as never,
|
|
177
|
+
addIndex: (table, columns, options = {}) => ({
|
|
178
|
+
op: 'addIndex',
|
|
179
|
+
table,
|
|
180
|
+
columns: [...columns],
|
|
181
|
+
unique: options.unique ?? false,
|
|
182
|
+
}),
|
|
183
|
+
widenColumn: (table, name, spec) => ({ op: 'widenColumn', table, column: name, spec: spec.spec }),
|
|
184
|
+
dropTable: (table) => ({ op: 'dropTable', table }) as never,
|
|
185
|
+
dropColumn: (table, name) => ({ op: 'dropColumn', table, column: name }) as never,
|
|
186
|
+
dropIndex: (table, columns) => ({ op: 'dropIndex', table, columns: [...columns] }),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ------------------------------------------------------------ the row types
|
|
190
|
+
|
|
191
|
+
type ColumnValue<C> =
|
|
192
|
+
C extends Column<infer T, infer N, boolean>
|
|
193
|
+
? N extends true
|
|
194
|
+
? ValueOf[T] | null
|
|
195
|
+
: ValueOf[T]
|
|
196
|
+
: never
|
|
197
|
+
|
|
198
|
+
/** A table's row, from its columns. */
|
|
199
|
+
export type RowOf<C extends Columns> = { [K in keyof C]: ColumnValue<C[K]> }
|
|
200
|
+
|
|
201
|
+
type Optional<C extends Columns> = {
|
|
202
|
+
[K in keyof C]: C[K] extends Column<ColumnType, infer N, infer D>
|
|
203
|
+
? N extends true
|
|
204
|
+
? K
|
|
205
|
+
: D extends true
|
|
206
|
+
? K
|
|
207
|
+
: never
|
|
208
|
+
: never
|
|
209
|
+
}[keyof C]
|
|
210
|
+
|
|
211
|
+
/** What an insert must and may supply: a nullable or defaulted column may be left out. */
|
|
212
|
+
export type InsertOf<C extends Columns> = Omit<RowOf<C>, Optional<C>> &
|
|
213
|
+
Partial<Pick<RowOf<C>, Optional<C>>>
|
|
214
|
+
|
|
215
|
+
/** Every table's columns, after a run of operations. */
|
|
216
|
+
export type TablesShape = Record<string, Columns>
|
|
217
|
+
|
|
218
|
+
type Effects<R> = R extends readonly unknown[] ? R[number] : R
|
|
219
|
+
type EffectOf<O> = O extends Op<infer E> ? E : never
|
|
220
|
+
|
|
221
|
+
type Apply<S extends TablesShape, E> =
|
|
222
|
+
E extends CreateTable<infer T, infer C>
|
|
223
|
+
? Omit<S, T> & { [K in T]: C }
|
|
224
|
+
: E extends AddColumn<infer T, infer K, infer C>
|
|
225
|
+
? T extends keyof S
|
|
226
|
+
? Omit<S, T> & { [P in T]: S[T] & { [Q in K]: C } }
|
|
227
|
+
: S
|
|
228
|
+
: E extends DropTable<infer T>
|
|
229
|
+
? Omit<S, T>
|
|
230
|
+
: E extends DropColumn<infer T, infer K>
|
|
231
|
+
? T extends keyof S
|
|
232
|
+
? Omit<S, T> & { [P in T]: Omit<S[T], K> }
|
|
233
|
+
: S
|
|
234
|
+
: S
|
|
235
|
+
|
|
236
|
+
type ApplyAll<S extends TablesShape, E> = [E] extends [never]
|
|
237
|
+
? S
|
|
238
|
+
: // A union of effects within one step is applied one member at a time.
|
|
239
|
+
UnionToTuple<E> extends infer L
|
|
240
|
+
? L extends readonly [infer H, ...infer Rest]
|
|
241
|
+
? ApplyAll<Flatten<Apply<S, H>>, Rest[number]>
|
|
242
|
+
: S
|
|
243
|
+
: S
|
|
244
|
+
|
|
245
|
+
type Flatten<T> = { [K in keyof T]: T[K] } & {}
|
|
246
|
+
|
|
247
|
+
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
|
|
248
|
+
k: infer I,
|
|
249
|
+
) => void
|
|
250
|
+
? I
|
|
251
|
+
: never
|
|
252
|
+
type LastOf<U> =
|
|
253
|
+
UnionToIntersection<U extends unknown ? () => U : never> extends () => infer R ? R : never
|
|
254
|
+
type UnionToTuple<U, L = LastOf<U>> = [U] extends [never] ? [] : [...UnionToTuple<Exclude<U, L>>, L]
|
|
255
|
+
|
|
256
|
+
/** The shape after every step, in order: what `context.host.tables` is typed from. */
|
|
257
|
+
export type ShapeOf<
|
|
258
|
+
Steps extends readonly unknown[],
|
|
259
|
+
S extends TablesShape = Record<never, never>,
|
|
260
|
+
> = Steps extends readonly [infer First, ...infer Rest]
|
|
261
|
+
? First extends { up: (s: SchemaBuilder) => infer R }
|
|
262
|
+
? ShapeOf<Rest, Flatten<ApplyAll<S, EffectOf<Effects<R>>>>>
|
|
263
|
+
: ShapeOf<Rest, S>
|
|
264
|
+
: S
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `tables` capability: a bundle's runtime access to its own tables, and
|
|
3
|
+
* nothing else. `docs/bundles.md`, Part 4.
|
|
4
|
+
*
|
|
5
|
+
* Three things keep it inside the bundle's tables:
|
|
6
|
+
*
|
|
7
|
+
* - **No identifier comes from the caller.** Every table and column name is
|
|
8
|
+
* looked up in the bundle's resolved schema and quoted from there, and an
|
|
9
|
+
* unknown one is refused before any SQL is built. Values are always bound.
|
|
10
|
+
* - **No `Db` is reachable.** The capability, each table handle and the
|
|
11
|
+
* transaction handle are frozen, null-prototype objects whose members are
|
|
12
|
+
* closures; the connection is never a property of any of them.
|
|
13
|
+
* - **Every statement passes the guard**, which refuses one naming any table
|
|
14
|
+
* that is not this bundle's.
|
|
15
|
+
*/
|
|
16
|
+
import type { Db } from '../database.ts'
|
|
17
|
+
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
18
|
+
import type { ColumnSpec, Columns, InsertOf, JsonValue, RowOf, TablesShape } from './builder.ts'
|
|
19
|
+
import { guardStatement } from './guard.ts'
|
|
20
|
+
import type { ResolvedSchema, ResolvedTable } from './schema.ts'
|
|
21
|
+
|
|
22
|
+
/** A request the capability refused: nothing reached the database. */
|
|
23
|
+
export class BundleQueryError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
readonly bundle: string,
|
|
26
|
+
reason: string,
|
|
27
|
+
) {
|
|
28
|
+
super(`Bundle "${bundle}": ${reason}.`)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Scalar<V> = Exclude<V, null>
|
|
33
|
+
|
|
34
|
+
/** A condition on one column. A bare value means equality, and `null` means `isNull`. */
|
|
35
|
+
export type Condition<V> =
|
|
36
|
+
| V
|
|
37
|
+
| {
|
|
38
|
+
eq?: Scalar<V>
|
|
39
|
+
in?: readonly Scalar<V>[]
|
|
40
|
+
lt?: Scalar<V>
|
|
41
|
+
lte?: Scalar<V>
|
|
42
|
+
gt?: Scalar<V>
|
|
43
|
+
gte?: Scalar<V>
|
|
44
|
+
isNull?: true
|
|
45
|
+
notNull?: true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type Where<R> = { [K in keyof R]?: Condition<R[K]> } & {
|
|
49
|
+
and?: readonly Where<R>[]
|
|
50
|
+
or?: readonly Where<R>[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SelectQuery<R> {
|
|
54
|
+
where?: Where<R>
|
|
55
|
+
orderBy?: { [K in keyof R]?: 'asc' | 'desc' }
|
|
56
|
+
/** At most 10,000; 1,000 when not given. */
|
|
57
|
+
limit?: number
|
|
58
|
+
offset?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TableHandle<C extends Columns> {
|
|
62
|
+
select(query?: SelectQuery<RowOf<C>>): Promise<RowOf<C>[]>
|
|
63
|
+
count(query?: { where?: Where<RowOf<C>> }): Promise<number>
|
|
64
|
+
insert(rows: InsertOf<C> | readonly InsertOf<C>[]): Promise<void>
|
|
65
|
+
/** A `where` is required; an unfiltered write is `truncate`. */
|
|
66
|
+
update(query: { where: Where<RowOf<C>>; set: Partial<RowOf<C>> }): Promise<void>
|
|
67
|
+
delete(query: { where: Where<RowOf<C>> }): Promise<void>
|
|
68
|
+
/** Every row, gone. */
|
|
69
|
+
truncate(): Promise<void>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type TableHandles<S extends TablesShape> = { readonly [T in keyof S]: TableHandle<S[T]> }
|
|
73
|
+
|
|
74
|
+
export type BundleTablesCapability<S extends TablesShape = TablesShape> = TableHandles<S> & {
|
|
75
|
+
/** Runs `fn` in one transaction, handing it the same tables bound to it. */
|
|
76
|
+
transaction<R>(fn: (tables: TableHandles<S>) => Promise<R>): Promise<R>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const MAX_LIMIT = 10_000
|
|
80
|
+
const DEFAULT_LIMIT = 1_000
|
|
81
|
+
const MAX_DEPTH = 8
|
|
82
|
+
const MAX_IN = 1_000
|
|
83
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
84
|
+
const OPERATORS = new Set(['eq', 'in', 'lt', 'lte', 'gt', 'gte', 'isNull', 'notNull'])
|
|
85
|
+
const COMPARISONS: Record<string, string> = { eq: '=', lt: '<', lte: '<=', gt: '>', gte: '>=' }
|
|
86
|
+
|
|
87
|
+
/** A plain object, and not a class instance or an array, read by its own keys only. */
|
|
88
|
+
function isPlain(value: unknown): value is Record<string, unknown> {
|
|
89
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
90
|
+
const proto = Object.getPrototypeOf(value)
|
|
91
|
+
return proto === Object.prototype || proto === null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function frozen<T extends object>(members: T): T {
|
|
95
|
+
return Object.freeze(Object.assign(Object.create(null), members)) as T
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface Compiler {
|
|
99
|
+
bundle: string
|
|
100
|
+
dialectName: Db['dialect']['name']
|
|
101
|
+
boolValue: Db['dialect']['boolValue']
|
|
102
|
+
quote: Db['dialect']['quote']
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function refuse(bundle: string, reason: string): never {
|
|
106
|
+
throw new BundleQueryError(bundle, reason)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A value as the column stores it, or a refusal. */
|
|
110
|
+
function toDb(
|
|
111
|
+
c: Compiler,
|
|
112
|
+
table: ResolvedTable,
|
|
113
|
+
column: string,
|
|
114
|
+
spec: ColumnSpec,
|
|
115
|
+
value: unknown,
|
|
116
|
+
): unknown {
|
|
117
|
+
const where = `"${table.name}.${column}"`
|
|
118
|
+
if (value === null || value === undefined) {
|
|
119
|
+
if (!spec.nullable) refuse(c.bundle, `${where} cannot be null`)
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
switch (spec.type) {
|
|
123
|
+
case 'uuid':
|
|
124
|
+
// Lowercased, so a key compares equal however it was written, on both dialects.
|
|
125
|
+
if (typeof value !== 'string' || !UUID.test(value)) refuse(c.bundle, `${where} takes a uuid`)
|
|
126
|
+
return value.toLowerCase()
|
|
127
|
+
case 'text':
|
|
128
|
+
if (typeof value !== 'string') refuse(c.bundle, `${where} takes a string`)
|
|
129
|
+
return value
|
|
130
|
+
case 'varchar':
|
|
131
|
+
if (typeof value !== 'string') refuse(c.bundle, `${where} takes a string`)
|
|
132
|
+
if (value.length > (spec.length ?? 0))
|
|
133
|
+
refuse(c.bundle, `${where} holds at most ${spec.length} characters`)
|
|
134
|
+
return value
|
|
135
|
+
case 'integer':
|
|
136
|
+
case 'bigint':
|
|
137
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value))
|
|
138
|
+
refuse(c.bundle, `${where} takes a whole number`)
|
|
139
|
+
if (spec.type === 'integer' && Math.abs(value) > 2_147_483_647)
|
|
140
|
+
refuse(c.bundle, `${where} is an integer, and ${value} needs a bigint`)
|
|
141
|
+
return value
|
|
142
|
+
case 'boolean':
|
|
143
|
+
if (typeof value !== 'boolean') refuse(c.bundle, `${where} takes true or false`)
|
|
144
|
+
return c.boolValue(value)
|
|
145
|
+
case 'timestamp': {
|
|
146
|
+
const date =
|
|
147
|
+
value instanceof Date ? value : typeof value === 'string' ? new Date(value) : undefined
|
|
148
|
+
if (!date || Number.isNaN(date.getTime())) refuse(c.bundle, `${where} takes a date`)
|
|
149
|
+
return DbDate.toDb(date)
|
|
150
|
+
}
|
|
151
|
+
case 'json':
|
|
152
|
+
try {
|
|
153
|
+
return JSON.stringify(value)
|
|
154
|
+
} catch {
|
|
155
|
+
return refuse(c.bundle, `${where} takes a value JSON can hold`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function fromDb(spec: ColumnSpec, value: unknown): unknown {
|
|
161
|
+
if (value === null || value === undefined) return null
|
|
162
|
+
switch (spec.type) {
|
|
163
|
+
case 'uuid':
|
|
164
|
+
return String(value).toLowerCase()
|
|
165
|
+
case 'varchar':
|
|
166
|
+
case 'text':
|
|
167
|
+
return String(value)
|
|
168
|
+
case 'integer':
|
|
169
|
+
case 'bigint':
|
|
170
|
+
return Number(value)
|
|
171
|
+
case 'boolean':
|
|
172
|
+
return fromDbBool(value)
|
|
173
|
+
case 'timestamp':
|
|
174
|
+
return DbDate.fromDb(value) ?? null
|
|
175
|
+
case 'json':
|
|
176
|
+
return JSON.parse(String(value)) as JsonValue
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The column a caller named, from the schema, or a refusal. */
|
|
181
|
+
function columnOf(c: Compiler, table: ResolvedTable, name: string): ColumnSpec {
|
|
182
|
+
const column = table.columns.get(name)
|
|
183
|
+
if (!column) refuse(c.bundle, `"${table.name}" has no column ${JSON.stringify(name)}`)
|
|
184
|
+
return column.spec
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function compileWhere(
|
|
188
|
+
c: Compiler,
|
|
189
|
+
table: ResolvedTable,
|
|
190
|
+
where: unknown,
|
|
191
|
+
params: unknown[],
|
|
192
|
+
depth: number,
|
|
193
|
+
): string {
|
|
194
|
+
if (depth > MAX_DEPTH) refuse(c.bundle, `a filter is nested more than ${MAX_DEPTH} deep`)
|
|
195
|
+
if (!isPlain(where)) refuse(c.bundle, 'a filter must be a plain object')
|
|
196
|
+
const parts: string[] = []
|
|
197
|
+
for (const key of Object.keys(where)) {
|
|
198
|
+
const value = where[key]
|
|
199
|
+
if (key === 'and' || key === 'or') {
|
|
200
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
201
|
+
refuse(c.bundle, `"${key}" takes a non-empty list of filters`)
|
|
202
|
+
const inner = value.map((item) => compileWhere(c, table, item, params, depth + 1))
|
|
203
|
+
parts.push(`(${inner.join(key === 'and' ? ' AND ' : ' OR ')})`)
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
const spec = columnOf(c, table, key)
|
|
207
|
+
const col = c.quote(key)
|
|
208
|
+
if (value === null) {
|
|
209
|
+
parts.push(`${col} IS NULL`)
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
if (isPlain(value)) {
|
|
213
|
+
const ops = Object.keys(value)
|
|
214
|
+
if (ops.length === 0) refuse(c.bundle, `the condition on "${key}" is empty`)
|
|
215
|
+
for (const op of ops) {
|
|
216
|
+
if (!OPERATORS.has(op)) refuse(c.bundle, `${JSON.stringify(op)} is not a condition`)
|
|
217
|
+
const operand = value[op]
|
|
218
|
+
if (op === 'isNull' || op === 'notNull') {
|
|
219
|
+
if (operand !== true) refuse(c.bundle, `"${op}" takes true`)
|
|
220
|
+
parts.push(`${col} ${op === 'isNull' ? 'IS NULL' : 'IS NOT NULL'}`)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
if (spec.type === 'json')
|
|
224
|
+
refuse(c.bundle, `"${key}" is json, which can only be tested for null`)
|
|
225
|
+
if (op === 'in') {
|
|
226
|
+
if (!Array.isArray(operand) || operand.length === 0 || operand.length > MAX_IN)
|
|
227
|
+
refuse(c.bundle, `"in" takes a list of 1 to ${MAX_IN} values`)
|
|
228
|
+
for (const item of operand)
|
|
229
|
+
params.push(toDb(c, table, key, { ...spec, nullable: false }, item))
|
|
230
|
+
parts.push(`${col} IN (${operand.map(() => '?').join(', ')})`)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
params.push(toDb(c, table, key, { ...spec, nullable: false }, operand))
|
|
234
|
+
parts.push(`${col} ${COMPARISONS[op]} ?`)
|
|
235
|
+
}
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (spec.type === 'json')
|
|
239
|
+
refuse(c.bundle, `"${key}" is json, which can only be tested for null`)
|
|
240
|
+
params.push(toDb(c, table, key, { ...spec, nullable: false }, value))
|
|
241
|
+
parts.push(`${col} = ?`)
|
|
242
|
+
}
|
|
243
|
+
if (parts.length === 0) refuse(c.bundle, 'a filter names no condition')
|
|
244
|
+
return parts.join(' AND ')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function readRow(table: ResolvedTable, row: Record<string, unknown>): Record<string, unknown> {
|
|
248
|
+
const out: Record<string, unknown> = {}
|
|
249
|
+
for (const [name, column] of table.columns) out[name] = fromDb(column.spec, row[name])
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function tableHandle(db: Db, schema: ResolvedSchema, table: ResolvedTable): TableHandle<Columns> {
|
|
254
|
+
const c: Compiler = {
|
|
255
|
+
bundle: schema.id,
|
|
256
|
+
dialectName: db.dialect.name,
|
|
257
|
+
boolValue: (value) => db.dialect.boolValue(value),
|
|
258
|
+
quote: (identifier) => db.dialect.quote(identifier),
|
|
259
|
+
}
|
|
260
|
+
const name = c.quote(table.physical)
|
|
261
|
+
const guard = (sql: string) => guardStatement(schema.id, schema.prefix, sql)
|
|
262
|
+
const columnList = [...table.columns.keys()].map(c.quote).join(', ')
|
|
263
|
+
|
|
264
|
+
const whereClause = (where: unknown, params: unknown[], required: boolean): string => {
|
|
265
|
+
if (where === undefined) {
|
|
266
|
+
if (required)
|
|
267
|
+
refuse(c.bundle, 'an update or a delete needs a "where"; use truncate to empty a table')
|
|
268
|
+
return ''
|
|
269
|
+
}
|
|
270
|
+
return ` WHERE ${compileWhere(c, table, where, params, 0)}`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return frozen({
|
|
274
|
+
async select(query: SelectQuery<Record<string, unknown>> = {}) {
|
|
275
|
+
if (!isPlain(query)) refuse(c.bundle, 'a query must be a plain object')
|
|
276
|
+
const params: unknown[] = []
|
|
277
|
+
let sql = `SELECT ${columnList} FROM ${name}${whereClause(query.where, params, false)}`
|
|
278
|
+
if (query.orderBy !== undefined) {
|
|
279
|
+
if (!isPlain(query.orderBy)) refuse(c.bundle, '"orderBy" must be a plain object')
|
|
280
|
+
const order = Object.keys(query.orderBy).map((key) => {
|
|
281
|
+
columnOf(c, table, key)
|
|
282
|
+
const direction = (query.orderBy as Record<string, unknown>)[key]
|
|
283
|
+
if (direction !== 'asc' && direction !== 'desc')
|
|
284
|
+
refuse(c.bundle, `the order of "${key}" is "asc" or "desc"`)
|
|
285
|
+
return `${c.quote(key)} ${direction === 'asc' ? 'ASC' : 'DESC'}`
|
|
286
|
+
})
|
|
287
|
+
if (order.length > 0) sql += ` ORDER BY ${order.join(', ')}`
|
|
288
|
+
}
|
|
289
|
+
const limit: unknown = query.limit ?? DEFAULT_LIMIT
|
|
290
|
+
const offset: unknown = query.offset ?? 0
|
|
291
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 0 || limit > MAX_LIMIT)
|
|
292
|
+
refuse(c.bundle, `"limit" is a whole number from 0 to ${MAX_LIMIT}`)
|
|
293
|
+
if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0)
|
|
294
|
+
refuse(c.bundle, '"offset" is a whole number from 0')
|
|
295
|
+
sql += ' LIMIT ? OFFSET ?'
|
|
296
|
+
params.push(limit, offset)
|
|
297
|
+
const rows = await db.query<Record<string, unknown>>(guard(sql), params)
|
|
298
|
+
return rows.map((row) => readRow(table, row))
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
async count(query: { where?: unknown } = {}) {
|
|
302
|
+
if (!isPlain(query)) refuse(c.bundle, 'a query must be a plain object')
|
|
303
|
+
const params: unknown[] = []
|
|
304
|
+
const sql = `SELECT COUNT(*) AS ${c.quote('n')} FROM ${name}${whereClause(query.where, params, false)}`
|
|
305
|
+
const rows = await db.query<{ n: unknown }>(guard(sql), params)
|
|
306
|
+
return Number(rows[0]?.n ?? 0)
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async insert(input: unknown) {
|
|
310
|
+
const rows = Array.isArray(input) ? input : [input]
|
|
311
|
+
if (rows.length === 0) return
|
|
312
|
+
for (const row of rows) {
|
|
313
|
+
if (!isPlain(row)) refuse(c.bundle, 'a row must be a plain object')
|
|
314
|
+
for (const key of Object.keys(row)) columnOf(c, table, key)
|
|
315
|
+
const names: string[] = []
|
|
316
|
+
const params: unknown[] = []
|
|
317
|
+
for (const [column, resolved] of table.columns) {
|
|
318
|
+
const given = Object.hasOwn(row, column)
|
|
319
|
+
if (!given) {
|
|
320
|
+
if (resolved.spec.default !== undefined || resolved.spec.nullable) continue
|
|
321
|
+
refuse(c.bundle, `a row of "${table.name}" needs "${column}"`)
|
|
322
|
+
}
|
|
323
|
+
names.push(c.quote(column))
|
|
324
|
+
params.push(toDb(c, table, column, resolved.spec, row[column]))
|
|
325
|
+
}
|
|
326
|
+
await db.exec(
|
|
327
|
+
guard(
|
|
328
|
+
`INSERT INTO ${name} (${names.join(', ')}) VALUES (${names.map(() => '?').join(', ')})`,
|
|
329
|
+
),
|
|
330
|
+
params,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
async update(query: { where?: unknown; set?: unknown }) {
|
|
336
|
+
if (!isPlain(query)) refuse(c.bundle, 'an update must be a plain object')
|
|
337
|
+
if (!isPlain(query.set) || Object.keys(query.set).length === 0)
|
|
338
|
+
refuse(c.bundle, 'an update needs a "set" naming at least one column')
|
|
339
|
+
const set = query.set
|
|
340
|
+
const params: unknown[] = []
|
|
341
|
+
const assignments = Object.keys(set).map((column) => {
|
|
342
|
+
params.push(toDb(c, table, column, columnOf(c, table, column), set[column]))
|
|
343
|
+
return `${c.quote(column)} = ?`
|
|
344
|
+
})
|
|
345
|
+
const where = whereClause(query.where, params, true)
|
|
346
|
+
await db.exec(guard(`UPDATE ${name} SET ${assignments.join(', ')}${where}`), params)
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
async delete(query: { where?: unknown }) {
|
|
350
|
+
if (!isPlain(query)) refuse(c.bundle, 'a delete must be a plain object')
|
|
351
|
+
const params: unknown[] = []
|
|
352
|
+
await db.exec(guard(`DELETE FROM ${name}${whereClause(query.where, params, true)}`), params)
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
async truncate() {
|
|
356
|
+
await db.exec(guard(`DELETE FROM ${name}`))
|
|
357
|
+
},
|
|
358
|
+
}) as TableHandle<Columns>
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function handlesOver(db: Db, schema: ResolvedSchema): TableHandles<TablesShape> {
|
|
362
|
+
const handles: Record<string, TableHandle<Columns>> = {}
|
|
363
|
+
for (const [name, table] of schema.tables) handles[name] = tableHandle(db, schema, table)
|
|
364
|
+
return frozen(handles) as TableHandles<TablesShape>
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The capability over a bundle's resolved schema, bound to a connection it never exposes. */
|
|
368
|
+
export function createTablesCapability<S extends TablesShape = TablesShape>(
|
|
369
|
+
db: Db,
|
|
370
|
+
schema: ResolvedSchema,
|
|
371
|
+
): BundleTablesCapability<S> {
|
|
372
|
+
return frozen({
|
|
373
|
+
...handlesOver(db, schema),
|
|
374
|
+
transaction: <R>(fn: (tables: TableHandles<TablesShape>) => Promise<R>) =>
|
|
375
|
+
db.transaction((tx) => fn(handlesOver(tx, schema))),
|
|
376
|
+
}) as unknown as BundleTablesCapability<S>
|
|
377
|
+
}
|