@vibeorm/extensions 2.0.0-alpha.3 → 2.0.0-alpha.4

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/dist/index.js CHANGED
@@ -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 = `${decl.base}_tsv`;
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: `${decl.base}_idx` },
211
+ owns: { kind: "index", table, name: indexName },
169
212
  create: [
170
- `CREATE INDEX IF NOT EXISTS ${quote(`${decl.base}_idx`)} ON ${quote(table)} USING gin (${quote(tsvColumn)});`
213
+ `CREATE INDEX IF NOT EXISTS ${quote(indexName)} ON ${quote(table)} USING gin (${quote(tsvColumn)});`
171
214
  ],
172
- drop: [`DROP INDEX IF EXISTS ${quote(`${decl.base}_idx`)};`]
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: `${decl.base}_idx` },
232
+ owns: { kind: "index", table, name: bm25Names.indexName },
189
233
  create: [
190
- `CREATE INDEX IF NOT EXISTS ${quote(`${decl.base}_idx`)} ON ${quote(table)} USING bm25 (${[pkColumn, ...columns].map((column) => quote(column)).join(", ")}) WITH (key_field='${pkColumn}');`
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(`${decl.base}_idx`)};`]
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: `${decl.base}_idx` },
293
+ owns: { kind: "index", table, name: fulltextNames.indexName },
248
294
  create: [
249
- `CREATE FULLTEXT INDEX ${quote(`${decl.base}_idx`)} ON ${quote(table)} (${columns.map((column) => quote(column)).join(", ")});`
295
+ `CREATE FULLTEXT INDEX ${quote(fulltextNames.indexName)} ON ${quote(table)} (${columns.map((column) => quote(column)).join(", ")});`
250
296
  ],
251
- drop: [`DROP INDEX ${quote(`${decl.base}_idx`)} ON ${quote(table)};`]
297
+ drop: [`DROP INDEX ${quote(fulltextNames.indexName)} ON ${quote(table)};`]
252
298
  });
253
299
  }
254
300
  return artifacts;
@@ -297,6 +343,15 @@ function createSearchDelegate(config) {
297
343
  const take = Math.max(1, Math.trunc(args.take ?? 10));
298
344
  const skip = args.skip === undefined ? null : Math.max(0, Math.trunc(args.skip));
299
345
  const hlColumn = highlightColumn({ config, args });
346
+ const idOnly = args.select === "id";
347
+ if (idOnly && config.pkColumn === undefined) {
348
+ throw new VibeError2({
349
+ code: "VIBE_VALIDATION",
350
+ 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)`,
351
+ meta: { table: config.table }
352
+ });
353
+ }
354
+ const projection = idOnly ? `${t}.${quote(config.pkColumn ?? "")}` : `${t}.*`;
300
355
  const collector = new ParamCollector(dialect);
