graphile-search 1.1.0

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.
Files changed (59) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +123 -0
  3. package/adapters/bm25.d.ts +32 -0
  4. package/adapters/bm25.js +119 -0
  5. package/adapters/index.d.ts +14 -0
  6. package/adapters/index.js +17 -0
  7. package/adapters/pgvector.d.ts +21 -0
  8. package/adapters/pgvector.js +125 -0
  9. package/adapters/trgm.d.ts +20 -0
  10. package/adapters/trgm.js +83 -0
  11. package/adapters/tsvector.d.ts +20 -0
  12. package/adapters/tsvector.js +60 -0
  13. package/codecs/bm25-codec.d.ts +42 -0
  14. package/codecs/bm25-codec.js +199 -0
  15. package/codecs/index.d.ts +12 -0
  16. package/codecs/index.js +22 -0
  17. package/codecs/operator-factories.d.ts +22 -0
  18. package/codecs/operator-factories.js +84 -0
  19. package/codecs/tsvector-codec.d.ts +53 -0
  20. package/codecs/tsvector-codec.js +162 -0
  21. package/codecs/vector-codec.d.ts +18 -0
  22. package/codecs/vector-codec.js +116 -0
  23. package/esm/adapters/bm25.d.ts +32 -0
  24. package/esm/adapters/bm25.js +116 -0
  25. package/esm/adapters/index.d.ts +14 -0
  26. package/esm/adapters/index.js +10 -0
  27. package/esm/adapters/pgvector.d.ts +21 -0
  28. package/esm/adapters/pgvector.js +122 -0
  29. package/esm/adapters/trgm.d.ts +20 -0
  30. package/esm/adapters/trgm.js +80 -0
  31. package/esm/adapters/tsvector.d.ts +20 -0
  32. package/esm/adapters/tsvector.js +57 -0
  33. package/esm/codecs/bm25-codec.d.ts +42 -0
  34. package/esm/codecs/bm25-codec.js +160 -0
  35. package/esm/codecs/index.d.ts +12 -0
  36. package/esm/codecs/index.js +10 -0
  37. package/esm/codecs/operator-factories.d.ts +22 -0
  38. package/esm/codecs/operator-factories.js +80 -0
  39. package/esm/codecs/tsvector-codec.d.ts +53 -0
  40. package/esm/codecs/tsvector-codec.js +155 -0
  41. package/esm/codecs/vector-codec.d.ts +18 -0
  42. package/esm/codecs/vector-codec.js +110 -0
  43. package/esm/index.d.ts +40 -0
  44. package/esm/index.js +41 -0
  45. package/esm/plugin.d.ts +50 -0
  46. package/esm/plugin.js +553 -0
  47. package/esm/preset.d.ts +79 -0
  48. package/esm/preset.js +82 -0
  49. package/esm/types.d.ts +171 -0
  50. package/esm/types.js +7 -0
  51. package/index.d.ts +40 -0
  52. package/index.js +60 -0
  53. package/package.json +66 -0
  54. package/plugin.d.ts +50 -0
  55. package/plugin.js +556 -0
  56. package/preset.d.ts +79 -0
  57. package/preset.js +85 -0
  58. package/types.d.ts +171 -0
  59. package/types.js +8 -0
