@lobomfz/db 0.3.8 → 0.4.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 CHANGED
@@ -5,7 +5,7 @@ SQLite database with Arktype schemas and typed Kysely client for Bun.
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- bun add @lobomfz/db arktype kysely
8
+ bun add @lobomfz/db
9
9
  ```
10
10
 
11
11
  ## Usage
@@ -65,12 +65,12 @@ type("string").configure({ unique: true }); // UNIQUE
65
65
  type("number.integer").configure({ references: "users.id", onDelete: "cascade" }); // FK
66
66
  ```
67
67
 
68
- JSON columns are validated against the schema on write by default. To also validate on read, or to disable write validation:
68
+ JSON columns are validated against the schema on write by default. To also validate on read:
69
69
 
70
70
  ```typescript
71
71
  new Database({
72
72
  // ...
73
- validation: { onRead: true }, // default: { onRead: false, onWrite: true }
73
+ validation: { onRead: true }, // default: { onRead: false }
74
74
  });
75
75
  ```
76
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobomfz/db",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "Bun SQLite database with Arktype schemas and typed Kysely client",
5
5
  "keywords": [
6
6
  "arktype",
package/src/database.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Database as BunDatabase } from "bun:sqlite";
2
2
 
3
3
  import type { Type } from "arktype";
4
- import { Kysely, ParseJSONResultsPlugin } from "kysely";
4
+ import { Kysely } from "kysely";
5
5
 
6
6
  import { BunSqliteDialect } from "./dialect/dialect.js";
7
7
  import type { DbFieldMeta } from "./env.js";
@@ -9,7 +9,7 @@ import type { GeneratedPreset } from "./generated.js";
9
9
  import { Differ, type DesiredTable } from "./migration/diff.js";
10
10
  import { Executor } from "./migration/execute.js";
11
11
  import { Introspector } from "./migration/introspect.js";
