@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 +3 -3
- package/package.json +1 -1
- package/src/database.ts +20 -31
- package/src/generated.ts +1 -1
- package/src/index.ts +1 -1
- package/src/plugin.ts +353 -142
- package/src/types.ts +17 -1
- package/src/validation-error.ts +8 -4
- package/src/write-validation-plugin.ts +412 -0
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
|
|
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
|
|
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
|
|
73
|
+
validation: { onRead: true }, // default: { onRead: false }
|
|
74
74
|
});
|
|
75
75
|
```
|
|
76
76
|
|
package/package.json
CHANGED
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
|
|
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 {
|
|
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
|
|
92
|
-
new
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
305
|
+
if (autoIncrementColumns.length === 0) {
|
|
306
|
+
return schema;
|
|
314
307
|
}
|
|
315
308
|
|
|
316
|
-
|
|
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
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 {
|
|
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
|
-
|
|
13
|
-
ValueNode,
|
|
11
|
+
CastNode,
|
|
14
12
|
ColumnNode,
|
|
15
|
-
DefaultInsertValueNode,
|
|
16
13
|
IdentifierNode,
|
|
17
|
-
ReferenceNode,
|
|
18
14
|
ParensNode,
|
|
19
|
-
|
|
15
|
+
ReferenceNode,
|
|
20
16
|
SelectQueryNode,
|
|
17
|
+
TableNode,
|
|
21
18
|
} from "kysely";
|
|
22
19
|
|
|
23
20
|
import { JsonParseError } from "./errors.js";
|
|
24
|
-
import type {
|
|
25
|
-
import {
|
|
21
|
+
import type { StructureProp } from "./types.js";
|
|
22
|
+
import { ValidationError } from "./validation-error.js";
|
|
26
23
|
|
|
27
|
-
|
|
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
|
|
35
|
-
private
|
|
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
|
-
|
|
39
|
-
private
|
|
40
|
-
|
|
41
|
-
|
|
87
|
+
schemas: Map<string, Type>,
|
|
88
|
+
private validation: { onRead: boolean },
|
|
89
|
+
) {
|
|
90
|
+
this.registerSchemas(schemas);
|
|
91
|
+
}
|
|
42
92
|
|
|
43
|
-
|
|
44
|
-
|
|
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 (
|
|
47
|
-
|
|
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
|
|
201
|
+
throw new ValidationError(table, result.summary, col);
|
|
99
202
|
}
|
|
100
203
|
}
|
|
101
204
|
|
|
102
|
-
private
|
|
103
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return;
|
|
213
|
+
private hydrateCoercion(plan: CoercionPlan, value: unknown) {
|
|
214
|
+
if (value === null || value === undefined) {
|
|
215
|
+
return value;
|
|
117
216
|
}
|
|
118
217
|
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
226
|
+
if (plan.coercion === "date") {
|
|
227
|
+
if (typeof value === "number") {
|
|
228
|
+
return new Date(value * 1000);
|
|
136
229
|
}
|
|
137
230
|
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
234
|
+
const parsed =
|
|
235
|
+
typeof value === "string" ? this.parseJson(plan.table, plan.column, value) : value;
|
|
152
236
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
237
|
+
if (this.validation.onRead) {
|
|
238
|
+
this.validateJsonValue(plan.table, plan.column, parsed, plan.coercion.schema);
|
|
239
|
+
}
|
|
156
240
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
yield [update.column.column.name, update.value.value] as [string, unknown];
|
|
160
|
-
}
|
|
161
|
-
}
|
|
241
|
+
return parsed;
|
|
242
|
+
}
|
|
162
243
|
|
|
163
|
-
|
|
244
|
+
private parseStructuredValue(table: string, column: string, value: unknown) {
|
|
245
|
+
if (value === null || value === undefined) {
|
|
246
|
+
return value;
|
|
164
247
|
}
|
|
165
248
|
|
|
166
|
-
if (
|
|
167
|
-
return;
|
|
249
|
+
if (typeof value === "string") {
|
|
250
|
+
return this.parseJson(table, column, value);
|
|
168
251
|
}
|
|
169
252
|
|
|
170
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
267
|
+
for (const [field, fieldPlan] of plan.fields) {
|
|
268
|
+
if (!(field in parsed)) {
|
|
269
|
+
continue;
|
|
189
270
|
}
|
|
190
271
|
|
|
191
|
-
|
|
272
|
+
parsed[field] = this.hydrateValue(fieldPlan, parsed[field]);
|
|
192
273
|
}
|
|
193
274
|
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
285
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
286
|
+
const item = parsed[i];
|
|
199
287
|
|
|
200
|
-
|
|
288
|
+
if (!this.isPlainObject(item)) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
201
291
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
292
|
+
for (const [field, fieldPlan] of plan.fields) {
|
|
293
|
+
if (!(field in item)) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
207
296
|
|
|
208
|
-
|
|
209
|
-
|
|
297
|
+
item[field] = this.hydrateValue(fieldPlan, item[field]);
|
|
298
|
+
}
|
|
210
299
|
}
|
|
211
300
|
|
|
212
|
-
|
|
301
|
+
return parsed;
|
|
213
302
|
}
|
|
214
303
|
|
|
215
|
-
private
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
304
|
+
private hydrateValue(plan: ValuePlan, value: unknown): unknown {
|
|
305
|
+
if (plan.kind === "coercion") {
|
|
306
|
+
return this.hydrateCoercion(plan, value);
|
|
307
|
+
}
|
|
220
308
|
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
489
|
+
output: string | null,
|
|
490
|
+
): ValuePlan | null {
|
|
320
491
|
if (AliasNode.is(node)) {
|
|
321
|
-
return this.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
526
|
+
return this.resolveSelectionPlan(node.aggregated[0]!, scope, output);
|
|
341
527
|
}
|
|
342
528
|
|
|
343
529
|
if (ParensNode.is(node)) {
|
|
344
|
-
return this.
|
|
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
|
|
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.
|
|
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
|
|
379
|
-
const result = new Map<string,
|
|
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
|
|
584
|
+
const plan = this.resolveSelectionPlan(selectionNode.selection, scope, output);
|
|
395
585
|
|
|
396
|
-
if (
|
|
397
|
-
result.set(output,
|
|
586
|
+
if (plan) {
|
|
587
|
+
result.set(output, plan);
|
|
398
588
|
}
|
|
399
589
|
}
|
|
400
590
|
|
|
401
591
|
return result;
|
|
402
592
|
}
|
|
403
593
|
|
|
404
|
-
|
|
405
|
-
const
|
|
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
|
-
|
|
408
|
-
|
|
600
|
+
row[column] = this.hydrateCoercion({
|
|
601
|
+
kind: "coercion",
|
|
602
|
+
table,
|
|
603
|
+
column,
|
|
604
|
+
coercion,
|
|
605
|
+
}, row[column]);
|
|
409
606
|
}
|
|
607
|
+
}
|
|
410
608
|
|
|
411
|
-
|
|
609
|
+
transformResult: KyselyPlugin["transformResult"] = async (args) => {
|
|
610
|
+
const plan = this.queryPlans.get(args.queryId);
|
|
412
611
|
|
|
413
|
-
if (!
|
|
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.
|
|
620
|
+
if (plan.table && mainCols) {
|
|
621
|
+
this.coerceMainRow(plan.table, row, mainCols);
|
|
424
622
|
}
|
|
425
623
|
|
|
426
|
-
for (const
|
|
427
|
-
|
|
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
|
-
|
|
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(
|
|
646
|
+
const coercion = otherCols.get(column);
|
|
444
647
|
|
|
445
|
-
if (coercion) {
|
|
446
|
-
|
|
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
|
|
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> = {
|
package/src/validation-error.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
export class
|
|
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(
|
|
7
|
+
super(
|
|
8
|
+
column
|
|
9
|
+
? `Validation failed for ${table}.${column}: ${summary}`
|
|
10
|
+
: `Validation failed for ${table}: ${summary}`,
|
|
11
|
+
);
|
|
8
12
|
|
|
9
|
-
this.name = "
|
|
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
|
+
}
|