package/plugin.js ADDED
@@ -0,0 +1,556 @@
1
+ "use strict";
2
+ /**
3
+ * Unified Search Plugin
4
+ *
5
+ * A single Graphile plugin that iterates over all registered SearchAdapters
6
+ * and wires their column detection, filter fields, score fields, and orderBy
7
+ * enums into the Graphile v5 hook system.
8
+ *
9
+ * This replaces the need for separate plugins per algorithm — one plugin,
10
+ * multiple adapters.
11
+ *
12
+ * ARCHITECTURE:
13
+ * - init hook: calls adapter.registerTypes() for each adapter
14
+ * - GraphQLObjectType_fields hook: adds score fields for each adapter's columns
15
+ * - GraphQLEnumType_values hook: adds orderBy enums for each adapter's columns
16
+ * - GraphQLInputObjectType_fields hook: adds filter fields for each adapter's columns
17
+ *
18
+ * Uses the same Grafast meta system (setMeta/getMeta) as the individual plugins.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.createUnifiedSearchPlugin = createUnifiedSearchPlugin;
22
+ require("graphile-build");
23
+ require("graphile-build-pg");
24
+ require("graphile-connection-filter");
25
+ const pg_1 = require("@dataplan/pg");
26
+ const graphile_connection_filter_1 = require("graphile-connection-filter");
27
+ /**
28
+ * Creates the unified search plugin with the given options.
29
+ */
30
+ function createUnifiedSearchPlugin(options) {
31
+ const { adapters, enableSearchScore = true, enableFullTextSearch = true } = options;
32
+ // Per-codec cache of discovered columns, keyed by codec name
33
+ const codecCache = new Map();
34
+ /**
35
+ * Get (or compute) the adapter columns for a given codec.
36
+ */
37
+ function getAdapterColumns(codec, build) {
38
+ const cacheKey = codec.name;
39
+ if (codecCache.has(cacheKey)) {
40
+ return codecCache.get(cacheKey);
41
+ }
42
+ const results = [];
43
+ for (const adapter of adapters) {
44
+ const columns = adapter.detectColumns(codec, build);
45
+ if (columns.length > 0) {
46
+ results.push({ adapter, columns });
47
+ }
48
+ }
49
+ codecCache.set(cacheKey, results);
50
+ return results;
51
+ }
52
+ return {
53
+ name: 'UnifiedSearchPlugin',
54
+ version: '1.0.0',
55
+ description: 'Unified search plugin — abstracts tsvector, BM25, pg_trgm, and pgvector behind a single adapter-based architecture',
56
+ after: [
57
+ 'PgAttributesPlugin',
58
+ 'PgConnectionArgFilterPlugin',
59
+ 'PgConnectionArgFilterAttributesPlugin',
60
+ 'PgConnectionArgFilterOperatorsPlugin',
61
+ 'AddConnectionFilterOperatorPlugin',
62
+ // Allow individual codec plugins to load first (e.g. Bm25CodecPlugin)
63
+ 'Bm25CodecPlugin',
64
+ 'VectorCodecPlugin',
65
+ ],
66
+ // ─── Custom Inflection Methods ─────────────────────────────────────
67
+ inflection: {
68
+ add: {
69
+ pgSearchScore(_preset, fieldName, algorithmName, metricName) {
70
+ // Dedup: if fieldName already ends with the algorithm name, skip it
71
+ const algoLower = algorithmName.toLowerCase();
72
+ const fieldLower = fieldName.toLowerCase();
73
+ const algoSuffix = fieldLower.endsWith(algoLower) ? '' : `-${algorithmName}`;
74
+ return this.camelCase(`${fieldName}${algoSuffix}-${metricName}`);
75
+ },
76
+ pgSearchOrderByEnum(_preset, codec, attributeName, algorithmName, metricName, ascending) {
77
+ const columnName = this._attributeName({
78
+ codec,
79
+ attributeName,
80
+ skipRowId: true,
81
+ });
82
+ // Dedup: if columnName already ends with the algorithm, skip it
83
+ const algoLower = algorithmName.toLowerCase();
84
+ const colLower = columnName.toLowerCase();
85
+ const algoSuffix = colLower.endsWith(`_${algoLower}`) || colLower.endsWith(algoLower)
86
+ ? '' : `_${algorithmName}`;
87
+ return this.constantCase(`${columnName}${algoSuffix}_${metricName}_${ascending ? 'asc' : 'desc'}`);
88
+ },
89
+ },
90
+ },
91
+ schema: {
92
+ // ─── Behavior Registry ─────────────────────────────────────────────
93
+ behaviorRegistry: {
94
+ add: {
95
+ 'unifiedSearch:select': {
96
+ description: 'Should unified search score fields be exposed for this attribute',
97
+ entities: ['pgCodecAttribute'],
98
+ },
99
+ 'unifiedSearch:orderBy': {
100
+ description: 'Should unified search orderBy enums be exposed for this attribute',
101
+ entities: ['pgCodecAttribute'],
102
+ },
103
+ },
104
+ },
105
+ entityBehavior: {
106
+ pgCodecAttribute: {
107
+ inferred: {
108
+ provides: ['default'],
109
+ before: ['inferred', 'override', 'PgAttributesPlugin'],
110
+ callback(behavior, [codec, attributeName], build) {
111
+ // Check if any adapter claims this column
112
+ for (const adapter of adapters) {
113
+ const columns = adapter.detectColumns(codec, build);
114
+ if (columns.some((c) => c.attributeName === attributeName)) {
115
+ return [
116
+ 'unifiedSearch:orderBy',
117
+ 'unifiedSearch:select',
118
+ behavior,
119
+ ];
120
+ }
121
+ }
122
+ return behavior;
123
+ },
124
+ },
125
+ },
126
+ },
127
+ hooks: {
128
+ /**
129
+ * Register all adapter-specific GraphQL types during init.
130
+ */
131
+ init(_, build) {
132
+ for (const adapter of adapters) {
133
+ adapter.registerTypes(build);
134
+ }
135
+ return _;
136
+ },
137
+ /**
138
+ * Add score/rank/similarity/distance fields for each adapter's columns
139
+ * on the appropriate output types.
140
+ */
141
+ GraphQLObjectType_fields(fields, build, context) {
142
+ const { inflection, graphql: { GraphQLFloat }, grafast: { lambda }, } = build;
143
+ const { scope: { isPgClassType, pgCodec: rawPgCodec }, fieldWithHooks, } = context;
144
+ if (!isPgClassType || !rawPgCodec?.attributes) {
145
+ return fields;
146
+ }
147
+ const codec = rawPgCodec;
148
+ const adapterColumns = getAdapterColumns(codec, build);
149
+ if (adapterColumns.length === 0) {
150
+ return fields;
151
+ }
152
+ let newFields = fields;
153
+ for (const { adapter, columns } of adapterColumns) {
154
+ for (const column of columns) {
155
+ const baseFieldName = inflection.attribute({
156
+ codec: codec,
157
+ attributeName: column.attributeName,
158
+ });
159
+ const fieldName = inflection.pgSearchScore(baseFieldName, adapter.name, adapter.scoreSemantics.metric);
160
+ const metaKey = `__unified_search_${adapter.name}_${baseFieldName}`;
161
+ newFields = build.extend(newFields, {
162
+ [fieldName]: fieldWithHooks({
163
+ fieldName,
164
+ isUnifiedSearchScoreField: true,
165
+ }, () => ({
166
+ description: `${adapter.name.toUpperCase()} ${adapter.scoreSemantics.metric} when searching \`${baseFieldName}\`. Returns null when no ${adapter.name} search filter is active.`,
167
+ type: GraphQLFloat,
168
+ plan($step) {
169
+ const $row = $step;
170
+ const $select = typeof $row.getClassStep === 'function'
171
+ ? $row.getClassStep()
172
+ : null;
173
+ if (!$select)
174
+ return build.grafast.constant(null);
175
+ if (typeof $select.setInliningForbidden === 'function') {
176
+ $select.setInliningForbidden();
177
+ }
178
+ const $details = $select.getMeta(metaKey);
179
+ return lambda([$details, $row], ([details, row]) => {
180
+ const d = details;
181
+ if (d == null || row == null || d.selectIndex == null) {
182
+ return null;
183
+ }
184
+ const rawValue = row[d.selectIndex];
185
+ return rawValue == null
186
+ ? null
187
+ : pg_1.TYPES.float.fromPg(rawValue);
188
+ });
189
+ },
190
+ })),
191
+ }, `UnifiedSearchPlugin adding ${adapter.name} ${adapter.scoreSemantics.metric} field '${fieldName}' for '${column.attributeName}' on '${codec.name}'`);
192
+ }
193
+ }
194
+ // ── Composite searchScore field ──
195
+ if (enableSearchScore && adapterColumns.length > 0) {
196
+ // Collect all meta keys for all adapters/columns so the
197
+ // composite field can read them at execution time
198
+ const allMetaKeys = [];
199
+ for (const { adapter, columns } of adapterColumns) {
200
+ for (const column of columns) {
201
+ const baseFieldName = inflection.attribute({
202
+ codec: codec,
203
+ attributeName: column.attributeName,
204
+ });
205
+ allMetaKeys.push({
206
+ adapterName: adapter.name,
207
+ metaKey: `__unified_search_${adapter.name}_${baseFieldName}`,
208
+ lowerIsBetter: adapter.scoreSemantics.lowerIsBetter,
209
+ range: adapter.scoreSemantics.range,
210
+ });
211
+ }
212
+ }
213
+ newFields = build.extend(newFields, {
214
+ searchScore: fieldWithHooks({
215
+ fieldName: 'searchScore',
216
+ isUnifiedSearchScoreField: true,
217
+ }, () => ({
218
+ description: 'Composite search relevance score (0..1, higher = more relevant). ' +
219
+ 'Computed by normalizing and averaging all active search signals. ' +
220
+ 'Returns null when no search filters are active.',
221
+ type: GraphQLFloat,
222
+ plan($step) {
223
+ const $row = $step;
224
+ const $select = typeof $row.getClassStep === 'function'
225
+ ? $row.getClassStep()
226
+ : null;
227
+ if (!$select)
228
+ return build.grafast.constant(null);
229
+ if (typeof $select.setInliningForbidden === 'function') {
230
+ $select.setInliningForbidden();
231
+ }
232
+ // Collect all meta steps for all adapters
233
+ const $metaSteps = allMetaKeys.map((mk) => $select.getMeta(mk.metaKey));
234
+ return lambda([...$metaSteps, $row], (args) => {
235
+ const row = args[args.length - 1];
236
+ if (row == null)
237
+ return null;
238
+ let sum = 0;
239
+ let count = 0;
240
+ for (let i = 0; i < allMetaKeys.length; i++) {
241
+ const details = args[i];
242
+ if (details == null || details.selectIndex == null)
243
+ continue;
244
+ const rawValue = row[details.selectIndex];
245
+ if (rawValue == null)
246
+ continue;
247
+ const score = pg_1.TYPES.float.fromPg(rawValue);
248
+ if (typeof score !== 'number' || isNaN(score))
249
+ continue;
250
+ const mk = allMetaKeys[i];
251
+ // Normalize to 0..1 (higher = better)
252
+ let normalized;
253
+ if (mk.range) {
254
+ // Known range: linear normalization
255
+ const [min, max] = mk.range;
256
+ normalized = mk.lowerIsBetter
257
+ ? 1 - (score - min) / (max - min)
258
+ : (score - min) / (max - min);
259
+ }
260
+ else {
261
+ // Unbounded: sigmoid normalization
262
+ if (mk.lowerIsBetter) {
263
+ // BM25: negative scores, more negative = better
264
+ // Map via 1 / (1 + abs(score))
265
+ normalized = 1 / (1 + Math.abs(score));
266
+ }
267
+ else {
268
+ // Hypothetical unbounded higher-is-better
269
+ normalized = score / (1 + score);
270
+ }
271
+ }
272
+ // Clamp to [0, 1]
273
+ normalized = Math.max(0, Math.min(1, normalized));
274
+ sum += normalized;
275
+ count++;
276
+ }
277
+ if (count === 0)
278
+ return null;
279
+ // Apply optional weights
280
+ if (options.searchScoreWeights) {
281
+ let weightedSum = 0;
282
+ let totalWeight = 0;
283
+ let weightIdx = 0;
284
+ for (let i = 0; i < allMetaKeys.length; i++) {
285
+ const details = args[i];
286
+ if (details == null || details.selectIndex == null)
287
+ continue;
288
+ const rawValue = row[details.selectIndex];
289
+ if (rawValue == null)
290
+ continue;
291
+ const mk = allMetaKeys[i];
292
+ const weight = options.searchScoreWeights[mk.adapterName] ?? 1;
293
+ const score = pg_1.TYPES.float.fromPg(rawValue);
294
+ if (typeof score !== 'number' || isNaN(score))
295
+ continue;
296
+ let normalized;
297
+ if (mk.range) {
298
+ const [min, max] = mk.range;
299
+ normalized = mk.lowerIsBetter
300
+ ? 1 - (score - min) / (max - min)
301
+ : (score - min) / (max - min);
302
+ }
303
+ else {
304
+ if (mk.lowerIsBetter) {
305
+ normalized = 1 / (1 + Math.abs(score));
306
+ }
307
+ else {
308
+ normalized = score / (1 + score);
309
+ }
310
+ }
311
+ normalized = Math.max(0, Math.min(1, normalized));
312
+ weightedSum += normalized * weight;
313
+ totalWeight += weight;
314
+ weightIdx++;
315
+ }
316
+ return totalWeight > 0 ? weightedSum / totalWeight : null;
317
+ }
318
+ return sum / count;
319
+ });
320
+ },
321
+ })),
322
+ }, `UnifiedSearchPlugin adding composite searchScore field on '${codec.name}'`);
323
+ }
324
+ return newFields;
325
+ },
326
+ /**
327
+ * Add orderBy enum values for each adapter's score metrics.
328
+ */
329
+ GraphQLEnumType_values(values, build, context) {
330
+ const { inflection } = build;
331
+ const { scope: { isPgRowSortEnum, pgCodec: rawPgCodec }, } = context;
332
+ if (!isPgRowSortEnum || !rawPgCodec?.attributes) {
333
+ return values;
334
+ }
335
+ const codec = rawPgCodec;
336
+ const adapterColumns = getAdapterColumns(codec, build);
337
+ if (adapterColumns.length === 0) {
338
+ return values;
339
+ }
340
+ let newValues = values;
341
+ for (const { adapter, columns } of adapterColumns) {
342
+ for (const column of columns) {
343
+ const baseFieldName = inflection.attribute({
344
+ codec: codec,
345
+ attributeName: column.attributeName,
346
+ });
347
+ const metaKey = `unified_order_${adapter.name}_${baseFieldName}`;
348
+ const makePlan = (direction) => (step) => {
349
+ if (typeof step.setMeta === 'function') {
350
+ step.setMeta(metaKey, direction);
351
+ }
352
+ };
353
+ const ascName = inflection.pgSearchOrderByEnum(codec, column.attributeName, adapter.name, adapter.scoreSemantics.metric, true);
354
+ const descName = inflection.pgSearchOrderByEnum(codec, column.attributeName, adapter.name, adapter.scoreSemantics.metric, false);
355
+ newValues = build.extend(newValues, {
356
+ [ascName]: {
357
+ extensions: {
358
+ grafast: {
359
+ apply: makePlan('ASC'),
360
+ },
361
+ },
362
+ },
363
+ [descName]: {
364
+ extensions: {
365
+ grafast: {
366
+ apply: makePlan('DESC'),
367
+ },
368
+ },
369
+ },
370
+ }, `UnifiedSearchPlugin adding ${adapter.name} orderBy for '${column.attributeName}' on '${codec.name}'`);
371
+ }
372
+ }
373
+ // ── Composite SEARCH_SCORE orderBy ──
374
+ if (enableSearchScore && adapterColumns.length > 0) {
375
+ const searchScoreAscName = inflection.constantCase('search_score_asc');
376
+ const searchScoreDescName = inflection.constantCase('search_score_desc');
377
+ const makeSearchScorePlan = (direction) => (step) => {
378
+ if (typeof step.setMeta === 'function') {
379
+ step.setMeta('unified_order_search_score', direction);
380
+ }
381
+ };
382
+ newValues = build.extend(newValues, {
383
+ [searchScoreAscName]: {
384
+ extensions: {
385
+ grafast: {
386
+ apply: makeSearchScorePlan('ASC'),
387
+ },
388
+ },
389
+ },
390
+ [searchScoreDescName]: {
391
+ extensions: {
392
+ grafast: {
393
+ apply: makeSearchScorePlan('DESC'),
394
+ },
395
+ },
396
+ },
397
+ }, `UnifiedSearchPlugin adding composite SEARCH_SCORE orderBy on '${codec.name}'`);
398
+ }
399
+ return newValues;
400
+ },
401
+ /**
402
+ * Add filter fields for each adapter's columns on connection filter
403
+ * input types.
404
+ */
405
+ GraphQLInputObjectType_fields(fields, build, context) {
406
+ const { inflection, sql } = build;
407
+ const { scope: { isPgConnectionFilter, pgCodec } = {}, fieldWithHooks, } = context;
408
+ if (!isPgConnectionFilter ||
409
+ !pgCodec ||
410
+ !pgCodec.attributes ||
411
+ pgCodec.isAnonymous) {
412
+ return fields;
413
+ }
414
+ const codec = pgCodec;
415
+ const adapterColumns = getAdapterColumns(codec, build);
416
+ if (adapterColumns.length === 0) {
417
+ return fields;
418
+ }
419
+ let newFields = fields;
420
+ for (const { adapter, columns } of adapterColumns) {
421
+ for (const column of columns) {
422
+ const fieldName = inflection.camelCase(`${adapter.filterPrefix}_${column.attributeName}`);
423
+ const baseFieldName = inflection.attribute({
424
+ codec: pgCodec,
425
+ attributeName: column.attributeName,
426
+ });
427
+ const scoreMetaKey = `__unified_search_${adapter.name}_${baseFieldName}`;
428
+ newFields = build.extend(newFields, {
429
+ [fieldName]: fieldWithHooks({
430
+ fieldName,
431
+ isPgConnectionFilterField: true,
432
+ }, {
433
+ description: build.wrapDescription(`${adapter.name.toUpperCase()} search on the \`${column.attributeName}\` column.`, 'field'),
434
+ type: build.getTypeByName(adapter.getFilterTypeName(build)),
435
+ apply: function plan($condition, val) {
436
+ if (val == null)
437
+ return;
438
+ const result = adapter.buildFilterApply(sql, $condition.alias, column, val, build);
439
+ if (!result)
440
+ return;
441
+ // Apply WHERE clause
442
+ if (result.whereClause) {
443
+ $condition.where(result.whereClause);
444
+ }
445
+ // Get the query builder for SELECT/ORDER BY injection
446
+ const qb = (0, graphile_connection_filter_1.getQueryBuilder)(build, $condition);
447
+ if (qb && qb.mode === 'normal') {
448
+ // Add score to the SELECT list
449
+ const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
450
+ const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
451
+ // Store the select index in meta for the output field plan
452
+ qb.setMeta(scoreMetaKey, {
453
+ selectIndex: scoreIndex,
454
+ });
455
+ }
456
+ // ORDER BY: only add when explicitly requested
457
+ if (qb && typeof qb.getMetaRaw === 'function') {
458
+ const orderMetaKey = `unified_order_${adapter.name}_${baseFieldName}`;
459
+ const explicitDir = qb.getMetaRaw(orderMetaKey);
460
+ if (explicitDir) {
461
+ qb.orderBy({
462
+ fragment: result.scoreExpression,
463
+ codec: pg_1.TYPES.float,
464
+ direction: explicitDir,
465
+ });
466
+ }
467
+ }
468
+ },
469
+ }),
470
+ }, `UnifiedSearchPlugin adding ${adapter.name} filter field '${fieldName}' for '${column.attributeName}' on '${codec.name}'`);
471
+ }
472
+ }
473
+ // ── fullTextSearch composite filter ──
474
+ // Adds a single `fullTextSearch: String` field that fans out the same
475
+ // text query to all adapters where supportsTextSearch is true.
476
+ // WHERE clauses are combined with OR (match ANY algorithm).
477
+ if (enableFullTextSearch) {
478
+ // Collect text-compatible adapters and their columns for this codec
479
+ const textAdapterColumns = adapterColumns.filter((ac) => ac.adapter.supportsTextSearch && ac.adapter.buildTextSearchInput);
480
+ if (textAdapterColumns.length > 0) {
481
+ const fieldName = 'fullTextSearch';
482
+ newFields = build.extend(newFields, {
483
+ [fieldName]: fieldWithHooks({
484
+ fieldName,
485
+ isPgConnectionFilterField: true,
486
+ }, {
487
+ description: build.wrapDescription('Composite full-text search. Provide a search string and it will be dispatched ' +
488
+ 'to all text-compatible search algorithms (tsvector, BM25, pg_trgm) simultaneously. ' +
489
+ 'Rows matching ANY algorithm are returned. All matching score fields are populated.', 'field'),
490
+ type: build.graphql.GraphQLString,
491
+ apply: function plan($condition, val) {
492
+ if (val == null || (typeof val === 'string' && val.trim().length === 0))
493
+ return;
494
+ const text = typeof val === 'string' ? val : String(val);
495
+ const qb = (0, graphile_connection_filter_1.getQueryBuilder)(build, $condition);
496
+ // Collect all WHERE clauses (combined with OR)
497
+ const whereClauses = [];
498
+ for (const { adapter, columns } of textAdapterColumns) {
499
+ for (const column of columns) {
500
+ // Convert text to adapter-specific filter input
501
+ const filterInput = adapter.buildTextSearchInput(text);
502
+ const result = adapter.buildFilterApply(sql, $condition.alias, column, filterInput, build);
503
+ if (!result)
504
+ continue;
505
+ // Collect WHERE clause for OR combination
506
+ if (result.whereClause) {
507
+ whereClauses.push(result.whereClause);
508
+ }
509
+ // Still inject score into SELECT so score fields are populated
510
+ if (qb && qb.mode === 'normal') {
511
+ const baseFieldName = inflection.attribute({
512
+ codec: pgCodec,
513
+ attributeName: column.attributeName,
514
+ });
515
+ const scoreMetaKey = `__unified_search_${adapter.name}_${baseFieldName}`;
516
+ const wrappedScoreSql = sql `${sql.parens(result.scoreExpression)}::text`;
517
+ const scoreIndex = qb.selectAndReturnIndex(wrappedScoreSql);
518
+ qb.setMeta(scoreMetaKey, {
519
+ selectIndex: scoreIndex,
520
+ });
521
+ // ORDER BY: only add when explicitly requested via orderBy enum
522
+ if (typeof qb.getMetaRaw === 'function') {
523
+ const orderMetaKey = `unified_order_${adapter.name}_${baseFieldName}`;
524
+ const explicitDir = qb.getMetaRaw(orderMetaKey);
525
+ if (explicitDir) {
526
+ qb.orderBy({
527
+ fragment: result.scoreExpression,
528
+ codec: pg_1.TYPES.float,
529
+ direction: explicitDir,
530
+ });
531
+ }
532
+ }
533
+ }
534
+ }
535
+ }
536
+ // Apply combined WHERE with OR
537
+ if (whereClauses.length > 0) {
538
+ if (whereClauses.length === 1) {
539
+ $condition.where(whereClauses[0]);
540
+ }
541
+ else {
542
+ const combined = sql.fragment `(${sql.join(whereClauses, ' OR ')})`;
543
+ $condition.where(combined);
544
+ }
545
+ }
546
+ },
547
+ }),
548
+ }, `UnifiedSearchPlugin adding fullTextSearch composite filter on '${codec.name}'`);
549
+ }
550
+ }
551
+ return newFields;
552
+ },
553
+ },
554
+ },
555
+ };
556
+ }
package/preset.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Unified Search Plugin Preset
3
+ *
4
+ * Convenience preset that bundles the unified search plugin with all 4 adapters
5
+ * plus the codec plugins that teach PostGraphile about tsvector, bm25query,
6
+ * and vector types.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { UnifiedSearchPreset } from 'graphile-search';
11
+ *
12
+ * const preset = {
13
+ * extends: [
14
+ * UnifiedSearchPreset(),
15
+ * ],
16
+ * };
17
+ * ```
18
+ */
19
+ import type { GraphileConfig } from 'graphile-config';
20
+ import type { TsvectorAdapterOptions } from './adapters/tsvector';
21
+ import type { Bm25AdapterOptions } from './adapters/bm25';
22
+ import type { TrgmAdapterOptions } from './adapters/trgm';
23
+ import type { PgvectorAdapterOptions } from './adapters/pgvector';
24
+ /**
25
+ * Options for configuring which adapters are enabled and their settings.
26
+ */
27
+ export interface UnifiedSearchPresetOptions {
28
+ /**
29
+ * Enable tsvector adapter. Pass true for defaults, or an options object.
30
+ * @default true
31
+ */
32
+ tsvector?: boolean | TsvectorAdapterOptions;
33
+ /**
34
+ * Enable BM25 adapter. Pass true for defaults, or an options object.
35
+ * @default true
36
+ */
37
+ bm25?: boolean | Bm25AdapterOptions;
38
+ /**
39
+ * Enable pg_trgm adapter. Pass true for defaults, or an options object.
40
+ * @default true
41
+ */
42
+ trgm?: boolean | TrgmAdapterOptions;
43
+ /**
44
+ * Enable pgvector adapter. Pass true for defaults, or an options object.
45
+ * @default true
46
+ */
47
+ pgvector?: boolean | PgvectorAdapterOptions;
48
+ /**
49
+ * Whether to expose the composite `searchScore` field.
50
+ * @default true
51
+ */
52
+ enableSearchScore?: boolean;
53
+ /**
54
+ * Whether to expose the composite `fullTextSearch` filter field.
55
+ * @default true
56
+ */
57
+ enableFullTextSearch?: boolean;
58
+ /**
59
+ * Custom weights for the composite searchScore.
60
+ * Keys are adapter names ('tsv', 'bm25', 'trgm', 'vector'),
61
+ * values are relative weights.
62
+ */
63
+ searchScoreWeights?: Record<string, number>;
64
+ /**
65
+ * Name of the custom GraphQL scalar for tsvector columns.
66
+ * @default 'FullText'
67
+ */
68
+ fullTextScalarName?: string;
69
+ /**
70
+ * PostgreSQL text search configuration.
71
+ * @default 'english'
72
+ */
73
+ tsConfig?: string;
74
+ }
75
+ /**
76
+ * Creates a preset that includes the unified search plugin with all enabled adapters.
77
+ */
78
+ export declare function UnifiedSearchPreset(options?: UnifiedSearchPresetOptions): GraphileConfig.Preset;
79
+ export default UnifiedSearchPreset;