@lobomfz/db 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobomfz/db",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Bun SQLite database with Arktype schemas and typed Kysely client",
5
5
  "keywords": [
6
6
  "arktype",
package/src/database.ts CHANGED
@@ -63,6 +63,7 @@ export class Database<T extends SchemaRecord> {
63
63
  private sqlite: BunDatabase;
64
64
 
65
65
  private columns: ColumnsMap = new Map();
66
+ private tableColumns = new Map<string, Set<string>>();
66
67
 
67
68
  readonly infer: TablesFromSchemas<T> = undefined as any;
68
69
 
@@ -82,7 +83,7 @@ export class Database<T extends SchemaRecord> {
82
83
 
83
84
  this.kysely = new Kysely<TablesFromSchemas<T>>({
84
85
  dialect: new BunSqliteDialect({ database: this.sqlite }),
85
- plugins: [new DeserializePlugin(this.columns, validation), new ParseJSONResultsPlugin()],
86
+ plugins: [new DeserializePlugin(this.columns, this.tableColumns, validation), new ParseJSONResultsPlugin()],
86
87
  });
87
88
  }
88
89
 
@@ -264,6 +265,8 @@ export class Database<T extends SchemaRecord> {
264
265
  }
265
266
 
266
267
  private registerColumns(tableName: string, props: Prop[]) {
268
+ this.tableColumns.set(tableName, new Set(props.map((p) => p.key)));
269
+
267
270
  const colMap = new Map<string, ColumnCoercion>();
268
271
 
269
272
  for (const prop of props) {
package/src/plugin.ts CHANGED
@@ -1,28 +1,41 @@
1
+ import { type } from "arktype";
2
+ import type { Type } from "arktype";
1
3
  import {
2
4
  type KyselyPlugin,
5
+ type OperationNode,
3
6
  type RootOperationNode,
4
7
  type UnknownRow,
5
8
  type QueryId,
9
+ AggregateFunctionNode,
6
10
  TableNode,
7
11
  AliasNode,
8
12
  ValuesNode,
9
13
  ValueNode,
10
14
  ColumnNode,
15
+ IdentifierNode,
16
+ ReferenceNode,
17
+ ParensNode,
18
+ CastNode,
19
+ SelectQueryNode,
11
20
  } from "kysely";
12
- import { type } from "arktype";
13
- import type { Type } from "arktype";
21
+
14
22
  import { JsonParseError } from "./errors";
15
- import { JsonValidationError } from "./validation-error";
16
23
  import type { JsonValidation } from "./types";
24
+ import { JsonValidationError } from "./validation-error";
17
25
 
18
26
  export type ColumnCoercion = "boolean" | "date" | { type: "json"; schema: Type };
19
27
  export type ColumnsMap = Map<string, Map<string, ColumnCoercion>>;
20
28
 
29
+ type ResolvedCoercion = { table: string; coercion: ColumnCoercion };
30
+
31
+ const typePreservingAggregateFunctions = new Set(["max", "min"]);
32
+
21
33
  export class DeserializePlugin implements KyselyPlugin {
22
34
  private queryNodes = new WeakMap<QueryId, RootOperationNode>();
23
35
 
24
36
  constructor(
25
37
  private columns: ColumnsMap,
38
+ private tableColumns: Map<string, Set<string>>,
26
39
  private validation: Required<JsonValidation>,
27
40
  ) {}
28
41
 
@@ -149,48 +162,231 @@ export class DeserializePlugin implements KyselyPlugin {
149
162
  }
150
163
  }
151
164
 
165
+ private coerceSingle(table: string, row: UnknownRow, col: string, coercion: ColumnCoercion) {
166
+ if (coercion === "boolean") {
167
+ if (typeof row[col] === "number") {
168
+ row[col] = row[col] === 1;
169
+ }
170
+
171
+ return;
172
+ }
173
+
174
+ if (coercion === "date") {
175
+ if (typeof row[col] === "number") {
176
+ row[col] = new Date(row[col] * 1000);
177
+ }
178
+
179
+ return;
180
+ }
181
+
182
+ if (typeof row[col] !== "string") {
183
+ return;
184
+ }
185
+
186
+ const value = row[col];
187
+
188
+ let parsed: unknown;
189
+
190
+ try {
191
+ parsed = JSON.parse(value);
192
+ } catch (e) {
193
+ throw new JsonParseError(table, col, value, e);
194
+ }
195
+
196
+ if (this.validation.onRead) {
197
+ this.validateJsonValue(table, col, parsed, coercion.schema);
198
+ }
199
+
200
+ row[col] = parsed;
201
+ }
202
+
152
203
  private coerceRow(table: string, row: UnknownRow, cols: Map<string, ColumnCoercion>) {
153
204
  for (const [col, coercion] of cols) {
154
205
  if (!(col in row)) {
155
206
  continue;
156
207
  }
157
208
 
158
- if (coercion === "boolean") {
159
- if (typeof row[col] === "number") {
160
- row[col] = row[col] === 1;
161
- }
209
+ this.coerceSingle(table, row, col, coercion);
210
+ }
211
+ }
162
212
 
163
- continue;
213
+ private getIdentifierName(node: OperationNode | undefined) {
214
+ if (!node || !IdentifierNode.is(node)) {
215
+ return null;
216
+ }
217
+
218
+ return node.name;
219
+ }
220
+
221
+ private addTableScopeEntry(scope: Map<string, string>, node: OperationNode) {
222
+ if (AliasNode.is(node) && TableNode.is(node.node)) {
223
+ const alias = this.getIdentifierName(node.alias);
224
+ const table = node.node.table.identifier.name;
225
+
226
+ scope.set(table, table);
227
+
228
+ if (alias) {
229
+ scope.set(alias, table);
164
230
  }
165
231
 
166
- if (coercion === "date") {
167
- if (typeof row[col] === "number") {
168
- row[col] = new Date(row[col] * 1000);
169
- }
232
+ return;
233
+ }
234
+
235
+ if (TableNode.is(node)) {
236
+ const table = node.table.identifier.name;
237
+ scope.set(table, table);
238
+ }
239
+ }
240
+
241
+ private getTableScope(node: SelectQueryNode) {
242
+ const scope = new Map<string, string>();
243
+
244
+ for (const fromNode of node.from?.froms ?? []) {
245
+ this.addTableScopeEntry(scope, fromNode);
246
+ }
247
+
248
+ for (const join of node.joins ?? []) {
249
+ this.addTableScopeEntry(scope, join.table);
250
+ }
251
+
252
+ return scope;
253
+ }
254
+
255
+ private resolveReferenceCoercion(node: ReferenceNode | ColumnNode, scope: Map<string, string>) {
256
+ const column = ColumnNode.is(node)
257
+ ? node.column.name
258
+ : ColumnNode.is(node.column)
259
+ ? node.column.column.name
260
+ : null;
261
+
262
+ if (!column) {
263
+ return null;
264
+ }
265
+
266
+ if (ReferenceNode.is(node) && node.table) {
267
+ const tableRef = node.table.table.identifier.name;
268
+ const table = scope.get(tableRef) ?? tableRef;
269
+ const coercion = this.columns.get(table)?.get(column);
270
+
271
+ if (!coercion) {
272
+ return null;
273
+ }
274
+
275
+ return { table, coercion } satisfies ResolvedCoercion;
276
+ }
277
+
278
+ let match: ResolvedCoercion | null = null;
279
+ const resolvedTables = new Set<string>();
170
280
 
281
+ for (const table of scope.values()) {
282
+ if (resolvedTables.has(table)) {
171
283
  continue;
172
284
  }
173
285
 
174
- if (typeof row[col] !== "string") {
286
+ resolvedTables.add(table);
287
+
288
+ const coercion = this.columns.get(table)?.get(column);
289
+
290
+ if (!coercion) {
175
291
  continue;
176
292
  }
177
293
 
178
- const value = row[col];
294
+ if (match) {
295
+ return null;
296
+ }
297
+
298
+ match = { table, coercion };
299
+ }
300
+
301
+ return match;
302
+ }
303
+
304
+ private resolveSelectionCoercion(node: OperationNode, scope: Map<string, string>): ResolvedCoercion | null {
305
+ if (AliasNode.is(node)) {
306
+ return this.resolveSelectionCoercion(node.node, scope);
307
+ }
308
+
309
+ if (ReferenceNode.is(node) || ColumnNode.is(node)) {
310
+ return this.resolveReferenceCoercion(node, scope);
311
+ }
179
312
 
180
- let parsed: unknown;
313
+ if (SelectQueryNode.is(node)) {
314
+ return this.resolveScalarSubqueryCoercion(node);
315
+ }
181
316
 
182
- try {
183
- parsed = JSON.parse(value);
184
- } catch (e) {
185
- throw new JsonParseError(table, col, value, e);
317
+ if (AggregateFunctionNode.is(node)) {
318
+ if (
319
+ node.aggregated.length !== 1 ||
320
+ !typePreservingAggregateFunctions.has(node.func.toLowerCase())
321
+ ) {
322
+ return null;
186
323
  }
187
324
 
188
- if (this.validation.onRead) {
189
- this.validateJsonValue(table, col, parsed, coercion.schema);
325
+ return this.resolveSelectionCoercion(node.aggregated[0]!, scope);
326
+ }
327
+
328
+ if (ParensNode.is(node)) {
329
+ return this.resolveSelectionCoercion(node.node, scope);
330
+ }
331
+
332
+ if (CastNode.is(node)) {
333
+ return null;
334
+ }
335
+
336
+ return null;
337
+ }
338
+
339
+ private resolveScalarSubqueryCoercion(node: SelectQueryNode) {
340
+ if (!node.selections || node.selections.length !== 1) {
341
+ return null;
342
+ }
343
+
344
+ return this.resolveSelectionCoercion(
345
+ node.selections[0]!.selection,
346
+ this.getTableScope(node),
347
+ );
348
+ }
349
+
350
+ private getSelectionOutputName(node: OperationNode) {
351
+ if (AliasNode.is(node)) {
352
+ return this.getIdentifierName(node.alias);
353
+ }
354
+
355
+ if (ReferenceNode.is(node) && ColumnNode.is(node.column)) {
356
+ return node.column.column.name;
357
+ }
358
+
359
+ if (ColumnNode.is(node)) {
360
+ return node.column.name;
361
+ }
362
+
363
+ return null;
364
+ }
365
+
366
+ private getSelectCoercions(node: RootOperationNode) {
367
+ const result = new Map<string, ResolvedCoercion>();
368
+
369
+ if (node.kind !== "SelectQueryNode" || !node.selections) {
370
+ return result;
371
+ }
372
+
373
+ const scope = this.getTableScope(node);
374
+
375
+ for (const selectionNode of node.selections) {
376
+ const output = this.getSelectionOutputName(selectionNode.selection);
377
+
378
+ if (!output) {
379
+ continue;
190
380
  }
191
381
 
192
- row[col] = parsed;
382
+ const resolved = this.resolveSelectionCoercion(selectionNode.selection, scope);
383
+
384
+ if (resolved) {
385
+ result.set(output, resolved);
386
+ }
193
387
  }
388
+
389
+ return result;
194
390
  }
195
391
 
196
392
  transformResult: KyselyPlugin["transformResult"] = async (args) => {
@@ -206,14 +402,40 @@ export class DeserializePlugin implements KyselyPlugin {
206
402
  return args.result;
207
403
  }
208
404
 
209
- const cols = this.columns.get(table);
210
-
211
- if (!cols) {
212
- return args.result;
213
- }
405
+ const mainCols = this.columns.get(table);
406
+ const mainTableColumns = this.tableColumns.get(table);
407
+ const selectCoercions = this.getSelectCoercions(node);
214
408
 
215
409
  for (const row of args.result.rows) {
216
- this.coerceRow(table, row, cols);
410
+ if (mainCols) {
411
+ this.coerceRow(table, row, mainCols);
412
+ }
413
+
414
+ for (const col of Object.keys(row)) {
415
+ const resolved = selectCoercions.get(col);
416
+
417
+ if (resolved) {
418
+ this.coerceSingle(resolved.table, row, col, resolved.coercion);
419
+ continue;
420
+ }
421
+
422
+ if (mainTableColumns?.has(col)) {
423
+ continue;
424
+ }
425
+
426
+ for (const [otherTable, otherCols] of this.columns) {
427
+ if (otherTable === table) {
428
+ continue;
429
+ }
430
+
431
+ const coercion = otherCols.get(col);
432
+
433
+ if (coercion) {
434
+ this.coerceSingle(otherTable, row, col, coercion);
435
+ break;
436
+ }
437
+ }
438
+ }
217
439
  }
218
440
 
219
441
  return { ...args.result };