301
356
  const userWhere = (qualify) => {
302
357
  if (args.where === undefined)
@@ -309,9 +364,10 @@ function createSearchDelegate(config) {
309
364
  let engineHasHighlights = true;
310
365
  if (config.engine === "tsvector") {
311
366
  const language = config.language ?? "english";
367
+ const ranker = config.rank ?? "ts_rank_cd";
312
368
  const tsv = `${t}.${quote(config.tsvColumn ?? "")}`;
313
369
  const tsquery = () => `websearch_to_tsquery('${language}', ${collector.add(args.query)})`;
314
- text = `SELECT ${t}.*, ts_rank_cd(${tsv}, ${tsquery()}) AS ${quote(SCORE_ALIAS)}`;
370
+ text = `SELECT ${projection}, ${ranker}(${tsv}, ${tsquery()}) AS ${quote(SCORE_ALIAS)}`;
315
371
  if (hlColumn !== null) {
316
372
  text += `, ts_headline('${language}', ${t}.${quote(hlColumn)}, ${tsquery()}) AS ${quote(HIGHLIGHT_ALIAS)}`;
317
373
  }
@@ -320,13 +376,13 @@ function createSearchDelegate(config) {
320
376
  if (where !== null)
321
377
  text += ` AND (${where})`;
322
378
  if (args.minScore !== undefined) {
323
- text += ` AND ts_rank_cd(${tsv}, ${tsquery()}) >= ${collector.add(args.minScore)}`;
379
+ text += ` AND ${ranker}(${tsv}, ${tsquery()}) >= ${collector.add(args.minScore)}`;
324
380
  }
325
381
  text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
326
- strip = [config.tsvColumn ?? ""];
382
+ strip = idOnly ? [] : [config.tsvColumn ?? ""];
327
383
  } else if (config.engine === "fts5") {
328
384
  const fts = quote(config.ftsTable ?? "");
329
- text = `SELECT ${t}.*, -bm25(${fts}) AS ${quote(SCORE_ALIAS)}`;
385
+ text = `SELECT ${projection}, -bm25(${fts}) AS ${quote(SCORE_ALIAS)}`;
330
386
  if (hlColumn !== null) {
331
387
  const index = config.fields.findIndex((field) => field.column === hlColumn);
332
388
  text += `, snippet(${fts}, ${index}, '<b>', '</b>', '\u2026', 16) AS ${quote(HIGHLIGHT_ALIAS)}`;
@@ -343,7 +399,7 @@ function createSearchDelegate(config) {
343
399
  } else if (config.engine === "fulltext") {
344
400
  engineHasHighlights = false;
345
401
  const match = () => `MATCH (${config.fields.map((field) => `${t}.${quote(field.column)}`).join(", ")}) AGAINST (${collector.add(args.query)} IN NATURAL LANGUAGE MODE)`;
346
- text = `SELECT ${t}.*, ${match()} AS ${quote(SCORE_ALIAS)} FROM ${quote(config.table)} AS ${t}`;
402
+ text = `SELECT ${projection}, ${match()} AS ${quote(SCORE_ALIAS)} FROM ${quote(config.table)} AS ${t}`;
347
403
  text += ` WHERE ${match()}`;
348
404
  const where = userWhere("t");
349
405
  if (where !== null)
@@ -354,7 +410,7 @@ function createSearchDelegate(config) {
354
410
  text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
355
411
  } else {
356
412
  const pk = `${t}.${quote(config.pkColumn ?? "")}`;
357
- text = `SELECT ${t}.*, paradedb.score(${pk}) AS ${quote(SCORE_ALIAS)}`;
413
+ text = `SELECT ${projection}, paradedb.score(${pk}) AS ${quote(SCORE_ALIAS)}`;
358
414
  if (hlColumn !== null) {
359
415
  text += `, paradedb.snippet(${t}.${quote(hlColumn)}) AS ${quote(HIGHLIGHT_ALIAS)}`;
360
416
  }
@@ -415,6 +471,16 @@ function search(options = {}) {
415
471
  if (language !== undefined && (typeof language !== "string" || !isSafeSqlName({ name: language }))) {
416
472
  return { error: `language must be a plain text-search config name` };
417
473
  }
474
+ for (const key of ["columnName", "indexName"]) {
475
+ const value = args.named[key];
476
+ if (value !== undefined && (typeof value !== "string" || !isSafeSqlName({ name: value }))) {
477
+ return { error: `${key} must be a plain identifier` };
478
+ }
479
+ }
480
+ const rank = args.named["rank"];
481
+ if (rank !== undefined && rank !== "ts_rank" && rank !== "ts_rank_cd") {
482
+ return { error: `rank must be "ts_rank" or "ts_rank_cd"` };
483
+ }
418
484
  return { declaration: { fields, ...args.named } };
419
485
  }
420
486
  }
@@ -438,6 +504,8 @@ function search(options = {}) {
438
504
  });
439
505
  }
440
506
  const pkField = primaryKeyFieldOf({ model });
507
+ const rank = resolveRank({ decl, options: extOptions, engine, dialect });
508
+ const names = resolveArtifactNames({ decl, engine, dialect });
441
509
  const config = {
442
510
  engine,
443
511
  table: tableNameOf({ model }),
@@ -445,26 +513,41 @@ function search(options = {}) {
445
513
  name: field,
446
514
  column: columnNameOf({ model, field })
447
515
  })),
448
- ...engine === "tsvector" ? { tsvColumn: `${decl.base}_tsv`, language } : {},
516
+ ...engine === "tsvector" ? { tsvColumn: names.tsvColumn, language, ...rank === undefined ? {} : { rank } } : {},
449
517
  ...engine === "fts5" ? { ftsTable: `${decl.base}_fts` } : {},
450
- ...engine === "bm25" && pkField !== null ? { pkColumn: columnNameOf({ model, field: pkField }) } : {}
518
+ ...pkField !== null ? { pkColumn: columnNameOf({ model, field: pkField }) } : {}
451
519
  };
452
520
  const fieldUnion = decl.fields.map((field) => JSON.stringify(field)).join(" | ");
521
+ const argLines = [
522
+ ` readonly query: string;`,
523
+ ` readonly where?: ${typeNames.whereInput};`,
524
+ ` readonly take?: number;`,
525
+ ` readonly skip?: number;`,
526
+ ` readonly minScore?: number;`,
527
+ ` readonly highlight?: { readonly field: ${fieldUnion} };`
528
+ ];
529
+ const dts = pkField === null ? [
530
+ `readonly search: (args: {`,
531
+ ...argLines,
532
+ `}) => Promise<SearchHit<${typeNames.payload}>[]>;`
533
+ ] : [
534
+ `readonly search: {`,
535
+ ` (args: {`,
536
+ ...argLines.map((line) => ` ${line}`),
537
+ ` readonly select: "id";`,
538
+ ` }): Promise<SearchHit<Pick<${typeNames.payload}, ${JSON.stringify(pkField)}>>[]>;`,
539
+ ` (args: {`,
540
+ ...argLines.map((line) => ` ${line}`),
541
+ ` readonly select?: "row";`,
542
+ ` }): Promise<SearchHit<${typeNames.payload}>[]>;`,
543
+ `};`
544
+ ];
453
545
  return {
454
546
  kind: "method",
455
547
  runtimeGlue: `createSearchDelegate(${JSON.stringify(config)})`,
456
548
  imports: [{ from: "@vibeorm/extensions/search", names: ["createSearchDelegate"] }],
457
549
  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
- ]
550
+ dts
468
551
  };
469
552
  }
470
553
  },
@@ -963,4 +1046,4 @@ export {
963
1046
  createSearchDelegate
964
1047
  };
965
1048
 
966
- //# debugId=B1B4930A9A8DBD3964756E2164756E21
1049
+ //# debugId=B16F8B32993DB05B64756E2164756E21