@lobomfz/db 0.3.8 → 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/package.json +1 -1
- package/src/database.ts +5 -4
- package/src/plugin.ts +290 -136
- package/src/write-validation-plugin.ts +137 -0
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, type ColumnCoercion, type ColumnsMap } 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;
|
|
@@ -88,8 +89,8 @@ export class Database<T extends SchemaRecord> {
|
|
|
88
89
|
this.kysely = new Kysely<TablesFromSchemas<T>>({
|
|
89
90
|
dialect: new BunSqliteDialect({ database: this.sqlite }),
|
|
90
91
|
plugins: [
|
|
91
|
-
new
|
|
92
|
-
new
|
|
92
|
+
new WriteValidationPlugin(this.columns, validation),
|
|
93
|
+
new ResultHydrationPlugin(this.columns, this.tableColumns, validation),
|
|
93
94
|
],
|
|
94
95
|
});
|
|
95
96
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -3,25 +3,21 @@ 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 { JsonValidation } from "./types.js";
|
|
25
21
|
import { JsonValidationError } from "./validation-error.js";
|
|
26
22
|
|
|
27
23
|
export type ColumnCoercion = "boolean" | "date" | { type: "json"; schema: Type };
|
|
@@ -29,23 +25,73 @@ export type ColumnsMap = Map<string, Map<string, ColumnCoercion>>;
|
|
|
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 queryPlans = new WeakMap<QueryId, QueryPlan>();
|
|
36
83
|
|
|
37
84
|
constructor(
|
|
38
85
|
private columns: ColumnsMap,
|
|
39
86
|
private tableColumns: Map<string, Set<string>>,
|
|
40
|
-
private validation:
|
|
87
|
+
private validation: { onRead: boolean },
|
|
41
88
|
) {}
|
|
42
89
|
|
|
43
90
|
transformQuery: KyselyPlugin["transformQuery"] = (args) => {
|
|
44
|
-
this.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
91
|
+
this.queryPlans.set(args.queryId, {
|
|
92
|
+
table: this.getTableFromNode(args.node),
|
|
93
|
+
selectionPlans: this.getSelectionPlans(args.node),
|
|
94
|
+
});
|
|
49
95
|
|
|
50
96
|
return args.node;
|
|
51
97
|
};
|
|
@@ -99,127 +145,115 @@ export class DeserializePlugin implements KyselyPlugin {
|
|
|
99
145
|
}
|
|
100
146
|
}
|
|
101
147
|
|
|
102
|
-
private
|
|
103
|
-
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const table = this.getTableFromNode(node);
|
|
108
|
-
|
|
109
|
-
if (!table) {
|
|
110
|
-
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);
|
|
111
153
|
}
|
|
154
|
+
}
|
|
112
155
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return;
|
|
156
|
+
private hydrateCoercion(plan: CoercionPlan, value: unknown) {
|
|
157
|
+
if (value === null || value === undefined) {
|
|
158
|
+
return value;
|
|
117
159
|
}
|
|
118
160
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (!coercion || typeof coercion === "string") {
|
|
123
|
-
continue;
|
|
161
|
+
if (plan.coercion === "boolean") {
|
|
162
|
+
if (typeof value === "number") {
|
|
163
|
+
return value === 1;
|
|
124
164
|
}
|
|
125
165
|
|
|
126
|
-
|
|
166
|
+
return value;
|
|
127
167
|
}
|
|
128
|
-
}
|
|
129
168
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (!columns || !node.values || !ValuesNode.is(node.values)) {
|
|
135
|
-
return;
|
|
169
|
+
if (plan.coercion === "date") {
|
|
170
|
+
if (typeof value === "number") {
|
|
171
|
+
return new Date(value * 1000);
|
|
136
172
|
}
|
|
137
173
|
|
|
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];
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
148
176
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
177
|
+
const parsed =
|
|
178
|
+
typeof value === "string" ? this.parseJson(plan.table, plan.column, value) : value;
|
|
152
179
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
180
|
+
if (this.validation.onRead) {
|
|
181
|
+
this.validateJsonValue(plan.table, plan.column, parsed, plan.coercion.schema);
|
|
182
|
+
}
|
|
156
183
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
yield [update.column.column.name, update.value.value] as [string, unknown];
|
|
160
|
-
}
|
|
161
|
-
}
|
|
184
|
+
return parsed;
|
|
185
|
+
}
|
|
162
186
|
|
|
163
|
-
|
|
187
|
+
private parseStructuredValue(table: string, column: string, value: unknown) {
|
|
188
|
+
if (value === null || value === undefined) {
|
|
189
|
+
return value;
|
|
164
190
|
}
|
|
165
191
|
|
|
166
|
-
if (
|
|
167
|
-
return;
|
|
192
|
+
if (typeof value === "string") {
|
|
193
|
+
return this.parseJson(table, column, value);
|
|
168
194
|
}
|
|
169
195
|
|
|
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
|
-
}
|
|
196
|
+
return value;
|
|
175
197
|
}
|
|
176
198
|
|
|
177
|
-
private
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
row[col] = row[col] === 1;
|
|
181
|
-
}
|
|
199
|
+
private isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
200
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
201
|
+
}
|
|
182
202
|
|
|
183
|
-
|
|
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;
|
|
184
208
|
}
|
|
185
209
|
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
210
|
+
for (const [field, fieldPlan] of plan.fields) {
|
|
211
|
+
if (!(field in parsed)) {
|
|
212
|
+
continue;
|
|
189
213
|
}
|
|
190
214
|
|
|
191
|
-
|
|
215
|
+
parsed[field] = this.hydrateValue(fieldPlan, parsed[field]);
|
|
192
216
|
}
|
|
193
217
|
|
|
194
|
-
|
|
195
|
-
|
|
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;
|
|
196
226
|
}
|
|
197
227
|
|
|
198
|
-
|
|
228
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
229
|
+
const item = parsed[i];
|
|
199
230
|
|
|
200
|
-
|
|
231
|
+
if (!this.isPlainObject(item)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
201
234
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
235
|
+
for (const [field, fieldPlan] of plan.fields) {
|
|
236
|
+
if (!(field in item)) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
207
239
|
|
|
208
|
-
|
|
209
|
-
|
|
240
|
+
item[field] = this.hydrateValue(fieldPlan, item[field]);
|
|
241
|
+
}
|
|
210
242
|
}
|
|
211
243
|
|
|
212
|
-
|
|
244
|
+
return parsed;
|
|
213
245
|
}
|
|
214
246
|
|
|
215
|
-
private
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
247
|
+
private hydrateValue(plan: ValuePlan, value: unknown): unknown {
|
|
248
|
+
if (plan.kind === "coercion") {
|
|
249
|
+
return this.hydrateCoercion(plan, value);
|
|
250
|
+
}
|
|
220
251
|
|
|
221
|
-
|
|
252
|
+
if (plan.kind === "object") {
|
|
253
|
+
return this.hydrateObject(plan, value);
|
|
222
254
|
}
|
|
255
|
+
|
|
256
|
+
return this.hydrateArray(plan, value);
|
|
223
257
|
}
|
|
224
258
|
|
|
225
259
|
private getIdentifierName(node: OperationNode | undefined) {
|
|
@@ -313,20 +347,115 @@ export class DeserializePlugin implements KyselyPlugin {
|
|
|
313
347
|
return match;
|
|
314
348
|
}
|
|
315
349
|
|
|
316
|
-
private
|
|
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(
|
|
317
430
|
node: OperationNode,
|
|
318
431
|
scope: Map<string, string>,
|
|
319
|
-
|
|
432
|
+
output: string | null,
|
|
433
|
+
): ValuePlan | null {
|
|
320
434
|
if (AliasNode.is(node)) {
|
|
321
|
-
return this.
|
|
435
|
+
return this.resolveSelectionPlan(node.node, scope, output ?? this.getIdentifierName(node.alias));
|
|
322
436
|
}
|
|
323
437
|
|
|
324
438
|
if (ReferenceNode.is(node) || ColumnNode.is(node)) {
|
|
325
|
-
|
|
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
|
+
};
|
|
326
455
|
}
|
|
327
456
|
|
|
328
457
|
if (SelectQueryNode.is(node)) {
|
|
329
|
-
return this.
|
|
458
|
+
return this.resolveScalarSubqueryPlan(node, output);
|
|
330
459
|
}
|
|
331
460
|
|
|
332
461
|
if (AggregateFunctionNode.is(node)) {
|
|
@@ -337,26 +466,30 @@ export class DeserializePlugin implements KyselyPlugin {
|
|
|
337
466
|
return null;
|
|
338
467
|
}
|
|
339
468
|
|
|
340
|
-
return this.
|
|
469
|
+
return this.resolveSelectionPlan(node.aggregated[0]!, scope, output);
|
|
341
470
|
}
|
|
342
471
|
|
|
343
472
|
if (ParensNode.is(node)) {
|
|
344
|
-
return this.
|
|
473
|
+
return this.resolveSelectionPlan(node.node, scope, output);
|
|
345
474
|
}
|
|
346
475
|
|
|
347
476
|
if (CastNode.is(node)) {
|
|
348
477
|
return null;
|
|
349
478
|
}
|
|
350
479
|
|
|
480
|
+
if (this.isRawNode(node)) {
|
|
481
|
+
return this.resolveJsonHelperPlan(node, output);
|
|
482
|
+
}
|
|
483
|
+
|
|
351
484
|
return null;
|
|
352
485
|
}
|
|
353
486
|
|
|
354
|
-
private
|
|
487
|
+
private resolveScalarSubqueryPlan(node: SelectQueryNode, output: string | null) {
|
|
355
488
|
if (!node.selections || node.selections.length !== 1) {
|
|
356
489
|
return null;
|
|
357
490
|
}
|
|
358
491
|
|
|
359
|
-
return this.
|
|
492
|
+
return this.resolveSelectionPlan(node.selections[0]!.selection, this.getTableScope(node), output);
|
|
360
493
|
}
|
|
361
494
|
|
|
362
495
|
private getSelectionOutputName(node: OperationNode) {
|
|
@@ -375,8 +508,8 @@ export class DeserializePlugin implements KyselyPlugin {
|
|
|
375
508
|
return null;
|
|
376
509
|
}
|
|
377
510
|
|
|
378
|
-
private
|
|
379
|
-
const result = new Map<string,
|
|
511
|
+
private getSelectionPlans(node: RootOperationNode) {
|
|
512
|
+
const result = new Map<string, ValuePlan>();
|
|
380
513
|
|
|
381
514
|
if (node.kind !== "SelectQueryNode" || !node.selections) {
|
|
382
515
|
return result;
|
|
@@ -391,65 +524,86 @@ export class DeserializePlugin implements KyselyPlugin {
|
|
|
391
524
|
continue;
|
|
392
525
|
}
|
|
393
526
|
|
|
394
|
-
const
|
|
527
|
+
const plan = this.resolveSelectionPlan(selectionNode.selection, scope, output);
|
|
395
528
|
|
|
396
|
-
if (
|
|
397
|
-
result.set(output,
|
|
529
|
+
if (plan) {
|
|
530
|
+
result.set(output, plan);
|
|
398
531
|
}
|
|
399
532
|
}
|
|
400
533
|
|
|
401
534
|
return result;
|
|
402
535
|
}
|
|
403
536
|
|
|
404
|
-
|
|
405
|
-
const
|
|
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
|
+
}
|
|
406
542
|
|
|
407
|
-
|
|
408
|
-
|
|
543
|
+
row[column] = this.hydrateCoercion({
|
|
544
|
+
kind: "coercion",
|
|
545
|
+
table,
|
|
546
|
+
column,
|
|
547
|
+
coercion,
|
|
548
|
+
}, row[column]);
|
|
409
549
|
}
|
|
550
|
+
}
|
|
410
551
|
|
|
411
|
-
|
|
552
|
+
transformResult: KyselyPlugin["transformResult"] = async (args) => {
|
|
553
|
+
const plan = this.queryPlans.get(args.queryId);
|
|
412
554
|
|
|
413
|
-
if (!
|
|
555
|
+
if (!plan) {
|
|
414
556
|
return args.result;
|
|
415
557
|
}
|
|
416
558
|
|
|
417
|
-
const mainCols = this.columns.get(table);
|
|
418
|
-
const mainTableColumns = this.tableColumns.get(table);
|
|
419
|
-
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;
|
|
420
561
|
|
|
421
562
|
for (const row of args.result.rows) {
|
|
422
|
-
if (mainCols) {
|
|
423
|
-
this.
|
|
563
|
+
if (plan.table && mainCols) {
|
|
564
|
+
this.coerceMainRow(plan.table, row, mainCols);
|
|
424
565
|
}
|
|
425
566
|
|
|
426
|
-
for (const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (resolved) {
|
|
430
|
-
this.coerceSingle(resolved.table, row, col, resolved.coercion);
|
|
567
|
+
for (const [column, selectionPlan] of plan.selectionPlans) {
|
|
568
|
+
if (!(column in row)) {
|
|
431
569
|
continue;
|
|
432
570
|
}
|
|
433
571
|
|
|
434
|
-
|
|
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)) {
|
|
435
581
|
continue;
|
|
436
582
|
}
|
|
437
583
|
|
|
438
584
|
for (const [otherTable, otherCols] of this.columns) {
|
|
439
|
-
if (otherTable === table) {
|
|
585
|
+
if (otherTable === plan.table) {
|
|
440
586
|
continue;
|
|
441
587
|
}
|
|
442
588
|
|
|
443
|
-
const coercion = otherCols.get(
|
|
589
|
+
const coercion = otherCols.get(column);
|
|
444
590
|
|
|
445
|
-
if (coercion) {
|
|
446
|
-
|
|
447
|
-
break;
|
|
591
|
+
if (!coercion) {
|
|
592
|
+
continue;
|
|
448
593
|
}
|
|
594
|
+
|
|
595
|
+
row[column] = this.hydrateCoercion({
|
|
596
|
+
kind: "coercion",
|
|
597
|
+
table: otherTable,
|
|
598
|
+
column,
|
|
599
|
+
coercion,
|
|
600
|
+
}, row[column]);
|
|
601
|
+
|
|
602
|
+
break;
|
|
449
603
|
}
|
|
450
604
|
}
|
|
451
605
|
}
|
|
452
606
|
|
|
453
|
-
return
|
|
607
|
+
return args.result;
|
|
454
608
|
};
|
|
455
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
|
+
}
|