@lobomfz/db 0.3.6 → 0.3.9

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
@@ -80,20 +80,20 @@ Schema changes are applied automatically on startup. Every time `new Database(..
80
80
 
81
81
  ### What's supported
82
82
 
83
- | Change | Strategy |
84
- |---|---|
85
- | New table | `CREATE TABLE` |
86
- | Removed table | `DROP TABLE` |
87
- | New nullable column | `ALTER TABLE ADD COLUMN` |
83
+ | Change | Strategy |
84
+ | -------------------------------- | ------------------------ |
85
+ | New table | `CREATE TABLE` |
86
+ | Removed table | `DROP TABLE` |
87
+ | New nullable column | `ALTER TABLE ADD COLUMN` |
88
88
  | New NOT NULL column with DEFAULT | `ALTER TABLE ADD COLUMN` |
89
- | Removed column | Table rebuild |
90
- | Type change | Table rebuild |
91
- | Nullability change | Table rebuild |
92
- | DEFAULT change | Table rebuild |
93
- | UNIQUE added/removed | Table rebuild |
94
- | FK added/removed/changed | Table rebuild |
95
- | Index added | `CREATE INDEX` |
96
- | Index removed | `DROP INDEX` |
89
+ | Removed column | Table rebuild |
90
+ | Type change | Table rebuild |
91
+ | Nullability change | Table rebuild |
92
+ | DEFAULT change | Table rebuild |
93
+ | UNIQUE added/removed | Table rebuild |
94
+ | FK added/removed/changed | Table rebuild |
95
+ | Index added | `CREATE INDEX` |
96
+ | Index removed | `DROP INDEX` |
97
97
 
98
98
  Table rebuilds follow SQLite's [recommended procedure](https://www.sqlite.org/lang_altertable.html#otheralter): create a new table with the target schema, copy data from the old table, drop the old table, rename the new one. Foreign keys are disabled during rebuilds and validated via `PRAGMA foreign_key_check` before committing.
99
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobomfz/db",
3
- "version": "0.3.6",
3
+ "version": "0.3.9",
4
4
  "description": "Bun SQLite database with Arktype schemas and typed Kysely client",
5
5
  "keywords": [
6
6
  "arktype",
@@ -28,7 +28,7 @@
28
28
  ".": "./src/index.ts"
29
29
  },
30
30
  "scripts": {
31
- "check": "tsgo && oxlint"
31
+ "check": "tsgo && oxlint && bun test.ts"
32
32
  },
33
33
  "dependencies": {},
34
34
  "devDependencies": {
@@ -38,6 +38,8 @@
38
38
  "oxlint": "^1.57.0"
39
39
  },
40
40
  "peerDependencies": {
41
+ "@ark/schema": ">=0.56.0",
42
+ "@ark/util": ">=0.56.0",
41
43
  "arktype": "^2.1.29",
42
44
  "kysely": "^0.28.14"
43
45
  }
package/src/database.ts CHANGED
@@ -1,22 +1,23 @@
1
1
  import { Database as BunDatabase } from "bun:sqlite";
2
2
 
3
3
  import type { Type } from "arktype";
4
- import { Kysely, ParseJSONResultsPlugin } from "kysely";
5
-
6
- import { BunSqliteDialect } from "./dialect/dialect";
7
- import type { DbFieldMeta } from "./env";
8
- import type { GeneratedPreset } from "./generated";
9
- import { Differ, type DesiredTable } from "./migration/diff";
10
- import { Executor } from "./migration/execute";
11
- import { Introspector } from "./migration/introspect";
12
- import { DeserializePlugin, type ColumnCoercion, type ColumnsMap } from "./plugin";
4
+ import { Kysely } from "kysely";
5
+
6
+ import { BunSqliteDialect } from "./dialect/dialect.js";
7
+ import type { DbFieldMeta } from "./env.js";
8
+ import type { GeneratedPreset } from "./generated.js";
9
+ import { Differ, type DesiredTable } from "./migration/diff.js";
10
+ import { Executor } from "./migration/execute.js";
11
+ import { Introspector } from "./migration/introspect.js";
12
+ import { ResultHydrationPlugin, type ColumnCoercion, type ColumnsMap } from "./plugin.js";
13
13
  import type {
14
14
  DatabaseOptions,
15
15
  IndexDefinition,
16
16
  SchemaRecord,
17
17
  TablesFromSchemas,
18
18
  DatabasePragmas,
19
- } from "./types";
19
+ } from "./types.js";
20
+ import { WriteValidationPlugin } from "./write-validation-plugin.js";
20
21
 
21
22
  type ArkBranch = {
22
23
  domain?: string;
@@ -24,6 +25,7 @@ type ArkBranch = {
24
25
  unit?: unknown;
25
26
  structure?: unknown;
26
27
  inner?: { divisor?: unknown };
28
+ meta?: DbFieldMeta & { _generated?: GeneratedPreset };
27
29
  };
28
30
 
29
31
  type StructureProp = {
@@ -87,8 +89,8 @@ export class Database<T extends SchemaRecord> {
87
89
  this.kysely = new Kysely<TablesFromSchemas<T>>({
88
90
  dialect: new BunSqliteDialect({ database: this.sqlite }),
89
91
  plugins: [
90
- new DeserializePlugin(this.columns, this.tableColumns, validation),
91
- new ParseJSONResultsPlugin(),
92
+ new WriteValidationPlugin(this.columns, validation),
93
+ new ResultHydrationPlugin(this.columns, this.tableColumns, validation),
92
94
  ],
93
95
  });
94
96
  }
@@ -114,46 +116,49 @@ export class Database<T extends SchemaRecord> {
114
116
  private normalizeProp(structureProp: StructureProp, parentSchema: Type) {
115
117
  const { key, value: v, inner } = structureProp;
116
118
  const kind: Prop["kind"] = structureProp.required ? "required" : "optional";
117
- const generated = v.meta._generated;
118
119
  const defaultValue = inner.default;
119
120
 
120
- const nonNull = v.branches.filter((b) => b.unit !== null);
121
- const nullable = nonNull.length < v.branches.length;
121
+ const concrete = v.branches.filter((b) => b.unit !== null && b.domain !== "undefined");
122
+ const nullable = concrete.length < v.branches.length;
122
123
 
123
- if (v.proto === Date || nonNull.some((b) => b.proto === Date)) {
124
+ const branchMeta = v.branches.find((b) => b.meta && Object.keys(b.meta).length > 0)?.meta;
125
+ const meta = { ...branchMeta, ...v.meta };
126
+ const generated = meta._generated;
127
+
128
+ if (v.proto === Date || concrete.some((b) => b.proto === Date)) {
124
129
  return { key, kind, nullable, isDate: true, generated, defaultValue };
125
130
  }
126
131
 
127
- if (v.proto === Uint8Array || nonNull.some((b) => b.proto === Uint8Array)) {
132
+ if (v.proto === Uint8Array || concrete.some((b) => b.proto === Uint8Array)) {
128
133
  return {
129
134
  key,
130
135
  kind,
131
136
  nullable,
132
137
  isBlob: true,
133
- meta: v.meta,
138
+ meta,
134
139
  generated,
135
140
  defaultValue,
136
141
  };
137
142
  }
138
143
 
139
- if (nonNull.length > 0 && nonNull.every((b) => b.domain === "boolean")) {
144
+ if (concrete.length > 0 && concrete.every((b) => b.domain === "boolean")) {
140
145
  return { key, kind, nullable, isBoolean: true, generated, defaultValue };
141
146
  }
142
147
 
143
- if (nonNull.some((b) => !!b.structure)) {
148
+ if (concrete.some((b) => !!b.structure)) {
144
149
  return {
145
150
  key,
146
151
  kind,
147
152
  nullable,
148
153
  isJson: true,
149
154
  jsonSchema: (parentSchema as any).get(key) as Type,
150
- meta: v.meta,
155
+ meta,
151
156
  generated,
152
157
  defaultValue,
153
158
  };
154
159
  }
155
160
 
156
- const branch = nonNull[0];
161
+ const branch = concrete[0];
157
162
 
158
163
  return {
159
164
  key,
@@ -161,7 +166,7 @@ export class Database<T extends SchemaRecord> {
161
166
  nullable,
162
167
  domain: branch?.domain,
163
168
  isInteger: !!branch?.inner?.divisor,
164
- meta: v.meta,
169
+ meta,
165
170
  generated,
166
171
  defaultValue,
167
172
  };
@@ -224,7 +229,9 @@ export class Database<T extends SchemaRecord> {
224
229
  return `DEFAULT ${prop.defaultValue}`;
225
230
  }
226
231
 
227
- throw new Error(`Unsupported default value type: ${typeof prop.defaultValue}`);
232
+ throw new Error(
233
+ `Unsupported default value type: ${typeof prop.defaultValue} ${JSON.stringify(prop)}`,
234
+ );
228
235
  }
229
236
 
230
237
  private columnDef(prop: Prop) {
@@ -1,6 +1,8 @@
1
1
  import type { Database } from "bun:sqlite";
2
+
2
3
  import type { CompiledQuery, DatabaseConnection, QueryResult } from "kysely";
3
- import { serializeParam } from "./serialize";
4
+
5
+ import { serializeParam } from "./serialize.js";
4
6
 
5
7
  export class BunSqliteConnection implements DatabaseConnection {
6
8
  readonly #db: Database;
@@ -9,8 +9,9 @@ import {
9
9
  type Kysely,
10
10
  type QueryCompiler,
11
11
  } from "kysely";
12
- import type { BunSqliteDialectConfig } from "./config";
13
- import { BunSqliteDriver } from "./driver";
12
+
13
+ import type { BunSqliteDialectConfig } from "./config.js";
14
+ import { BunSqliteDriver } from "./driver.js";
14
15
 
15
16
  export class BunSqliteDialect implements Dialect {
16
17
  readonly #config: BunSqliteDialectConfig;
@@ -1,8 +1,10 @@
1
1
  import type { Database } from "bun:sqlite";
2
+
2
3
  import { CompiledQuery, type DatabaseConnection, type Driver } from "kysely";
3
- import type { BunSqliteDialectConfig } from "./config";
4
- import { BunSqliteConnection } from "./connection";
5
- import { ConnectionMutex } from "./mutex";
4
+
5
+ import type { BunSqliteDialectConfig } from "./config.js";
6
+ import { BunSqliteConnection } from "./connection.js";
7
+ import { ConnectionMutex } from "./mutex.js";
6
8
 
7
9
  export class BunSqliteDriver implements Driver {
8
10
  readonly #config: BunSqliteDialectConfig;
package/src/env.ts CHANGED
@@ -3,6 +3,7 @@ export type DbFieldMeta = {
3
3
  unique?: boolean;
4
4
  references?: `${string}.${string}`;
5
5
  onDelete?: "cascade" | "set null" | "restrict";
6
+ _generated?: "autoincrement" | "now";
6
7
  };
7
8
 
8
9
  declare global {
package/src/generated.ts CHANGED
@@ -3,13 +3,10 @@ import { type } from "arktype";
3
3
  export type GeneratedPreset = "autoincrement" | "now";
4
4
 
5
5
  const generatedTypes = {
6
- autoincrement: () =>
7
- type("number.integer")
8
- .configure({ _generated: "autoincrement" } as any)
9
- .default(0),
6
+ autoincrement: () => type("number.integer").configure({ _generated: "autoincrement" }).default(0),
10
7
  now: () =>
11
8
  type("Date")
12
- .configure({ _generated: "now" } as any)
9
+ .configure({ _generated: "now" })
13
10
  .default(() => new Date(0)),
14
11
  };
15
12
 
package/src/index.ts CHANGED
@@ -1,10 +1,19 @@
1
- export { Database } from "./database";
2
- export { generated, type GeneratedPreset } from "./generated";
3
- export { JsonParseError } from "./errors";
4
- export { sql, type Selectable, type Insertable, type Updateable, type Kysely } from "kysely";
5
- export { type } from "arktype";
6
- export { JsonValidationError } from "./validation-error";
7
- export type { DbFieldMeta } from "./env";
1
+ export { Database } from "./database.js";
2
+ export { generated, type GeneratedPreset } from "./generated.js";
3
+ export { JsonParseError } from "./errors.js";
4
+ export { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
5
+ export {
6
+ sql,
7
+ type Selectable,
8
+ type Insertable,
9
+ type Updateable,
10
+ type Kysely,
11
+ type ExpressionBuilder,
12
+ } from "kysely";
13
+ export { type, type Type } from "arktype";
14
+ export { configure } from "arktype/config";
15
+ export { JsonValidationError } from "./validation-error.js";
16
+ export type { DbFieldMeta } from "./env.js";
8
17
  export type {
9
18
  DatabaseOptions,
10
19
  SchemaRecord,
@@ -16,4 +25,4 @@ export type {
16
25
  DatabaseSchema,
17
26
  JsonValidation,
18
27
  SqliteMasterRow,
19
- } from "./types";
28
+ } from "./types.js";
@@ -1,4 +1,4 @@
1
- import type { ColumnSchema, IntrospectedTable, ColumnCopy, MigrationOp } from "./types";
1
+ import type { ColumnSchema, IntrospectedTable, ColumnCopy, MigrationOp } from "./types.js";
2
2
 
3
3
  export interface DesiredColumn extends ColumnSchema {
4
4
  addable: boolean;
@@ -42,7 +42,11 @@ export class Differ {
42
42
  const existingTable = this.existing.get(table.name);
43
43
 
44
44
  if (!existingTable) {
45
- this.ops.push({ type: "CreateTable", table: table.name, sql: table.sql });
45
+ this.ops.push({
46
+ type: "CreateTable",
47
+ table: table.name,
48
+ sql: table.sql,
49
+ });
46
50
  this.rebuiltTables.add(table.name);
47
51
  continue;
48
52
  }
@@ -101,13 +105,21 @@ export class Differ {
101
105
  }
102
106
 
103
107
  if (!existing.notnull && col.notnull && col.defaultValue !== null) {
104
- columnCopies.push({ name: col.name, expr: `COALESCE("${col.name}", ${col.defaultValue})` });
108
+ columnCopies.push({
109
+ name: col.name,
110
+ expr: `COALESCE("${col.name}", ${col.defaultValue})`,
111
+ });
105
112
  } else {
106
113
  columnCopies.push({ name: col.name, expr: `"${col.name}"` });
107
114
  }
108
115
  }
109
116
 
110
- this.ops.push({ type: "RebuildTable", table: table.name, createSql: table.sql, columnCopies });
117
+ this.ops.push({
118
+ type: "RebuildTable",
119
+ table: table.name,
120
+ createSql: table.sql,
121
+ columnCopies,
122
+ });
111
123
  this.rebuiltTables.add(table.name);
112
124
  }
113
125
 
@@ -134,7 +146,11 @@ export class Differ {
134
146
  }
135
147
 
136
148
  for (const col of newColumns) {
137
- this.ops.push({ type: "AddColumn", table: table.name, columnDef: col.columnDef });
149
+ this.ops.push({
150
+ type: "AddColumn",
151
+ table: table.name,
152
+ columnDef: col.columnDef,
153
+ });
138
154
  }
139
155
  }
140
156
 
@@ -163,7 +179,12 @@ export class Differ {
163
179
 
164
180
  if (this.rebuiltTables.has(table.name)) {
165
181
  for (const idx of tableIndexes) {
166
- this.ops.push({ type: "CreateIndex", table: table.name, columns: idx.columns, sql: idx.sql });
182
+ this.ops.push({
183
+ type: "CreateIndex",
184
+ table: table.name,
185
+ columns: idx.columns,
186
+ sql: idx.sql,
187
+ });
167
188
  }
168
189
 
169
190
  continue;
@@ -180,7 +201,12 @@ export class Differ {
180
201
 
181
202
  for (const idx of tableIndexes) {
182
203
  if (!existingNames.has(idx.name)) {
183
- this.ops.push({ type: "CreateIndex", table: table.name, columns: idx.columns, sql: idx.sql });
204
+ this.ops.push({
205
+ type: "CreateIndex",
206
+ table: table.name,
207
+ columns: idx.columns,
208
+ sql: idx.sql,
209
+ });
184
210
  }
185
211
  }
186
212
 
@@ -1,5 +1,6 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import type { MigrationOp, RebuildTableOp } from "./types";
2
+
3
+ import type { MigrationOp, RebuildTableOp } from "./types.js";
3
4
 
4
5
  export class Executor {
5
6
  constructor(
@@ -1,5 +1,6 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import type { IntrospectedColumn, IntrospectedIndex, IntrospectedTable } from "./types";
2
+
3
+ import type { IntrospectedColumn, IntrospectedIndex, IntrospectedTable } from "./types.js";
3
4
 
4
5
  type TableListRow = {
5
6
  name: string;
@@ -131,7 +132,8 @@ export class Introspector {
131
132
  onDelete: fk?.onDelete ?? null,
132
133
  hasNulls:
133
134
  !isNotnull &&
134
- this.db.prepare(`SELECT 1 FROM "${table}" WHERE "${col.name}" IS NULL LIMIT 1`).get() !== null,
135
+ this.db.prepare(`SELECT 1 FROM "${table}" WHERE "${col.name}" IS NULL LIMIT 1`).get() !==
136
+ null,
135
137
  });
136
138
  }
137
139
 
package/src/plugin.ts CHANGED
@@ -3,48 +3,95 @@ 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
13
  IdentifierNode,
16
- ReferenceNode,
17
14
  ParensNode,
18
- CastNode,
15
+ ReferenceNode,
19
16
  SelectQueryNode,
17
+ TableNode,
20
18
  } from "kysely";
21
19
 
22
- import { JsonParseError } from "./errors";
23
- import type { JsonValidation } from "./types";
24
- import { JsonValidationError } from "./validation-error";
20
+ import { JsonParseError } from "./errors.js";
21
+ import { JsonValidationError } from "./validation-error.js";
25
22
 
26
23
  export type ColumnCoercion = "boolean" | "date" | { type: "json"; schema: Type };
27
24
  export type ColumnsMap = Map<string, Map<string, ColumnCoercion>>;
28
25
 
29
26
  type ResolvedCoercion = { table: string; coercion: ColumnCoercion };
30
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
+
31
79
  const typePreservingAggregateFunctions = new Set(["max", "min"]);
32
80
 
33
- export class DeserializePlugin implements KyselyPlugin {
34
- private queryNodes = new WeakMap<QueryId, RootOperationNode>();
81
+ export class ResultHydrationPlugin implements KyselyPlugin {
82
+ private queryPlans = new WeakMap<QueryId, QueryPlan>();
35
83
 
36
84
  constructor(
37
85
  private columns: ColumnsMap,
38
86
  private tableColumns: Map<string, Set<string>>,
39
- private validation: Required<JsonValidation>,
87
+ private validation: { onRead: boolean },
40
88
  ) {}
41
89
 
42
90
  transformQuery: KyselyPlugin["transformQuery"] = (args) => {
43
- this.queryNodes.set(args.queryId, args.node);
44
-
45
- if (this.validation.onWrite) {
46
- this.validateWriteNode(args.node);
47
- }
91
+ this.queryPlans.set(args.queryId, {
92
+ table: this.getTableFromNode(args.node),
93
+ selectionPlans: this.getSelectionPlans(args.node),
94
+ });
48
95
 
49
96
  return args.node;
50
97
  };
@@ -98,116 +145,115 @@ export class DeserializePlugin implements KyselyPlugin {
98
145
  }
99
146
  }
100
147
 
101
- private validateWriteNode(node: RootOperationNode) {
102
- if (node.kind !== "InsertQueryNode" && node.kind !== "UpdateQueryNode") {
103
- return;
148
+ private parseJson(table: string, column: string, value: string) {
149
+ try {
150
+ return JSON.parse(value);
151
+ } catch (e) {
152
+ throw new JsonParseError(table, column, value, e);
104
153
  }
154
+ }
105
155
 
106
- const table = this.getTableFromNode(node);
107
-
108
- if (!table) {
109
- return;
156
+ private hydrateCoercion(plan: CoercionPlan, value: unknown) {
157
+ if (value === null || value === undefined) {
158
+ return value;
110
159
  }
111
160
 
112
- const cols = this.columns.get(table);
161
+ if (plan.coercion === "boolean") {
162
+ if (typeof value === "number") {
163
+ return value === 1;
164
+ }
113
165
 
114
- if (!cols) {
115
- return;
166
+ return value;
116
167
  }
117
168
 
118
- for (const [col, value] of this.writeValues(node)) {
119
- const coercion = cols.get(col);
120
-
121
- if (!coercion || typeof coercion === "string") {
122
- continue;
169
+ if (plan.coercion === "date") {
170
+ if (typeof value === "number") {
171
+ return new Date(value * 1000);
123
172
  }
124
173
 
125
- this.validateJsonValue(table, col, value, coercion.schema);
174
+ return value;
126
175
  }
127
- }
128
-
129
- private *writeValues(node: RootOperationNode) {
130
- if (node.kind === "InsertQueryNode") {
131
- const columns = node.columns?.map((c) => c.column.name);
132
-
133
- if (!columns || !node.values || !ValuesNode.is(node.values)) {
134
- return;
135
- }
136
176
 
137
- for (const valueList of node.values.values) {
138
- for (let i = 0; i < columns.length; i++) {
139
- const col = columns[i]!;
177
+ const parsed =
178
+ typeof value === "string" ? this.parseJson(plan.table, plan.column, value) : value;
140
179
 
141
- if (valueList.kind === "PrimitiveValueListNode") {
142
- yield [col, valueList.values[i]] as [string, unknown];
143
- continue;
144
- }
180
+ if (this.validation.onRead) {
181
+ this.validateJsonValue(plan.table, plan.column, parsed, plan.coercion.schema);
182
+ }
145
183
 
146
- const raw = valueList.values[i];
147
- yield [col, raw && ValueNode.is(raw) ? raw.value : raw] as [string, unknown];
148
- }
149
- }
184
+ return parsed;
185
+ }
150
186
 
151
- return;
187
+ private parseStructuredValue(table: string, column: string, value: unknown) {
188
+ if (value === null || value === undefined) {
189
+ return value;
152
190
  }
153
191
 
154
- if (node.kind !== "UpdateQueryNode" || !node.updates) {
155
- return;
192
+ if (typeof value === "string") {
193
+ return this.parseJson(table, column, value);
156
194
  }
157
195
 
158
- for (const update of node.updates) {
159
- if (ColumnNode.is(update.column) && ValueNode.is(update.value)) {
160
- yield [update.column.column.name, update.value.value] as [string, unknown];
161
- }
162
- }
196
+ return value;
163
197
  }
164
198
 
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
- }
199
+ private isPlainObject(value: unknown): value is Record<string, unknown> {
200
+ return typeof value === "object" && value !== null && !Array.isArray(value);
201
+ }
170
202
 
171
- return;
203
+ private hydrateObject(plan: ObjectPlan, value: unknown) {
204
+ const parsed = this.parseStructuredValue(plan.table, plan.column, value);
205
+
206
+ if (!this.isPlainObject(parsed)) {
207
+ return parsed;
172
208
  }
173
209
 
174
- if (coercion === "date") {
175
- if (typeof row[col] === "number") {
176
- row[col] = new Date(row[col] * 1000);
210
+ for (const [field, fieldPlan] of plan.fields) {
211
+ if (!(field in parsed)) {
212
+ continue;
177
213
  }
178
214
 
179
- return;
215
+ parsed[field] = this.hydrateValue(fieldPlan, parsed[field]);
180
216
  }
181
217
 
182
- if (typeof row[col] !== "string") {
183
- return;
218
+ return parsed;
219
+ }
220
+
221
+ private hydrateArray(plan: ArrayPlan, value: unknown) {
222
+ const parsed = this.parseStructuredValue(plan.table, plan.column, value);
223
+
224
+ if (!Array.isArray(parsed)) {
225
+ return parsed;
184
226
  }
185
227
 
186
- const value = row[col];
228
+ for (let i = 0; i < parsed.length; i++) {
229
+ const item = parsed[i];
187
230
 
188
- let parsed: unknown;
231
+ if (!this.isPlainObject(item)) {
232
+ continue;
233
+ }
189
234
 
190
- try {
191
- parsed = JSON.parse(value);
192
- } catch (e) {
193
- throw new JsonParseError(table, col, value, e);
194
- }
235
+ for (const [field, fieldPlan] of plan.fields) {
236
+ if (!(field in item)) {
237
+ continue;
238
+ }
195
239
 
196
- if (this.validation.onRead) {
197
- this.validateJsonValue(table, col, parsed, coercion.schema);
240
+ item[field] = this.hydrateValue(fieldPlan, item[field]);
241
+ }
198
242
  }
199
243
 
200
- row[col] = parsed;
244
+ return parsed;
201
245
  }
202
246
 
203
- private coerceRow(table: string, row: UnknownRow, cols: Map<string, ColumnCoercion>) {
204
- for (const [col, coercion] of cols) {
205
- if (!(col in row)) {
206
- continue;
207
- }
247
+ private hydrateValue(plan: ValuePlan, value: unknown): unknown {
248
+ if (plan.kind === "coercion") {
249
+ return this.hydrateCoercion(plan, value);
250
+ }
208
251
 
209
- this.coerceSingle(table, row, col, coercion);
252
+ if (plan.kind === "object") {
253
+ return this.hydrateObject(plan, value);
210
254
  }
255
+
256
+ return this.hydrateArray(plan, value);
211
257
  }
212
258
 
213
259
  private getIdentifierName(node: OperationNode | undefined) {
@@ -301,17 +347,115 @@ export class DeserializePlugin implements KyselyPlugin {
301
347
  return match;
302
348
  }
303
349
 
304
- private resolveSelectionCoercion(node: OperationNode, scope: Map<string, string>): ResolvedCoercion | null {
350
+ private isRawNode(node: OperationNode): node is RawOperationNode {
351
+ return node.kind === "RawNode";
352
+ }
353
+
354
+ private matchesFragments(
355
+ fragments: readonly string[],
356
+ expected: readonly [string, string, string],
357
+ ) {
358
+ return (
359
+ fragments.length === expected.length &&
360
+ fragments.every((fragment, index) => fragment === expected[index])
361
+ );
362
+ }
363
+
364
+ private getJsonHelper(node: RawOperationNode): JsonHelper | null {
365
+ const query = node.parameters[1];
366
+
367
+ if (!query || !SelectQueryNode.is(query)) {
368
+ return null;
369
+ }
370
+
371
+ if (this.matchesFragments(node.sqlFragments, jsonObjectFromFragments)) {
372
+ return { kind: "object", query };
373
+ }
374
+
375
+ if (this.matchesFragments(node.sqlFragments, jsonArrayFromFragments)) {
376
+ return { kind: "array", query };
377
+ }
378
+
379
+ return null;
380
+ }
381
+
382
+ private getStructuredFieldPlans(node: SelectQueryNode) {
383
+ const result = new Map<string, ValuePlan>();
384
+
385
+ if (!node.selections) {
386
+ return result;
387
+ }
388
+
389
+ const scope = this.getTableScope(node);
390
+
391
+ for (const selectionNode of node.selections) {
392
+ const output = this.getSelectionOutputName(selectionNode.selection);
393
+
394
+ if (!output) {
395
+ continue;
396
+ }
397
+
398
+ const plan = this.resolveSelectionPlan(selectionNode.selection, scope, output);
399
+
400
+ if (plan) {
401
+ result.set(output, plan);
402
+ }
403
+ }
404
+
405
+ return result;
406
+ }
407
+
408
+ private resolveJsonHelperPlan(node: RawOperationNode, output: string | null): ValuePlan | null {
409
+ if (!output) {
410
+ return null;
411
+ }
412
+
413
+ const helper = this.getJsonHelper(node);
414
+
415
+ if (!helper) {
416
+ return null;
417
+ }
418
+
419
+ const table = this.getTableFromNode(helper.query) ?? output;
420
+ const fields = this.getStructuredFieldPlans(helper.query);
421
+
422
+ if (helper.kind === "object") {
423
+ return { kind: "object", table, column: output, fields };
424
+ }
425
+
426
+ return { kind: "array", table, column: output, fields };
427
+ }
428
+
429
+ private resolveSelectionPlan(
430
+ node: OperationNode,
431
+ scope: Map<string, string>,
432
+ output: string | null,
433
+ ): ValuePlan | null {
305
434
  if (AliasNode.is(node)) {
306
- return this.resolveSelectionCoercion(node.node, scope);
435
+ return this.resolveSelectionPlan(node.node, scope, output ?? this.getIdentifierName(node.alias));
307
436
  }
308
437
 
309
438
  if (ReferenceNode.is(node) || ColumnNode.is(node)) {
310
- return this.resolveReferenceCoercion(node, scope);
439
+ if (!output) {
440
+ return null;
441
+ }
442
+
443
+ const resolved = this.resolveReferenceCoercion(node, scope);
444
+
445
+ if (!resolved) {
446
+ return null;
447
+ }
448
+
449
+ return {
450
+ kind: "coercion",
451
+ table: resolved.table,
452
+ column: output,
453
+ coercion: resolved.coercion,
454
+ };
311
455
  }
312
456
 
313
457
  if (SelectQueryNode.is(node)) {
314
- return this.resolveScalarSubqueryCoercion(node);
458
+ return this.resolveScalarSubqueryPlan(node, output);
315
459
  }
316
460
 
317
461
  if (AggregateFunctionNode.is(node)) {
@@ -322,29 +466,30 @@ export class DeserializePlugin implements KyselyPlugin {
322
466
  return null;
323
467
  }
324
468
 
325
- return this.resolveSelectionCoercion(node.aggregated[0]!, scope);
469
+ return this.resolveSelectionPlan(node.aggregated[0]!, scope, output);
326
470
  }
327
471
 
328
472
  if (ParensNode.is(node)) {
329
- return this.resolveSelectionCoercion(node.node, scope);
473
+ return this.resolveSelectionPlan(node.node, scope, output);
330
474
  }
331
475
 
332
476
  if (CastNode.is(node)) {
333
477
  return null;
334
478
  }
335
479
 
480
+ if (this.isRawNode(node)) {
481
+ return this.resolveJsonHelperPlan(node, output);
482
+ }
483
+
336
484
  return null;
337
485
  }
338
486
 
339
- private resolveScalarSubqueryCoercion(node: SelectQueryNode) {
487
+ private resolveScalarSubqueryPlan(node: SelectQueryNode, output: string | null) {
340
488
  if (!node.selections || node.selections.length !== 1) {
341
489
  return null;
342
490
  }
343
491
 
344
- return this.resolveSelectionCoercion(
345
- node.selections[0]!.selection,
346
- this.getTableScope(node),
347
- );
492
+ return this.resolveSelectionPlan(node.selections[0]!.selection, this.getTableScope(node), output);
348
493
  }
349
494
 
350
495
  private getSelectionOutputName(node: OperationNode) {
@@ -363,8 +508,8 @@ export class DeserializePlugin implements KyselyPlugin {
363
508
  return null;
364
509
  }
365
510
 
366
- private getSelectCoercions(node: RootOperationNode) {
367
- const result = new Map<string, ResolvedCoercion>();
511
+ private getSelectionPlans(node: RootOperationNode) {
512
+ const result = new Map<string, ValuePlan>();
368
513
 
369
514
  if (node.kind !== "SelectQueryNode" || !node.selections) {
370
515
  return result;
@@ -379,65 +524,86 @@ export class DeserializePlugin implements KyselyPlugin {
379
524
  continue;
380
525
  }
381
526
 
382
- const resolved = this.resolveSelectionCoercion(selectionNode.selection, scope);
527
+ const plan = this.resolveSelectionPlan(selectionNode.selection, scope, output);
383
528
 
384
- if (resolved) {
385
- result.set(output, resolved);
529
+ if (plan) {
530
+ result.set(output, plan);
386
531
  }
387
532
  }
388
533
 
389
534
  return result;
390
535
  }
391
536
 
392
- transformResult: KyselyPlugin["transformResult"] = async (args) => {
393
- const node = this.queryNodes.get(args.queryId);
537
+ private coerceMainRow(table: string, row: UnknownRow, cols: Map<string, ColumnCoercion>) {
538
+ for (const [column, coercion] of cols) {
539
+ if (!(column in row)) {
540
+ continue;
541
+ }
394
542
 
395
- if (!node) {
396
- return args.result;
543
+ row[column] = this.hydrateCoercion({
544
+ kind: "coercion",
545
+ table,
546
+ column,
547
+ coercion,
548
+ }, row[column]);
397
549
  }
550
+ }
398
551
 
399
- const table = this.getTableFromNode(node);
552
+ transformResult: KyselyPlugin["transformResult"] = async (args) => {
553
+ const plan = this.queryPlans.get(args.queryId);
400
554
 
401
- if (!table) {
555
+ if (!plan) {
402
556
  return args.result;
403
557
  }
404
558
 
405
- const mainCols = this.columns.get(table);
406
- const mainTableColumns = this.tableColumns.get(table);
407
- const selectCoercions = this.getSelectCoercions(node);
559
+ const mainCols = plan.table ? this.columns.get(plan.table) : null;
560
+ const mainTableColumns = plan.table ? this.tableColumns.get(plan.table) : null;
408
561
 
409
562
  for (const row of args.result.rows) {
410
- if (mainCols) {
411
- this.coerceRow(table, row, mainCols);
563
+ if (plan.table && mainCols) {
564
+ this.coerceMainRow(plan.table, row, mainCols);
412
565
  }
413
566
 
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);
567
+ for (const [column, selectionPlan] of plan.selectionPlans) {
568
+ if (!(column in row)) {
419
569
  continue;
420
570
  }
421
571
 
422
- if (mainTableColumns?.has(col)) {
572
+ row[column] = this.hydrateValue(selectionPlan, row[column]);
573
+ }
574
+
575
+ if (!plan.table) {
576
+ continue;
577
+ }
578
+
579
+ for (const column of Object.keys(row)) {
580
+ if (plan.selectionPlans.has(column) || mainTableColumns?.has(column)) {
423
581
  continue;
424
582
  }
425
583
 
426
584
  for (const [otherTable, otherCols] of this.columns) {
427
- if (otherTable === table) {
585
+ if (otherTable === plan.table) {
428
586
  continue;
429
587
  }
430
588
 
431
- const coercion = otherCols.get(col);
589
+ const coercion = otherCols.get(column);
432
590
 
433
- if (coercion) {
434
- this.coerceSingle(otherTable, row, col, coercion);
435
- break;
591
+ if (!coercion) {
592
+ continue;
436
593
  }
594
+
595
+ row[column] = this.hydrateCoercion({
596
+ kind: "coercion",
597
+ table: otherTable,
598
+ column,
599
+ coercion,
600
+ }, row[column]);
601
+
602
+ break;
437
603
  }
438
604
  }
439
605
  }
440
606
 
441
- return { ...args.result };
607
+ return args.result;
442
608
  };
443
609
  }
@@ -0,0 +1,137 @@
1
+ import { type, type Type } from "arktype";
2
+ import {
3
+ type KyselyPlugin,
4
+ type RootOperationNode,
5
+ ColumnNode,
6
+ DefaultInsertValueNode,
7
+ TableNode,
8
+ ValueNode,
9
+ ValuesNode,
10
+ } from "kysely";
11
+
12
+ import type { ColumnsMap } from "./plugin.js";
13
+ import { JsonValidationError } from "./validation-error.js";
14
+
15
+ export class WriteValidationPlugin implements KyselyPlugin {
16
+ constructor(
17
+ private columns: ColumnsMap,
18
+ private validation: { onWrite: boolean },
19
+ ) {}
20
+
21
+ transformQuery: KyselyPlugin["transformQuery"] = (args) => {
22
+ if (this.validation.onWrite) {
23
+ this.validateWriteNode(args.node);
24
+ }
25
+
26
+ return args.node;
27
+ };
28
+
29
+ transformResult: KyselyPlugin["transformResult"] = async (args) => {
30
+ return args.result;
31
+ };
32
+
33
+ private getTableFromNode(node: RootOperationNode) {
34
+ switch (node.kind) {
35
+ case "InsertQueryNode":
36
+ return node.into?.table.identifier.name ?? null;
37
+
38
+ case "UpdateQueryNode": {
39
+ if (node.table && TableNode.is(node.table)) {
40
+ return node.table.table.identifier.name;
41
+ }
42
+
43
+ return null;
44
+ }
45
+
46
+ default:
47
+ return null;
48
+ }
49
+ }
50
+
51
+ private validateJsonValue(table: string, col: string, value: unknown, schema: Type) {
52
+ if (value === null || value === undefined) {
53
+ return;
54
+ }
55
+
56
+ const result = schema(value);
57
+
58
+ if (result instanceof type.errors) {
59
+ throw new JsonValidationError(table, col, result.summary);
60
+ }
61
+ }
62
+
63
+ private validateWriteNode(node: RootOperationNode) {
64
+ if (node.kind !== "InsertQueryNode" && node.kind !== "UpdateQueryNode") {
65
+ return;
66
+ }
67
+
68
+ const table = this.getTableFromNode(node);
69
+
70
+ if (!table) {
71
+ return;
72
+ }
73
+
74
+ const cols = this.columns.get(table);
75
+
76
+ if (!cols) {
77
+ return;
78
+ }
79
+
80
+ for (const [col, value] of this.writeValues(node)) {
81
+ const coercion = cols.get(col);
82
+
83
+ if (!coercion || typeof coercion === "string") {
84
+ continue;
85
+ }
86
+
87
+ this.validateJsonValue(table, col, value, coercion.schema);
88
+ }
89
+ }
90
+
91
+ private *writeValues(node: RootOperationNode) {
92
+ if (node.kind === "InsertQueryNode") {
93
+ const columns = node.columns?.map((column) => column.column.name);
94
+
95
+ if (!columns || !node.values || !ValuesNode.is(node.values)) {
96
+ return;
97
+ }
98
+
99
+ for (const valueList of node.values.values) {
100
+ for (let i = 0; i < columns.length; i++) {
101
+ const col = columns[i]!;
102
+
103
+ if (valueList.kind === "PrimitiveValueListNode") {
104
+ yield [col, valueList.values[i]] as [string, unknown];
105
+ continue;
106
+ }
107
+
108
+ const raw = valueList.values[i];
109
+
110
+ if (!raw || DefaultInsertValueNode.is(raw)) {
111
+ continue;
112
+ }
113
+
114
+ yield [col, ValueNode.is(raw) ? raw.value : raw] as [string, unknown];
115
+ }
116
+ }
117
+
118
+ for (const update of node.onConflict?.updates ?? []) {
119
+ if (ColumnNode.is(update.column) && ValueNode.is(update.value)) {
120
+ yield [update.column.column.name, update.value.value] as [string, unknown];
121
+ }
122
+ }
123
+
124
+ return;
125
+ }
126
+
127
+ if (node.kind !== "UpdateQueryNode" || !node.updates) {
128
+ return;
129
+ }
130
+
131
+ for (const update of node.updates) {
132
+ if (ColumnNode.is(update.column) && ValueNode.is(update.value)) {
133
+ yield [update.column.column.name, update.value.value] as [string, unknown];
134
+ }
135
+ }
136
+ }
137
+ }