@vibeorm/extensions 2.0.0-alpha.1 → 2.0.0-alpha.10
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 +68 -0
- package/dist/index.js +147 -35
- package/dist/index.js.map +4 -4
- package/dist/search/index.d.ts +35 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +134 -34
- package/dist/search/index.js.map +3 -3
- package/dist/vector/index.d.ts +4 -2
- package/dist/vector/index.d.ts.map +1 -1
- package/dist/vector/index.js +14 -2
- package/dist/vector/index.js.map +3 -3
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -85,6 +85,74 @@ Two gates worth knowing before you pick a dialect:
|
|
|
85
85
|
- `engine: "bm25"` is refused on `pglite` (it does not bundle `pg_search`) and on non-postgres dialects. `"auto"` never selects it, because ParadeDB availability is not knowable at generate time.
|
|
86
86
|
- The `fts5` engine requires the searched model to have a single `Int` or `BigInt` `@id`. That is the only primary key that aliases and pins the SQLite rowid the external-content table keys on; anything else is refused at generate time.
|
|
87
87
|
|
|
88
|
+
### Type-ahead (prefix) matching
|
|
89
|
+
|
|
90
|
+
All engines match complete words by default — a user still typing gets nothing for `"piz"`. For search-as-you-type boxes pass `prefix: true`:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const hits = await db.post.search({ query: "piz", prefix: true, take: 8 });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The query is split into unicode letter/number tokens (punctuation separates and never matches) and every token matches as a prefix. Per engine: tsvector builds `to_tsquery('lang', 'piz:*')` (still one bound parameter — `websearch_to_tsquery` has no prefix syntax), fts5 uses `"piz"*` in the MATCH string, mysql switches to `+piz*` in BOOLEAN MODE. A query with no tokens resolves to `[]` without touching the database. Three caveats:
|
|
97
|
+
|
|
98
|
+
- tsvector prefixes run against **stemmed** lexemes, so a stemming `language` ("english", "norwegian") can miss mid-stem prefixes. Declare `language: "simple"` on models meant for type-ahead.
|
|
99
|
+
- `engine: "bm25"` refuses `prefix` (`VIBE_UNSUPPORTED_CAPABILITY`) — ParadeDB prefix behavior is decided by the index tokenizer, which this extension does not configure.
|
|
100
|
+
- mysql InnoDB ignores tokens shorter than `innodb_ft_min_token_size` (default 3), so one- and two-letter prefixes match nothing there.
|
|
101
|
+
|
|
102
|
+
### Filtering hits: `where` composes with the core filter language
|
|
103
|
+
|
|
104
|
+
`search({ where })` compiles through the same filter compiler as every core query — scalar
|
|
105
|
+
filters, relation filters and `dbNow()` comparisons all AND into the match. The menu
|
|
106
|
+
composition (type-ahead over items that are orderable *right now*):
|
|
107
|
+
|
|
108
|
+
```prisma
|
|
109
|
+
model MenuItem {
|
|
110
|
+
id Int @id @default(autoincrement())
|
|
111
|
+
name String
|
|
112
|
+
description String
|
|
113
|
+
availableFrom DateTime?
|
|
114
|
+
availableTo DateTime?
|
|
115
|
+
category Category? @relation(fields: [categoryId], references: [id])
|
|
116
|
+
categoryId Int?
|
|
117
|
+
|
|
118
|
+
@@search([name, description], weights: [2, 1], language: "simple")
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { dbNow } from "@vibeorm/runtime";
|
|
124
|
+
|
|
125
|
+
const hits = await db.menuItem.search({
|
|
126
|
+
query: "piz", // mid-keystroke
|
|
127
|
+
prefix: true,
|
|
128
|
+
take: 8,
|
|
129
|
+
where: {
|
|
130
|
+
category: { is: { active: true } },
|
|
131
|
+
availableFrom: { lte: dbNow() }, // the DATABASE's clock, not the process's
|
|
132
|
+
availableTo: { gte: dbNow() },
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
For typo tolerance ("piza") declare `@@trgm([name])` too and fall back to
|
|
138
|
+
`findMany({ where: { name: { similarTo: query } } })` when a prefix search returns nothing
|
|
139
|
+
(postgres/pglite only).
|
|
140
|
+
|
|
141
|
+
### Hydrating hits with relations
|
|
142
|
+
|
|
143
|
+
`search()` returns bare rows — it has no `include`/`select` of relations. The intended pattern is the `select: "id"` projection plus a re-read through your own typed query, which carries relations, `omit`, computed fields and everything else the core API does:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
const hits = await db.post.search({ query: "piz", prefix: true, select: "id", take: 8 });
|
|
147
|
+
const rows = await db.post.findMany({
|
|
148
|
+
where: { id: { in: hits.map((hit) => hit.row.id) } },
|
|
149
|
+
include: { author: { select: { id: true, name: true } } },
|
|
150
|
+
});
|
|
151
|
+
// findMany does not preserve rank — re-order by score:
|
|
152
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
153
|
+
const ranked = hits.flatMap((hit) => byId.get(hit.row.id) ?? []);
|
|
154
|
+
```
|
|
155
|
+
|
|
88
156
|
`createSearchDelegate(config)` is the runtime glue the generator emits into the client. You do not call it by hand.
|
|
89
157
|
|
|
90
158
|
## Vector
|
package/dist/index.js
CHANGED
|
@@ -74,7 +74,7 @@ async function pgExtensionCheck(params) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
// packages/extensions/src/search/index.ts
|
|
77
|
-
var VERSION = "2.0.0-alpha.
|
|
77
|
+
var VERSION = "2.0.0-alpha.1";
|
|
78
78
|
var SCORE_ALIAS = "_vibe_score";
|
|
79
79
|
var HIGHLIGHT_ALIAS = "_vibe_highlight";
|
|
80
80
|
var WEIGHT_LETTERS = ["A", "B", "C", "D"];
|
|
@@ -101,6 +101,10 @@ function resolveSearchEngine(params) {
|
|
|
101
101
|
hint: `this dialect supports "${native}"${dialect === "postgres" ? ' and "bm25"' : ""}`
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
|
+
function declRank(declaration) {
|
|
105
|
+
const value = declaration["rank"];
|
|
106
|
+
return value === "ts_rank" || value === "ts_rank_cd" ? value : undefined;
|
|
107
|
+
}
|
|
104
108
|
function readDeclaration(params) {
|
|
105
109
|
const fields = declStringArray(params.declaration, "fields");
|
|
106
110
|
if (fields === undefined || fields.length === 0)
|
|
@@ -109,7 +113,10 @@ function readDeclaration(params) {
|
|
|
109
113
|
fields,
|
|
110
114
|
weights: declNumberArray(params.declaration, "weights"),
|
|
111
115
|
base: declString(params.declaration, "name") ?? `${tableNameOf({ model: params.model })}_search`,
|
|
112
|
-
language: declString(params.declaration, "language")
|
|
116
|
+
language: declString(params.declaration, "language"),
|
|
117
|
+
columnName: declString(params.declaration, "columnName"),
|
|
118
|
+
indexName: declString(params.declaration, "indexName"),
|
|
119
|
+
rank: declRank(params.declaration)
|
|
113
120
|
};
|
|
114
121
|
}
|
|
115
122
|
function weightLetters(params) {
|
|
@@ -119,6 +126,41 @@ function weightLetters(params) {
|
|
|
119
126
|
const ranked = [...new Set(decl.weights)].sort((a, b) => b - a);
|
|
120
127
|
return decl.weights.map((weight) => WEIGHT_LETTERS[Math.min(ranked.indexOf(weight), WEIGHT_LETTERS.length - 1)]);
|
|
121
128
|
}
|
|
129
|
+
function resolveArtifactNames(params) {
|
|
130
|
+
const { decl, engine, dialect } = params;
|
|
131
|
+
if (engine === "fts5" && (decl.columnName !== undefined || decl.indexName !== undefined)) {
|
|
132
|
+
throw unsupportedCapability({
|
|
133
|
+
dialect,
|
|
134
|
+
feature: "@@search columnName/indexName overrides on the fts5 engine",
|
|
135
|
+
hint: "fts5 emits a shadow table + triggers \u2014 there is no column or index to rename; use name: to change the table base"
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (decl.columnName !== undefined && engine !== "tsvector") {
|
|
139
|
+
throw unsupportedCapability({
|
|
140
|
+
dialect,
|
|
141
|
+
feature: `@@search columnName on the ${engine} engine`,
|
|
142
|
+
hint: "only the tsvector engine materializes a generated column \u2014 drop columnName or target tsvector"
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
tsvColumn: decl.columnName ?? `${decl.base}_tsv`,
|
|
147
|
+
indexName: decl.indexName ?? `${decl.base}_idx`
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function resolveRank(params) {
|
|
151
|
+
const { decl, options, engine, dialect } = params;
|
|
152
|
+
if (decl.rank !== undefined && engine !== "tsvector") {
|
|
153
|
+
throw unsupportedCapability({
|
|
154
|
+
dialect,
|
|
155
|
+
feature: `@@search rank on the ${engine} engine`,
|
|
156
|
+
hint: "rank chooses between the postgres ranking functions ts_rank and ts_rank_cd \u2014 a tsvector-only concept"
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (engine !== "tsvector")
|
|
160
|
+
return;
|
|
161
|
+
const fallback = options["rank"];
|
|
162
|
+
return decl.rank ?? (fallback === "ts_rank" || fallback === "ts_rank_cd" ? fallback : undefined);
|
|
163
|
+
}
|
|
122
164
|
function tsvectorExpression(params) {
|
|
123
165
|
const { dialect, model, decl, language } = params;
|
|
124
166
|
const quote = (name) => dialect.quoteIdent({ name });
|
|
@@ -152,9 +194,10 @@ function searchArtifacts(ctx) {
|
|
|
152
194
|
continue;
|
|
153
195
|
const table = tableNameOf({ model });
|
|
154
196
|
const columns = decl.fields.map((field) => columnNameOf({ model, field }));
|
|
197
|
+
resolveRank({ decl, options: ctx.options, engine, dialect: ctx.dialect });
|
|
155
198
|
if (engine === "tsvector") {
|
|
156
199
|
const language = decl.language ?? ctx.options["language"] ?? "english";
|
|
157
|
-
const tsvColumn =
|
|
200
|
+
const { tsvColumn, indexName } = resolveArtifactNames({ decl, engine, dialect: ctx.dialect });
|
|
158
201
|
const expression = tsvectorExpression({ dialect, model, decl, language });
|
|
159
202
|
artifacts.push({
|
|
160
203
|
id: `search:${modelName}:column`,
|
|
@@ -165,11 +208,11 @@ function searchArtifacts(ctx) {
|
|
|
165
208
|
drop: [`ALTER TABLE ${quote(table)} DROP COLUMN IF EXISTS ${quote(tsvColumn)};`]
|
|
166
209
|
}, {
|
|
167
210
|
id: `search:${modelName}:index`,
|
|
168
|
-
owns: { kind: "index", table, name:
|
|
211
|
+
owns: { kind: "index", table, name: indexName },
|
|
169
212
|
create: [
|
|
170
|
-
`CREATE INDEX IF NOT EXISTS ${quote(
|
|
213
|
+
`CREATE INDEX IF NOT EXISTS ${quote(indexName)} ON ${quote(table)} USING gin (${quote(tsvColumn)});`
|
|
171
214
|
],
|
|
172
|
-
drop: [`DROP INDEX IF EXISTS ${quote(
|
|
215
|
+
drop: [`DROP INDEX IF EXISTS ${quote(indexName)};`]
|
|
173
216
|
});
|
|
174
217
|
continue;
|
|
175
218
|
}
|
|
@@ -183,17 +226,19 @@ function searchArtifacts(ctx) {
|
|
|
183
226
|
});
|
|
184
227
|
}
|
|
185
228
|
const pkColumn = columnNameOf({ model, field: pkField });
|
|
229
|
+
const bm25Names = resolveArtifactNames({ decl, engine, dialect: ctx.dialect });
|
|
186
230
|
artifacts.push({
|
|
187
231
|
id: `search:${modelName}:index`,
|
|
188
|
-
owns: { kind: "index", table, name:
|
|
232
|
+
owns: { kind: "index", table, name: bm25Names.indexName },
|
|
189
233
|
create: [
|
|
190
|
-
`CREATE INDEX IF NOT EXISTS ${quote(
|
|
234
|
+
`CREATE INDEX IF NOT EXISTS ${quote(bm25Names.indexName)} ON ${quote(table)} USING bm25 (${[pkColumn, ...columns].map((column) => quote(column)).join(", ")}) WITH (key_field='${pkColumn}');`
|
|
191
235
|
],
|
|
192
|
-
drop: [`DROP INDEX IF EXISTS ${quote(
|
|
236
|
+
drop: [`DROP INDEX IF EXISTS ${quote(bm25Names.indexName)};`]
|
|
193
237
|
});
|
|
194
238
|
continue;
|
|
195
239
|
}
|
|
196
240
|
if (engine === "fts5") {
|
|
241
|
+
resolveArtifactNames({ decl, engine, dialect: ctx.dialect });
|
|
197
242
|
const pkName = primaryKeyFieldOf({ model });
|
|
198
243
|
const pkField = pkName === null ? undefined : model.fields.find((field) => field.name === pkName);
|
|
199
244
|
const pkIsRowidAlias = pkField !== undefined && !pkField.isList && (pkField.type === "Int" || pkField.type === "BigInt");
|
|
@@ -242,13 +287,14 @@ END;`
|
|
|
242
287
|
});
|
|
243
288
|
continue;
|
|
244
289
|
}
|
|
290
|
+
const fulltextNames = resolveArtifactNames({ decl, engine, dialect: ctx.dialect });
|
|
245
291
|
artifacts.push({
|
|
246
292
|
id: `search:${modelName}:index`,
|
|
247
|
-
owns: { kind: "index", table, name:
|
|
293
|
+
owns: { kind: "index", table, name: fulltextNames.indexName },
|
|
248
294
|
create: [
|
|
249
|
-
`CREATE FULLTEXT INDEX ${quote(
|
|
295
|
+
`CREATE FULLTEXT INDEX ${quote(fulltextNames.indexName)} ON ${quote(table)} (${columns.map((column) => quote(column)).join(", ")});`
|
|
250
296
|
],
|
|
251
|
-
drop: [`DROP INDEX ${quote(
|
|
297
|
+
drop: [`DROP INDEX ${quote(fulltextNames.indexName)} ON ${quote(table)};`]
|
|
252
298
|
});
|
|
253
299
|
}
|
|
254
300
|
return artifacts;
|
|
@@ -294,9 +340,33 @@ function createSearchDelegate(config) {
|
|
|
294
340
|
meta: { table: config.table }
|
|
295
341
|
});
|
|
296
342
|
}
|
|
343
|
+
const prefix = args.prefix === true;
|
|
344
|
+
if (prefix && config.engine === "bm25") {
|
|
345
|
+
throw unsupportedCapability({
|
|
346
|
+
dialect: "postgres",
|
|
347
|
+
feature: 'search prefix mode on the "bm25" engine',
|
|
348
|
+
hint: "ParadeDB prefix behavior is decided by the index tokenizer (ngram/edge-ngram) \u2014 configure the index instead, or use the tsvector engine"
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
let matchText = args.query;
|
|
352
|
+
if (prefix) {
|
|
353
|
+
const tokens = args.query.match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
354
|
+
if (tokens.length === 0)
|
|
355
|
+
return [];
|
|
356
|
+
matchText = config.engine === "tsvector" ? tokens.map((token) => `${token}:*`).join(" & ") : config.engine === "fts5" ? tokens.map((token) => `"${token}"*`).join(" ") : tokens.map((token) => `+${token}*`).join(" ");
|
|
357
|
+
}
|
|
297
358
|
const take = Math.max(1, Math.trunc(args.take ?? 10));
|
|
298
359
|
const skip = args.skip === undefined ? null : Math.max(0, Math.trunc(args.skip));
|
|
299
360
|
const hlColumn = highlightColumn({ config, args });
|
|
361
|
+
const idOnly = args.select === "id";
|
|
362
|
+
if (idOnly && config.pkColumn === undefined) {
|
|
363
|
+
throw new VibeError2({
|
|
364
|
+
code: "VIBE_VALIDATION",
|
|
365
|
+
message: `select: "id" needs a single-column primary key baked into the client \u2014 ${config.table} has none, or the client predates the projection (run vibeorm generate)`,
|
|
366
|
+
meta: { table: config.table }
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
const projection = idOnly ? `${t}.${quote(config.pkColumn ?? "")}` : `${t}.*`;
|
|
300
370
|
const collector = new ParamCollector(dialect);
|
|
301
371
|
const userWhere = (qualify) => {
|
|
302
372
|
if (args.where === undefined)
|
|
@@ -309,9 +379,10 @@ function createSearchDelegate(config) {
|
|
|
309
379
|
let engineHasHighlights = true;
|
|
310
380
|
if (config.engine === "tsvector") {
|
|
311
381
|
const language = config.language ?? "english";
|
|
382
|
+
const ranker = config.rank ?? "ts_rank_cd";
|
|
312
383
|
const tsv = `${t}.${quote(config.tsvColumn ?? "")}`;
|
|
313
|
-
const tsquery = () => `websearch_to_tsquery('${language}', ${collector.add(
|
|
314
|
-
text = `SELECT ${
|
|
384
|
+
const tsquery = () => prefix ? `to_tsquery('${language}', ${collector.add(matchText)})` : `websearch_to_tsquery('${language}', ${collector.add(matchText)})`;
|
|
385
|
+
text = `SELECT ${projection}, ${ranker}(${tsv}, ${tsquery()}) AS ${quote(SCORE_ALIAS)}`;
|
|
315
386
|
if (hlColumn !== null) {
|
|
316
387
|
text += `, ts_headline('${language}', ${t}.${quote(hlColumn)}, ${tsquery()}) AS ${quote(HIGHLIGHT_ALIAS)}`;
|
|
317
388
|
}
|
|
@@ -320,19 +391,19 @@ function createSearchDelegate(config) {
|
|
|
320
391
|
if (where !== null)
|
|
321
392
|
text += ` AND (${where})`;
|
|
322
393
|
if (args.minScore !== undefined) {
|
|
323
|
-
text += ` AND
|
|
394
|
+
text += ` AND ${ranker}(${tsv}, ${tsquery()}) >= ${collector.add(args.minScore)}`;
|
|
324
395
|
}
|
|
325
396
|
text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
|
|
326
|
-
strip = [config.tsvColumn ?? ""];
|
|
397
|
+
strip = idOnly ? [] : [config.tsvColumn ?? ""];
|
|
327
398
|
} else if (config.engine === "fts5") {
|
|
328
399
|
const fts = quote(config.ftsTable ?? "");
|
|
329
|
-
text = `SELECT ${
|
|
400
|
+
text = `SELECT ${projection}, -bm25(${fts}) AS ${quote(SCORE_ALIAS)}`;
|
|
330
401
|
if (hlColumn !== null) {
|
|
331
402
|
const index = config.fields.findIndex((field) => field.column === hlColumn);
|
|
332
403
|
text += `, snippet(${fts}, ${index}, '<b>', '</b>', '\u2026', 16) AS ${quote(HIGHLIGHT_ALIAS)}`;
|
|
333
404
|
}
|
|
334
405
|
text += ` FROM ${fts} JOIN ${quote(config.table)} AS ${t} ON ${t}.rowid = ${fts}.rowid`;
|
|
335
|
-
text += ` WHERE ${fts} MATCH ${collector.add(
|
|
406
|
+
text += ` WHERE ${fts} MATCH ${collector.add(matchText)}`;
|
|
336
407
|
const where = userWhere("t");
|
|
337
408
|
if (where !== null)
|
|
338
409
|
text += ` AND (${where})`;
|
|
@@ -342,8 +413,9 @@ function createSearchDelegate(config) {
|
|
|
342
413
|
text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
|
|
343
414
|
} else if (config.engine === "fulltext") {
|
|
344
415
|
engineHasHighlights = false;
|
|
345
|
-
const
|
|
346
|
-
|
|
416
|
+
const mode = prefix ? "IN BOOLEAN MODE" : "IN NATURAL LANGUAGE MODE";
|
|
417
|
+
const match = () => `MATCH (${config.fields.map((field) => `${t}.${quote(field.column)}`).join(", ")}) AGAINST (${collector.add(matchText)} ${mode})`;
|
|
418
|
+
text = `SELECT ${projection}, ${match()} AS ${quote(SCORE_ALIAS)} FROM ${quote(config.table)} AS ${t}`;
|
|
347
419
|
text += ` WHERE ${match()}`;
|
|
348
420
|
const where = userWhere("t");
|
|
349
421
|
if (where !== null)
|
|
@@ -354,7 +426,7 @@ function createSearchDelegate(config) {
|
|
|
354
426
|
text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
|
|
355
427
|
} else {
|
|
356
428
|
const pk = `${t}.${quote(config.pkColumn ?? "")}`;
|
|
357
|
-
text = `SELECT ${
|
|
429
|
+
text = `SELECT ${projection}, paradedb.score(${pk}) AS ${quote(SCORE_ALIAS)}`;
|
|
358
430
|
if (hlColumn !== null) {
|
|
359
431
|
text += `, paradedb.snippet(${t}.${quote(hlColumn)}) AS ${quote(HIGHLIGHT_ALIAS)}`;
|
|
360
432
|
}
|
|
@@ -415,6 +487,16 @@ function search(options = {}) {
|
|
|
415
487
|
if (language !== undefined && (typeof language !== "string" || !isSafeSqlName({ name: language }))) {
|
|
416
488
|
return { error: `language must be a plain text-search config name` };
|
|
417
489
|
}
|
|
490
|
+
for (const key of ["columnName", "indexName"]) {
|
|
491
|
+
const value = args.named[key];
|
|
492
|
+
if (value !== undefined && (typeof value !== "string" || !isSafeSqlName({ name: value }))) {
|
|
493
|
+
return { error: `${key} must be a plain identifier` };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const rank = args.named["rank"];
|
|
497
|
+
if (rank !== undefined && rank !== "ts_rank" && rank !== "ts_rank_cd") {
|
|
498
|
+
return { error: `rank must be "ts_rank" or "ts_rank_cd"` };
|
|
499
|
+
}
|
|
418
500
|
return { declaration: { fields, ...args.named } };
|
|
419
501
|
}
|
|
420
502
|
}
|
|
@@ -438,6 +520,8 @@ function search(options = {}) {
|
|
|
438
520
|
});
|
|
439
521
|
}
|
|
440
522
|
const pkField = primaryKeyFieldOf({ model });
|
|
523
|
+
const rank = resolveRank({ decl, options: extOptions, engine, dialect });
|
|
524
|
+
const names = resolveArtifactNames({ decl, engine, dialect });
|
|
441
525
|
const config = {
|
|
442
526
|
engine,
|
|
443
527
|
table: tableNameOf({ model }),
|
|
@@ -445,26 +529,42 @@ function search(options = {}) {
|
|
|
445
529
|
name: field,
|
|
446
530
|
column: columnNameOf({ model, field })
|
|
447
531
|
})),
|
|
448
|
-
...engine === "tsvector" ? { tsvColumn:
|
|
532
|
+
...engine === "tsvector" ? { tsvColumn: names.tsvColumn, language, ...rank === undefined ? {} : { rank } } : {},
|
|
449
533
|
...engine === "fts5" ? { ftsTable: `${decl.base}_fts` } : {},
|
|
450
|
-
...
|
|
534
|
+
...pkField !== null ? { pkColumn: columnNameOf({ model, field: pkField }) } : {}
|
|
451
535
|
};
|
|
452
536
|
const fieldUnion = decl.fields.map((field) => JSON.stringify(field)).join(" | ");
|
|
537
|
+
const argLines = [
|
|
538
|
+
` readonly query: string;`,
|
|
539
|
+
` readonly where?: ${typeNames.whereInput};`,
|
|
540
|
+
` readonly take?: number;`,
|
|
541
|
+
` readonly skip?: number;`,
|
|
542
|
+
` readonly minScore?: number;`,
|
|
543
|
+
` readonly highlight?: { readonly field: ${fieldUnion} };`,
|
|
544
|
+
` readonly prefix?: boolean;`
|
|
545
|
+
];
|
|
546
|
+
const dts = pkField === null ? [
|
|
547
|
+
`readonly search: (args: {`,
|
|
548
|
+
...argLines,
|
|
549
|
+
`}) => Promise<SearchHit<${typeNames.payload}>[]>;`
|
|
550
|
+
] : [
|
|
551
|
+
`readonly search: {`,
|
|
552
|
+
` (args: {`,
|
|
553
|
+
...argLines.map((line) => ` ${line}`),
|
|
554
|
+
` readonly select: "id";`,
|
|
555
|
+
` }): Promise<SearchHit<Pick<${typeNames.payload}, ${JSON.stringify(pkField)}>>[]>;`,
|
|
556
|
+
` (args: {`,
|
|
557
|
+
...argLines.map((line) => ` ${line}`),
|
|
558
|
+
` readonly select?: "row";`,
|
|
559
|
+
` }): Promise<SearchHit<${typeNames.payload}>[]>;`,
|
|
560
|
+
`};`
|
|
561
|
+
];
|
|
453
562
|
return {
|
|
454
563
|
kind: "method",
|
|
455
564
|
runtimeGlue: `createSearchDelegate(${JSON.stringify(config)})`,
|
|
456
565
|
imports: [{ from: "@vibeorm/extensions/search", names: ["createSearchDelegate"] }],
|
|
457
566
|
dtsImports: [{ from: "@vibeorm/extensions/search", names: ["SearchHit"] }],
|
|
458
|
-
dts
|
|
459
|
-
`readonly search: (args: {`,
|
|
460
|
-
` readonly query: string;`,
|
|
461
|
-
` readonly where?: ${typeNames.whereInput};`,
|
|
462
|
-
` readonly take?: number;`,
|
|
463
|
-
` readonly skip?: number;`,
|
|
464
|
-
` readonly minScore?: number;`,
|
|
465
|
-
` readonly highlight?: { readonly field: ${fieldUnion} };`,
|
|
466
|
-
`}) => Promise<SearchHit<${typeNames.payload}>[]>;`
|
|
467
|
-
]
|
|
567
|
+
dts
|
|
468
568
|
};
|
|
469
569
|
}
|
|
470
570
|
},
|
|
@@ -571,7 +671,8 @@ function vectorArtifacts(ctx) {
|
|
|
571
671
|
});
|
|
572
672
|
}
|
|
573
673
|
const column = columnNameOf({ model, field });
|
|
574
|
-
const
|
|
674
|
+
const nameOverride = indexFields.length === 1 ? declString(declaration, "indexName") : undefined;
|
|
675
|
+
const indexName = nameOverride ?? `${table}_${column}_${kindRaw}_idx`;
|
|
575
676
|
const withOptions = [];
|
|
576
677
|
const m = declaration["m"];
|
|
577
678
|
const efConstruction = declaration["efConstruction"];
|
|
@@ -719,6 +820,17 @@ function vector(options = {}) {
|
|
|
719
820
|
return { error: `${key} must be a number` };
|
|
720
821
|
}
|
|
721
822
|
}
|
|
823
|
+
const indexName = args.named["indexName"];
|
|
824
|
+
if (indexName !== undefined) {
|
|
825
|
+
if (typeof indexName !== "string" || !isSafeSqlName({ name: indexName })) {
|
|
826
|
+
return { error: `indexName must be a plain identifier` };
|
|
827
|
+
}
|
|
828
|
+
if (fields.length !== 1) {
|
|
829
|
+
return {
|
|
830
|
+
error: `indexName needs exactly one indexed field \u2014 split @@vector declarations to name each index`
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
}
|
|
722
834
|
return { declaration: { fields, ...args.named } };
|
|
723
835
|
}
|
|
724
836
|
}
|
|
@@ -963,4 +1075,4 @@ export {
|
|
|
963
1075
|
createSearchDelegate
|
|
964
1076
|
};
|
|
965
1077
|
|
|
966
|
-
//# debugId=
|
|
1078
|
+
//# debugId=92CFAFA74775D03064756E2164756E21
|