12
- import { DeserializePlugin, type ColumnCoercion, type ColumnsMap } from "./plugin.js";
12
+ import { ResultHydrationPlugin } from "./plugin.js";
13
13
  import type {
14
14
  DatabaseOptions,
15
15
  IndexDefinition,
@@ -17,6 +17,7 @@ import type {
17
17
  TablesFromSchemas,
18
18
  DatabasePragmas,
19
19
  } from "./types.js";
20
+ import { WriteValidationPlugin } from "./write-validation-plugin.js";
20
21
 
21
22
  type ArkBranch = {
22
23
  domain?: string;
@@ -66,14 +67,19 @@ const defaultPragmas: DatabasePragmas = {
66
67
  export class Database<T extends SchemaRecord> {
67
68
  private sqlite: BunDatabase;
68
69
 
69
- private columns: ColumnsMap = new Map();
70
- private tableColumns = new Map<string, Set<string>>();
71
-
72
70
  readonly infer: TablesFromSchemas<T> = undefined as any;
73
71
 
74
72
  readonly kysely: Kysely<TablesFromSchemas<T>>;
75
73
 
76
74
  constructor(private options: DatabaseOptions<T>) {
75
+ const tableSchemas = new Map<string, Type>(Object.entries(options.schema.tables));
76
+ const writeSchemas = new Map<string, Type>(
77
+ Object.entries(options.schema.tables).map(([table, schema]) => [
78
+ table,
79
+ this.createWriteSchema(schema),
80
+ ]),
81
+ );
82
+
77
83
  this.sqlite = new BunDatabase(options.path);
78
84
 
79
85
  this.applyPragmas();
@@ -82,14 +88,13 @@ export class Database<T extends SchemaRecord> {
82
88
 
83
89
  const validation = {
84
90
  onRead: options.validation?.onRead ?? false,
85
- onWrite: options.validation?.onWrite ?? true,
86
91
  };
87
92
 
88
93
  this.kysely = new Kysely<TablesFromSchemas<T>>({
89
94
  dialect: new BunSqliteDialect({ database: this.sqlite }),
90
95
  plugins: [
91
- new DeserializePlugin(this.columns, this.tableColumns, validation),
92
- new ParseJSONResultsPlugin(),
96
+ new WriteValidationPlugin(writeSchemas),
97
+ new ResultHydrationPlugin(tableSchemas, validation),
93
98
  ],
94
99
  });
95
100
  }
@@ -292,30 +297,16 @@ export class Database<T extends SchemaRecord> {
292
297
  return structureProps.map((p) => this.normalizeProp(p, schema));
293
298
  }
294
299
 
295
- private registerColumns(tableName: string, props: Prop[]) {
296
- this.tableColumns.set(tableName, new Set(props.map((p) => p.key)));
297
-
298
- const colMap = new Map<string, ColumnCoercion>();
299
-
300
- for (const prop of props) {
301
- if (prop.isBoolean) {
302
- colMap.set(prop.key, "boolean");
303
- continue;
304
- }
305
-
306
- if (prop.isDate) {
307
- colMap.set(prop.key, "date");
308
- continue;
309
- }
300
+ private createWriteSchema(schema: Type) {
301
+ const autoIncrementColumns = this.parseSchemaProps(schema)
302
+ .filter((prop) => prop.generated === "autoincrement")
303
+ .map((prop) => prop.key);
310
304
 
311
- if (prop.isJson && prop.jsonSchema) {
312
- colMap.set(prop.key, { type: "json", schema: prop.jsonSchema });
313
- }
305
+ if (autoIncrementColumns.length === 0) {
306
+ return schema;
314
307
  }
315
308
 
316
- if (colMap.size > 0) {
317
- this.columns.set(tableName, colMap);
318
- }
309
+ return (schema as any).omit(...autoIncrementColumns) as Type;
319
310
  }
320
311
 
321
312
  private generateCreateTableSQL(tableName: string, props: Prop[]) {
@@ -342,8 +333,6 @@ export class Database<T extends SchemaRecord> {
342
333
  for (const [name, schema] of Object.entries(this.options.schema.tables)) {
343
334
  const props = this.parseSchemaProps(schema);
344
335
 
345
- this.registerColumns(name, props);
346
-
347
336
  const columns = props.map((prop) => {
348
337
  const isNotNull = this.columnConstraint(prop) === "NOT NULL";
349
338
  const defaultClause = this.defaultClause(prop);
package/src/generated.ts CHANGED
@@ -7,7 +7,7 @@ const generatedTypes = {
7
7
  now: () =>
8
8
  type("Date")
9
9
  .configure({ _generated: "now" })
10
- .default(() => new Date(0)),
10
+ .default(() => new Date()),
11
11
  };
12
12
 
13
13
  export function generated<P extends GeneratedPreset>(
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ export {
12
12
  } from "kysely";
13
13
  export { type, type Type } from "arktype";
14
14
  export { configure } from "arktype/config";
15
- export { JsonValidationError } from "./validation-error.js";
15
+ export { ValidationError } from "./validation-error.js";
16
16
  export type { DbFieldMeta } from "./env.js";
17
17
  export type {
18
18
  DatabaseOptions,
package/src/plugin.ts CHANGED
@@ -3,50 +3,153 @@ import type { Type } from "arktype";
3
3
  import {
4
4
  type KyselyPlugin,
5
5
  type OperationNode,
6
+ type QueryId,
6
7
  type RootOperationNode,
7
8
  type UnknownRow,
8
- type QueryId,
9
9
  AggregateFunctionNode,
10
- TableNode,
11
10
  AliasNode,
12
- ValuesNode,
13
- ValueNode,
11
+ CastNode,
14
12
  ColumnNode,
15
- DefaultInsertValueNode,
16
13
  IdentifierNode,
17
- ReferenceNode,
18
14
  ParensNode,
19
- CastNode,
15
+ ReferenceNode,
20
16
  SelectQueryNode,
17
+ TableNode,
21
18
  } from "kysely";
22
19
 
23
20
  import { JsonParseError } from "./errors.js";
24
- import type { JsonValidation } from "./types.js";
25
- import { JsonValidationError } from "./validation-error.js";
21
+ import type { StructureProp } from "./types.js";
22
+ import { ValidationError } from "./validation-error.js";
26
23
 
27
- export type ColumnCoercion = "boolean" | "date" | { type: "json"; schema: Type };
28
- export type ColumnsMap = Map<string, Map<string, ColumnCoercion>>;
24
+ type ColumnCoercion = "boolean" | "date" | { type: "json"; schema: Type };
29
25
 
30
26
  type ResolvedCoercion = { table: string; coercion: ColumnCoercion };
31
27
 
28
+ type CoercionPlan = {
29
+ kind: "coercion";
30
+ table: string;
31
+ column: string;
32
+ coercion: ColumnCoercion;
33
+ };
34
+
35
+ type ObjectPlan = {
36
+ kind: "object";
37
+ table: string;
38
+ column: string;
39
+ fields: Map<string, ValuePlan>;
40
+ };
41
+
42
+ type ArrayPlan = {
43
+ kind: "array";
44
+ table: string;
45
+ column: string;
46
+ fields: Map<string, ValuePlan>;
47
+ };
48
+
49
+ type ValuePlan = CoercionPlan | ObjectPlan | ArrayPlan;
50
+
51
+ type QueryPlan = {
52
+ table: string | null;
53
+ selectionPlans: Map<string, ValuePlan>;
54
+ };
55
+
56
+ type JsonHelper = {
57
+ kind: "object" | "array";
58
+ query: SelectQueryNode;
59
+ };
60
+
61
+ type RawOperationNode = OperationNode & {
62
+ kind: "RawNode";
63
+ sqlFragments: readonly string[];
64
+ parameters: readonly OperationNode[];
65
+ };
66
+
67
+ const jsonArrayFromFragments = [
68
+ "(select coalesce(json_group_array(json_object(",
69
+ ")), '[]') from ",
70
+ " as agg)",
71
+ ] as const;
72
+
73
+ const jsonObjectFromFragments = [
74
+ "(select json_object(",
75
+ ") from ",
76
+ " as obj)",
77
+ ] as const;
78
+
32
79
  const typePreservingAggregateFunctions = new Set(["max", "min"]);
33
80
 
34
- export class DeserializePlugin implements KyselyPlugin {
35
- private queryNodes = new WeakMap<QueryId, RootOperationNode>();
81
+ export class ResultHydrationPlugin implements KyselyPlugin {
82
+ private columns = new Map<string, Map<string, ColumnCoercion>>();
83
+ private tableColumns = new Map<string, Set<string>>();
84
+ private queryPlans = new WeakMap<QueryId, QueryPlan>();
36
85
 
37
86
  constructor(
38
- private columns: ColumnsMap,
39
- private tableColumns: Map<string, Set<string>>,
40
- private validation: Required<JsonValidation>,
41
- ) {}
87
+ schemas: Map<string, Type>,
88
+ private validation: { onRead: boolean },
89
+ ) {
90
+ this.registerSchemas(schemas);
91
+ }
42
92
 
43
- transformQuery: KyselyPlugin["transformQuery"] = (args) => {
44
- this.queryNodes.set(args.queryId, args.node);
93
+ private registerSchemas(schemas: Map<string, Type>) {
94
+ for (const [table, schema] of schemas) {
95
+ const structureProps = (schema as any).structure?.props as StructureProp[] | undefined;
96
+
97
+ if (!structureProps) {
98
+ continue;
99
+ }
100
+
101
+ this.tableColumns.set(
102
+ table,
103
+ new Set(structureProps.map((prop) => prop.key)),
104
+ );
105
+
106
+ const columns = new Map<string, ColumnCoercion>();
107
+
108
+ for (const prop of structureProps) {
109
+ const coercion = this.getColumnCoercion(prop, schema);
110
+
111
+ if (!coercion) {
112
+ continue;
113
+ }
114
+
115
+ columns.set(prop.key, coercion);
116
+ }
117
+
118
+ if (columns.size > 0) {
119
+ this.columns.set(table, columns);
120
+ }
121
+ }
122
+ }
123
+
124
+ private getColumnCoercion(prop: StructureProp, parentSchema: Type) {
125
+ const concrete = prop.value.branches.filter(
126
+ (branch) => branch.unit !== null && branch.domain !== "undefined",
127
+ );
45
128
 
46
- if (this.validation.onWrite) {
47
- this.validateWriteNode(args.node);
129
+ if (prop.value.proto === Date || concrete.some((branch) => branch.proto === Date)) {
130
+ return "date" satisfies ColumnCoercion;
48
131
  }
49
132
 
133
+ if (concrete.length > 0 && concrete.every((branch) => branch.domain === "boolean")) {
134
+ return "boolean" satisfies ColumnCoercion;
135
+ }
136
+
137
+ if (concrete.some((branch) => !!branch.structure)) {
138
+ return {
139
+ type: "json",
140
+ schema: (parentSchema as any).get(prop.key) as Type,
141
+ } satisfies ColumnCoercion;
142
+ }
143
+
144
+ return null;
145
+ }
146
+
147
+ transformQuery: KyselyPlugin["transformQuery"] = (args) => {
148
+ this.queryPlans.set(args.queryId, {
149
+ table: this.getTableFromNode(args.node),
150
+ selectionPlans: this.getSelectionPlans(args.node),
151
+ });
152
+
50
153
  return args.node;
51
154
  };
52
155
 
@@ -95,131 +198,119 @@ export class DeserializePlugin implements KyselyPlugin {
95
198
  const result = schema(value);
96
199
 
97
200
  if (result instanceof type.errors) {
98
- throw new JsonValidationError(table, col, result.summary);
201
+ throw new ValidationError(table, result.summary, col);
99
202
  }
100
203
  }
101
204
 
102
- private validateWriteNode(node: RootOperationNode) {
103
- if (node.kind !== "InsertQueryNode" && node.kind !== "UpdateQueryNode") {
104
- return;
105
- }
106
-
107
- const table = this.getTableFromNode(node);
108
-
109
- if (!table) {
110
- return;
205
+ private parseJson(table: string, column: string, value: string) {
206
+ try {
207
+ return JSON.parse(value);
208
+ } catch (e) {
209
+ throw new JsonParseError(table, column, value, e);
111
210
  }
211
+ }
112
212
 
113
- const cols = this.columns.get(table);
114
-
115
- if (!cols) {
116
- return;
213
+ private hydrateCoercion(plan: CoercionPlan, value: unknown) {
214
+ if (value === null || value === undefined) {
215
+ return value;
117
216
  }
118
217
 
119
- for (const [col, value] of this.writeValues(node)) {
120
- const coercion = cols.get(col);
121
-
122
- if (!coercion || typeof coercion === "string") {
123
- continue;
218
+ if (plan.coercion === "boolean") {
219
+ if (typeof value === "number") {
220
+ return value === 1;
124
221
  }
125
222
 
126
- this.validateJsonValue(table, col, value, coercion.schema);
223
+ return value;
127
224
  }
128
- }
129
-
130
- private *writeValues(node: RootOperationNode) {
131
- if (node.kind === "InsertQueryNode") {
132
- const columns = node.columns?.map((c) => c.column.name);
133
225
 
134
- if (!columns || !node.values || !ValuesNode.is(node.values)) {
135
- return;
226
+ if (plan.coercion === "date") {
227
+ if (typeof value === "number") {
228
+ return new Date(value * 1000);
136
229
  }
137
230
 
138
- for (const valueList of node.values.values) {
139
- for (let i = 0; i < columns.length; i++) {
140
- const col = columns[i]!;
141
-
142
- if (valueList.kind === "PrimitiveValueListNode") {
143
- yield [col, valueList.values[i]] as [string, unknown];
144
- continue;
145
- }
146
-
147
- const raw = valueList.values[i];
231
+ return value;
232
+ }
148
233
 
149
- if (!raw || DefaultInsertValueNode.is(raw)) {
150
- continue;
151
- }
234
+ const parsed =
235
+ typeof value === "string" ? this.parseJson(plan.table, plan.column, value) : value;
152
236
 
153
- yield [col, ValueNode.is(raw) ? raw.value : raw] as [string, unknown];
154
- }
155
- }
237
+ if (this.validation.onRead) {
238
+ this.validateJsonValue(plan.table, plan.column, parsed, plan.coercion.schema);
239
+ }
156
240
 
157
- for (const update of node.onConflict?.updates ?? []) {
158
- if (ColumnNode.is(update.column) && ValueNode.is(update.value)) {
159
- yield [update.column.column.name, update.value.value] as [string, unknown];
160
- }
161
- }
241
+ return parsed;
242
+ }
162
243
 
163
- return;
244
+ private parseStructuredValue(table: string, column: string, value: unknown) {
245
+ if (value === null || value === undefined) {
246
+ return value;
164
247
  }
165
248
 
166
- if (node.kind !== "UpdateQueryNode" || !node.updates) {
167
- return;
249
+ if (typeof value === "string") {
250
+ return this.parseJson(table, column, value);
168
251
  }
169
252
 
170
- for (const update of node.updates) {
171
- if (ColumnNode.is(update.column) && ValueNode.is(update.value)) {
172
- yield [update.column.column.name, update.value.value] as [string, unknown];
173
- }
174
- }
253
+ return value;
175
254
  }
176
255
 
177
- private coerceSingle(table: string, row: UnknownRow, col: string, coercion: ColumnCoercion) {
178
- if (coercion === "boolean") {
179
- if (typeof row[col] === "number") {
180
- row[col] = row[col] === 1;
181
- }
256
+ private isPlainObject(value: unknown): value is Record<string, unknown> {
257
+ return typeof value === "object" && value !== null && !Array.isArray(value);
258
+ }
182
259
 
183
- return;
260
+ private hydrateObject(plan: ObjectPlan, value: unknown) {
261
+ const parsed = this.parseStructuredValue(plan.table, plan.column, value);
262
+
263
+ if (!this.isPlainObject(parsed)) {
264
+ return parsed;
184
265
  }
185
266
 
186
- if (coercion === "date") {
187
- if (typeof row[col] === "number") {
188
- row[col] = new Date(row[col] * 1000);
267
+ for (const [field, fieldPlan] of plan.fields) {
268
+ if (!(field in parsed)) {
269
+ continue;
189
270
  }
190
271
 
191
- return;
272
+ parsed[field] = this.hydrateValue(fieldPlan, parsed[field]);
192
273
  }
193
274
 
194
- if (typeof row[col] !== "string") {
195
- return;
275
+ return parsed;
276
+ }
277
+
278
+ private hydrateArray(plan: ArrayPlan, value: unknown) {
279
+ const parsed = this.parseStructuredValue(plan.table, plan.column, value);
280
+
281
+ if (!Array.isArray(parsed)) {
282
+ return parsed;
196
283
  }
197
284
 
198
- const value = row[col];
285
+ for (let i = 0; i < parsed.length; i++) {
286
+ const item = parsed[i];
199
287
 
200
- let parsed: unknown;
288
+ if (!this.isPlainObject(item)) {
289
+ continue;
290
+ }
201
291
 
202
- try {
203
- parsed = JSON.parse(value);
204
- } catch (e) {
205
- throw new JsonParseError(table, col, value, e);
206
- }
292
+ for (const [field, fieldPlan] of plan.fields) {
293
+ if (!(field in item)) {
294
+ continue;
295
+ }
207
296
 
208
- if (this.validation.onRead) {
209
- this.validateJsonValue(table, col, parsed, coercion.schema);
297
+ item[field] = this.hydrateValue(fieldPlan, item[field]);
298
+ }
210
299
  }
211
300
 
212
- row[col] = parsed;
301
+ return parsed;
213
302
  }
214
303
 
215
- private coerceRow(table: string, row: UnknownRow, cols: Map<string, ColumnCoercion>) {
216
- for (const [col, coercion] of cols) {
217
- if (!(col in row)) {
218
- continue;
219
- }
304
+ private hydrateValue(plan: ValuePlan, value: unknown): unknown {
305
+ if (plan.kind === "coercion") {
306
+ return this.hydrateCoercion(plan, value);
307
+ }
220
308
 
221
- this.coerceSingle(table, row, col, coercion);
309
+ if (plan.kind === "object") {
310
+ return this.hydrateObject(plan, value);
222
311
  }
312
+
313
+ return this.hydrateArray(plan, value);
223
314
  }
224
315
 
225
316
  private getIdentifierName(node: OperationNode | undefined) {
@@ -313,20 +404,115 @@ export class DeserializePlugin implements KyselyPlugin {
313
404
  return match;
314
405
  }
315
406
 
316
- private resolveSelectionCoercion(
407
+ private isRawNode(node: OperationNode): node is RawOperationNode {
408
+ return node.kind === "RawNode";
409
+ }
410
+
411
+ private matchesFragments(
412
+ fragments: readonly string[],
413
+ expected: readonly [string, string, string],
414
+ ) {
415
+ return (
416
+ fragments.length === expected.length &&
417
+ fragments.every((fragment, index) => fragment === expected[index])
418
+ );
419
+ }
420
+
421
+ private getJsonHelper(node: RawOperationNode): JsonHelper | null {
422
+ const query = node.parameters[1];
423
+
424
+ if (!query || !SelectQueryNode.is(query)) {
425
+ return null;
426
+ }
427
+
428
+ if (this.matchesFragments(node.sqlFragments, jsonObjectFromFragments)) {
429
+ return { kind: "object", query };
430
+ }
431
+
432
+ if (this.matchesFragments(node.sqlFragments, jsonArrayFromFragments)) {
433
+ return { kind: "array", query };
434
+ }
435
+
436
+ return null;
437
+ }
438
+
439
+ private getStructuredFieldPlans(node: SelectQueryNode) {
440
+ const result = new Map<string, ValuePlan>();
441
+
442
+ if (!node.selections) {
443
+ return result;
444
+ }
445
+
446
+ const scope = this.getTableScope(node);
447
+
448
+ for (const selectionNode of node.selections) {
449
+ const output = this.getSelectionOutputName(selectionNode.selection);
450
+
451
+ if (!output) {
452
+ continue;
453
+ }
454
+
455
+ const plan = this.resolveSelectionPlan(selectionNode.selection, scope, output);
456
+
457
+ if (plan) {
458
+ result.set(output, plan);
459
+ }
460
+ }
461
+
462
+ return result;
463
+ }
464
+
465
+ private resolveJsonHelperPlan(node: RawOperationNode, output: string | null): ValuePlan | null {
466
+ if (!output) {
467
+ return null;
468
+ }
469
+
470
+ const helper = this.getJsonHelper(node);
471
+
472
+ if (!helper) {
473
+ return null;
474
+ }
475
+
476
+ const table = this.getTableFromNode(helper.query) ?? output;
477
+ const fields = this.getStructuredFieldPlans(helper.query);
478
+
479
+ if (helper.kind === "object") {
480
+ return { kind: "object", table, column: output, fields };
481
+ }
482
+
483
+ return { kind: "array", table, column: output, fields };
484
+ }
485
+
486
+ private resolveSelectionPlan(
317
487
  node: OperationNode,
318
488
  scope: Map<string, string>,
319
- ): ResolvedCoercion | null {
489
+ output: string | null,
490
+ ): ValuePlan | null {
320
491
  if (AliasNode.is(node)) {
321
- return this.resolveSelectionCoercion(node.node, scope);
492
+ return this.resolveSelectionPlan(node.node, scope, output ?? this.getIdentifierName(node.alias));
322
493
  }
323
494
 
324
495
  if (ReferenceNode.is(node) || ColumnNode.is(node)) {
325
- return this.resolveReferenceCoercion(node, scope);
496
+ if (!output) {
497
+ return null;
498
+ }
499
+
500
+ const resolved = this.resolveReferenceCoercion(node, scope);
501
+
502
+ if (!resolved) {
503
+ return null;
504
+ }
505
+
506
+ return {
507
+ kind: "coercion",
508
+ table: resolved.table,
509
+ column: output,
510
+ coercion: resolved.coercion,
511
+ };
326
512
  }
327
513
 
328
514
  if (SelectQueryNode.is(node)) {
329
- return this.resolveScalarSubqueryCoercion(node);
515
+ return this.resolveScalarSubqueryPlan(node, output);
330
516
  }
331
517
 
332
518
  if (AggregateFunctionNode.is(node)) {
@@ -337,26 +523,30 @@ export class DeserializePlugin implements KyselyPlugin {
337
523
  return null;
338
524
  }
339
525
 
340
- return this.resolveSelectionCoercion(node.aggregated[0]!, scope);
526
+ return this.resolveSelectionPlan(node.aggregated[0]!, scope, output);
341
527
  }
342
528
 
343
529
  if (ParensNode.is(node)) {
344
- return this.resolveSelectionCoercion(node.node, scope);
530
+ return this.resolveSelectionPlan(node.node, scope, output);
345
531
  }
346
532
 
347
533
  if (CastNode.is(node)) {
348
534
  return null;
349
535
  }
350
536
 
537
+ if (this.isRawNode(node)) {
538
+ return this.resolveJsonHelperPlan(node, output);
539
+ }
540
+
351
541
  return null;
352
542
  }
353
543
 
354
- private resolveScalarSubqueryCoercion(node: SelectQueryNode) {
544
+ private resolveScalarSubqueryPlan(node: SelectQueryNode, output: string | null) {
355
545
  if (!node.selections || node.selections.length !== 1) {
356
546
  return null;
357
547
  }
358
548
 
359
- return this.resolveSelectionCoercion(node.selections[0]!.selection, this.getTableScope(node));
549
+ return this.resolveSelectionPlan(node.selections[0]!.selection, this.getTableScope(node), output);
360
550
  }
361
551
 
362
552
  private getSelectionOutputName(node: OperationNode) {
@@ -375,8 +565,8 @@ export class DeserializePlugin implements KyselyPlugin {
375
565
  return null;
376
566
  }
377
567
 
378
- private getSelectCoercions(node: RootOperationNode) {
379
- const result = new Map<string, ResolvedCoercion>();
568
+ private getSelectionPlans(node: RootOperationNode) {
569
+ const result = new Map<string, ValuePlan>();
380
570
 
381
571
  if (node.kind !== "SelectQueryNode" || !node.selections) {
382
572
  return result;
@@ -391,65 +581,86 @@ export class DeserializePlugin implements KyselyPlugin {
391
581
  continue;
392
582
  }
393
583
 
394
- const resolved = this.resolveSelectionCoercion(selectionNode.selection, scope);
584
+ const plan = this.resolveSelectionPlan(selectionNode.selection, scope, output);
395
585
 
396
- if (resolved) {
397
- result.set(output, resolved);
586
+ if (plan) {
587
+ result.set(output, plan);
398
588
  }
399
589
  }
400
590
 
401
591
  return result;
402
592
  }
403
593
 
404
- transformResult: KyselyPlugin["transformResult"] = async (args) => {
405
- const node = this.queryNodes.get(args.queryId);
594
+ private coerceMainRow(table: string, row: UnknownRow, cols: Map<string, ColumnCoercion>) {
595
+ for (const [column, coercion] of cols) {
596
+ if (!(column in row)) {
597
+ continue;
598
+ }
406
599
 
407
- if (!node) {
408
- return args.result;
600
+ row[column] = this.hydrateCoercion({
601
+ kind: "coercion",
602
+ table,
603
+ column,
604
+ coercion,
605
+ }, row[column]);
409
606
  }
607
+ }
410
608
 
411
- const table = this.getTableFromNode(node);
609
+ transformResult: KyselyPlugin["transformResult"] = async (args) => {
610
+ const plan = this.queryPlans.get(args.queryId);
412
611
 
413
- if (!table) {
612
+ if (!plan) {
414
613
  return args.result;
415
614
  }
416
615
 
417
- const mainCols = this.columns.get(table);
418
- const mainTableColumns = this.tableColumns.get(table);
419
- const selectCoercions = this.getSelectCoercions(node);
616
+ const mainCols = plan.table ? this.columns.get(plan.table) : null;
617
+ const mainTableColumns = plan.table ? this.tableColumns.get(plan.table) : null;
420
618
 
421
619
  for (const row of args.result.rows) {
422
- if (mainCols) {
423
- this.coerceRow(table, row, mainCols);
620
+ if (plan.table && mainCols) {
621
+ this.coerceMainRow(plan.table, row, mainCols);
424
622
  }
425
623
 
426
- for (const col of Object.keys(row)) {
427
- const resolved = selectCoercions.get(col);
428
-
429
- if (resolved) {
430
- this.coerceSingle(resolved.table, row, col, resolved.coercion);
624
+ for (const [column, selectionPlan] of plan.selectionPlans) {
625
+ if (!(column in row)) {
431
626
  continue;
432
627
  }
433
628
 
434
- if (mainTableColumns?.has(col)) {
629
+ row[column] = this.hydrateValue(selectionPlan, row[column]);
630
+ }
631
+
632
+ if (!plan.table) {
633
+ continue;
634
+ }
635
+
636
+ for (const column of Object.keys(row)) {
637
+ if (plan.selectionPlans.has(column) || mainTableColumns?.has(column)) {
435
638
  continue;
436
639
  }
437
640
 
438
641
  for (const [otherTable, otherCols] of this.columns) {
439
- if (otherTable === table) {
642
+ if (otherTable === plan.table) {
440
643
  continue;
441
644
  }
442
645
 
443
- const coercion = otherCols.get(col);
646
+ const coercion = otherCols.get(column);
444
647
 
445
- if (coercion) {
446
- this.coerceSingle(otherTable, row, col, coercion);
447
- break;
648
+ if (!coercion) {
649
+ continue;
448
650
  }
651
+
652
+ row[column] = this.hydrateCoercion({
653
+ kind: "coercion",
654
+ table: otherTable,
655
+ column,
656
+ coercion,
657
+ }, row[column]);
658
+
659
+ break;
449
660
  }
450
661
  }
451
662
  }
452
663
 
453
- return { ...args.result };
664
+ return args.result;
454
665
  };
455
666
  }
package/src/types.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  import type { Generated } from "kysely";
2
2
  import type { Type } from "arktype";
3
3
 
4
+ export type ArkBranch = {
5
+ domain?: string;
6
+ proto?: unknown;
7
+ unit?: unknown;
8
+ structure?: unknown;
9
+ };
10
+
11
+ export type StructureProp = {
12
+ key: string;
13
+ required: boolean;
14
+ inner: { default?: unknown };
15
+ value: Type & {
16
+ branches: ArkBranch[];
17
+ proto?: unknown;
18
+ };
19
+ };
20
+
4
21
  type ExtractInput<T> = T extends { inferIn: infer I } ? I : never;
5
22
  type ExtractOutput<T> = T extends { infer: infer O } ? O : never;
6
23
 
@@ -60,7 +77,6 @@ export type DatabaseSchema<T extends SchemaRecord> = {
60
77
 
61
78
  export type JsonValidation = {
62
79
  onRead?: boolean;
63
- onWrite?: boolean;
64
80
  };
65
81
 
66
82
  export type DatabaseOptions<T extends SchemaRecord> = {
@@ -1,11 +1,15 @@
1
- export class JsonValidationError extends Error {
1
+ export class ValidationError extends Error {
2
2
  constructor(
3
3
  readonly table: string,
4
- readonly column: string,
5
4
  readonly summary: string,
5
+ readonly column: string | null = null,
6
6
  ) {
7
- super(`JSON validation failed for ${table}.${column}: ${summary}`);
7
+ super(
8
+ column
9
+ ? `Validation failed for ${table}.${column}: ${summary}`
10
+ : `Validation failed for ${table}: ${summary}`,
11
+ );
8
12
 
9
- this.name = "JsonValidationError";
13
+ this.name = "ValidationError";
10
14
  }
11
15
  }
@@ -0,0 +1,412 @@
1
+ import { type, type Type } from "arktype";
2
+ import {
3
+ type KyselyPlugin,
4
+ type InsertQueryNode as KyselyInsertQueryNode,
5
+ type UpdateQueryNode as KyselyUpdateQueryNode,
6
+ type OnConflictNode as KyselyOnConflictNode,
7
+ type ColumnUpdateNode as KyselyColumnUpdateNode,
8
+ type ValuesItemNode,
9
+ type OperationNode,
10
+ type RootOperationNode,
11
+ ColumnNode,
12
+ ColumnUpdateNode,
13
+ DefaultInsertValueNode,
14
+ InsertQueryNode,
15
+ OnConflictNode,
16
+ PrimitiveValueListNode,
17
+ ReferenceNode,
18
+ TableNode,
19
+ ValueListNode,
20
+ ValueNode,
21
+ ValuesNode,
22
+ } from "kysely";
23
+
24
+ import type { StructureProp } from "./types.js";
25
+ import { ValidationError } from "./validation-error.js";
26
+
27
+ type TableWriteSchema = {
28
+ schema: Type;
29
+ columns: string[];
30
+ columnSet: Set<string>;
31
+ optionalNonNullColumns: Set<string>;
32
+ insertNullColumns: Set<string>;
33
+ };
34
+
35
+ type InsertRow = {
36
+ values: Record<string, unknown>;
37
+ passthrough: Map<string, OperationNode>;
38
+ };
39
+
40
+ export class WriteValidationPlugin implements KyselyPlugin {
41
+ private schemas = new Map<string, TableWriteSchema>();
42
+
43
+ constructor(schemas: Map<string, Type>) {
44
+ this.registerSchemas(schemas);
45
+ }
46
+
47
+ transformQuery: KyselyPlugin["transformQuery"] = (args) => {
48
+ return this.transformWriteNode(args.node);
49
+ };
50
+
51
+ transformResult: KyselyPlugin["transformResult"] = async (args) => {
52
+ return args.result;
53
+ };
54
+
55
+ private registerSchemas(schemas: Map<string, Type>) {
56
+ for (const [table, schema] of schemas) {
57
+ const structureProps = (schema as any).structure?.props as StructureProp[] | undefined;
58
+ const columns = structureProps?.map((prop) => prop.key) ?? [];
59
+
60
+ this.schemas.set(table, {
61
+ schema,
62
+ columns,
63
+ columnSet: new Set(columns),
64
+ optionalNonNullColumns: new Set(
65
+ structureProps
66
+ ?.filter((prop) => !prop.required && !this.acceptsNull(prop.value))
67
+ .map((prop) => prop.key) ?? [],
68
+ ),
69
+ insertNullColumns: new Set(
70
+ structureProps
71
+ ?.filter((prop) => this.acceptsNull(prop.value) && prop.inner.default === undefined)
72
+ .map((prop) => prop.key) ?? [],
73
+ ),
74
+ });
75
+ }
76
+ }
77
+
78
+ private acceptsNull(field: StructureProp["value"]) {
79
+ return field.branches.some((branch) => branch.unit === null || branch.domain === "null");
80
+ }
81
+
82
+ private getTableFromNode(node: RootOperationNode) {
83
+ switch (node.kind) {
84
+ case "InsertQueryNode":
85
+ return node.into?.table.identifier.name ?? null;
86
+
87
+ case "UpdateQueryNode": {
88
+ if (node.table && TableNode.is(node.table)) {
89
+ return node.table.table.identifier.name;
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ default:
96
+ return null;
97
+ }
98
+ }
99
+
100
+ private firstErrorColumn(result: { [index: number]: { path: ArrayLike<unknown> } | undefined }) {
101
+ const column = result[0]?.path[0];
102
+
103
+ if (typeof column === "string") {
104
+ return column;
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ private morph(table: string, schema: Type, value: Record<string, unknown>) {
111
+ const result = schema(value);
112
+
113
+ if (result instanceof type.errors) {
114
+ throw new ValidationError(table, result.summary, this.firstErrorColumn(result));
115
+ }
116
+
117
+ return result as Record<string, unknown>;
118
+ }
119
+
120
+ private pickSchema(schema: Type, columns: Iterable<string>) {
121
+ return (schema as any).pick(...columns) as Type;
122
+ }
123
+
124
+ private stripNullOptionalFields(tableSchema: TableWriteSchema, value: Record<string, unknown>) {
125
+ const stripped = { ...value };
126
+
127
+ for (const column of tableSchema.optionalNonNullColumns) {
128
+ if (stripped[column] === null) {
129
+ delete stripped[column];
130
+ }
131
+ }
132
+
133
+ return stripped;
134
+ }
135
+
136
+ private prepareInsertValue(tableSchema: TableWriteSchema, value: Record<string, unknown>) {
137
+ const prepared = this.stripNullOptionalFields(tableSchema, value);
138
+
139
+ for (const column of tableSchema.insertNullColumns) {
140
+ if (!Object.prototype.hasOwnProperty.call(prepared, column)) {
141
+ prepared[column] = null;
142
+ }
143
+ }
144
+
145
+ return prepared;
146
+ }
147
+
148
+ private transformWriteNode(node: RootOperationNode) {
149
+ const table = this.getTableFromNode(node);
150
+
151
+ if (!table) {
152
+ return node;
153
+ }
154
+
155
+ if (node.kind === "InsertQueryNode") {
156
+ return this.transformInsert(node, table);
157
+ }
158
+
159
+ if (node.kind === "UpdateQueryNode") {
160
+ return this.transformUpdate(node, table);
161
+ }
162
+
163
+ return node;
164
+ }
165
+
166
+ private getInsertRow(columns: string[], valueList: ValuesItemNode) {
167
+ const row: InsertRow = {
168
+ values: {},
169
+ passthrough: new Map(),
170
+ };
171
+
172
+ if (PrimitiveValueListNode.is(valueList)) {
173
+ for (let i = 0; i < columns.length; i++) {
174
+ row.values[columns[i]!] = valueList.values[i];
175
+ }
176
+
177
+ return row;
178
+ }
179
+
180
+ if (!ValueListNode.is(valueList)) {
181
+ return null;
182
+ }
183
+
184
+ for (let i = 0; i < columns.length; i++) {
185
+ const column = columns[i]!;
186
+ const value = valueList.values[i];
187
+
188
+ if (!value || DefaultInsertValueNode.is(value)) {
189
+ continue;
190
+ }
191
+
192
+ if (ValueNode.is(value)) {
193
+ row.values[column] = value.value;
194
+ continue;
195
+ }
196
+
197
+ row.passthrough.set(column, value);
198
+ }
199
+
200
+ return row;
201
+ }
202
+
203
+ private morphInsertRow(table: string, tableSchema: TableWriteSchema, row: InsertRow) {
204
+ const schemaLiteralValues = Object.fromEntries(
205
+ Object.entries(row.values).filter(([column]) => tableSchema.columnSet.has(column)),
206
+ );
207
+ const nonSchemaLiteralValues = Object.fromEntries(
208
+ Object.entries(row.values).filter(([column]) => !tableSchema.columnSet.has(column)),
209
+ );
210
+ const schemaLiteralColumns = Object.keys(schemaLiteralValues);
211
+
212
+ if (schemaLiteralColumns.length === 0) {
213
+ return row;
214
+ }
215
+
216
+ const morphedSchemaValues = this.morph(
217
+ table,
218
+ this.pickSchema(tableSchema.schema, schemaLiteralColumns),
219
+ this.prepareInsertValue(tableSchema, schemaLiteralValues),
220
+ );
221
+
222
+ return {
223
+ values: { ...nonSchemaLiteralValues, ...morphedSchemaValues },
224
+ passthrough: row.passthrough,
225
+ };
226
+ }
227
+
228
+ private getInsertColumns(
229
+ tableSchema: TableWriteSchema,
230
+ originalColumns: string[],
231
+ rows: InsertRow[],
232
+ ) {
233
+ const columns = [...originalColumns];
234
+
235
+ for (const column of tableSchema.columns) {
236
+ if (columns.includes(column)) {
237
+ continue;
238
+ }
239
+
240
+ if (rows.some((row) => Object.prototype.hasOwnProperty.call(row.values, column))) {
241
+ columns.push(column);
242
+ }
243
+ }
244
+
245
+ for (const row of rows) {
246
+ for (const column of Object.keys(row.values)) {
247
+ if (!columns.includes(column)) {
248
+ columns.push(column);
249
+ }
250
+ }
251
+ }
252
+
253
+ return columns;
254
+ }
255
+
256
+ private createInsertValueList(columns: string[], row: InsertRow) {
257
+ return ValueListNode.create(
258
+ columns.map((column) => {
259
+ const passthrough = row.passthrough.get(column);
260
+
261
+ if (passthrough) {
262
+ return passthrough;
263
+ }
264
+
265
+ if (Object.prototype.hasOwnProperty.call(row.values, column)) {
266
+ return ValueNode.create(row.values[column]);
267
+ }
268
+
269
+ return DefaultInsertValueNode.create();
270
+ }),
271
+ );
272
+ }
273
+
274
+ private transformInsert(node: KyselyInsertQueryNode, table: string) {
275
+ const onConflict = node.onConflict
276
+ ? this.transformOnConflict(table, node.onConflict)
277
+ : undefined;
278
+ const tableSchema = this.schemas.get(table);
279
+
280
+ if (!tableSchema) {
281
+ if (onConflict) {
282
+ return InsertQueryNode.cloneWith(node, { onConflict });
283
+ }
284
+
285
+ return node;
286
+ }
287
+
288
+ const columns = node.columns?.map((column) => column.column.name);
289
+
290
+ if (!columns || !node.values || !ValuesNode.is(node.values)) {
291
+ if (onConflict) {
292
+ return InsertQueryNode.cloneWith(node, { onConflict });
293
+ }
294
+
295
+ return node;
296
+ }
297
+
298
+ const rows: InsertRow[] = [];
299
+
300
+ for (const valueList of node.values.values) {
301
+ const row = this.getInsertRow(columns, valueList);
302
+
303
+ if (!row) {
304
+ if (onConflict) {
305
+ return InsertQueryNode.cloneWith(node, { onConflict });
306
+ }
307
+
308
+ return node;
309
+ }
310
+
311
+ rows.push(this.morphInsertRow(table, tableSchema, row));
312
+ }
313
+
314
+ const insertColumns = this.getInsertColumns(tableSchema, columns, rows);
315
+
316
+ return InsertQueryNode.cloneWith(node, {
317
+ columns: insertColumns.map((column) => ColumnNode.create(column)),
318
+ values: ValuesNode.create(rows.map((row) => this.createInsertValueList(insertColumns, row))),
319
+ onConflict,
320
+ });
321
+ }
322
+
323
+ private transformUpdates(table: string, updates: readonly KyselyColumnUpdateNode[]) {
324
+ const tableSchema = this.schemas.get(table);
325
+
326
+ if (!tableSchema) {
327
+ return updates;
328
+ }
329
+
330
+ const literalColumns = new Set<string>();
331
+ const literalValues: Record<string, unknown> = {};
332
+ const nullOptionalColumns = new Set<string>();
333
+
334
+ for (const update of updates) {
335
+ if (!ColumnNode.is(update.column) || !ValueNode.is(update.value)) {
336
+ continue;
337
+ }
338
+
339
+ const column = update.column.column.name;
340
+
341
+ if (!tableSchema.columnSet.has(column)) {
342
+ continue;
343
+ }
344
+
345
+ if (update.value.value === null && tableSchema.optionalNonNullColumns.has(column)) {
346
+ nullOptionalColumns.add(column);
347
+ continue;
348
+ }
349
+
350
+ literalColumns.add(column);
351
+ literalValues[column] = update.value.value;
352
+ }
353
+
354
+ if (literalColumns.size === 0 && nullOptionalColumns.size === 0) {
355
+ return updates;
356
+ }
357
+
358
+ const morphed =
359
+ literalColumns.size === 0
360
+ ? {}
361
+ : this.morph(
362
+ table,
363
+ this.pickSchema(tableSchema.schema, literalColumns),
364
+ this.stripNullOptionalFields(tableSchema, literalValues),
365
+ );
366
+
367
+ return updates.flatMap((update) => {
368
+ if (!ColumnNode.is(update.column) || !ValueNode.is(update.value)) {
369
+ return [update];
370
+ }
371
+
372
+ const column = update.column.column.name;
373
+
374
+ if (!literalColumns.has(column) || !tableSchema.columnSet.has(column)) {
375
+ if (nullOptionalColumns.has(column)) {
376
+ return [ColumnUpdateNode.create(update.column, ValueNode.create(null))];
377
+ }
378
+
379
+ return [update];
380
+ }
381
+
382
+ if (!Object.prototype.hasOwnProperty.call(morphed, column)) {
383
+ return [
384
+ ColumnUpdateNode.create(update.column, ReferenceNode.create(ColumnNode.create(column))),
385
+ ];
386
+ }
387
+
388
+ return [ColumnUpdateNode.create(update.column, ValueNode.create(morphed[column]))];
389
+ });
390
+ }
391
+
392
+ private transformOnConflict(table: string, node: KyselyOnConflictNode) {
393
+ if (!node.updates) {
394
+ return node;
395
+ }
396
+
397
+ return OnConflictNode.cloneWith(node, {
398
+ updates: this.transformUpdates(table, node.updates),
399
+ });
400
+ }
401
+
402
+ private transformUpdate(node: KyselyUpdateQueryNode, table: string) {
403
+ if (!node.updates) {
404
+ return node;
405
+ }
406
+
407
+ return Object.freeze({
408
+ ...node,
409
+ updates: this.transformUpdates(table, node.updates),
410
+ });
411
+ }
412
+ }