@rwillians/exto 0.1.2 → 0.2.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/README.md +207 -153
- package/dist/cjs/bun-postgres.js +36 -11
- package/dist/cjs/bun-sqlite.js +9 -3
- package/dist/cjs/index.js +418 -159
- package/dist/cjs/standard-schema.js +10 -1
- package/dist/esm/bun-postgres.js +36 -11
- package/dist/esm/bun-sqlite.js +9 -3
- package/dist/esm/index.js +414 -159
- package/dist/esm/standard-schema.js +8 -0
- package/dist/types/bun-postgres.d.ts +1 -0
- package/dist/types/bun-sqlite.d.ts +1 -0
- package/dist/types/index.d.ts +205 -82
- package/dist/types/standard-schema.d.ts +6 -0
- package/package.json +9 -8
package/dist/esm/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as std from './standard-schema';
|
|
2
2
|
import { ValidationError } from './standard-schema';
|
|
3
|
-
import {
|
|
3
|
+
import { raise, snakeCase } from './utils';
|
|
4
|
+
const NUMERIC = ['BIGINT', 'DECIMAL', 'FLOAT', 'INTEGER', 'SEQUENCE'];
|
|
4
5
|
class ColumnBuilder {
|
|
5
6
|
props;
|
|
6
7
|
constructor(props) {
|
|
@@ -14,14 +15,15 @@ class ColumnBuilder {
|
|
|
14
15
|
throw new Error('Only UUID columns can be generated, use t.sequence() for integers');
|
|
15
16
|
return new ColumnBuilder({ ...this.props, generated: true });
|
|
16
17
|
}
|
|
17
|
-
nullable
|
|
18
|
+
// nullable in the database, and optional on insert
|
|
19
|
+
optional() {
|
|
18
20
|
if (this.props.primaryKey)
|
|
19
|
-
throw new Error('Cannot make a primary key column
|
|
20
|
-
return new ColumnBuilder({ ...this.props, nullable: true
|
|
21
|
+
throw new Error('Cannot make a primary key column optional');
|
|
22
|
+
return new ColumnBuilder({ ...this.props, nullable: true });
|
|
21
23
|
}
|
|
22
24
|
primaryKey() {
|
|
23
25
|
if (this.props.nullable)
|
|
24
|
-
throw new Error('Cannot make
|
|
26
|
+
throw new Error('Cannot make an optional column a primary key');
|
|
25
27
|
return new ColumnBuilder({ ...this.props, primaryKey: true });
|
|
26
28
|
}
|
|
27
29
|
unique() {
|
|
@@ -29,16 +31,40 @@ class ColumnBuilder {
|
|
|
29
31
|
throw new Error('Primary key columns are already unique');
|
|
30
32
|
return new ColumnBuilder({ ...this.props, unique: true });
|
|
31
33
|
}
|
|
34
|
+
unsigned() {
|
|
35
|
+
return new ColumnBuilder({ ...this.props, schema: std.integer({ min: 0 }) });
|
|
36
|
+
}
|
|
32
37
|
}
|
|
33
38
|
const column = (props) => new ColumnBuilder(props);
|
|
39
|
+
const columnsOfShape = (shape) => Object.fromEntries(Object.entries(shape).map(([key, { props }]) => [key, { ...props, key, name: snakeCase(key) }]));
|
|
40
|
+
// embedded fields keep their keys as they are inside the JSON document
|
|
41
|
+
const fieldsOfShape = (shape) => Object.fromEntries(Object.entries(shape).map(([key, { props }]) => [key, { ...props, key, name: key }]));
|
|
42
|
+
// validation happens field by field (see `check`), so the schema of an
|
|
43
|
+
// embedded column is only there for its types
|
|
44
|
+
const typed = () => std.optional(std.string());
|
|
45
|
+
const embedded = {
|
|
46
|
+
one: (shape) => column({
|
|
47
|
+
type: 'JSON',
|
|
48
|
+
schema: typed(),
|
|
49
|
+
fields: fieldsOfShape(shape),
|
|
50
|
+
}),
|
|
51
|
+
many: (shape) => column({
|
|
52
|
+
type: 'JSON',
|
|
53
|
+
schema: typed(),
|
|
54
|
+
fields: fieldsOfShape(shape),
|
|
55
|
+
many: true,
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
// a column of a primitive type, validated by any standard schema
|
|
59
|
+
const defineType = ({ primitive, schema }) => column({ type: primitive, schema });
|
|
34
60
|
const types = {
|
|
35
61
|
bigint: () => column({ type: 'BIGINT', schema: std.bigint() }),
|
|
36
62
|
binary: () => column({ type: 'BINARY', schema: std.instanceOf(Uint8Array) }),
|
|
37
63
|
boolean: () => column({ type: 'BOOLEAN', schema: std.boolean() }),
|
|
38
64
|
// case-insensitive text; postgres needs `CREATE EXTENSION citext` for it
|
|
39
65
|
citext: () => column({ type: 'CITEXT', schema: std.string() }),
|
|
66
|
+
// a calendar date, as a `YYYY-MM-DD` string
|
|
40
67
|
date: () => column({ type: 'DATE', schema: std.isoDate() }),
|
|
41
|
-
datetime: () => column({ type: 'DATETIME', schema: std.date() }),
|
|
42
68
|
// `precision` and `scale` only shape postgres' NUMERIC; values are still
|
|
43
69
|
// numbers, so they're as exact as a double
|
|
44
70
|
decimal: (options) => column({ type: 'DECIMAL', schema: std.number(), ...options }),
|
|
@@ -49,29 +75,149 @@ const types = {
|
|
|
49
75
|
}),
|
|
50
76
|
float: () => column({ type: 'FLOAT', schema: std.number() }),
|
|
51
77
|
integer: () => column({ type: 'INTEGER', schema: std.integer() }),
|
|
78
|
+
// any JSON document, validated by the given standard schema
|
|
52
79
|
json: (schema) => column({ type: 'JSON', schema }),
|
|
80
|
+
record: () => column({ type: 'JSON', schema: std.record() }),
|
|
81
|
+
// an integer the database generates, counting up
|
|
53
82
|
sequence: () => column({ type: 'SEQUENCE', schema: std.integer(), generated: true }),
|
|
54
83
|
string: ({ size = 255 } = {}) => column({ type: 'STRING', schema: std.string({ max: size }), length: size }),
|
|
55
84
|
text: () => column({ type: 'TEXT', schema: std.string() }),
|
|
56
|
-
|
|
85
|
+
timestamp: () => column({ type: 'TIMESTAMP', schema: std.date() }),
|
|
86
|
+
uuidv7: () => column({ type: 'UUID', schema: std.uuid() }),
|
|
87
|
+
};
|
|
88
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
|
89
|
+
const defaultOf = (col) => typeof col.default === 'function'
|
|
90
|
+
? col.default()
|
|
91
|
+
: structuredClone(col.default);
|
|
92
|
+
// validates a column's value, filling in its defaults (embedded fields'
|
|
93
|
+
// included), and collects every issue rather than stopping at the first
|
|
94
|
+
const check = (col, value, path, issues) => {
|
|
95
|
+
if (value === undefined && 'default' in col)
|
|
96
|
+
value = defaultOf(col);
|
|
97
|
+
if (value === undefined && col.nullable)
|
|
98
|
+
value = null;
|
|
99
|
+
if (value === undefined)
|
|
100
|
+
return void issues.push({ path, message: 'is required' });
|
|
101
|
+
if (value === null)
|
|
102
|
+
return col.nullable ? null : void issues.push({ path, message: 'must not be null' });
|
|
103
|
+
if (col.fields && col.many)
|
|
104
|
+
return Array.isArray(value)
|
|
105
|
+
? value.map((item, i) => checkObject(col.fields, item, [...path, i], issues))
|
|
106
|
+
: void issues.push({ path, message: 'must be an array' });
|
|
107
|
+
if (col.fields)
|
|
108
|
+
return checkObject(col.fields, value, path, issues);
|
|
109
|
+
const parsed = std.parse(col.schema, value);
|
|
110
|
+
if (parsed.issues)
|
|
111
|
+
return void issues.push(...parsed.issues.map(issue => ({ ...issue, path: [...path, ...(issue.path ?? [])] })));
|
|
112
|
+
return parsed.value;
|
|
113
|
+
};
|
|
114
|
+
// `partial` leaves missing keys out, rather than filling them in
|
|
115
|
+
const checkObject = (cols, value, path, issues, { partial = false } = {}) => {
|
|
116
|
+
if (!isPlainObject(value))
|
|
117
|
+
return void issues.push({ path, message: 'must be an object' });
|
|
118
|
+
for (const key of Object.keys(value)) {
|
|
119
|
+
if (!Object.hasOwn(cols, key))
|
|
120
|
+
issues.push({ path: [...path, key], message: 'unknown field' });
|
|
121
|
+
}
|
|
122
|
+
const checked = {};
|
|
123
|
+
for (const col of Object.values(cols)) {
|
|
124
|
+
if (partial && value[col.key] === undefined)
|
|
125
|
+
continue;
|
|
126
|
+
checked[col.key] = check(col, value[col.key], [...path, col.key], issues);
|
|
127
|
+
}
|
|
128
|
+
return checked;
|
|
129
|
+
};
|
|
130
|
+
const validate = (cols, value, options = {}) => {
|
|
131
|
+
const issues = [];
|
|
132
|
+
const checked = checkObject(cols, value, [], issues, options);
|
|
133
|
+
if (issues.length > 0)
|
|
134
|
+
throw new ValidationError(issues);
|
|
135
|
+
return checked;
|
|
136
|
+
};
|
|
137
|
+
// ─── tables ──────────────────────────────────────────────────────────────────
|
|
138
|
+
const NAME = Symbol('exto.name');
|
|
139
|
+
const ALIAS = Symbol('exto.alias');
|
|
140
|
+
const NODE = Symbol('exto.node');
|
|
141
|
+
const isOperand = (value) => typeof value === 'object' && value !== null && NODE in value;
|
|
142
|
+
const operand = (node) => ({ [NODE]: node });
|
|
143
|
+
const refOf = (alias, col, path = []) => {
|
|
144
|
+
const leaf = path.at(-1) ?? col;
|
|
145
|
+
const nested = leaf.fields && !leaf.many
|
|
146
|
+
? Object.values(leaf.fields).map(field => [field.key, refOf(alias, col, [...path, field])])
|
|
147
|
+
: [];
|
|
148
|
+
return Object.assign(operand({ kind: 'ref', alias, col, path }), Object.fromEntries(nested));
|
|
149
|
+
};
|
|
150
|
+
// a table is shaped like its columns: each enumerable property references a
|
|
151
|
+
// column under the table's alias, which is its name unless `as` says
|
|
152
|
+
// otherwise. The name and alias are symbols and `as` is non-enumerable, so
|
|
153
|
+
// spreading a table copies only its column references
|
|
154
|
+
const tableOf = (name, alias, columns) => Object.defineProperties(Object.fromEntries(columns.map(col => [col.key, refOf(alias, col)])), {
|
|
155
|
+
[NAME]: { value: name },
|
|
156
|
+
[ALIAS]: { value: alias },
|
|
157
|
+
as: { value: (alias) => tableOf(name, alias, columns) },
|
|
158
|
+
});
|
|
159
|
+
const columnsOf = (table) => Object.values(table).map(ref => ref[NODE].col);
|
|
160
|
+
const columnsByKey = (table) => Object.fromEntries(columnsOf(table).map(col => [col.key, col]));
|
|
161
|
+
const columnOf = (table, key) => columnsByKey(table)[key] ?? raise(`Unknown column "${key}" in table "${table[NAME]}"`);
|
|
162
|
+
// `as` aliases the table
|
|
163
|
+
const RESERVED = ['as'];
|
|
164
|
+
const defineTable = (name, shape) => {
|
|
165
|
+
const columns = Object.values(columnsOfShape(shape));
|
|
166
|
+
const reserved = columns.find(col => RESERVED.includes(col.key));
|
|
167
|
+
if (reserved)
|
|
168
|
+
throw new Error(`"${reserved.key}" is reserved, it can't be a column of table "${name}"`);
|
|
169
|
+
return tableOf(name, name, columns);
|
|
170
|
+
};
|
|
171
|
+
// validates a row to be inserted into the table, filling in its defaults
|
|
172
|
+
const parse = (table, input) => {
|
|
173
|
+
const insertable = Object.fromEntries(columnsOf(table).filter(col => !col.generated).map(col => [col.key, col]));
|
|
174
|
+
return validate(insertable, input);
|
|
57
175
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
176
|
+
// ─── encoding ────────────────────────────────────────────────────────────────
|
|
177
|
+
// inside JSON documents, timestamps are ISO 8601 strings, bigints are
|
|
178
|
+
// strings of digits and binaries are base64 strings
|
|
179
|
+
const toJson = (col, value) => {
|
|
180
|
+
if (value === null || value === undefined)
|
|
181
|
+
return null;
|
|
182
|
+
if (col.fields && col.many)
|
|
183
|
+
return value.map(item => toJson({ ...col, many: undefined }, item));
|
|
184
|
+
if (col.fields)
|
|
185
|
+
return Object.fromEntries(Object.values(col.fields).map(field => [field.key, toJson(field, value[field.key])]));
|
|
186
|
+
if (col.type === 'TIMESTAMP')
|
|
187
|
+
return value.toISOString();
|
|
188
|
+
if (col.type === 'BIGINT')
|
|
189
|
+
return String(value);
|
|
190
|
+
if (col.type === 'BINARY')
|
|
191
|
+
return Buffer.from(value).toString('base64');
|
|
192
|
+
return value;
|
|
64
193
|
};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
194
|
+
// databases may read JSON numbers as bigints and booleans as numbers
|
|
195
|
+
const fromJson = (col, value) => {
|
|
196
|
+
if (value === null || value === undefined)
|
|
197
|
+
return null;
|
|
198
|
+
if (col.fields && col.many)
|
|
199
|
+
return value.map(item => fromJson({ ...col, many: undefined }, item));
|
|
200
|
+
if (col.fields)
|
|
201
|
+
return Object.fromEntries(Object.values(col.fields).map(field => [field.key, fromJson(field, value[field.key])]));
|
|
202
|
+
if (col.type === 'TIMESTAMP')
|
|
203
|
+
return new Date(value);
|
|
204
|
+
if (col.type === 'BIGINT')
|
|
205
|
+
return BigInt(value);
|
|
206
|
+
if (col.type === 'BINARY')
|
|
207
|
+
return new Uint8Array(Buffer.from(value, 'base64'));
|
|
208
|
+
if (NUMERIC.includes(col.type))
|
|
209
|
+
return Number(value);
|
|
210
|
+
if (col.type === 'BOOLEAN')
|
|
211
|
+
return value === true || value === 1 || value === 1n;
|
|
212
|
+
return value;
|
|
70
213
|
};
|
|
214
|
+
const encode = (dialect, col, value) => value === null || value === undefined
|
|
215
|
+
? null
|
|
216
|
+
: dialect.codecs[col.type].encode(col.type === 'JSON' ? toJson(col, value) : value);
|
|
217
|
+
const decode = (dialect, col, value) => value === null || value === undefined
|
|
218
|
+
? null
|
|
219
|
+
: col.type === 'JSON' ? fromJson(col, dialect.codecs.JSON.decode(value)) : dialect.codecs[col.type].decode(value);
|
|
71
220
|
const quote = (ident) => `"${ident.replaceAll('"', '""')}"`;
|
|
72
|
-
const ref = (col, qualifier) => qualifier
|
|
73
|
-
? `${quote(qualifier)}.${quote(col.name)}`
|
|
74
|
-
: quote(col.name);
|
|
75
221
|
class SqlBuilder {
|
|
76
222
|
dialect;
|
|
77
223
|
frags = [];
|
|
@@ -99,13 +245,6 @@ class SqlBuilder {
|
|
|
99
245
|
return { sql: this.frags.join(''), params: this.params };
|
|
100
246
|
}
|
|
101
247
|
}
|
|
102
|
-
const encode = (dialect, col, value) => value === null || value === undefined
|
|
103
|
-
? null
|
|
104
|
-
: dialect.codecs[col.type].encode(value);
|
|
105
|
-
const decode = (dialect, col, value) => value === null || value === undefined
|
|
106
|
-
? null
|
|
107
|
-
: dialect.codecs[col.type].decode(value);
|
|
108
|
-
const decoder = (dialect, cols) => (row) => Object.fromEntries(cols.map(col => [col.key, decode(dialect, col, row[col.key])]));
|
|
109
248
|
const run = async (db, { sql, params }) => {
|
|
110
249
|
db.loggers.forEach(logger => logger.debug(sql, params));
|
|
111
250
|
try {
|
|
@@ -116,103 +255,182 @@ const run = async (db, { sql, params }) => {
|
|
|
116
255
|
throw error;
|
|
117
256
|
}
|
|
118
257
|
};
|
|
119
|
-
// runs a raw statement, e.g.
|
|
258
|
+
// runs a raw statement, e.g. a data fix in a migration
|
|
120
259
|
const exec = async (db, sql, params = []) => {
|
|
121
260
|
await run(db, { sql, params });
|
|
122
261
|
};
|
|
262
|
+
const renderTable = (b, table) => table[ALIAS] === table[NAME]
|
|
263
|
+
? b.push(quote(table[NAME]))
|
|
264
|
+
: b.push(quote(table[NAME]), ' AS ', quote(table[ALIAS]));
|
|
265
|
+
const renderNode = (b, node) => {
|
|
266
|
+
switch (node.kind) {
|
|
267
|
+
case 'ref': {
|
|
268
|
+
const sql = `${quote(node.alias)}.${quote(node.col.name)}`;
|
|
269
|
+
if (node.path.length === 0)
|
|
270
|
+
return b.push(sql);
|
|
271
|
+
return b.push(b.dialect.json(sql, node.path.map(field => field.name), node.path.at(-1).type));
|
|
272
|
+
}
|
|
273
|
+
case 'compare': {
|
|
274
|
+
const { lhs, op, rhs } = node;
|
|
275
|
+
renderNode(b, lhs).push(' ', op.toUpperCase(), ' ');
|
|
276
|
+
if (op === 'is' || op === 'is not')
|
|
277
|
+
return b.push('NULL');
|
|
278
|
+
if (rhs.kind === 'value')
|
|
279
|
+
return b.param(encodeFor(b.dialect, lhs, rhs.value));
|
|
280
|
+
return renderNode(b, rhs);
|
|
281
|
+
}
|
|
282
|
+
case 'group': {
|
|
283
|
+
const { op, nodes } = node;
|
|
284
|
+
if (nodes.length === 0)
|
|
285
|
+
return b.push(op === 'AND' ? '1 = 1' : '1 = 0');
|
|
286
|
+
return b.push('(').join(nodes, ` ${op} `, node => renderNode(b, node)).push(')');
|
|
287
|
+
}
|
|
288
|
+
case 'fragment':
|
|
289
|
+
node.strings.forEach((str, i) => {
|
|
290
|
+
b.push(str);
|
|
291
|
+
if (i >= node.values.length)
|
|
292
|
+
return;
|
|
293
|
+
const value = node.values[i];
|
|
294
|
+
if (isOperand(value))
|
|
295
|
+
renderNode(b, value[NODE]);
|
|
296
|
+
else
|
|
297
|
+
b.param(value);
|
|
298
|
+
});
|
|
299
|
+
return b;
|
|
300
|
+
case 'call':
|
|
301
|
+
return node.fn === 'SUM'
|
|
302
|
+
? renderNode(b.push('COALESCE(SUM('), node.arg).push('), 0)')
|
|
303
|
+
: renderNode(b.push('COUNT('), node.arg).push(')');
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
// values compared with a column are encoded the way the column stores them
|
|
307
|
+
const encodeFor = (dialect, node, value) => {
|
|
308
|
+
if (node.kind !== 'ref')
|
|
309
|
+
return value;
|
|
310
|
+
if (node.path.length === 0)
|
|
311
|
+
return encode(dialect, node.col, value);
|
|
312
|
+
const leaf = node.path.at(-1);
|
|
313
|
+
return leaf.type === 'JSON' ? dialect.codecs.JSON.encode(toJson(leaf, value)) : toJson(leaf, value);
|
|
314
|
+
};
|
|
315
|
+
const decodeFor = (dialect, node) => (value) => {
|
|
316
|
+
if (node.kind === 'call')
|
|
317
|
+
return Number(value ?? 0);
|
|
318
|
+
if (node.kind !== 'ref')
|
|
319
|
+
return typeof value === 'bigint' ? Number(value) : value;
|
|
320
|
+
if (node.path.length === 0)
|
|
321
|
+
return decode(dialect, node.col, value);
|
|
322
|
+
const leaf = node.path.at(-1);
|
|
323
|
+
if (value === null || value === undefined)
|
|
324
|
+
return null;
|
|
325
|
+
return fromJson(leaf, leaf.type === 'JSON' ? dialect.codecs.JSON.decode(value) : value);
|
|
326
|
+
};
|
|
327
|
+
const compare = (lhs, op, rhs) => ({
|
|
328
|
+
kind: 'compare',
|
|
329
|
+
lhs: lhs[NODE],
|
|
330
|
+
op,
|
|
331
|
+
rhs: isOperand(rhs) ? rhs[NODE] : { kind: 'value', value: rhs },
|
|
332
|
+
});
|
|
333
|
+
const exprOf = (lhs, op, rhs) => operand(compare(lhs, op, rhs));
|
|
334
|
+
const group = (op) => (exprs) => operand({ kind: 'group', op, nodes: exprs.map(expr => expr[NODE]) });
|
|
335
|
+
const call = (fn) => (arg) => operand({ kind: 'call', fn, arg: arg[NODE] });
|
|
336
|
+
// aggregates: a sum of no rows is 0, rather than SQL's null
|
|
337
|
+
const expr = Object.assign(exprOf, {
|
|
338
|
+
and: group('AND'),
|
|
339
|
+
or: group('OR'),
|
|
340
|
+
count: call('COUNT'),
|
|
341
|
+
sum: call('SUM'),
|
|
342
|
+
});
|
|
343
|
+
// a fragment of raw SQL: interpolated operands are rendered in place, and
|
|
344
|
+
// any other value is passed as a parameter
|
|
345
|
+
const f = (strings, ...values) => operand({ kind: 'fragment', strings: [...strings], values });
|
|
346
|
+
const assertInScope = (scope, node) => {
|
|
347
|
+
switch (node.kind) {
|
|
348
|
+
case 'ref': {
|
|
349
|
+
const table = scope.get(node.alias);
|
|
350
|
+
if (!table || !columnsOf(table).includes(node.col))
|
|
351
|
+
throw new Error(`"${node.alias}.${node.col.key}" isn't part of the query`);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case 'compare':
|
|
355
|
+
assertInScope(scope, node.lhs);
|
|
356
|
+
if (node.rhs.kind !== 'value')
|
|
357
|
+
assertInScope(scope, node.rhs);
|
|
358
|
+
break;
|
|
359
|
+
case 'group':
|
|
360
|
+
node.nodes.forEach(node => assertInScope(scope, node));
|
|
361
|
+
break;
|
|
362
|
+
case 'fragment':
|
|
363
|
+
node.values.filter(isOperand).forEach(value => assertInScope(scope, value[NODE]));
|
|
364
|
+
break;
|
|
365
|
+
case 'call':
|
|
366
|
+
assertInScope(scope, node.arg);
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
return node;
|
|
370
|
+
};
|
|
371
|
+
const renderWhere = (b, where) => where.length === 0
|
|
372
|
+
? b
|
|
373
|
+
: b.push(' WHERE ').join(where, ' AND ', node => renderNode(b, node));
|
|
374
|
+
const returnedColumn = (table, item) => {
|
|
375
|
+
if (typeof item === 'string')
|
|
376
|
+
return columnOf(table, item);
|
|
377
|
+
const node = item[NODE];
|
|
378
|
+
const owned = node.kind === 'ref' && node.path.length === 0 && node.alias === table[ALIAS] && columnsOf(table).includes(node.col);
|
|
379
|
+
if (!owned)
|
|
380
|
+
throw new Error(`Only columns of "${table[ALIAS]}" can be returned`);
|
|
381
|
+
return node.col;
|
|
382
|
+
};
|
|
123
383
|
const runReturning = async (db, b, table, returning) => {
|
|
124
384
|
if (returning === false) {
|
|
125
385
|
await run(db, b.build());
|
|
126
386
|
return undefined;
|
|
127
387
|
}
|
|
128
388
|
const cols = returning === true
|
|
129
|
-
?
|
|
130
|
-
: returning.map(
|
|
389
|
+
? columnsOf(table)
|
|
390
|
+
: returning.map(item => returnedColumn(table, item));
|
|
131
391
|
// an empty RETURNING isn't valid SQL, but it still takes one to know how
|
|
132
392
|
// many rows were affected
|
|
133
393
|
if (cols.length === 0) {
|
|
134
394
|
const rows = await run(db, b.push(' RETURNING 1').build());
|
|
135
395
|
return rows.map(() => ({}));
|
|
136
396
|
}
|
|
137
|
-
b.push(' RETURNING ').join(cols, ', ', col => b.push(
|
|
397
|
+
b.push(' RETURNING ').join(cols, ', ', col => b.push(quote(col.name), ' AS ', quote(col.key)));
|
|
138
398
|
const rows = await run(db, b.build());
|
|
139
|
-
return rows.map(
|
|
399
|
+
return rows.map(row => Object.fromEntries(cols.map(col => [col.key, decode(db.dialect, col, row[col.key])])));
|
|
140
400
|
};
|
|
141
|
-
const expr = Object.assign((key, op, value) => ({ key, op, value }), {
|
|
142
|
-
and: (exprs) => ({ group: 'AND', exprs }),
|
|
143
|
-
or: (exprs) => ({ group: 'OR', exprs }),
|
|
144
|
-
});
|
|
145
|
-
const toCondition = (table, node) => 'group' in node
|
|
146
|
-
? { group: node.group, conditions: node.exprs.map(expr => toCondition(table, expr)) }
|
|
147
|
-
: { col: columnOf(table, node.key), op: node.op, value: node.value };
|
|
148
|
-
const renderCondition = (b, qualifier) => (condition) => {
|
|
149
|
-
if ('group' in condition) {
|
|
150
|
-
const { group, conditions } = condition;
|
|
151
|
-
if (conditions.length === 0)
|
|
152
|
-
return b.push(group === 'AND' ? '1 = 1' : '1 = 0');
|
|
153
|
-
return b.push('(').join(conditions, ` ${group} `, renderCondition(b, qualifier)).push(')');
|
|
154
|
-
}
|
|
155
|
-
const { col, op, value } = condition;
|
|
156
|
-
if (op === 'is' || op === 'is not')
|
|
157
|
-
return b.push(ref(col, qualifier), ' ', op.toUpperCase(), ' NULL');
|
|
158
|
-
if (op !== 'in' && op !== 'not in')
|
|
159
|
-
return b.push(ref(col, qualifier), ' ', op.toUpperCase(), ' ').param(encode(b.dialect, col, value));
|
|
160
|
-
const values = value;
|
|
161
|
-
if (values.length === 0)
|
|
162
|
-
return b.push(op === 'in' ? '1 = 0' : '1 = 1');
|
|
163
|
-
return b
|
|
164
|
-
.push(ref(col, qualifier), ' ', op.toUpperCase(), ' (')
|
|
165
|
-
.join(values, ', ', v => b.param(encode(b.dialect, col, v)))
|
|
166
|
-
.push(')');
|
|
167
|
-
};
|
|
168
|
-
const renderWhere = (b, conditions, qualifier) => conditions.length === 0
|
|
169
|
-
? b
|
|
170
|
-
: b.push(' WHERE ').join(conditions, ' AND ', renderCondition(b, qualifier));
|
|
171
401
|
const parseChanges = (table, { set = {}, inc = {}, replace = [] }) => {
|
|
172
|
-
const updatable = Object.
|
|
173
|
-
const
|
|
174
|
-
const sets =
|
|
175
|
-
const incs =
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
402
|
+
const updatable = Object.fromEntries(columnsOf(table).filter(col => !col.generated && !col.primaryKey).map(col => [col.key, col]));
|
|
403
|
+
const updatableColumn = (key) => updatable[key] ?? raise(`"${key}" isn't an updatable column of "${table[NAME]}"`);
|
|
404
|
+
const sets = validate(updatable, set, { partial: true });
|
|
405
|
+
const incs = Object.entries(inc).filter(([, by]) => by !== undefined).map(([key, by]) => {
|
|
406
|
+
const col = updatableColumn(key);
|
|
407
|
+
if (!NUMERIC.includes(col.type))
|
|
408
|
+
throw new Error(`"${key}" isn't a numeric column of "${table[NAME]}"`);
|
|
409
|
+
if (!Number.isFinite(by))
|
|
410
|
+
throw new ValidationError([{ path: [key], message: 'must be a finite number' }]);
|
|
411
|
+
return { col, op: 'inc', value: by };
|
|
181
412
|
});
|
|
182
413
|
return [
|
|
183
|
-
...Object.entries(sets).
|
|
184
|
-
...
|
|
185
|
-
...
|
|
414
|
+
...Object.entries(sets).map(([key, value]) => ({ col: updatableColumn(key), op: 'set', value })),
|
|
415
|
+
...incs,
|
|
416
|
+
...replace.map(key => ({ col: updatableColumn(key), op: 'replace' })),
|
|
186
417
|
];
|
|
187
418
|
};
|
|
188
|
-
//
|
|
419
|
+
// `qualifier` tells the row's current value apart from EXCLUDED's, on conflict
|
|
189
420
|
const renderAssignments = (b, assignments, qualifier) => b
|
|
190
421
|
.join(assignments, ', ', ({ col, op, value }) => {
|
|
191
422
|
if (op === 'replace')
|
|
192
|
-
return b.push(
|
|
423
|
+
return b.push(quote(col.name), ' = EXCLUDED.', quote(col.name));
|
|
193
424
|
if (op === 'inc')
|
|
194
|
-
return b.push(
|
|
195
|
-
return b.push(
|
|
425
|
+
return b.push(quote(col.name), ' = ', quote(qualifier), '.', quote(col.name), ' + ').param(value);
|
|
426
|
+
return b.push(quote(col.name), ' = ').param(encode(b.dialect, col, value));
|
|
196
427
|
});
|
|
197
|
-
const
|
|
428
|
+
const GENERATORS = {
|
|
198
429
|
UUID: () => Bun.randomUUIDv7(),
|
|
199
430
|
};
|
|
431
|
+
const generate = (col) => (GENERATORS[col.type] ?? raise(`Cannot generate a value for column "${col.name}"`))();
|
|
432
|
+
// columns the database generates are left out of inserts
|
|
200
433
|
const isGeneratedByClient = (dialect, col) => col.generated === true && dialect.generated(col) === undefined;
|
|
201
|
-
const resolveInsertValue = (col, row) => {
|
|
202
|
-
if (col.generated) {
|
|
203
|
-
const generate = CLIENT_GENERATORS[col.type];
|
|
204
|
-
if (!generate)
|
|
205
|
-
throw new Error(`Cannot generate a value for column "${col.name}" client-side`);
|
|
206
|
-
return generate();
|
|
207
|
-
}
|
|
208
|
-
if (row[col.key] !== undefined)
|
|
209
|
-
return row[col.key];
|
|
210
|
-
if ('default' in col)
|
|
211
|
-
return typeof col.default === 'function' ? col.default() : col.default;
|
|
212
|
-
if (col.nullable)
|
|
213
|
-
return null;
|
|
214
|
-
return undefined;
|
|
215
|
-
};
|
|
216
434
|
class InsertBuilder {
|
|
217
435
|
table;
|
|
218
436
|
rows;
|
|
@@ -221,27 +439,24 @@ class InsertBuilder {
|
|
|
221
439
|
this.rows = rows;
|
|
222
440
|
}
|
|
223
441
|
values(rows) {
|
|
224
|
-
return new InsertBuilder(this.table, [...this.rows, ...
|
|
442
|
+
return new InsertBuilder(this.table, [...this.rows, ...(Array.isArray(rows) ? rows : [rows])]);
|
|
225
443
|
}
|
|
226
444
|
async insert(db, { returning = true, conflict } = {}) {
|
|
227
445
|
if (this.rows.length === 0)
|
|
228
446
|
return returning === false ? undefined : [];
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
const rows =
|
|
233
|
-
...row,
|
|
234
|
-
...Object.fromEntries(insertable.map(col => [col.key, resolveInsertValue(col, row)])),
|
|
235
|
-
})));
|
|
447
|
+
const { table } = this;
|
|
448
|
+
const cols = columnsOf(table).filter(col => !col.generated || isGeneratedByClient(db.dialect, col));
|
|
449
|
+
const insertable = Object.fromEntries(cols.filter(col => !col.generated).map(col => [col.key, col]));
|
|
450
|
+
const rows = this.rows.map(row => validate(insertable, row));
|
|
236
451
|
const b = new SqlBuilder(db.dialect)
|
|
237
|
-
.push('INSERT INTO ', quote(
|
|
452
|
+
.push('INSERT INTO ', quote(table[NAME]), ' (', cols.map(col => quote(col.name)).join(', '), ') VALUES ');
|
|
238
453
|
b.join(rows, ', ', row => b
|
|
239
454
|
.push('(')
|
|
240
|
-
.join(
|
|
455
|
+
.join(cols, ', ', col => b.param(encode(db.dialect, col, col.generated ? generate(col) : row[col.key])))
|
|
241
456
|
.push(')'));
|
|
242
457
|
if (conflict)
|
|
243
458
|
this.renderConflict(b, conflict);
|
|
244
|
-
return await runReturning(db, b,
|
|
459
|
+
return await runReturning(db, b, table, returning);
|
|
245
460
|
}
|
|
246
461
|
renderConflict(b, conflict) {
|
|
247
462
|
if (conflict.do === 'throw')
|
|
@@ -254,96 +469,122 @@ class InsertBuilder {
|
|
|
254
469
|
const assignments = parseChanges(this.table, conflict.do);
|
|
255
470
|
if (assignments.length === 0)
|
|
256
471
|
throw new Error('Nothing to update on conflict: `set`, `inc` and `replace` are empty');
|
|
257
|
-
return renderAssignments(b.push(' DO UPDATE SET '), assignments, this.table
|
|
472
|
+
return renderAssignments(b.push(' DO UPDATE SET '), assignments, this.table[NAME]);
|
|
258
473
|
}
|
|
259
474
|
}
|
|
260
475
|
const into = (table) => new InsertBuilder(table);
|
|
261
|
-
|
|
476
|
+
const count = (value, name) => Number.isSafeInteger(value) && value >= 0
|
|
477
|
+
? value
|
|
478
|
+
: raise(`${name} must be a non-negative integer, got ${value}`);
|
|
479
|
+
// `T` is the queried table, `A` the aliases in scope, `N` those of them whose
|
|
480
|
+
// rows may be missing (left joined), and `R` the row the query reads: a whole
|
|
481
|
+
// row of `T` unless `select` says otherwise
|
|
262
482
|
class QueryBuilder {
|
|
263
483
|
query;
|
|
264
484
|
constructor(query) {
|
|
265
485
|
this.query = query;
|
|
266
486
|
}
|
|
267
|
-
select(
|
|
268
|
-
|
|
487
|
+
select(projection) {
|
|
488
|
+
const fields = Object.entries(projection).map(([key, value]) => isOperand(value)
|
|
489
|
+
? [key, assertInScope(this.query.scope, value[NODE])]
|
|
490
|
+
: raise(`"${key}" must be a column reference or an expression`));
|
|
491
|
+
return new QueryBuilder({ ...this.query, select: fields });
|
|
269
492
|
}
|
|
270
493
|
where(...args) {
|
|
271
|
-
const node = args.length === 1 ? args[0]
|
|
272
|
-
return new QueryBuilder({ ...this.query, where: [...this.query.where,
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
494
|
+
const node = args.length === 1 ? args[0][NODE] : compare(...args);
|
|
495
|
+
return new QueryBuilder({ ...this.query, where: [...this.query.where, assertInScope(this.query.scope, node)] });
|
|
496
|
+
}
|
|
497
|
+
// the joined table's columns read as null for rows it has no match for
|
|
498
|
+
leftJoin(table, on) {
|
|
499
|
+
const alias = table[ALIAS];
|
|
500
|
+
if (this.query.scope.has(alias))
|
|
501
|
+
throw new Error(`"${alias}" is already part of the query, join it under another alias`);
|
|
502
|
+
const scope = new Map(this.query.scope).set(alias, table);
|
|
503
|
+
const joins = [...this.query.joins, { table, on: assertInScope(scope, on[NODE]) }];
|
|
504
|
+
return new QueryBuilder({ ...this.query, scope, joins });
|
|
505
|
+
}
|
|
506
|
+
groupBy(operands) {
|
|
507
|
+
const groupBy = operands.map(item => assertInScope(this.query.scope, item[NODE]));
|
|
508
|
+
return new QueryBuilder({ ...this.query, groupBy: [...this.query.groupBy, ...groupBy] });
|
|
509
|
+
}
|
|
510
|
+
orderBy(operands) {
|
|
511
|
+
const orderBy = operands.map(item => Array.isArray(item)
|
|
512
|
+
? [assertInScope(this.query.scope, item[0][NODE]), item[1]]
|
|
513
|
+
: [assertInScope(this.query.scope, item[NODE]), 'ASC']);
|
|
278
514
|
return new QueryBuilder({ ...this.query, orderBy: [...this.query.orderBy, ...orderBy] });
|
|
279
515
|
}
|
|
280
516
|
limit(n) {
|
|
281
|
-
return new QueryBuilder({ ...this.query, limit: n });
|
|
517
|
+
return new QueryBuilder({ ...this.query, limit: count(n, 'limit') });
|
|
282
518
|
}
|
|
283
519
|
offset(n) {
|
|
284
|
-
return new QueryBuilder({ ...this.query, offset: n });
|
|
520
|
+
return new QueryBuilder({ ...this.query, offset: count(n, 'offset') });
|
|
285
521
|
}
|
|
286
522
|
async all(db) {
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const rows = await run(db, this.renderSelect(new SqlBuilder(db.dialect), cols).build());
|
|
292
|
-
return rows.map(decoder(db.dialect, cols));
|
|
523
|
+
const fields = this.fields();
|
|
524
|
+
const rows = await run(db, this.renderSelect(new SqlBuilder(db.dialect), fields).build());
|
|
525
|
+
const decoders = fields.map(([key, node]) => [key, decodeFor(db.dialect, node)]);
|
|
526
|
+
return rows.map(row => Object.fromEntries(decoders.map(([key, decode]) => [key, decode(row[key])])));
|
|
293
527
|
}
|
|
294
528
|
async one(db) {
|
|
295
529
|
const [row] = await this.top().all(db);
|
|
296
530
|
return row ?? null;
|
|
297
531
|
}
|
|
298
532
|
async exists(db) {
|
|
299
|
-
const rows = await this.top().select(
|
|
533
|
+
const rows = await this.top().select({}).all(db);
|
|
300
534
|
return rows.length > 0;
|
|
301
535
|
}
|
|
302
|
-
// counts the rows `
|
|
303
|
-
//
|
|
536
|
+
// counts the rows `all` would return: as a subquery, limit and offset still
|
|
537
|
+
// apply, and postgres doesn't take ORDER BY next to a bare COUNT(*)
|
|
304
538
|
async count(db) {
|
|
305
539
|
const b = new SqlBuilder(db.dialect).push('SELECT COUNT(*) AS "count" FROM (');
|
|
306
540
|
const [row] = await run(db, this.renderSelect(b, []).push(') AS "q"').build());
|
|
307
|
-
// postgres returns COUNT(*) as a bigint
|
|
308
541
|
return Number(row['count']);
|
|
309
542
|
}
|
|
310
543
|
async update(db, { returning = true, ...changes }) {
|
|
311
|
-
this.
|
|
544
|
+
this.assertPlain('update');
|
|
312
545
|
const { table, where } = this.query;
|
|
313
546
|
const assignments = parseChanges(table, changes);
|
|
314
547
|
if (assignments.length === 0)
|
|
315
548
|
throw new Error('Nothing to update: both `set` and `inc` are empty');
|
|
316
|
-
const b = new SqlBuilder(db.dialect).push('UPDATE ',
|
|
317
|
-
renderAssignments(b, assignments);
|
|
549
|
+
const b = renderTable(new SqlBuilder(db.dialect).push('UPDATE '), table).push(' SET ');
|
|
550
|
+
renderAssignments(b, assignments, table[ALIAS]);
|
|
318
551
|
renderWhere(b, where);
|
|
319
552
|
return await runReturning(db, b, table, returning);
|
|
320
553
|
}
|
|
321
554
|
async delete(db, { returning = false } = {}) {
|
|
322
|
-
this.
|
|
555
|
+
this.assertPlain('delete');
|
|
323
556
|
const { table, where } = this.query;
|
|
324
|
-
const b = new SqlBuilder(db.dialect).push('DELETE FROM ',
|
|
557
|
+
const b = renderTable(new SqlBuilder(db.dialect).push('DELETE FROM '), table);
|
|
325
558
|
renderWhere(b, where);
|
|
326
559
|
return await runReturning(db, b, table, returning);
|
|
327
560
|
}
|
|
328
|
-
|
|
329
|
-
|
|
561
|
+
// every column of the queried table, unless `select` says otherwise
|
|
562
|
+
fields() {
|
|
563
|
+
const { table, select } = this.query;
|
|
564
|
+
return select ?? columnsOf(table).map(col => [col.key, { kind: 'ref', alias: table[ALIAS], col, path: [] }]);
|
|
565
|
+
}
|
|
566
|
+
renderSelect(b, fields) {
|
|
567
|
+
const { table, joins, where, groupBy, orderBy, limit, offset } = this.query;
|
|
330
568
|
b.push('SELECT ');
|
|
331
569
|
// an empty select list isn't valid SQL, but each matching row still needs
|
|
332
570
|
// an (empty) object
|
|
333
|
-
if (
|
|
571
|
+
if (fields.length === 0)
|
|
334
572
|
b.push('1');
|
|
335
573
|
else
|
|
336
|
-
b.join(
|
|
337
|
-
b.push(' FROM '
|
|
338
|
-
|
|
574
|
+
b.join(fields, ', ', ([key, node]) => renderNode(b, node).push(' AS ', quote(key)));
|
|
575
|
+
renderTable(b.push(' FROM '), table);
|
|
576
|
+
joins.forEach(join => renderNode(renderTable(b.push(' LEFT JOIN '), join.table).push(' ON '), join.on));
|
|
577
|
+
renderWhere(b, where);
|
|
578
|
+
if (groupBy.length > 0)
|
|
579
|
+
b.push(' GROUP BY ').join(groupBy, ', ', node => renderNode(b, node));
|
|
339
580
|
if (orderBy.length > 0)
|
|
340
|
-
b.push(' ORDER BY ').join(orderBy, ', ', ([
|
|
581
|
+
b.push(' ORDER BY ').join(orderBy, ', ', ([node, dir]) => renderNode(b, node).push(' ', dir));
|
|
341
582
|
// sqlite requires LIMIT whenever there's an OFFSET, and there's no "no
|
|
342
583
|
// limit" value both sqlite and postgres accept other than a huge number
|
|
343
584
|
if (limit !== undefined || offset !== undefined)
|
|
344
|
-
b.push(
|
|
585
|
+
b.push(` LIMIT ${limit ?? Number.MAX_SAFE_INTEGER}`);
|
|
345
586
|
if (offset !== undefined)
|
|
346
|
-
b.push(
|
|
587
|
+
b.push(` OFFSET ${offset}`);
|
|
347
588
|
return b;
|
|
348
589
|
}
|
|
349
590
|
// keeps a `limit(0)`, which can never match
|
|
@@ -352,14 +593,21 @@ class QueryBuilder {
|
|
|
352
593
|
}
|
|
353
594
|
// UPDATE/DELETE ... ORDER BY/LIMIT isn't portable, so refuse it rather than
|
|
354
595
|
// silently affecting more rows than the query suggests
|
|
355
|
-
|
|
356
|
-
const { orderBy, limit, offset } = this.query;
|
|
357
|
-
if (orderBy.length > 0 || limit !== undefined || offset !== undefined) {
|
|
358
|
-
throw new Error(`Cannot ${operation} with orderBy, limit or offset`);
|
|
596
|
+
assertPlain(operation) {
|
|
597
|
+
const { joins, select, groupBy, orderBy, limit, offset } = this.query;
|
|
598
|
+
if (joins.length > 0 || select || groupBy.length > 0 || orderBy.length > 0 || limit !== undefined || offset !== undefined) {
|
|
599
|
+
throw new Error(`Cannot ${operation} with leftJoin, select, groupBy, orderBy, limit or offset`);
|
|
359
600
|
}
|
|
360
601
|
}
|
|
361
602
|
}
|
|
362
|
-
const from = (table) => new QueryBuilder({
|
|
603
|
+
const from = (table) => new QueryBuilder({
|
|
604
|
+
table,
|
|
605
|
+
scope: new Map([[table[ALIAS], table]]),
|
|
606
|
+
joins: [],
|
|
607
|
+
where: [],
|
|
608
|
+
groupBy: [],
|
|
609
|
+
orderBy: [],
|
|
610
|
+
});
|
|
363
611
|
// ─── ddl ─────────────────────────────────────────────────────────────────────
|
|
364
612
|
const renderColumnDefinition = (dialect, col) => [
|
|
365
613
|
quote(col.name),
|
|
@@ -372,16 +620,16 @@ const renderColumnDefinition = (dialect, col) => [
|
|
|
372
620
|
const createIndex = (table, keys, { ifNotExists = false, name, unique = false, } = {}) => ({
|
|
373
621
|
onto: async (db) => {
|
|
374
622
|
const cols = keys.map(key => columnOf(table, key).name);
|
|
375
|
-
const index = name ?? `${table
|
|
376
|
-
const sql = `CREATE ${unique ? 'UNIQUE ' : ''}INDEX ${ifNotExists ? 'IF NOT EXISTS ' : ''}${quote(index)} ON ${quote(table
|
|
623
|
+
const index = name ?? `${table[NAME]}_${cols.join('_')}_index`;
|
|
624
|
+
const sql = `CREATE ${unique ? 'UNIQUE ' : ''}INDEX ${ifNotExists ? 'IF NOT EXISTS ' : ''}${quote(index)} ON ${quote(table[NAME])} (${cols.map(quote).join(', ')})`;
|
|
377
625
|
await run(db, { sql, params: [] });
|
|
378
626
|
},
|
|
379
627
|
});
|
|
380
628
|
const create = {
|
|
381
629
|
table: (table, { ifNotExists = false } = {}) => ({
|
|
382
630
|
onto: async (db) => {
|
|
383
|
-
const cols =
|
|
384
|
-
const sql = `CREATE TABLE ${ifNotExists ? 'IF NOT EXISTS ' : ''}${quote(table
|
|
631
|
+
const cols = columnsOf(table).map(col => renderColumnDefinition(db.dialect, col));
|
|
632
|
+
const sql = `CREATE TABLE ${ifNotExists ? 'IF NOT EXISTS ' : ''}${quote(table[NAME])} (${cols.join(', ')})`;
|
|
385
633
|
await run(db, { sql, params: [] });
|
|
386
634
|
},
|
|
387
635
|
}),
|
|
@@ -389,20 +637,27 @@ const create = {
|
|
|
389
637
|
uniqueIndex: (table, keys, options = {}) => createIndex(table, keys, { ...options, unique: true }),
|
|
390
638
|
};
|
|
391
639
|
// ─── migrations ──────────────────────────────────────────────────────────────
|
|
392
|
-
const schemaMigrations = defineTable('schema_migrations',
|
|
393
|
-
id:
|
|
394
|
-
|
|
395
|
-
})
|
|
640
|
+
const schemaMigrations = defineTable('schema_migrations', {
|
|
641
|
+
id: types.string().primaryKey(),
|
|
642
|
+
timestamp: types.timestamp(),
|
|
643
|
+
});
|
|
644
|
+
// applies the pending migrations in the order of their ids, each in a
|
|
645
|
+
// transaction of its own, tracking the applied ones in `schema_migrations`
|
|
396
646
|
const defineMigrations = (migrations) => async (db) => {
|
|
397
647
|
await create.table(schemaMigrations, { ifNotExists: true }).onto(db);
|
|
398
|
-
const
|
|
648
|
+
const { dialect } = db;
|
|
649
|
+
const rows = await run(db, { sql: 'SELECT "id" FROM "schema_migrations"', params: [] });
|
|
650
|
+
const applied = new Set(rows.map(row => row['id']));
|
|
399
651
|
for (const id of Object.keys(migrations).sort()) {
|
|
400
652
|
if (applied.has(id))
|
|
401
653
|
continue;
|
|
402
654
|
await db.transaction(async (tx) => {
|
|
403
655
|
await migrations[id](tx);
|
|
404
|
-
await
|
|
656
|
+
await run(tx, {
|
|
657
|
+
sql: `INSERT INTO "schema_migrations" ("id", "timestamp") VALUES (${dialect.placeholder(1)}, ${dialect.placeholder(2)})`,
|
|
658
|
+
params: [id, encode(dialect, columnOf(schemaMigrations, 'timestamp'), new Date())],
|
|
659
|
+
});
|
|
405
660
|
});
|
|
406
661
|
}
|
|
407
662
|
};
|
|
408
|
-
export { ValidationError, column, create, defineMigrations, defineTable as table, exec, expr, from, into, types as t, };
|
|
663
|
+
export { ValidationError, column, create, defineMigrations, defineTable as table, defineType, embedded, exec, expr, f, from, into, parse, types as t, };
|