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.
- package/LICENSE +23 -0
- package/README.md +123 -0
- package/adapters/bm25.d.ts +32 -0
- package/adapters/bm25.js +119 -0
- package/adapters/index.d.ts +14 -0
- package/adapters/index.js +17 -0
- package/adapters/pgvector.d.ts +21 -0
- package/adapters/pgvector.js +125 -0
- package/adapters/trgm.d.ts +20 -0
- package/adapters/trgm.js +83 -0
- package/adapters/tsvector.d.ts +20 -0
- package/adapters/tsvector.js +60 -0
- package/codecs/bm25-codec.d.ts +42 -0
- package/codecs/bm25-codec.js +199 -0
- package/codecs/index.d.ts +12 -0
- package/codecs/index.js +22 -0
- package/codecs/operator-factories.d.ts +22 -0
- package/codecs/operator-factories.js +84 -0
- package/codecs/tsvector-codec.d.ts +53 -0
- package/codecs/tsvector-codec.js +162 -0
- package/codecs/vector-codec.d.ts +18 -0
- package/codecs/vector-codec.js +116 -0
- package/esm/adapters/bm25.d.ts +32 -0
- package/esm/adapters/bm25.js +116 -0
- package/esm/adapters/index.d.ts +14 -0
- package/esm/adapters/index.js +10 -0
- package/esm/adapters/pgvector.d.ts +21 -0
- package/esm/adapters/pgvector.js +122 -0
- package/esm/adapters/trgm.d.ts +20 -0
- package/esm/adapters/trgm.js +80 -0
- package/esm/adapters/tsvector.d.ts +20 -0
- package/esm/adapters/tsvector.js +57 -0
- package/esm/codecs/bm25-codec.d.ts +42 -0
- package/esm/codecs/bm25-codec.js +160 -0
- package/esm/codecs/index.d.ts +12 -0
- package/esm/codecs/index.js +10 -0
- package/esm/codecs/operator-factories.d.ts +22 -0
- package/esm/codecs/operator-factories.js +80 -0
- package/esm/codecs/tsvector-codec.d.ts +53 -0
- package/esm/codecs/tsvector-codec.js +155 -0
- package/esm/codecs/vector-codec.d.ts +18 -0
- package/esm/codecs/vector-codec.js +110 -0
- package/esm/index.d.ts +40 -0
- package/esm/index.js +41 -0
- package/esm/plugin.d.ts +50 -0
- package/esm/plugin.js +553 -0
- package/esm/preset.d.ts +79 -0
- package/esm/preset.js +82 -0
- package/esm/types.d.ts +171 -0
- package/esm/types.js +7 -0
- package/index.d.ts +40 -0
- package/index.js +60 -0
- package/package.json +66 -0
- package/plugin.d.ts +50 -0
- package/plugin.js +556 -0
- package/preset.d.ts +79 -0
- package/preset.js +85 -0
- package/types.d.ts +171 -0
- 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;
|