@rwillians/qx 0.1.2 → 0.1.3

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.
@@ -0,0 +1,483 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { inspect } from 'node:util';
3
+ import * as u from './utils';
4
+ import { is, } from './index';
5
+ /**
6
+ * @private Mapping of qx's primitive types to SQLite native types.
7
+ * @since 0.1.0
8
+ * @version 1
9
+ */
10
+ const TYPES_MAPPING = {
11
+ BINARY: () => 'BLOB',
12
+ BOOLEAN: () => 'INTEGER',
13
+ DATETIME: () => 'INTEGER',
14
+ FLOAT: () => 'REAL',
15
+ INTEGER: () => 'INTEGER',
16
+ TEXT: () => 'TEXT',
17
+ VARCHAR: () => `TEXT`,
18
+ };
19
+ /**
20
+ * @private Registry of codecs for Bun SQLite.
21
+ * @since 0.1.0
22
+ * @version 1
23
+ */
24
+ const CODECS = {
25
+ BINARY: {
26
+ encode: (value) => value,
27
+ decode: (value) => value,
28
+ },
29
+ BOOLEAN: {
30
+ encode: (value) => (value ? 1 : 0),
31
+ decode: (value) => value === 1,
32
+ },
33
+ DATETIME: {
34
+ encode: (value) => value.valueOf(),
35
+ decode: (value) => new Date(value),
36
+ },
37
+ FLOAT: {
38
+ encode: (value) => value * 1.0,
39
+ decode: (value) => value * 1.0,
40
+ },
41
+ INTEGER: {
42
+ encode: (value) => ~~value, // ← nifty little trick to truncate to integer
43
+ decode: (value) => ~~value,
44
+ },
45
+ TEXT: {
46
+ encode: (value) => value,
47
+ decode: (value) => value,
48
+ },
49
+ VARCHAR: {
50
+ encode: (value) => value,
51
+ decode: (value) => value,
52
+ },
53
+ };
54
+ /**
55
+ * @private An empty rendered result.
56
+ * @since 0.1.0
57
+ * @version 1
58
+ */
59
+ const EMPTY_RENDER_RESULT = { frags: [], params: [] };
60
+ /**
61
+ * @private A function combinator that accumulates rendered fragments
62
+ * and parameters when used in a reduce operation.
63
+ * @since 0.1.0
64
+ * @version 1
65
+ */
66
+ const collect = (fn) => (acc, value) => {
67
+ const result = fn(value);
68
+ return { frags: [...acc.frags, ...result.frags], params: [...acc.params, ...result.params] };
69
+ };
70
+ /**
71
+ * @private A function combinator that glues rendered fragments
72
+ * together.
73
+ * @since 0.1.0
74
+ * @version 1
75
+ */
76
+ const glue = (fn) => (...args) => {
77
+ const result = fn(...args);
78
+ return { frags: [result.frags.join('')], params: result.params };
79
+ };
80
+ /**
81
+ * @private Functions for rendering fragments while generateing DDL.
82
+ * @since 0.1.0
83
+ * @version 1
84
+ */
85
+ const render = {
86
+ /**
87
+ * @private Renders an object reference (e.g., table name, column
88
+ * name, etc).
89
+ * @since 0.1.0
90
+ * @version 1
91
+ */
92
+ ref: (value) => `"${value}"`,
93
+ /**
94
+ * @private Renders a column definition, for the create table
95
+ * statement.
96
+ * @since 0.1.0
97
+ * @version 1
98
+ */
99
+ column: glue((col) => {
100
+ const { name, primaryKey = false, autoincrement = false, nullable = false, unique = false, } = col;
101
+ const type = TYPES_MAPPING[col.type](col);
102
+ // @TODO DDL should not have to know that it needs to snake_case here
103
+ const frags = [render.ref(u.snakeCase(name)), type];
104
+ if (primaryKey)
105
+ frags.push('PRIMARY KEY ASC');
106
+ if (autoincrement)
107
+ frags.push('AUTOINCREMENT');
108
+ if (primaryKey)
109
+ return { frags: [frags.join(' ')], params: [] };
110
+ if (!nullable)
111
+ frags.push('NOT NULL');
112
+ if (unique)
113
+ frags.push('UNIQUE');
114
+ return { frags: [frags.join(' ')], params: [] };
115
+ }),
116
+ /**
117
+ * @private Renders a value placeholder for an insert statement.
118
+ * @since 0.1.0
119
+ * @version 1
120
+ */
121
+ row: (shape) => glue((record) => {
122
+ const frags = [];
123
+ const params = [];
124
+ for (const key of Object.keys(shape)) {
125
+ frags.push('?');
126
+ params.push(record[key]);
127
+ }
128
+ return { frags: ['(' + frags.join(', ') + ')'], params };
129
+ }),
130
+ /**
131
+ * @private Renders the columns of a query's selection.
132
+ * @since 0.1.0
133
+ * @version 1
134
+ */
135
+ selection: glue((selection) => {
136
+ const frags = Object
137
+ .entries(selection)
138
+ // @TODO DDL should not have to know that it needs to snake_case here
139
+ .map(([alias, col]) => `${render.expr.column(col).frags.join('')} AS ${render.ref(alias)}`);
140
+ return { frags: [frags.join(', ')], params: [] };
141
+ }),
142
+ /**
143
+ * @private Renders an order by clause.
144
+ * @since 0.1.0
145
+ * @version 1
146
+ *
147
+ * Not using {@link glue} here becuase it's breaking typescript
148
+ * ¯\_(ツ)_/¯
149
+ */
150
+ orderBy: ([expr, dir]) => {
151
+ const { frags, params } = render.expr.any(expr);
152
+ return { frags: [[...frags, dir].join(' ')], params };
153
+ },
154
+ /**
155
+ * @private Expression rendering functions.
156
+ * @since 0.1.0
157
+ * @version 1
158
+ */
159
+ expr: {
160
+ /**
161
+ * @private Renders any {@link Expr}.
162
+ * @since 0.1.0
163
+ * @version 1
164
+ */
165
+ any: glue((value) => {
166
+ if (is.binaryOp(value))
167
+ return render.expr.binaryOp(value);
168
+ if (is.and(value))
169
+ return render.expr.and(value);
170
+ if (is.or(value))
171
+ return render.expr.or(value);
172
+ if (is.not(value))
173
+ return render.expr.not(value);
174
+ if (is.column(value))
175
+ return render.expr.column(value);
176
+ if (is.literal(value))
177
+ return render.expr.literal(value);
178
+ throw new Error(`Unsupported expression type: ${inspect(value)}`);
179
+ }),
180
+ // // // // // // // // // // // // // // // // // // // // // //
181
+ // BINARY OP EXPRESSIONS //
182
+ // // // // // // // // // // // // // // // // // // // // // //
183
+ /**
184
+ * @private Renders any binary op expression.
185
+ * @since 0.1.0
186
+ * @version 1
187
+ */
188
+ binaryOp: glue((value) => {
189
+ const lhs = render.expr.any(value.lhs);
190
+ const rhs = Array.isArray(value.rhs)
191
+ ? render.expr.array(value.rhs)
192
+ : render.expr.any(value.rhs);
193
+ return { frags: ['(', ...lhs.frags, ` ${value.op} `, ...rhs.frags, ')'], params: [...lhs.params, ...rhs.params] };
194
+ }),
195
+ // // // // // // // // // // // // // // // // // // // // // //
196
+ // BOOLEAN OP EXPRESSIONS //
197
+ // // // // // // // // // // // // // // // // // // // // // //
198
+ /**
199
+ * @private Renders an AND expression.
200
+ * @since 0.1.0
201
+ * @version 1
202
+ */
203
+ and: glue((value) => {
204
+ const inner = value.and.reduce(collect(render.expr.any), EMPTY_RENDER_RESULT);
205
+ return { frags: ['(', inner.frags.join(' AND '), ')'], params: inner.params };
206
+ }),
207
+ /**
208
+ * @private Renders an OR expression.
209
+ * @since 0.1.0
210
+ * @version 1
211
+ */
212
+ or: glue((value) => {
213
+ const inner = value.or.reduce(collect(render.expr.any), EMPTY_RENDER_RESULT);
214
+ return { frags: ['(', inner.frags.join(' OR '), ')'], params: inner.params };
215
+ }),
216
+ /**
217
+ * @private Renders a NOT expression.
218
+ * @since 0.1.0
219
+ * @version 1
220
+ */
221
+ not: glue((value) => {
222
+ const inner = render.expr.any(value.not);
223
+ return { frags: ['NOT ', ...inner.frags], params: inner.params };
224
+ }),
225
+ // // // // // // // // // // // // // // // // // // // // // //
226
+ // OTHER EXPRESSIONS //
227
+ // // // // // // // // // // // // // // // // // // // // // //
228
+ array: glue((values) => {
229
+ const { frags, params } = values.reduce(collect(render.expr.any), EMPTY_RENDER_RESULT);
230
+ return { frags: ['(', frags.join(', '), ')'], params };
231
+ }),
232
+ /**
233
+ * @private Renders a column expression.
234
+ * @since 0.1.0
235
+ * @version 1
236
+ */
237
+ column: glue((col) => ({
238
+ // @TODO DDL should not have to know that it needs to snake_case here
239
+ frags: [`${render.ref(col.table)}.${render.ref(u.snakeCase(col.name))}`],
240
+ params: [],
241
+ })),
242
+ /**
243
+ * @private Renders a literal expression.
244
+ * @since 0.1.0
245
+ * @version 1
246
+ */
247
+ literal: glue((value) => {
248
+ if (is.null(value))
249
+ return render.expr.null();
250
+ if (is.boolean(value))
251
+ return render.expr.boolean(value);
252
+ if (is.date(value))
253
+ return render.expr.date(value);
254
+ if (is.number(value))
255
+ return render.expr.number(value);
256
+ if (is.string(value))
257
+ return render.expr.string(value);
258
+ throw new Error(`Unsupported literal expression: ${inspect(value)}`);
259
+ }),
260
+ // // // // // // // // // // // // // // // // // // // // // //
261
+ // LITERALS //
262
+ // // // // // // // // // // // // // // // // // // // // // //
263
+ /**
264
+ * @private Renders a boolean literal expression.
265
+ * @since 0.1.0
266
+ * @version 1
267
+ */
268
+ boolean: glue((value) => ({
269
+ frags: [value ? 'TRUE' : 'FALSE'],
270
+ params: [],
271
+ })),
272
+ /**
273
+ * @private Renders a date literal expression.
274
+ * @since 0.1.0
275
+ * @version 1
276
+ */
277
+ date: glue((value) => ({
278
+ frags: ['?'],
279
+ params: [CODECS.DATETIME.encode(value)],
280
+ })),
281
+ /**
282
+ * @private Renders a null literal expression.
283
+ * @since 0.1.0
284
+ * @version 1
285
+ */
286
+ null: glue(() => ({ frags: ['NULL'], params: [] })),
287
+ /**
288
+ * @private Renders a number literal expression.
289
+ * @since 0.1.0
290
+ * @version 1
291
+ */
292
+ number: glue((value) => ({ frags: ['?'], params: [value] })),
293
+ /**
294
+ * @private Renders a string literal expression.
295
+ * @since 0.1.0
296
+ * @version 1
297
+ */
298
+ string: glue((value) => ({ frags: ['?'], params: [value] })),
299
+ },
300
+ };
301
+ /**
302
+ * @private DDL generation functions.
303
+ * @since 0.1.0
304
+ * @version 1
305
+ */
306
+ const ddl = {
307
+ /**
308
+ * @private Generates DDL for create table statement.
309
+ * @since 0.1.0
310
+ * @version 1
311
+ */
312
+ createTable: (op) => {
313
+ const columns = op
314
+ .columns
315
+ .map(col => render.column(col).frags.join(''))
316
+ .join(', ');
317
+ const frags = [
318
+ 'CREATE ',
319
+ (op.unlogged ? 'UNLOGGED ' : ''),
320
+ 'TABLE ',
321
+ (op.ifNotExists ? 'IF NOT EXISTS ' : ''),
322
+ render.ref(op.table),
323
+ ' (',
324
+ ...columns,
325
+ ');',
326
+ ];
327
+ return { sql: frags.join(''), params: [] };
328
+ },
329
+ /**
330
+ * @private Generates DDL for insert statement.
331
+ * @since 0.1.0
332
+ * @version 1
333
+ */
334
+ insert: (op) => {
335
+ const values = op
336
+ .records
337
+ .reduce(collect(render.row(op.insertShape)), EMPTY_RENDER_RESULT);
338
+ const frags = [
339
+ 'INSERT INTO ',
340
+ render.ref(op.table),
341
+ ' (',
342
+ Object.keys(op.insertShape).map(render.ref).join(', '),
343
+ ') VALUES ',
344
+ values.frags.join(', '),
345
+ ' RETURNING ',
346
+ render.selection(op.returnShape).frags.join(', '),
347
+ ';'
348
+ ];
349
+ return { sql: frags.join(''), params: values.params };
350
+ },
351
+ /**
352
+ * @private Generates DDL for select statement.
353
+ * @since 0.1.0
354
+ * @version 1
355
+ */
356
+ select: (op) => {
357
+ const where = op.where
358
+ ? render.expr.any(op.where)
359
+ : EMPTY_RENDER_RESULT;
360
+ const orderBy = op.orderBy && op.orderBy.length > 0
361
+ ? op.orderBy.reduce(collect(render.orderBy), EMPTY_RENDER_RESULT)
362
+ : EMPTY_RENDER_RESULT;
363
+ const limit = op.limit !== undefined
364
+ ? { frags: [' LIMIT ', '?'], params: [op.limit] }
365
+ : EMPTY_RENDER_RESULT;
366
+ const offset = op.offset !== undefined
367
+ ? { frags: [' OFFSET ', '?'], params: [op.offset] }
368
+ : EMPTY_RENDER_RESULT;
369
+ const frags = [
370
+ 'SELECT ',
371
+ render.selection(op.select).frags.join(', '),
372
+ ' FROM ',
373
+ render.ref(op.registry[op.from]),
374
+ ' AS ',
375
+ render.ref(op.from),
376
+ (where.frags.length > 0 ? ' WHERE ' : ''),
377
+ ...where.frags,
378
+ (orderBy.frags.length > 0 ? ' ORDER BY ' + orderBy.frags.join(', ') : ''),
379
+ ...limit.frags,
380
+ ...offset.frags,
381
+ ';'
382
+ ];
383
+ return { sql: frags.join(''), params: [...where.params, ...limit.params, ...offset.params] };
384
+ },
385
+ };
386
+ /**
387
+ * @private Creates an encoder function that converts a row into the
388
+ * expected shape and format expected by the database.
389
+ * @since 0.1.0
390
+ * @version 1
391
+ */
392
+ const createEncoder = (shape) => {
393
+ const encoders = Object.fromEntries(Object
394
+ .entries(shape)
395
+ .map(([key, col]) => [key, (value) => value === null ? null : CODECS[col.type].encode(value)]));
396
+ return (row) => Object.fromEntries(Object
397
+ .entries(encoders)
398
+ .map(([key, encode]) => [u.snakeCase(key), encode(row[key])]));
399
+ };
400
+ /**
401
+ * @private Creates a decoder function that converts a database row
402
+ * into the shape and format expected by the application.
403
+ * @since 0.1.0
404
+ * @version 1
405
+ */
406
+ const createDecoder = (shape) => {
407
+ const decoders = Object.fromEntries(Object
408
+ .entries(shape)
409
+ .map(([key, col]) => [key, (value) => value === null ? null : CODECS[col.type].decode(value)]));
410
+ return (row) => Object.fromEntries(Object
411
+ .entries(decoders)
412
+ .map(([key, decode]) => [key, decode(row[key])]));
413
+ };
414
+ /**
415
+ * @private Bun SQLite database adapter implementation.
416
+ * @since 0.1.0
417
+ * @version 1
418
+ */
419
+ class BunSQLite {
420
+ conn;
421
+ loggers;
422
+ constructor(conn, loggers = []) {
423
+ this.conn = conn;
424
+ this.loggers = loggers;
425
+ }
426
+ /**
427
+ * @public Attaches a logger to the database instance.
428
+ * @since 0.1.0
429
+ * @version 1
430
+ */
431
+ attachLogger(logger) {
432
+ this.loggers.push(logger);
433
+ return this;
434
+ }
435
+ /**
436
+ * @public Executes a create table statement.
437
+ * @since 0.1.0
438
+ * @version 1
439
+ */
440
+ async createTable(op) {
441
+ const { sql } = ddl.createTable(op);
442
+ this.loggers.forEach(logger => logger.debug(sql, []));
443
+ this.conn.run(sql);
444
+ }
445
+ /**
446
+ * @public Executes an insert statement.
447
+ * @since 0.1.0
448
+ * @version 1
449
+ */
450
+ async insert(op) {
451
+ const { sql, params } = ddl.insert({
452
+ ...op,
453
+ records: op.records.map(createEncoder(op.insertShape)),
454
+ insertShape: u.mapKeys(op.insertShape, u.snakeCase),
455
+ });
456
+ this.loggers.forEach(logger => logger.debug(sql, params));
457
+ const stmt = this.conn.prepare(sql);
458
+ const rows = stmt.all(...params);
459
+ return rows.map(createDecoder(op.returnShape));
460
+ }
461
+ /**
462
+ * @public Executes a select statement.
463
+ * @since 0.1.0
464
+ * @version 1
465
+ */
466
+ async query(op) {
467
+ const { sql, params } = ddl.select(op);
468
+ this.loggers.forEach(logger => logger.debug(sql, params));
469
+ const stmt = this.conn.prepare(sql);
470
+ const rows = stmt.all(...params);
471
+ return rows.map(createDecoder(op.select));
472
+ }
473
+ }
474
+ /**
475
+ * @public Creates a connection to the database.
476
+ * @since 0.1.0
477
+ * @version 1
478
+ */
479
+ const connect = (...args) => new BunSQLite(new Database(...args));
480
+ // // // // // // // // // // // // // // // // // // // // // // // //
481
+ // EXPORTS //
482
+ // // // // // // // // // // // // // // // // // // // // // // // //
483
+ export { connect, };