@rwillians/exto 0.1.1 → 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 +100 -0
- package/dist/cjs/bun-sqlite.js +100 -0
- package/dist/cjs/index.js +712 -0
- package/dist/cjs/standard-schema.js +249 -0
- package/dist/cjs/utils.js +17 -0
- package/dist/esm/bun-postgres.js +96 -0
- package/dist/esm/bun-sqlite.js +96 -0
- package/dist/esm/index.js +663 -0
- package/dist/esm/standard-schema.js +237 -0
- package/dist/esm/utils.js +10 -0
- package/dist/types/bun-postgres.d.ts +17 -0
- package/dist/types/bun-sqlite.d.ts +17 -0
- package/dist/types/index.d.ts +438 -0
- package/dist/types/standard-schema.d.ts +180 -0
- package/dist/types/utils.d.ts +11 -0
- package/package.json +9 -8
- package/dist/.gitignore +0 -2
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
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
|
+
const std = __importStar(require("./standard-schema"));
|
|
38
|
+
const standard_schema_1 = require("./standard-schema");
|
|
39
|
+
Object.defineProperty(exports, "ValidationError", { enumerable: true, get: function () { return standard_schema_1.ValidationError; } });
|
|
40
|
+
const utils_1 = require("./utils");
|
|
41
|
+
const NUMERIC = ['BIGINT', 'DECIMAL', 'FLOAT', 'INTEGER', 'SEQUENCE'];
|
|
42
|
+
class ColumnBuilder {
|
|
43
|
+
props;
|
|
44
|
+
constructor(props) {
|
|
45
|
+
this.props = props;
|
|
46
|
+
}
|
|
47
|
+
default(value) {
|
|
48
|
+
return new ColumnBuilder({ ...this.props, default: value });
|
|
49
|
+
}
|
|
50
|
+
generated() {
|
|
51
|
+
if (this.props.type !== 'UUID')
|
|
52
|
+
throw new Error('Only UUID columns can be generated, use t.sequence() for integers');
|
|
53
|
+
return new ColumnBuilder({ ...this.props, generated: true });
|
|
54
|
+
}
|
|
55
|
+
// nullable in the database, and optional on insert
|
|
56
|
+
optional() {
|
|
57
|
+
if (this.props.primaryKey)
|
|
58
|
+
throw new Error('Cannot make a primary key column optional');
|
|
59
|
+
return new ColumnBuilder({ ...this.props, nullable: true });
|
|
60
|
+
}
|
|
61
|
+
primaryKey() {
|
|
62
|
+
if (this.props.nullable)
|
|
63
|
+
throw new Error('Cannot make an optional column a primary key');
|
|
64
|
+
return new ColumnBuilder({ ...this.props, primaryKey: true });
|
|
65
|
+
}
|
|
66
|
+
unique() {
|
|
67
|
+
if (this.props.primaryKey)
|
|
68
|
+
throw new Error('Primary key columns are already unique');
|
|
69
|
+
return new ColumnBuilder({ ...this.props, unique: true });
|
|
70
|
+
}
|
|
71
|
+
unsigned() {
|
|
72
|
+
return new ColumnBuilder({ ...this.props, schema: std.integer({ min: 0 }) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const column = (props) => new ColumnBuilder(props);
|
|
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;
|
|
100
|
+
const types = {
|
|
101
|
+
bigint: () => column({ type: 'BIGINT', schema: std.bigint() }),
|
|
102
|
+
binary: () => column({ type: 'BINARY', schema: std.instanceOf(Uint8Array) }),
|
|
103
|
+
boolean: () => column({ type: 'BOOLEAN', schema: std.boolean() }),
|
|
104
|
+
// case-insensitive text; postgres needs `CREATE EXTENSION citext` for it
|
|
105
|
+
citext: () => column({ type: 'CITEXT', schema: std.string() }),
|
|
106
|
+
// a calendar date, as a `YYYY-MM-DD` string
|
|
107
|
+
date: () => column({ type: 'DATE', schema: std.isoDate() }),
|
|
108
|
+
// `precision` and `scale` only shape postgres' NUMERIC; values are still
|
|
109
|
+
// numbers, so they're as exact as a double
|
|
110
|
+
decimal: (options) => column({ type: 'DECIMAL', schema: std.number(), ...options }),
|
|
111
|
+
enum: (values) => column({
|
|
112
|
+
type: 'STRING',
|
|
113
|
+
schema: std.enumOf(values),
|
|
114
|
+
length: Math.max(...values.map(value => value.length)),
|
|
115
|
+
}),
|
|
116
|
+
float: () => column({ type: 'FLOAT', schema: std.number() }),
|
|
117
|
+
integer: () => column({ type: 'INTEGER', schema: std.integer() }),
|
|
118
|
+
// any JSON document, validated by the given standard schema
|
|
119
|
+
json: (schema) => column({ type: 'JSON', schema }),
|
|
120
|
+
record: () => column({ type: 'JSON', schema: std.record() }),
|
|
121
|
+
// an integer the database generates, counting up
|
|
122
|
+
sequence: () => column({ type: 'SEQUENCE', schema: std.integer(), generated: true }),
|
|
123
|
+
string: ({ size = 255 } = {}) => column({ type: 'STRING', schema: std.string({ max: size }), length: size }),
|
|
124
|
+
text: () => column({ type: 'TEXT', schema: std.string() }),
|
|
125
|
+
timestamp: () => column({ type: 'TIMESTAMP', schema: std.date() }),
|
|
126
|
+
uuidv7: () => column({ type: 'UUID', schema: std.uuid() }),
|
|
127
|
+
};
|
|
128
|
+
exports.t = types;
|
|
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);
|
|
211
|
+
};
|
|
212
|
+
exports.table = defineTable;
|
|
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);
|
|
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);
|
|
263
|
+
const quote = (ident) => `"${ident.replaceAll('"', '""')}"`;
|
|
264
|
+
class SqlBuilder {
|
|
265
|
+
dialect;
|
|
266
|
+
frags = [];
|
|
267
|
+
params = [];
|
|
268
|
+
constructor(dialect) {
|
|
269
|
+
this.dialect = dialect;
|
|
270
|
+
}
|
|
271
|
+
push(...frags) {
|
|
272
|
+
this.frags.push(...frags);
|
|
273
|
+
return this;
|
|
274
|
+
}
|
|
275
|
+
param(value) {
|
|
276
|
+
this.params.push(value);
|
|
277
|
+
return this.push(this.dialect.placeholder(this.params.length));
|
|
278
|
+
}
|
|
279
|
+
join(items, separator, fn) {
|
|
280
|
+
items.forEach((item, i) => {
|
|
281
|
+
if (i > 0)
|
|
282
|
+
this.push(separator);
|
|
283
|
+
fn(item);
|
|
284
|
+
});
|
|
285
|
+
return this;
|
|
286
|
+
}
|
|
287
|
+
build() {
|
|
288
|
+
return { sql: this.frags.join(''), params: this.params };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const run = async (db, { sql, params }) => {
|
|
292
|
+
db.loggers.forEach(logger => logger.debug(sql, params));
|
|
293
|
+
try {
|
|
294
|
+
return await db.execute(sql, params);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
db.loggers.forEach(logger => logger.error(sql, params, error));
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
// runs a raw statement, e.g. a data fix in a migration
|
|
302
|
+
const exec = async (db, sql, params = []) => {
|
|
303
|
+
await run(db, { sql, params });
|
|
304
|
+
};
|
|
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
|
+
};
|
|
429
|
+
const runReturning = async (db, b, table, returning) => {
|
|
430
|
+
if (returning === false) {
|
|
431
|
+
await run(db, b.build());
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
const cols = returning === true
|
|
435
|
+
? columnsOf(table)
|
|
436
|
+
: returning.map(item => returnedColumn(table, item));
|
|
437
|
+
// an empty RETURNING isn't valid SQL, but it still takes one to know how
|
|
438
|
+
// many rows were affected
|
|
439
|
+
if (cols.length === 0) {
|
|
440
|
+
const rows = await run(db, b.push(' RETURNING 1').build());
|
|
441
|
+
return rows.map(() => ({}));
|
|
442
|
+
}
|
|
443
|
+
b.push(' RETURNING ').join(cols, ', ', col => b.push(quote(col.name), ' AS ', quote(col.key)));
|
|
444
|
+
const rows = await run(db, b.build());
|
|
445
|
+
return rows.map(row => Object.fromEntries(cols.map(col => [col.key, decode(db.dialect, col, row[col.key])])));
|
|
446
|
+
};
|
|
447
|
+
const parseChanges = (table, { set = {}, inc = {}, replace = [] }) => {
|
|
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 };
|
|
458
|
+
});
|
|
459
|
+
return [
|
|
460
|
+
...Object.entries(sets).map(([key, value]) => ({ col: updatableColumn(key), op: 'set', value })),
|
|
461
|
+
...incs,
|
|
462
|
+
...replace.map(key => ({ col: updatableColumn(key), op: 'replace' })),
|
|
463
|
+
];
|
|
464
|
+
};
|
|
465
|
+
// `qualifier` tells the row's current value apart from EXCLUDED's, on conflict
|
|
466
|
+
const renderAssignments = (b, assignments, qualifier) => b
|
|
467
|
+
.join(assignments, ', ', ({ col, op, value }) => {
|
|
468
|
+
if (op === 'replace')
|
|
469
|
+
return b.push(quote(col.name), ' = EXCLUDED.', quote(col.name));
|
|
470
|
+
if (op === 'inc')
|
|
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));
|
|
473
|
+
});
|
|
474
|
+
const GENERATORS = {
|
|
475
|
+
UUID: () => Bun.randomUUIDv7(),
|
|
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
|
|
479
|
+
const isGeneratedByClient = (dialect, col) => col.generated === true && dialect.generated(col) === undefined;
|
|
480
|
+
class InsertBuilder {
|
|
481
|
+
table;
|
|
482
|
+
rows;
|
|
483
|
+
constructor(table, rows = []) {
|
|
484
|
+
this.table = table;
|
|
485
|
+
this.rows = rows;
|
|
486
|
+
}
|
|
487
|
+
values(rows) {
|
|
488
|
+
return new InsertBuilder(this.table, [...this.rows, ...(Array.isArray(rows) ? rows : [rows])]);
|
|
489
|
+
}
|
|
490
|
+
async insert(db, { returning = true, conflict } = {}) {
|
|
491
|
+
if (this.rows.length === 0)
|
|
492
|
+
return returning === false ? undefined : [];
|
|
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));
|
|
497
|
+
const b = new SqlBuilder(db.dialect)
|
|
498
|
+
.push('INSERT INTO ', quote(table[NAME]), ' (', cols.map(col => quote(col.name)).join(', '), ') VALUES ');
|
|
499
|
+
b.join(rows, ', ', row => b
|
|
500
|
+
.push('(')
|
|
501
|
+
.join(cols, ', ', col => b.param(encode(db.dialect, col, col.generated ? generate(col) : row[col.key])))
|
|
502
|
+
.push(')'));
|
|
503
|
+
if (conflict)
|
|
504
|
+
this.renderConflict(b, conflict);
|
|
505
|
+
return await runReturning(db, b, table, returning);
|
|
506
|
+
}
|
|
507
|
+
renderConflict(b, conflict) {
|
|
508
|
+
if (conflict.do === 'throw')
|
|
509
|
+
return b;
|
|
510
|
+
b.push(' ON CONFLICT');
|
|
511
|
+
if (conflict.on.length > 0)
|
|
512
|
+
b.push(' (', conflict.on.map(key => quote(columnOf(this.table, key).name)).join(', '), ')');
|
|
513
|
+
if (conflict.do === 'nothing')
|
|
514
|
+
return b.push(' DO NOTHING');
|
|
515
|
+
const assignments = parseChanges(this.table, conflict.do);
|
|
516
|
+
if (assignments.length === 0)
|
|
517
|
+
throw new Error('Nothing to update on conflict: `set`, `inc` and `replace` are empty');
|
|
518
|
+
return renderAssignments(b.push(' DO UPDATE SET '), assignments, this.table[NAME]);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const into = (table) => new InsertBuilder(table);
|
|
522
|
+
exports.into = into;
|
|
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
|
|
529
|
+
class QueryBuilder {
|
|
530
|
+
query;
|
|
531
|
+
constructor(query) {
|
|
532
|
+
this.query = query;
|
|
533
|
+
}
|
|
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 });
|
|
539
|
+
}
|
|
540
|
+
where(...args) {
|
|
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']);
|
|
561
|
+
return new QueryBuilder({ ...this.query, orderBy: [...this.query.orderBy, ...orderBy] });
|
|
562
|
+
}
|
|
563
|
+
limit(n) {
|
|
564
|
+
return new QueryBuilder({ ...this.query, limit: count(n, 'limit') });
|
|
565
|
+
}
|
|
566
|
+
offset(n) {
|
|
567
|
+
return new QueryBuilder({ ...this.query, offset: count(n, 'offset') });
|
|
568
|
+
}
|
|
569
|
+
async all(db) {
|
|
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])])));
|
|
574
|
+
}
|
|
575
|
+
async one(db) {
|
|
576
|
+
const [row] = await this.top().all(db);
|
|
577
|
+
return row ?? null;
|
|
578
|
+
}
|
|
579
|
+
async exists(db) {
|
|
580
|
+
const rows = await this.top().select({}).all(db);
|
|
581
|
+
return rows.length > 0;
|
|
582
|
+
}
|
|
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(*)
|
|
585
|
+
async count(db) {
|
|
586
|
+
const b = new SqlBuilder(db.dialect).push('SELECT COUNT(*) AS "count" FROM (');
|
|
587
|
+
const [row] = await run(db, this.renderSelect(b, []).push(') AS "q"').build());
|
|
588
|
+
return Number(row['count']);
|
|
589
|
+
}
|
|
590
|
+
async update(db, { returning = true, ...changes }) {
|
|
591
|
+
this.assertPlain('update');
|
|
592
|
+
const { table, where } = this.query;
|
|
593
|
+
const assignments = parseChanges(table, changes);
|
|
594
|
+
if (assignments.length === 0)
|
|
595
|
+
throw new Error('Nothing to update: both `set` and `inc` are empty');
|
|
596
|
+
const b = renderTable(new SqlBuilder(db.dialect).push('UPDATE '), table).push(' SET ');
|
|
597
|
+
renderAssignments(b, assignments, table[ALIAS]);
|
|
598
|
+
renderWhere(b, where);
|
|
599
|
+
return await runReturning(db, b, table, returning);
|
|
600
|
+
}
|
|
601
|
+
async delete(db, { returning = false } = {}) {
|
|
602
|
+
this.assertPlain('delete');
|
|
603
|
+
const { table, where } = this.query;
|
|
604
|
+
const b = renderTable(new SqlBuilder(db.dialect).push('DELETE FROM '), table);
|
|
605
|
+
renderWhere(b, where);
|
|
606
|
+
return await runReturning(db, b, table, returning);
|
|
607
|
+
}
|
|
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;
|
|
615
|
+
b.push('SELECT ');
|
|
616
|
+
// an empty select list isn't valid SQL, but each matching row still needs
|
|
617
|
+
// an (empty) object
|
|
618
|
+
if (fields.length === 0)
|
|
619
|
+
b.push('1');
|
|
620
|
+
else
|
|
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));
|
|
627
|
+
if (orderBy.length > 0)
|
|
628
|
+
b.push(' ORDER BY ').join(orderBy, ', ', ([node, dir]) => renderNode(b, node).push(' ', dir));
|
|
629
|
+
// sqlite requires LIMIT whenever there's an OFFSET, and there's no "no
|
|
630
|
+
// limit" value both sqlite and postgres accept other than a huge number
|
|
631
|
+
if (limit !== undefined || offset !== undefined)
|
|
632
|
+
b.push(` LIMIT ${limit ?? Number.MAX_SAFE_INTEGER}`);
|
|
633
|
+
if (offset !== undefined)
|
|
634
|
+
b.push(` OFFSET ${offset}`);
|
|
635
|
+
return b;
|
|
636
|
+
}
|
|
637
|
+
// keeps a `limit(0)`, which can never match
|
|
638
|
+
top() {
|
|
639
|
+
return this.limit(Math.min(this.query.limit ?? 1, 1));
|
|
640
|
+
}
|
|
641
|
+
// UPDATE/DELETE ... ORDER BY/LIMIT isn't portable, so refuse it rather than
|
|
642
|
+
// silently affecting more rows than the query suggests
|
|
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`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const from = (table) => new QueryBuilder({
|
|
651
|
+
table,
|
|
652
|
+
scope: new Map([[table[ALIAS], table]]),
|
|
653
|
+
joins: [],
|
|
654
|
+
where: [],
|
|
655
|
+
groupBy: [],
|
|
656
|
+
orderBy: [],
|
|
657
|
+
});
|
|
658
|
+
exports.from = from;
|
|
659
|
+
// ─── ddl ─────────────────────────────────────────────────────────────────────
|
|
660
|
+
const renderColumnDefinition = (dialect, col) => [
|
|
661
|
+
quote(col.name),
|
|
662
|
+
dialect.type(col),
|
|
663
|
+
col.primaryKey && 'PRIMARY KEY',
|
|
664
|
+
col.generated && dialect.generated(col),
|
|
665
|
+
!col.nullable && 'NOT NULL',
|
|
666
|
+
col.unique && 'UNIQUE',
|
|
667
|
+
].filter(Boolean).join(' ');
|
|
668
|
+
const createIndex = (table, keys, { ifNotExists = false, name, unique = false, } = {}) => ({
|
|
669
|
+
onto: async (db) => {
|
|
670
|
+
const cols = keys.map(key => columnOf(table, key).name);
|
|
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(', ')})`;
|
|
673
|
+
await run(db, { sql, params: [] });
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
const create = {
|
|
677
|
+
table: (table, { ifNotExists = false } = {}) => ({
|
|
678
|
+
onto: async (db) => {
|
|
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(', ')})`;
|
|
681
|
+
await run(db, { sql, params: [] });
|
|
682
|
+
},
|
|
683
|
+
}),
|
|
684
|
+
index: createIndex,
|
|
685
|
+
uniqueIndex: (table, keys, options = {}) => createIndex(table, keys, { ...options, unique: true }),
|
|
686
|
+
};
|
|
687
|
+
exports.create = create;
|
|
688
|
+
// ─── migrations ──────────────────────────────────────────────────────────────
|
|
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`
|
|
695
|
+
const defineMigrations = (migrations) => async (db) => {
|
|
696
|
+
await create.table(schemaMigrations, { ifNotExists: true }).onto(db);
|
|
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']));
|
|
700
|
+
for (const id of Object.keys(migrations).sort()) {
|
|
701
|
+
if (applied.has(id))
|
|
702
|
+
continue;
|
|
703
|
+
await db.transaction(async (tx) => {
|
|
704
|
+
await migrations[id](tx);
|
|
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
|
+
});
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
exports.defineMigrations = defineMigrations;
|