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