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