@lobomfz/db 0.3.9 → 0.4.1

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.
@@ -1,35 +1,172 @@
1
1
  import { type, type Type } from "arktype";
2
2
  import {
3
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,
4
10
  type RootOperationNode,
5
11
  ColumnNode,
12
+ ColumnUpdateNode,
6
13
  DefaultInsertValueNode,
14
+ InsertQueryNode,
15
+ OnConflictNode,
16
+ PrimitiveValueListNode,
17
+ ReferenceNode,
7
18
  TableNode,
19
+ ValueListNode,
8
20
  ValueNode,
9
21
  ValuesNode,
10
22
  } from "kysely";
11
23
 
12
- import type { ColumnsMap } from "./plugin.js";
13
- import { JsonValidationError } from "./validation-error.js";
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
+ vectorColumns: Map<string, number>;
34
+ };
35
+
36
+ type InsertRow = {
37
+ values: Record<string, unknown>;
38
+ passthrough: Map<string, OperationNode>;
39
+ };
14
40
 
15
41
  export class WriteValidationPlugin implements KyselyPlugin {
16
- constructor(
17
- private columns: ColumnsMap,
18
- private validation: { onWrite: boolean },
19
- ) {}
42
+ private schemas = new Map<string, TableWriteSchema>();
20
43
 
21
- transformQuery: KyselyPlugin["transformQuery"] = (args) => {
22
- if (this.validation.onWrite) {
23
- this.validateWriteNode(args.node);
24
- }
44
+ constructor(schemas: Map<string, Type>) {
45
+ this.registerSchemas(schemas);
46
+ }
25
47
 
26
- return args.node;
48
+ transformQuery: KyselyPlugin["transformQuery"] = (args) => {
49
+ return this.transformWriteNode(args.node);
27
50
  };
28
51
 
29
52
  transformResult: KyselyPlugin["transformResult"] = async (args) => {
30
53
  return args.result;
31
54
  };
32
55
 
56
+ private registerSchemas(schemas: Map<string, Type>) {
57
+ for (const [table, schema] of schemas) {
58
+ const structureProps = (schema as any).structure?.props as StructureProp[] | undefined;
59
+ const columns = structureProps?.map((prop) => prop.key) ?? [];
60
+
61
+ const vectorColumns = new Map<string, number>();
62
+
63
+ for (const prop of structureProps ?? []) {
64
+ const meta = this.extractFieldMeta(prop);
65
+
66
+ if (meta?._generated === "vector" && typeof meta._vectorDim === "number") {
67
+ vectorColumns.set(prop.key, meta._vectorDim);
68
+ }
69
+ }
70
+
71
+ this.schemas.set(table, {
72
+ schema,
73
+ columns,
74
+ columnSet: new Set(columns),
75
+ optionalNonNullColumns: new Set(
76
+ structureProps
77
+ ?.filter((prop) => !prop.required && !this.acceptsNull(prop.value))
78
+ .map((prop) => prop.key) ?? [],
79
+ ),
80
+ insertNullColumns: new Set(
81
+ structureProps
82
+ ?.filter((prop) => this.acceptsNull(prop.value) && prop.inner.default === undefined)
83
+ .map((prop) => prop.key) ?? [],
84
+ ),
85
+ vectorColumns,
86
+ });
87
+ }
88
+ }
89
+
90
+ private validateAndCoerceVector(
91
+ table: string,
92
+ column: string,
93
+ dim: number,
94
+ value: unknown,
95
+ ): Uint8Array {
96
+ const vec =
97
+ value instanceof Float32Array
98
+ ? value
99
+ : Array.isArray(value)
100
+ ? Float32Array.from(value as number[])
101
+ : null;
102
+
103
+ if (!vec) {
104
+ throw new ValidationError(
105
+ table,
106
+ `expected Float32Array or number[] for vector column, got ${typeof value}`,
107
+ column,
108
+ );
109
+ }
110
+
111
+ if (vec.length !== dim) {
112
+ throw new ValidationError(
113
+ table,
114
+ `vector dim mismatch: expected ${dim}, received ${vec.length}`,
115
+ column,
116
+ );
117
+ }
118
+
119
+ for (let i = 0; i < vec.length; i++) {
120
+ if (!Number.isFinite(vec[i]!)) {
121
+ throw new ValidationError(
122
+ table,
123
+ `non-finite vector element at index ${i}: ${vec[i]}`,
124
+ column,
125
+ );
126
+ }
127
+ }
128
+
129
+ return new Uint8Array(vec.buffer, vec.byteOffset, vec.byteLength);
130
+ }
131
+
132
+ private coerceVectorsInRow(
133
+ table: string,
134
+ tableSchema: TableWriteSchema,
135
+ values: Record<string, unknown>,
136
+ ) {
137
+ for (const [column, dim] of tableSchema.vectorColumns) {
138
+ if (!Object.prototype.hasOwnProperty.call(values, column)) {
139
+ continue;
140
+ }
141
+
142
+ const value = values[column];
143
+
144
+ if (value === null || value === undefined) {
145
+ continue;
146
+ }
147
+
148
+ values[column] = this.validateAndCoerceVector(table, column, dim, value);
149
+ }
150
+ }
151
+
152
+ private acceptsNull(field: StructureProp["value"]) {
153
+ return field.branches.some((branch) => branch.unit === null || branch.domain === "null");
154
+ }
155
+
156
+ private extractFieldMeta(prop: StructureProp) {
157
+ const rootMeta = (prop.value as any).meta as
158
+ | { _generated?: string; _vectorDim?: number }
159
+ | undefined;
160
+ const branchMeta = prop.value.branches.find(
161
+ (b) => (b as any).meta && Object.keys((b as any).meta).length > 0,
162
+ );
163
+ const fromBranch = (branchMeta as any)?.meta as
164
+ | { _generated?: string; _vectorDim?: number }
165
+ | undefined;
166
+
167
+ return { ...fromBranch, ...rootMeta };
168
+ }
169
+
33
170
  private getTableFromNode(node: RootOperationNode) {
34
171
  switch (node.kind) {
35
172
  case "InsertQueryNode":
@@ -48,90 +185,320 @@ export class WriteValidationPlugin implements KyselyPlugin {
48
185
  }
49
186
  }
50
187
 
51
- private validateJsonValue(table: string, col: string, value: unknown, schema: Type) {
52
- if (value === null || value === undefined) {
53
- return;
188
+ private firstErrorColumn(result: { [index: number]: { path: ArrayLike<unknown> } | undefined }) {
189
+ const column = result[0]?.path[0];
190
+
191
+ if (typeof column === "string") {
192
+ return column;
54
193
  }
55
194
 
195
+ return null;
196
+ }
197
+
198
+ private morph(table: string, schema: Type, value: Record<string, unknown>) {
56
199
  const result = schema(value);
57
200
 
58
201
  if (result instanceof type.errors) {
59
- throw new JsonValidationError(table, col, result.summary);
202
+ throw new ValidationError(table, result.summary, this.firstErrorColumn(result));
60
203
  }
204
+
205
+ return result as Record<string, unknown>;
206
+ }
207
+
208
+ private pickSchema(schema: Type, columns: Iterable<string>) {
209
+ return (schema as any).pick(...columns) as Type;
210
+ }
211
+
212
+ private stripNullOptionalFields(tableSchema: TableWriteSchema, value: Record<string, unknown>) {
213
+ const stripped = { ...value };
214
+
215
+ for (const column of tableSchema.optionalNonNullColumns) {
216
+ if (stripped[column] === null) {
217
+ delete stripped[column];
218
+ }
219
+ }
220
+
221
+ return stripped;
61
222
  }
62
223
 
63
- private validateWriteNode(node: RootOperationNode) {
64
- if (node.kind !== "InsertQueryNode" && node.kind !== "UpdateQueryNode") {
65
- return;
224
+ private prepareInsertValue(tableSchema: TableWriteSchema, value: Record<string, unknown>) {
225
+ const prepared = this.stripNullOptionalFields(tableSchema, value);
226
+
227
+ for (const column of tableSchema.insertNullColumns) {
228
+ if (!Object.prototype.hasOwnProperty.call(prepared, column)) {
229
+ prepared[column] = null;
230
+ }
66
231
  }
67
232
 
233
+ return prepared;
234
+ }
235
+
236
+ private transformWriteNode(node: RootOperationNode) {
68
237
  const table = this.getTableFromNode(node);
69
238
 
70
239
  if (!table) {
71
- return;
240
+ return node;
72
241
  }
73
242
 
74
- const cols = this.columns.get(table);
243
+ if (node.kind === "InsertQueryNode") {
244
+ return this.transformInsert(node, table);
245
+ }
75
246
 
76
- if (!cols) {
77
- return;
247
+ if (node.kind === "UpdateQueryNode") {
248
+ return this.transformUpdate(node, table);
78
249
  }
79
250
 
80
- for (const [col, value] of this.writeValues(node)) {
81
- const coercion = cols.get(col);
251
+ return node;
252
+ }
253
+
254
+ private getInsertRow(columns: string[], valueList: ValuesItemNode) {
255
+ const row: InsertRow = {
256
+ values: {},
257
+ passthrough: new Map(),
258
+ };
259
+
260
+ if (PrimitiveValueListNode.is(valueList)) {
261
+ for (let i = 0; i < columns.length; i++) {
262
+ row.values[columns[i]!] = valueList.values[i];
263
+ }
264
+
265
+ return row;
266
+ }
267
+
268
+ if (!ValueListNode.is(valueList)) {
269
+ return null;
270
+ }
271
+
272
+ for (let i = 0; i < columns.length; i++) {
273
+ const column = columns[i]!;
274
+ const value = valueList.values[i];
275
+
276
+ if (!value || DefaultInsertValueNode.is(value)) {
277
+ continue;
278
+ }
82
279
 
83
- if (!coercion || typeof coercion === "string") {
280
+ if (ValueNode.is(value)) {
281
+ row.values[column] = value.value;
84
282
  continue;
85
283
  }
86
284
 
87
- this.validateJsonValue(table, col, value, coercion.schema);
285
+ row.passthrough.set(column, value);
88
286
  }
287
+
288
+ return row;
89
289
  }
90
290
 
91
- private *writeValues(node: RootOperationNode) {
92
- if (node.kind === "InsertQueryNode") {
93
- const columns = node.columns?.map((column) => column.column.name);
291
+ private morphInsertRow(table: string, tableSchema: TableWriteSchema, row: InsertRow) {
292
+ const schemaLiteralValues = Object.fromEntries(
293
+ Object.entries(row.values).filter(([column]) => tableSchema.columnSet.has(column)),
294
+ );
295
+ const nonSchemaLiteralValues = Object.fromEntries(
296
+ Object.entries(row.values).filter(([column]) => !tableSchema.columnSet.has(column)),
297
+ );
298
+ const schemaLiteralColumns = Object.keys(schemaLiteralValues);
299
+
300
+ if (schemaLiteralColumns.length === 0) {
301
+ return row;
302
+ }
303
+
304
+ const morphedSchemaValues = this.morph(
305
+ table,
306
+ this.pickSchema(tableSchema.schema, schemaLiteralColumns),
307
+ this.prepareInsertValue(tableSchema, schemaLiteralValues),
308
+ );
309
+
310
+ this.coerceVectorsInRow(table, tableSchema, morphedSchemaValues);
311
+
312
+ return {
313
+ values: { ...nonSchemaLiteralValues, ...morphedSchemaValues },
314
+ passthrough: row.passthrough,
315
+ };
316
+ }
94
317
 
95
- if (!columns || !node.values || !ValuesNode.is(node.values)) {
96
- return;
318
+ private getInsertColumns(
319
+ tableSchema: TableWriteSchema,
320
+ originalColumns: string[],
321
+ rows: InsertRow[],
322
+ ) {
323
+ const columns = [...originalColumns];
324
+
325
+ for (const column of tableSchema.columns) {
326
+ if (columns.includes(column)) {
327
+ continue;
97
328
  }
98
329
 
99
- for (const valueList of node.values.values) {
100
- for (let i = 0; i < columns.length; i++) {
101
- const col = columns[i]!;
330
+ if (rows.some((row) => Object.prototype.hasOwnProperty.call(row.values, column))) {
331
+ columns.push(column);
332
+ }
333
+ }
102
334
 
103
- if (valueList.kind === "PrimitiveValueListNode") {
104
- yield [col, valueList.values[i]] as [string, unknown];
105
- continue;
106
- }
335
+ for (const row of rows) {
336
+ for (const column of Object.keys(row.values)) {
337
+ if (!columns.includes(column)) {
338
+ columns.push(column);
339
+ }
340
+ }
341
+ }
107
342
 
108
- const raw = valueList.values[i];
343
+ return columns;
344
+ }
109
345
 
110
- if (!raw || DefaultInsertValueNode.is(raw)) {
111
- continue;
112
- }
346
+ private createInsertValueList(columns: string[], row: InsertRow) {
347
+ return ValueListNode.create(
348
+ columns.map((column) => {
349
+ const passthrough = row.passthrough.get(column);
113
350
 
114
- yield [col, ValueNode.is(raw) ? raw.value : raw] as [string, unknown];
351
+ if (passthrough) {
352
+ return passthrough;
115
353
  }
354
+
355
+ if (Object.prototype.hasOwnProperty.call(row.values, column)) {
356
+ return ValueNode.create(row.values[column]);
357
+ }
358
+
359
+ return DefaultInsertValueNode.create();
360
+ }),
361
+ );
362
+ }
363
+
364
+ private transformInsert(node: KyselyInsertQueryNode, table: string) {
365
+ const onConflict = node.onConflict
366
+ ? this.transformOnConflict(table, node.onConflict)
367
+ : undefined;
368
+ const tableSchema = this.schemas.get(table);
369
+
370
+ if (!tableSchema) {
371
+ if (onConflict) {
372
+ return InsertQueryNode.cloneWith(node, { onConflict });
116
373
  }
117
374
 
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];
375
+ return node;
376
+ }
377
+
378
+ const columns = node.columns?.map((column) => column.column.name);
379
+
380
+ if (!columns || !node.values || !ValuesNode.is(node.values)) {
381
+ if (onConflict) {
382
+ return InsertQueryNode.cloneWith(node, { onConflict });
383
+ }
384
+
385
+ return node;
386
+ }
387
+
388
+ const rows: InsertRow[] = [];
389
+
390
+ for (const valueList of node.values.values) {
391
+ const row = this.getInsertRow(columns, valueList);
392
+
393
+ if (!row) {
394
+ if (onConflict) {
395
+ return InsertQueryNode.cloneWith(node, { onConflict });
121
396
  }
397
+
398
+ return node;
122
399
  }
123
400
 
124
- return;
401
+ rows.push(this.morphInsertRow(table, tableSchema, row));
125
402
  }
126
403
 
127
- if (node.kind !== "UpdateQueryNode" || !node.updates) {
128
- return;
404
+ const insertColumns = this.getInsertColumns(tableSchema, columns, rows);
405
+
406
+ return InsertQueryNode.cloneWith(node, {
407
+ columns: insertColumns.map((column) => ColumnNode.create(column)),
408
+ values: ValuesNode.create(rows.map((row) => this.createInsertValueList(insertColumns, row))),
409
+ onConflict,
410
+ });
411
+ }
412
+
413
+ private transformUpdates(table: string, updates: readonly KyselyColumnUpdateNode[]) {
414
+ const tableSchema = this.schemas.get(table);
415
+
416
+ if (!tableSchema) {
417
+ return updates;
418
+ }
419
+
420
+ const literalColumns = new Set<string>();
421
+ const literalValues: Record<string, unknown> = {};
422
+ const nullOptionalColumns = new Set<string>();
423
+
424
+ for (const update of updates) {
425
+ if (!ColumnNode.is(update.column) || !ValueNode.is(update.value)) {
426
+ continue;
427
+ }
428
+
429
+ const column = update.column.column.name;
430
+
431
+ if (!tableSchema.columnSet.has(column)) {
432
+ continue;
433
+ }
434
+
435
+ if (update.value.value === null && tableSchema.optionalNonNullColumns.has(column)) {
436
+ nullOptionalColumns.add(column);
437
+ continue;
438
+ }
439
+
440
+ literalColumns.add(column);
441
+ literalValues[column] = update.value.value;
442
+ }
443
+
444
+ if (literalColumns.size === 0 && nullOptionalColumns.size === 0) {
445
+ return updates;
129
446
  }
130
447
 
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];
448
+ const morphed =
449
+ literalColumns.size === 0
450
+ ? {}
451
+ : this.morph(
452
+ table,
453
+ this.pickSchema(tableSchema.schema, literalColumns),
454
+ this.stripNullOptionalFields(tableSchema, literalValues),
455
+ );
456
+
457
+ this.coerceVectorsInRow(table, tableSchema, morphed);
458
+
459
+ return updates.flatMap((update) => {
460
+ if (!ColumnNode.is(update.column) || !ValueNode.is(update.value)) {
461
+ return [update];
462
+ }
463
+
464
+ const column = update.column.column.name;
465
+
466
+ if (!literalColumns.has(column) || !tableSchema.columnSet.has(column)) {
467
+ if (nullOptionalColumns.has(column)) {
468
+ return [ColumnUpdateNode.create(update.column, ValueNode.create(null))];
469
+ }
470
+
471
+ return [update];
472
+ }
473
+
474
+ if (!Object.prototype.hasOwnProperty.call(morphed, column)) {
475
+ return [
476
+ ColumnUpdateNode.create(update.column, ReferenceNode.create(ColumnNode.create(column))),
477
+ ];
134
478
  }
479
+
480
+ return [ColumnUpdateNode.create(update.column, ValueNode.create(morphed[column]))];
481
+ });
482
+ }
483
+
484
+ private transformOnConflict(table: string, node: KyselyOnConflictNode) {
485
+ if (!node.updates) {
486
+ return node;
487
+ }
488
+
489
+ return OnConflictNode.cloneWith(node, {
490
+ updates: this.transformUpdates(table, node.updates),
491
+ });
492
+ }
493
+
494
+ private transformUpdate(node: KyselyUpdateQueryNode, table: string) {
495
+ if (!node.updates) {
496
+ return node;
135
497
  }
498
+
499
+ return Object.freeze({
500
+ ...node,
501
+ updates: this.transformUpdates(table, node.updates),
502
+ });
136
503
  }
137
504
  }