@lobomfz/db 0.4.0 → 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.
- package/package.json +8 -5
- package/src/database.ts +218 -13
- package/src/dialect/serialize.ts +1 -1
- package/src/env.ts +2 -1
- package/src/fts/resolve.ts +208 -0
- package/src/fts/sql.ts +148 -0
- package/src/fts/tokenizer.ts +27 -0
- package/src/index.ts +15 -1
- package/src/json.ts +13 -0
- package/src/migration/diff.ts +194 -1
- package/src/migration/execute.ts +42 -0
- package/src/migration/introspect.ts +133 -1
- package/src/migration/types.ts +68 -1
- package/src/plugin.ts +206 -3
- package/src/types.ts +94 -12
- package/src/vector-index/load.ts +67 -0
- package/src/vector-index/nearest.ts +68 -0
- package/src/vector-index/resolve.ts +78 -0
- package/src/vector-index/sql.ts +76 -0
- package/src/vector.ts +13 -0
- package/src/write-validation-plugin.ts +92 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobomfz/db",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Bun SQLite database with Arktype schemas and typed Kysely client",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"arktype",
|
|
@@ -32,15 +32,18 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@types/bun": "^1.3.
|
|
36
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
37
|
-
"oxfmt": "^0.
|
|
38
|
-
"oxlint": "^1.
|
|
35
|
+
"@types/bun": "^1.3.12",
|
|
36
|
+
"@typescript/native-preview": "^7.0.0-dev.20260417.1",
|
|
37
|
+
"oxfmt": "^0.45.0",
|
|
38
|
+
"oxlint": "^1.60.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@ark/schema": ">=0.56.0",
|
|
42
42
|
"@ark/util": ">=0.56.0",
|
|
43
43
|
"arktype": "^2.1.29",
|
|
44
44
|
"kysely": "^0.28.14"
|
|
45
|
+
},
|
|
46
|
+
"optionalDependencies": {
|
|
47
|
+
"sqlite-vec": "^0.1.9"
|
|
45
48
|
}
|
|
46
49
|
}
|
package/src/database.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { Kysely } from "kysely";
|
|
|
5
5
|
|
|
6
6
|
import { BunSqliteDialect } from "./dialect/dialect.js";
|
|
7
7
|
import type { DbFieldMeta } from "./env.js";
|
|
8
|
+
import { resolveFtsPlan, type FtsTableInfo, type ResolvedFtsPlan } from "./fts/resolve.js";
|
|
9
|
+
import { generateRebuildSelect } from "./fts/sql.js";
|
|
8
10
|
import type { GeneratedPreset } from "./generated.js";
|
|
9
11
|
import { Differ, type DesiredTable } from "./migration/diff.js";
|
|
10
12
|
import { Executor } from "./migration/execute.js";
|
|
@@ -12,11 +14,23 @@ import { Introspector } from "./migration/introspect.js";
|
|
|
12
14
|
import { ResultHydrationPlugin } from "./plugin.js";
|
|
13
15
|
import type {
|
|
14
16
|
DatabaseOptions,
|
|
17
|
+
DatabasePragmas,
|
|
18
|
+
FtsApi,
|
|
19
|
+
FtsConfig,
|
|
15
20
|
IndexDefinition,
|
|
16
21
|
SchemaRecord,
|
|
17
22
|
TablesFromSchemas,
|
|
18
|
-
|
|
23
|
+
VectorIndexApi,
|
|
24
|
+
VectorIndexConfig,
|
|
19
25
|
} from "./types.js";
|
|
26
|
+
import { loadExtensions } from "./vector-index/load.js";
|
|
27
|
+
import { registerVectorIndex, unregisterVectorIndex } from "./vector-index/nearest.js";
|
|
28
|
+
import {
|
|
29
|
+
resolveVectorIndex,
|
|
30
|
+
type ResolvedVectorIndex,
|
|
31
|
+
type VectorAnchorInfo,
|
|
32
|
+
} from "./vector-index/resolve.js";
|
|
33
|
+
import { generateRebuildVectorSql } from "./vector-index/sql.js";
|
|
20
34
|
import { WriteValidationPlugin } from "./write-validation-plugin.js";
|
|
21
35
|
|
|
22
36
|
type ArkBranch = {
|
|
@@ -25,7 +39,7 @@ type ArkBranch = {
|
|
|
25
39
|
unit?: unknown;
|
|
26
40
|
structure?: unknown;
|
|
27
41
|
inner?: { divisor?: unknown };
|
|
28
|
-
meta?: DbFieldMeta
|
|
42
|
+
meta?: DbFieldMeta;
|
|
29
43
|
};
|
|
30
44
|
|
|
31
45
|
type StructureProp = {
|
|
@@ -34,7 +48,7 @@ type StructureProp = {
|
|
|
34
48
|
value: Type & {
|
|
35
49
|
branches: ArkBranch[];
|
|
36
50
|
proto?: unknown;
|
|
37
|
-
meta: DbFieldMeta
|
|
51
|
+
meta: DbFieldMeta;
|
|
38
52
|
};
|
|
39
53
|
inner: { default?: unknown };
|
|
40
54
|
};
|
|
@@ -49,6 +63,8 @@ type Prop = {
|
|
|
49
63
|
isDate?: boolean;
|
|
50
64
|
isBlob?: boolean;
|
|
51
65
|
isJson?: boolean;
|
|
66
|
+
isVector?: boolean;
|
|
67
|
+
vectorDim?: number;
|
|
52
68
|
jsonSchema?: Type;
|
|
53
69
|
meta?: DbFieldMeta;
|
|
54
70
|
generated?: GeneratedPreset;
|
|
@@ -64,14 +80,24 @@ const defaultPragmas: DatabasePragmas = {
|
|
|
64
80
|
foreign_keys: true,
|
|
65
81
|
};
|
|
66
82
|
|
|
67
|
-
export class Database<
|
|
83
|
+
export class Database<
|
|
84
|
+
T extends SchemaRecord,
|
|
85
|
+
F extends FtsConfig<T> = FtsConfig<T>,
|
|
86
|
+
V extends VectorIndexConfig<T> = VectorIndexConfig<T>,
|
|
87
|
+
> {
|
|
68
88
|
private sqlite: BunDatabase;
|
|
89
|
+
private ftsPlans = new Map<string, ResolvedFtsPlan>();
|
|
90
|
+
private vectorIndexPlans = new Map<string, ResolvedVectorIndex>();
|
|
91
|
+
|
|
92
|
+
readonly infer: TablesFromSchemas<T, F, V> = undefined as any;
|
|
93
|
+
|
|
94
|
+
readonly kysely: Kysely<TablesFromSchemas<T, F, V>>;
|
|
69
95
|
|
|
70
|
-
readonly
|
|
96
|
+
readonly fts: FtsApi<F>;
|
|
71
97
|
|
|
72
|
-
readonly
|
|
98
|
+
readonly vectorIndex: VectorIndexApi<V>;
|
|
73
99
|
|
|
74
|
-
constructor(private options: DatabaseOptions<T>) {
|
|
100
|
+
constructor(private options: DatabaseOptions<T, F, V>) {
|
|
75
101
|
const tableSchemas = new Map<string, Type>(Object.entries(options.schema.tables));
|
|
76
102
|
const writeSchemas = new Map<string, Type>(
|
|
77
103
|
Object.entries(options.schema.tables).map(([table, schema]) => [
|
|
@@ -83,6 +109,8 @@ export class Database<T extends SchemaRecord> {
|
|
|
83
109
|
this.sqlite = new BunDatabase(options.path);
|
|
84
110
|
|
|
85
111
|
this.applyPragmas();
|
|
112
|
+
this.assertVectorIndexesHaveExtension();
|
|
113
|
+
this.loadConfiguredExtensions();
|
|
86
114
|
|
|
87
115
|
this.migrate();
|
|
88
116
|
|
|
@@ -90,13 +118,59 @@ export class Database<T extends SchemaRecord> {
|
|
|
90
118
|
onRead: options.validation?.onRead ?? false,
|
|
91
119
|
};
|
|
92
120
|
|
|
93
|
-
this.kysely = new Kysely<TablesFromSchemas<T>>({
|
|
121
|
+
this.kysely = new Kysely<TablesFromSchemas<T, F, V>>({
|
|
94
122
|
dialect: new BunSqliteDialect({ database: this.sqlite }),
|
|
95
123
|
plugins: [
|
|
96
124
|
new WriteValidationPlugin(writeSchemas),
|
|
97
125
|
new ResultHydrationPlugin(tableSchemas, validation),
|
|
98
126
|
],
|
|
99
127
|
});
|
|
128
|
+
|
|
129
|
+
this.fts = {
|
|
130
|
+
rebuild: (name: string) => this.rebuildFts(name),
|
|
131
|
+
} as FtsApi<F>;
|
|
132
|
+
|
|
133
|
+
this.vectorIndex = {
|
|
134
|
+
rebuild: (name: string) => this.rebuildVectorIndex(name),
|
|
135
|
+
} as VectorIndexApi<V>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private loadConfiguredExtensions() {
|
|
139
|
+
const extensions = this.options.extensions ?? [];
|
|
140
|
+
|
|
141
|
+
loadExtensions(this.sqlite, extensions);
|
|
142
|
+
|
|
143
|
+
const hasSqliteVec = extensions.some((e) => e.kind === "sqlite-vec");
|
|
144
|
+
|
|
145
|
+
if (hasSqliteVec) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const probe = new Introspector(this.sqlite);
|
|
150
|
+
|
|
151
|
+
if (probe.hasVec0Tables()) {
|
|
152
|
+
loadExtensions(this.sqlite, [{ kind: "sqlite-vec" }]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private assertVectorIndexesHaveExtension() {
|
|
157
|
+
const vectorIndexes = this.options.schema.vectorIndexes;
|
|
158
|
+
|
|
159
|
+
if (!vectorIndexes || Object.keys(vectorIndexes).length === 0) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const hasSqliteVec = (this.options.extensions ?? []).some((e) => e.kind === "sqlite-vec");
|
|
164
|
+
|
|
165
|
+
if (hasSqliteVec) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const [first] = Object.keys(vectorIndexes);
|
|
170
|
+
|
|
171
|
+
throw new Error(
|
|
172
|
+
`vectorIndex "${first}" requires the sqlite-vec extension. Add { kind: "sqlite-vec" } to DatabaseOptions.extensions.`,
|
|
173
|
+
);
|
|
100
174
|
}
|
|
101
175
|
|
|
102
176
|
private applyPragmas() {
|
|
@@ -127,7 +201,19 @@ export class Database<T extends SchemaRecord> {
|
|
|
127
201
|
|
|
128
202
|
const branchMeta = v.branches.find((b) => b.meta && Object.keys(b.meta).length > 0)?.meta;
|
|
129
203
|
const meta = { ...branchMeta, ...v.meta };
|
|
130
|
-
const generated = meta._generated;
|
|
204
|
+
const generated = meta._generated === "vector" ? undefined : meta._generated;
|
|
205
|
+
|
|
206
|
+
if (meta._generated === "vector") {
|
|
207
|
+
return {
|
|
208
|
+
key,
|
|
209
|
+
kind,
|
|
210
|
+
nullable,
|
|
211
|
+
isVector: true,
|
|
212
|
+
vectorDim: meta._vectorDim,
|
|
213
|
+
meta,
|
|
214
|
+
defaultValue,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
131
217
|
|
|
132
218
|
if (v.proto === Date || concrete.some((b) => b.proto === Date)) {
|
|
133
219
|
return { key, kind, nullable, isDate: true, generated, defaultValue };
|
|
@@ -181,7 +267,7 @@ export class Database<T extends SchemaRecord> {
|
|
|
181
267
|
return "TEXT";
|
|
182
268
|
}
|
|
183
269
|
|
|
184
|
-
if (prop.isBlob) {
|
|
270
|
+
if (prop.isBlob || prop.isVector) {
|
|
185
271
|
return "BLOB";
|
|
186
272
|
}
|
|
187
273
|
|
|
@@ -214,7 +300,7 @@ export class Database<T extends SchemaRecord> {
|
|
|
214
300
|
|
|
215
301
|
private defaultClause(prop: Prop) {
|
|
216
302
|
if (prop.generated === "now") {
|
|
217
|
-
return "DEFAULT (unixepoch())";
|
|
303
|
+
return "DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))";
|
|
218
304
|
}
|
|
219
305
|
|
|
220
306
|
if (prop.defaultValue === undefined || prop.generated === "autoincrement") {
|
|
@@ -328,6 +414,8 @@ export class Database<T extends SchemaRecord> {
|
|
|
328
414
|
|
|
329
415
|
private migrate() {
|
|
330
416
|
const desiredTables: DesiredTable[] = [];
|
|
417
|
+
const tableInfos = new Map<string, FtsTableInfo>();
|
|
418
|
+
const vectorAnchors = new Map<string, VectorAnchorInfo>();
|
|
331
419
|
const schemaIndexes = this.options.schema.indexes;
|
|
332
420
|
|
|
333
421
|
for (const [name, schema] of Object.entries(this.options.schema.tables)) {
|
|
@@ -365,12 +453,129 @@ export class Database<T extends SchemaRecord> {
|
|
|
365
453
|
columns,
|
|
366
454
|
indexes,
|
|
367
455
|
});
|
|
456
|
+
|
|
457
|
+
tableInfos.set(name, this.buildTableInfo(props));
|
|
458
|
+
vectorAnchors.set(name, this.buildVectorAnchorInfo(props));
|
|
368
459
|
}
|
|
369
460
|
|
|
370
|
-
const
|
|
371
|
-
|
|
461
|
+
const desiredFts: ResolvedFtsPlan[] = [];
|
|
462
|
+
|
|
463
|
+
if (this.options.schema.fts) {
|
|
464
|
+
for (const [name, entry] of Object.entries(this.options.schema.fts)) {
|
|
465
|
+
const plan = resolveFtsPlan(name, entry, tableInfos);
|
|
466
|
+
desiredFts.push(plan);
|
|
467
|
+
this.ftsPlans.set(name, plan);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const desiredVectorIndexes: ResolvedVectorIndex[] = [];
|
|
472
|
+
|
|
473
|
+
if (this.options.schema.vectorIndexes) {
|
|
474
|
+
for (const [name, entry] of Object.entries(this.options.schema.vectorIndexes)) {
|
|
475
|
+
const plan = resolveVectorIndex(name, entry as any, vectorAnchors);
|
|
476
|
+
desiredVectorIndexes.push(plan);
|
|
477
|
+
this.vectorIndexPlans.set(name, plan);
|
|
478
|
+
registerVectorIndex(plan);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const introspector = new Introspector(this.sqlite);
|
|
483
|
+
const existing = introspector.introspect();
|
|
484
|
+
const existingFts = introspector.introspectFts();
|
|
485
|
+
const existingVectorIndexes = introspector.introspectVectorIndexes();
|
|
486
|
+
|
|
487
|
+
const ops = new Differ(
|
|
488
|
+
desiredTables,
|
|
489
|
+
existing,
|
|
490
|
+
desiredFts,
|
|
491
|
+
existingFts,
|
|
492
|
+
desiredVectorIndexes,
|
|
493
|
+
existingVectorIndexes,
|
|
494
|
+
).diff();
|
|
372
495
|
|
|
373
496
|
new Executor(this.sqlite, ops).execute();
|
|
497
|
+
|
|
498
|
+
for (const name of existingVectorIndexes.keys()) {
|
|
499
|
+
if (!this.vectorIndexPlans.has(name)) {
|
|
500
|
+
unregisterVectorIndex(name);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private rebuildFts(name: string) {
|
|
506
|
+
const plan = this.ftsPlans.get(name);
|
|
507
|
+
|
|
508
|
+
if (!plan) {
|
|
509
|
+
throw new Error(`FTS "${name}" is not declared in the schema`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
this.sqlite.transaction(() => {
|
|
513
|
+
this.sqlite.run(`DELETE FROM "${name}"`);
|
|
514
|
+
this.sqlite.run(generateRebuildSelect(plan));
|
|
515
|
+
})();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private rebuildVectorIndex(name: string) {
|
|
519
|
+
const plan = this.vectorIndexPlans.get(name);
|
|
520
|
+
|
|
521
|
+
if (!plan) {
|
|
522
|
+
throw new Error(`vector index "${name}" is not declared in the schema`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
this.sqlite.transaction(() => {
|
|
526
|
+
this.sqlite.run(`DELETE FROM "${name}"`);
|
|
527
|
+
this.sqlite.run(generateRebuildVectorSql(plan));
|
|
528
|
+
})();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private buildTableInfo(props: Prop[]): FtsTableInfo {
|
|
532
|
+
const pkProp = props.find(
|
|
533
|
+
(p) => p.generated === "autoincrement" || p.meta?.primaryKey === true,
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
columns: new Set(props.map((p) => p.key)),
|
|
538
|
+
pkColumn: pkProp?.key ?? null,
|
|
539
|
+
pkIsInteger: pkProp ? this.sqlType(pkProp) === "INTEGER" : false,
|
|
540
|
+
foreignKeys: props.flatMap((p) => {
|
|
541
|
+
const ref = p.meta?.references;
|
|
542
|
+
|
|
543
|
+
if (!ref) {
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const [referencedTable, referencedColumn] = ref.split(".");
|
|
548
|
+
|
|
549
|
+
if (!referencedTable || !referencedColumn) {
|
|
550
|
+
throw new Error(
|
|
551
|
+
`Invalid references format "${ref}" for column "${p.key}": expected "table.column"`,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return [{ column: p.key, referencedTable, referencedColumn }];
|
|
556
|
+
}),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private buildVectorAnchorInfo(props: Prop[]): VectorAnchorInfo {
|
|
561
|
+
const pkProp = props.find(
|
|
562
|
+
(p) => p.generated === "autoincrement" || p.meta?.primaryKey === true,
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const vectorColumns = new Map<string, number>();
|
|
566
|
+
|
|
567
|
+
for (const p of props) {
|
|
568
|
+
if (p.isVector && typeof p.vectorDim === "number") {
|
|
569
|
+
vectorColumns.set(p.key, p.vectorDim);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
columns: new Set(props.map((p) => p.key)),
|
|
575
|
+
pkColumn: pkProp?.key ?? null,
|
|
576
|
+
pkIsInteger: pkProp ? this.sqlType(pkProp) === "INTEGER" : false,
|
|
577
|
+
vectorColumns,
|
|
578
|
+
};
|
|
374
579
|
}
|
|
375
580
|
|
|
376
581
|
private generateIndexName(tableName: string, columns: string[], unique: boolean) {
|
package/src/dialect/serialize.ts
CHANGED
package/src/env.ts
CHANGED
|
@@ -3,7 +3,8 @@ export type DbFieldMeta = {
|
|
|
3
3
|
unique?: boolean;
|
|
4
4
|
references?: `${string}.${string}`;
|
|
5
5
|
onDelete?: "cascade" | "set null" | "restrict";
|
|
6
|
-
_generated?: "autoincrement" | "now";
|
|
6
|
+
_generated?: "autoincrement" | "now" | "vector";
|
|
7
|
+
_vectorDim?: number;
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
declare global {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { FtsEntry, FtsFieldRef, FtsTokenizer } from "../types.js";
|
|
2
|
+
import { DEFAULT_TOKENIZER } from "./tokenizer.js";
|
|
3
|
+
|
|
4
|
+
export type FtsTableInfo = {
|
|
5
|
+
columns: Set<string>;
|
|
6
|
+
pkColumn: string | null;
|
|
7
|
+
pkIsInteger: boolean;
|
|
8
|
+
foreignKeys: Array<{
|
|
9
|
+
column: string;
|
|
10
|
+
referencedTable: string;
|
|
11
|
+
referencedColumn: string;
|
|
12
|
+
}>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ResolvedFtsField = {
|
|
16
|
+
name: string;
|
|
17
|
+
sourceTable: string;
|
|
18
|
+
sourceColumn: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ResolvedFtsSource = {
|
|
22
|
+
table: string;
|
|
23
|
+
fkColumn: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ResolvedFtsPlan = {
|
|
27
|
+
name: string;
|
|
28
|
+
anchor: string;
|
|
29
|
+
anchorPkColumn: string;
|
|
30
|
+
fields: ResolvedFtsField[];
|
|
31
|
+
sources: ResolvedFtsSource[];
|
|
32
|
+
tokenizer: FtsTokenizer;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function parseFieldRef(name: string, fieldName: string, ref: FtsFieldRef) {
|
|
36
|
+
const source = typeof ref === "string" ? ref : ref.source;
|
|
37
|
+
const [sourceTable, sourceColumn, ...rest] = source.split(".");
|
|
38
|
+
|
|
39
|
+
if (!sourceTable || !sourceColumn || rest.length > 0) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`FTS "${name}": field "${fieldName}" has invalid reference "${source}", expected "table.column"`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
sourceTable,
|
|
47
|
+
sourceColumn,
|
|
48
|
+
via: typeof ref === "string" ? undefined : ref.via,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseVia(name: string, fieldName: string, via: string) {
|
|
53
|
+
const [table, column, ...rest] = via.split(".");
|
|
54
|
+
|
|
55
|
+
if (!table || !column || rest.length > 0) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`FTS "${name}": field "${fieldName}" has invalid via "${via}", expected "table.column"`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { table, column };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveCrossTableFk(
|
|
65
|
+
name: string,
|
|
66
|
+
fieldName: string,
|
|
67
|
+
sourceTable: string,
|
|
68
|
+
sourceInfo: FtsTableInfo,
|
|
69
|
+
anchor: string,
|
|
70
|
+
anchorPk: string,
|
|
71
|
+
via: string | undefined,
|
|
72
|
+
) {
|
|
73
|
+
if (via) {
|
|
74
|
+
const parsed = parseVia(name, fieldName, via);
|
|
75
|
+
|
|
76
|
+
if (parsed.table !== sourceTable) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`FTS "${name}": field "${fieldName}" via "${via}" must reference a column on source table "${sourceTable}"`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!sourceInfo.columns.has(parsed.column)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`FTS "${name}": field "${fieldName}" via "${via}" column does not exist`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const fk = sourceInfo.foreignKeys.find((f) => f.column === parsed.column);
|
|
89
|
+
|
|
90
|
+
if (!fk || fk.referencedTable !== anchor || fk.referencedColumn !== anchorPk) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`FTS "${name}": field "${fieldName}" via "${via}" does not reference "${anchor}.${anchorPk}"`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return parsed.column;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const matches = sourceInfo.foreignKeys.filter(
|
|
100
|
+
(f) => f.referencedTable === anchor && f.referencedColumn === anchorPk,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (matches.length === 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`FTS "${name}": field "${fieldName}" source table "${sourceTable}" has no foreign key to anchor "${anchor}.${anchorPk}"; add \`via\` to disambiguate`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (matches.length > 1) {
|
|
110
|
+
const cols = matches.map((m) => m.column).join(", ");
|
|
111
|
+
|
|
112
|
+
throw new Error(
|
|
113
|
+
`FTS "${name}": field "${fieldName}" source table "${sourceTable}" has multiple foreign keys to anchor "${anchor}.${anchorPk}" (${cols}); add \`via\` to disambiguate`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return matches[0]!.column;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveFtsPlan(
|
|
121
|
+
name: string,
|
|
122
|
+
entry: FtsEntry<any>,
|
|
123
|
+
tables: Map<string, FtsTableInfo>,
|
|
124
|
+
) {
|
|
125
|
+
const anchor = tables.get(entry.anchor);
|
|
126
|
+
|
|
127
|
+
if (!anchor) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`FTS "${name}": anchor table "${entry.anchor}" is not declared in schema`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!anchor.pkColumn) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`FTS "${name}": anchor table "${entry.anchor}" has no primary key; anchor PK must be an integer`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!anchor.pkIsInteger) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`FTS "${name}": anchor table "${entry.anchor}" primary key "${anchor.pkColumn}" must be an integer`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const fields: ResolvedFtsField[] = [];
|
|
146
|
+
const sources = new Map<string, ResolvedFtsSource>();
|
|
147
|
+
const fieldEntries = Object.entries(entry.fields);
|
|
148
|
+
|
|
149
|
+
if (fieldEntries.length === 0) {
|
|
150
|
+
throw new Error(`FTS "${name}": must declare at least one field`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const [fieldName, ref] of fieldEntries) {
|
|
154
|
+
const parsed = parseFieldRef(name, fieldName, ref);
|
|
155
|
+
const sourceTable = tables.get(parsed.sourceTable);
|
|
156
|
+
|
|
157
|
+
if (!sourceTable) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`FTS "${name}": field "${fieldName}" references unknown table "${parsed.sourceTable}"`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!sourceTable.columns.has(parsed.sourceColumn)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`FTS "${name}": field "${fieldName}" references unknown column "${parsed.sourceTable}.${parsed.sourceColumn}"`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (parsed.sourceTable !== entry.anchor) {
|
|
170
|
+
const fkColumn = resolveCrossTableFk(
|
|
171
|
+
name,
|
|
172
|
+
fieldName,
|
|
173
|
+
parsed.sourceTable,
|
|
174
|
+
sourceTable,
|
|
175
|
+
entry.anchor,
|
|
176
|
+
anchor.pkColumn,
|
|
177
|
+
parsed.via,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const existing = sources.get(parsed.sourceTable);
|
|
181
|
+
|
|
182
|
+
if (existing && existing.fkColumn !== fkColumn) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`FTS "${name}": field "${fieldName}" resolves to via "${parsed.sourceTable}.${fkColumn}" but source already bound to "${parsed.sourceTable}.${existing.fkColumn}"`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!existing) {
|
|
189
|
+
sources.set(parsed.sourceTable, { table: parsed.sourceTable, fkColumn });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
fields.push({
|
|
194
|
+
name: fieldName,
|
|
195
|
+
sourceTable: parsed.sourceTable,
|
|
196
|
+
sourceColumn: parsed.sourceColumn,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
name,
|
|
202
|
+
anchor: entry.anchor,
|
|
203
|
+
anchorPkColumn: anchor.pkColumn,
|
|
204
|
+
fields,
|
|
205
|
+
sources: [...sources.values()],
|
|
206
|
+
tokenizer: entry.tokenizer ?? DEFAULT_TOKENIZER,
|
|
207
|
+
};
|
|
208
|
+
}
|