graphile-search-plugin 3.3.1 → 3.6.2
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/esm/plugin.d.ts +40 -11
- package/esm/plugin.js +290 -119
- package/package.json +5 -5
- package/plugin.d.ts +40 -11
- package/plugin.js +290 -119
package/esm/plugin.d.ts
CHANGED
|
@@ -17,23 +17,52 @@
|
|
|
17
17
|
* search fields for each tsvector column found on a table's codec.
|
|
18
18
|
*
|
|
19
19
|
* ARCHITECTURE NOTE:
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Uses the Grafast meta system (setMeta/getMeta) to pass data between
|
|
21
|
+
* the condition apply phase, the orderBy enum apply, and the output field
|
|
22
|
+
* plan, following the pattern from Benjie's postgraphile-plugin-fulltext-filter.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* the rank
|
|
24
|
+
* 1. Condition apply (runs first): adds ts_rank to the query builder's
|
|
25
|
+
* SELECT list via selectAndReturnIndex, stores { selectIndex, scoreFragment }
|
|
26
|
+
* in meta via qb.setMeta(key, { selectIndex, scoreFragment }).
|
|
27
|
+
* 2. OrderBy enum apply (runs second): reads the scoreFragment from meta
|
|
28
|
+
* and calls qb.orderBy({ fragment, codec, direction }) directly.
|
|
29
|
+
* 3. Output field plan (planning phase): calls $select.getMeta(key) which
|
|
30
|
+
* returns a Grafast Step that resolves at execution time.
|
|
31
|
+
* 4. lambda([$details, $row]) reads the rank from row[details.selectIndex].
|
|
32
32
|
*/
|
|
33
33
|
import 'graphile-build';
|
|
34
34
|
import 'graphile-build-pg';
|
|
35
|
+
import type { PgCodecWithAttributes, PgResource } from '@dataplan/pg';
|
|
35
36
|
import type { GraphileConfig } from 'graphile-config';
|
|
36
37
|
import type { PgSearchPluginOptions } from './types';
|
|
38
|
+
declare global {
|
|
39
|
+
namespace GraphileBuild {
|
|
40
|
+
interface Inflection {
|
|
41
|
+
/** Name for the FullText scalar type */
|
|
42
|
+
fullTextScalarTypeName(this: Inflection): string;
|
|
43
|
+
/** Name for the rank field (e.g. "bodyRank") */
|
|
44
|
+
pgTsvRank(this: Inflection, fieldName: string): string;
|
|
45
|
+
/** Name for orderBy enum value for column rank */
|
|
46
|
+
pgTsvOrderByColumnRankEnum(this: Inflection, codec: PgCodecWithAttributes, attributeName: string, ascending: boolean): string;
|
|
47
|
+
/** Name for orderBy enum value for computed column rank */
|
|
48
|
+
pgTsvOrderByComputedColumnRankEnum(this: Inflection, codec: PgCodecWithAttributes, resource: PgResource, ascending: boolean): string;
|
|
49
|
+
}
|
|
50
|
+
interface ScopeObjectFieldsField {
|
|
51
|
+
isPgTSVRankField?: boolean;
|
|
52
|
+
}
|
|
53
|
+
interface BehaviorStrings {
|
|
54
|
+
'attributeFtsRank:select': true;
|
|
55
|
+
'procFtsRank:select': true;
|
|
56
|
+
'attributeFtsRank:orderBy': true;
|
|
57
|
+
'procFtsRank:orderBy': true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
namespace GraphileConfig {
|
|
61
|
+
interface Plugins {
|
|
62
|
+
PgSearchPlugin: true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
37
66
|
/**
|
|
38
67
|
* Creates the search plugin with the given options.
|
|
39
68
|
*/
|
package/esm/plugin.js
CHANGED
|
@@ -17,38 +17,50 @@
|
|
|
17
17
|
* search fields for each tsvector column found on a table's codec.
|
|
18
18
|
*
|
|
19
19
|
* ARCHITECTURE NOTE:
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Uses the Grafast meta system (setMeta/getMeta) to pass data between
|
|
21
|
+
* the condition apply phase, the orderBy enum apply, and the output field
|
|
22
|
+
* plan, following the pattern from Benjie's postgraphile-plugin-fulltext-filter.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* the rank
|
|
24
|
+
* 1. Condition apply (runs first): adds ts_rank to the query builder's
|
|
25
|
+
* SELECT list via selectAndReturnIndex, stores { selectIndex, scoreFragment }
|
|
26
|
+
* in meta via qb.setMeta(key, { selectIndex, scoreFragment }).
|
|
27
|
+
* 2. OrderBy enum apply (runs second): reads the scoreFragment from meta
|
|
28
|
+
* and calls qb.orderBy({ fragment, codec, direction }) directly.
|
|
29
|
+
* 3. Output field plan (planning phase): calls $select.getMeta(key) which
|
|
30
|
+
* returns a Grafast Step that resolves at execution time.
|
|
31
|
+
* 4. lambda([$details, $row]) reads the rank from row[details.selectIndex].
|
|
32
32
|
*/
|
|
33
33
|
import 'graphile-build';
|
|
34
34
|
import 'graphile-build-pg';
|
|
35
35
|
import { TYPES } from '@dataplan/pg';
|
|
36
|
-
const ftsRankSlots = new WeakMap();
|
|
37
36
|
function isTsvectorCodec(codec) {
|
|
38
37
|
return (codec?.extensions?.pg?.schemaName === 'pg_catalog' &&
|
|
39
38
|
codec?.extensions?.pg?.name === 'tsvector');
|
|
40
39
|
}
|
|
41
40
|
/**
|
|
42
|
-
*
|
|
43
|
-
* Uses
|
|
41
|
+
* Walks from a PgCondition up to the PgSelectQueryBuilder.
|
|
42
|
+
* Uses the .parent property on PgCondition to traverse up the chain,
|
|
43
|
+
* following Benjie's pattern from postgraphile-plugin-fulltext-filter.
|
|
44
|
+
*
|
|
45
|
+
* Returns the query builder if found, or null if the traversal fails.
|
|
44
46
|
*/
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
47
|
+
function getQueryBuilder(build, $condition) {
|
|
48
|
+
const PgCondition = build.dataplanPg?.PgCondition;
|
|
49
|
+
if (!PgCondition)
|
|
50
|
+
return null;
|
|
51
|
+
let current = $condition;
|
|
52
|
+
const { alias } = current;
|
|
53
|
+
// Walk up through nested PgConditions (e.g. and/or/not)
|
|
54
|
+
while (current &&
|
|
55
|
+
current instanceof PgCondition &&
|
|
56
|
+
current.alias === alias) {
|
|
57
|
+
current = current.parent;
|
|
49
58
|
}
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
// Verify we found a query builder with matching alias
|
|
60
|
+
if (current &&
|
|
61
|
+
typeof current.selectAndReturnIndex === 'function' &&
|
|
62
|
+
current.alias === alias) {
|
|
63
|
+
return current;
|
|
52
64
|
}
|
|
53
65
|
return null;
|
|
54
66
|
}
|
|
@@ -62,7 +74,92 @@ export function createPgSearchPlugin(options = {}) {
|
|
|
62
74
|
version: '2.0.0',
|
|
63
75
|
description: 'Generates search conditions for tsvector columns in PostGraphile v5',
|
|
64
76
|
after: ['PgAttributesPlugin', 'PgConnectionArgFilterPlugin', 'PgConnectionArgFilterOperatorsPlugin', 'AddConnectionFilterOperatorPlugin'],
|
|
77
|
+
// ─── Custom Inflection Methods ─────────────────────────────────────
|
|
78
|
+
// Makes field naming configurable and overridable by downstream plugins.
|
|
79
|
+
inflection: {
|
|
80
|
+
add: {
|
|
81
|
+
fullTextScalarTypeName() {
|
|
82
|
+
return fullTextScalarName;
|
|
83
|
+
},
|
|
84
|
+
pgTsvRank(_preset, fieldName) {
|
|
85
|
+
return this.camelCase(`${fieldName}-rank`);
|
|
86
|
+
},
|
|
87
|
+
pgTsvOrderByColumnRankEnum(_preset, codec, attributeName, ascending) {
|
|
88
|
+
const columnName = this._attributeName({
|
|
89
|
+
codec,
|
|
90
|
+
attributeName,
|
|
91
|
+
skipRowId: true,
|
|
92
|
+
});
|
|
93
|
+
return this.constantCase(`${columnName}_rank_${ascending ? 'asc' : 'desc'}`);
|
|
94
|
+
},
|
|
95
|
+
pgTsvOrderByComputedColumnRankEnum(_preset, _codec, resource, ascending) {
|
|
96
|
+
const columnName = this.computedAttributeField({
|
|
97
|
+
resource,
|
|
98
|
+
});
|
|
99
|
+
return this.constantCase(`${columnName}_rank_${ascending ? 'asc' : 'desc'}`);
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
65
103
|
schema: {
|
|
104
|
+
// ─── Behavior Registry ─────────────────────────────────────────────
|
|
105
|
+
// Declarative control over which columns get FTS features.
|
|
106
|
+
// Users can opt out per-column via `@behavior -attributeFtsRank:select`.
|
|
107
|
+
behaviorRegistry: {
|
|
108
|
+
add: {
|
|
109
|
+
'attributeFtsRank:select': {
|
|
110
|
+
description: 'Should the full text search rank be exposed for this attribute',
|
|
111
|
+
entities: ['pgCodecAttribute'],
|
|
112
|
+
},
|
|
113
|
+
'procFtsRank:select': {
|
|
114
|
+
description: 'Should the full text search rank be exposed for this computed column function',
|
|
115
|
+
entities: ['pgResource'],
|
|
116
|
+
},
|
|
117
|
+
'attributeFtsRank:orderBy': {
|
|
118
|
+
description: 'Should you be able to order by the FTS rank for this attribute',
|
|
119
|
+
entities: ['pgCodecAttribute'],
|
|
120
|
+
},
|
|
121
|
+
'procFtsRank:orderBy': {
|
|
122
|
+
description: 'Should you be able to order by the FTS rank for this computed column function',
|
|
123
|
+
entities: ['pgResource'],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
entityBehavior: {
|
|
128
|
+
pgCodecAttribute: {
|
|
129
|
+
override: {
|
|
130
|
+
provides: ['PgSearchPlugin'],
|
|
131
|
+
after: ['inferred'],
|
|
132
|
+
before: ['override'],
|
|
133
|
+
callback(behavior, [codec, attributeName]) {
|
|
134
|
+
const attr = codec.attributes[attributeName];
|
|
135
|
+
if (isTsvectorCodec(attr.codec)) {
|
|
136
|
+
return [
|
|
137
|
+
behavior,
|
|
138
|
+
'attributeFtsRank:orderBy',
|
|
139
|
+
'attributeFtsRank:select',
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
return behavior;
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
pgResource: {
|
|
147
|
+
override: {
|
|
148
|
+
provides: ['PgSearchPlugin'],
|
|
149
|
+
after: ['inferred'],
|
|
150
|
+
before: ['override'],
|
|
151
|
+
callback(behavior, resource) {
|
|
152
|
+
if (!resource.parameters) {
|
|
153
|
+
return behavior;
|
|
154
|
+
}
|
|
155
|
+
if (!isTsvectorCodec(resource.codec)) {
|
|
156
|
+
return behavior;
|
|
157
|
+
}
|
|
158
|
+
return [behavior, 'procFtsRank:orderBy', 'procFtsRank:select'];
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
66
163
|
hooks: {
|
|
67
164
|
init(_, build) {
|
|
68
165
|
const { sql, graphql: { GraphQLString }, } = build;
|
|
@@ -84,96 +181,172 @@ export function createPgSearchPlugin(options = {}) {
|
|
|
84
181
|
return _;
|
|
85
182
|
},
|
|
86
183
|
GraphQLObjectType_fields(fields, build, context) {
|
|
87
|
-
const {
|
|
88
|
-
const { scope: { isPgClassType, pgCodec }, fieldWithHooks, } = context;
|
|
89
|
-
if (!isPgClassType || !
|
|
184
|
+
const { inflection, graphql: { GraphQLFloat }, grafast: { lambda }, } = build;
|
|
185
|
+
const { scope: { isPgClassType, pgCodec: rawPgCodec }, fieldWithHooks, } = context;
|
|
186
|
+
if (!isPgClassType || !rawPgCodec?.attributes) {
|
|
90
187
|
return fields;
|
|
91
188
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
[fieldName]: fieldWithHooks({
|
|
189
|
+
const codec = rawPgCodec;
|
|
190
|
+
const behavior = build.behavior;
|
|
191
|
+
const pgRegistry = build.input?.pgRegistry;
|
|
192
|
+
// Helper to add a rank field for a given base field name
|
|
193
|
+
function addTsvField(baseFieldName, fieldName, origin) {
|
|
194
|
+
const metaKey = `__fts_ranks_${baseFieldName}`;
|
|
195
|
+
fields = build.extend(fields, {
|
|
196
|
+
[fieldName]: fieldWithHooks({
|
|
197
|
+
fieldName,
|
|
198
|
+
isPgTSVRankField: true,
|
|
199
|
+
}, () => ({
|
|
100
200
|
description: `Full-text search ranking when filtered by \`${baseFieldName}\`. Returns null when no search condition is active.`,
|
|
101
201
|
type: GraphQLFloat,
|
|
102
202
|
plan($step) {
|
|
103
|
-
const $
|
|
203
|
+
const $row = $step;
|
|
204
|
+
const $select = typeof $row.getClassStep === 'function'
|
|
205
|
+
? $row.getClassStep()
|
|
206
|
+
: null;
|
|
104
207
|
if (!$select)
|
|
105
|
-
return constant(null);
|
|
208
|
+
return build.grafast.constant(null);
|
|
106
209
|
if (typeof $select.setInliningForbidden === 'function') {
|
|
107
210
|
$select.setInliningForbidden();
|
|
108
211
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
// Return a lambda that reads the rank value from the result
|
|
118
|
-
// row at a dynamically-determined index. The index is set
|
|
119
|
-
// by the condition apply (deferred phase) via the proxy's
|
|
120
|
-
// selectAndReturnIndex, and stored in the WeakMap slot.
|
|
121
|
-
const capturedField = baseFieldName;
|
|
122
|
-
const capturedAlias = alias;
|
|
123
|
-
return lambda($step, (row) => {
|
|
124
|
-
if (row == null)
|
|
125
|
-
return null;
|
|
126
|
-
const slot = ftsRankSlots.get(capturedAlias);
|
|
127
|
-
if (!slot || slot.indices[capturedField] === undefined)
|
|
212
|
+
const $details = $select.getMeta(metaKey);
|
|
213
|
+
return lambda([$details, $row], ([details, row]) => {
|
|
214
|
+
const d = details;
|
|
215
|
+
if (d == null ||
|
|
216
|
+
row == null ||
|
|
217
|
+
d.selectIndex == null) {
|
|
128
218
|
return null;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
219
|
+
}
|
|
220
|
+
const rawValue = row[d.selectIndex];
|
|
221
|
+
return rawValue == null
|
|
222
|
+
? null
|
|
223
|
+
: TYPES.float.fromPg(rawValue);
|
|
224
|
+
});
|
|
132
225
|
},
|
|
133
226
|
})),
|
|
134
|
-
},
|
|
227
|
+
}, origin);
|
|
135
228
|
}
|
|
136
|
-
|
|
229
|
+
// ── Direct tsvector columns ──
|
|
230
|
+
for (const [attributeName, attribute] of Object.entries(codec.attributes)) {
|
|
231
|
+
if (!isTsvectorCodec(attribute.codec))
|
|
232
|
+
continue;
|
|
233
|
+
// Check behavior registry — skip if user opted out
|
|
234
|
+
if (behavior &&
|
|
235
|
+
typeof behavior.pgCodecAttributeMatches === 'function' &&
|
|
236
|
+
!behavior.pgCodecAttributeMatches([codec, attributeName], 'attributeFtsRank:select')) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const baseFieldName = inflection.attribute({ codec: codec, attributeName });
|
|
240
|
+
const fieldName = inflection.pgTsvRank(baseFieldName);
|
|
241
|
+
addTsvField(baseFieldName, fieldName, `PgSearchPlugin adding rank field for ${attributeName}`);
|
|
242
|
+
}
|
|
243
|
+
// ── Computed columns (functions returning tsvector) ──
|
|
244
|
+
if (pgRegistry) {
|
|
245
|
+
const tsvProcs = Object.values(pgRegistry.pgResources).filter((r) => {
|
|
246
|
+
if (r.codec !== build.dataplanPg?.TYPES?.tsvector)
|
|
247
|
+
return false;
|
|
248
|
+
if (!r.parameters)
|
|
249
|
+
return false;
|
|
250
|
+
if (!r.parameters[0])
|
|
251
|
+
return false;
|
|
252
|
+
if (r.parameters[0].codec !== codec)
|
|
253
|
+
return false;
|
|
254
|
+
if (behavior &&
|
|
255
|
+
typeof behavior.pgResourceMatches === 'function') {
|
|
256
|
+
if (!behavior.pgResourceMatches(r, 'typeField'))
|
|
257
|
+
return false;
|
|
258
|
+
if (!behavior.pgResourceMatches(r, 'procFtsRank:select'))
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
if (typeof r.from !== 'function')
|
|
262
|
+
return false;
|
|
263
|
+
return true;
|
|
264
|
+
});
|
|
265
|
+
for (const resource of tsvProcs) {
|
|
266
|
+
const baseFieldName = inflection.computedAttributeField({ resource: resource });
|
|
267
|
+
const fieldName = inflection.pgTsvRank(baseFieldName);
|
|
268
|
+
addTsvField(baseFieldName, fieldName, `PgSearchPlugin adding rank field for computed column ${resource.name} on ${context.Self.name}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return fields;
|
|
137
272
|
},
|
|
138
273
|
GraphQLEnumType_values(values, build, context) {
|
|
139
|
-
const { sql, inflection, } = build;
|
|
140
|
-
const { scope: { isPgRowSortEnum, pgCodec }, } = context;
|
|
141
|
-
if (!isPgRowSortEnum || !
|
|
274
|
+
const { sql, inflection, dataplanPg: { TYPES: DP_TYPES }, } = build;
|
|
275
|
+
const { scope: { isPgRowSortEnum, pgCodec: rawPgCodec }, } = context;
|
|
276
|
+
if (!isPgRowSortEnum || !rawPgCodec?.attributes) {
|
|
142
277
|
return values;
|
|
143
278
|
}
|
|
279
|
+
const codec = rawPgCodec;
|
|
280
|
+
const behavior = build.behavior;
|
|
281
|
+
const pgRegistry = build.input?.pgRegistry;
|
|
144
282
|
let newValues = values;
|
|
145
|
-
|
|
283
|
+
// The enum apply runs at PLANNING time (receives PgSelectStep).
|
|
284
|
+
// It stores a direction flag in meta. The condition apply runs at
|
|
285
|
+
// EXECUTION time (receives proxy whose meta was copied from
|
|
286
|
+
// PgSelectStep._meta). The condition apply reads this flag and
|
|
287
|
+
// adds the ORDER BY with the scoreFragment it computes.
|
|
288
|
+
const makeApply = (fieldName, direction) => (queryBuilder) => {
|
|
289
|
+
const orderMetaKey = `__fts_orderBy_${fieldName}`;
|
|
290
|
+
queryBuilder.setMeta(orderMetaKey, {
|
|
291
|
+
direction,
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
const makeSpec = (fieldName, direction) => ({
|
|
295
|
+
extensions: {
|
|
296
|
+
grafast: {
|
|
297
|
+
apply: makeApply(fieldName, direction),
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
// ── Direct tsvector columns ──
|
|
302
|
+
for (const [attributeName, attribute] of Object.entries(codec.attributes)) {
|
|
146
303
|
if (!isTsvectorCodec(attribute.codec))
|
|
147
304
|
continue;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
const ascName = inflection.constantCase(`${attributeName}_rank_asc`);
|
|
160
|
-
const descName = inflection.constantCase(`${attributeName}_rank_desc`);
|
|
305
|
+
// Check behavior registry
|
|
306
|
+
if (behavior &&
|
|
307
|
+
typeof behavior.pgCodecAttributeMatches === 'function' &&
|
|
308
|
+
!behavior.pgCodecAttributeMatches([codec, attributeName], 'attributeFtsRank:orderBy')) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const fieldName = inflection.attribute({ codec: codec, attributeName });
|
|
312
|
+
const ascName = inflection.pgTsvOrderByColumnRankEnum(codec, attributeName, true);
|
|
313
|
+
const descName = inflection.pgTsvOrderByColumnRankEnum(codec, attributeName, false);
|
|
161
314
|
newValues = build.extend(newValues, {
|
|
162
|
-
[ascName]:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
315
|
+
[ascName]: makeSpec(fieldName, 'ASC'),
|
|
316
|
+
[descName]: makeSpec(fieldName, 'DESC'),
|
|
317
|
+
}, `PgSearchPlugin adding rank orderBy for '${attributeName}' on '${codec.name}'`);
|
|
318
|
+
}
|
|
319
|
+
// ── Computed columns returning tsvector ──
|
|
320
|
+
if (pgRegistry) {
|
|
321
|
+
const tsvProcs = Object.values(pgRegistry.pgResources).filter((r) => {
|
|
322
|
+
if (r.codec !== build.dataplanPg?.TYPES?.tsvector)
|
|
323
|
+
return false;
|
|
324
|
+
if (!r.parameters)
|
|
325
|
+
return false;
|
|
326
|
+
if (!r.parameters[0])
|
|
327
|
+
return false;
|
|
328
|
+
if (r.parameters[0].codec !== codec)
|
|
329
|
+
return false;
|
|
330
|
+
if (behavior &&
|
|
331
|
+
typeof behavior.pgResourceMatches === 'function') {
|
|
332
|
+
if (!behavior.pgResourceMatches(r, 'typeField'))
|
|
333
|
+
return false;
|
|
334
|
+
if (!behavior.pgResourceMatches(r, 'procFtsRank:orderBy'))
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
if (typeof r.from !== 'function')
|
|
338
|
+
return false;
|
|
339
|
+
return true;
|
|
340
|
+
});
|
|
341
|
+
for (const resource of tsvProcs) {
|
|
342
|
+
const fieldName = inflection.computedAttributeField({ resource: resource });
|
|
343
|
+
const ascName = inflection.pgTsvOrderByComputedColumnRankEnum(codec, resource, true);
|
|
344
|
+
const descName = inflection.pgTsvOrderByComputedColumnRankEnum(codec, resource, false);
|
|
345
|
+
newValues = build.extend(newValues, {
|
|
346
|
+
[ascName]: makeSpec(fieldName, 'ASC'),
|
|
347
|
+
[descName]: makeSpec(fieldName, 'DESC'),
|
|
348
|
+
}, `PgSearchPlugin adding rank orderBy for computed column '${resource.name}' on '${codec.name}'`);
|
|
349
|
+
}
|
|
177
350
|
}
|
|
178
351
|
return newValues;
|
|
179
352
|
},
|
|
@@ -194,6 +367,7 @@ export function createPgSearchPlugin(options = {}) {
|
|
|
194
367
|
for (const [attributeName] of tsvectorAttributes) {
|
|
195
368
|
const fieldName = inflection.camelCase(`${pgSearchPrefix}_${attributeName}`);
|
|
196
369
|
const baseFieldName = inflection.attribute({ codec: pgCodec, attributeName });
|
|
370
|
+
const rankMetaKey = `__fts_ranks_${baseFieldName}`;
|
|
197
371
|
newFields = build.extend(newFields, {
|
|
198
372
|
[fieldName]: fieldWithHooks({
|
|
199
373
|
fieldName,
|
|
@@ -208,38 +382,35 @@ export function createPgSearchPlugin(options = {}) {
|
|
|
208
382
|
const columnExpr = sql `${$condition.alias}.${sql.identifier(attributeName)}`;
|
|
209
383
|
// WHERE: column @@ tsquery
|
|
210
384
|
$condition.where(sql `${columnExpr} @@ ${tsquery}`);
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
385
|
+
// Get the query builder (execution-time proxy) via
|
|
386
|
+
// meta-safe traversal.
|
|
387
|
+
const qb = getQueryBuilder(build, $condition);
|
|
388
|
+
if (qb) {
|
|
389
|
+
// Add ts_rank to the SELECT list
|
|
390
|
+
const scoreFragment = sql `ts_rank(${columnExpr}, ${tsquery})`;
|
|
391
|
+
const wrappedRankSql = sql `${sql.parens(scoreFragment)}::text`;
|
|
392
|
+
const rankIndex = qb.selectAndReturnIndex(wrappedRankSql);
|
|
393
|
+
const rankDetails = {
|
|
394
|
+
selectIndex: rankIndex,
|
|
395
|
+
scoreFragment,
|
|
396
|
+
};
|
|
397
|
+
// Store via qb.setMeta for the output field plan.
|
|
398
|
+
// ($select.getMeta() creates a deferred Step that works
|
|
399
|
+
// across the proxy/step boundary at execution time.)
|
|
400
|
+
qb.setMeta(rankMetaKey, rankDetails);
|
|
401
|
+
// Check if the orderBy enum stored a direction flag
|
|
402
|
+
// at planning time. The flag was set on PgSelectStep._meta
|
|
403
|
+
// and copied into this proxy's meta closure.
|
|
404
|
+
const orderMetaKey = `__fts_orderBy_${baseFieldName}`;
|
|
405
|
+
const orderRequest = qb.getMetaRaw(orderMetaKey);
|
|
406
|
+
if (orderRequest) {
|
|
407
|
+
qb.orderBy({
|
|
408
|
+
codec: build.dataplanPg?.TYPES?.float,
|
|
409
|
+
fragment: scoreFragment,
|
|
410
|
+
direction: orderRequest.direction,
|
|
411
|
+
});
|
|
225
412
|
}
|
|
226
413
|
}
|
|
227
|
-
// ORDER BY ts_rank: only add when the user explicitly
|
|
228
|
-
// requested rank ordering via the FULL_TEXT_RANK_ASC/DESC
|
|
229
|
-
// enum values. The enum's apply stores direction in meta
|
|
230
|
-
// during planning; if no meta is set, skip the orderBy
|
|
231
|
-
// entirely so cursors remain stable across pages.
|
|
232
|
-
const metaKey = `fts_order_${baseFieldName}`;
|
|
233
|
-
const explicitDir = typeof $parent.getMetaRaw === 'function'
|
|
234
|
-
? $parent.getMetaRaw(metaKey)
|
|
235
|
-
: undefined;
|
|
236
|
-
if (explicitDir) {
|
|
237
|
-
$parent.orderBy({
|
|
238
|
-
fragment: sql `ts_rank(${columnExpr}, ${tsquery})`,
|
|
239
|
-
codec: TYPES.float4,
|
|
240
|
-
direction: explicitDir,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
414
|
},
|
|
244
415
|
}),
|
|
245
416
|
}, `PgSearchPlugin adding condition field '${fieldName}' for tsvector column '${attributeName}' on '${pgCodec.name}'`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-search-plugin",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.2",
|
|
4
4
|
"description": "Generate search conditions for your tsvector columns (PostGraphile v5)",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -41,10 +41,10 @@
|
|
|
41
41
|
"url": "https://github.com/constructive-io/constructive/issues"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@types/node": "^22.19.
|
|
45
|
-
"graphile-test": "^4.
|
|
44
|
+
"@types/node": "^22.19.11",
|
|
45
|
+
"graphile-test": "^4.5.2",
|
|
46
46
|
"makage": "^0.1.10",
|
|
47
|
-
"pgsql-test": "^4.
|
|
47
|
+
"pgsql-test": "^4.5.2",
|
|
48
48
|
"postgraphile-plugin-connection-filter": "3.0.0-rc.1"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
@@ -62,5 +62,5 @@
|
|
|
62
62
|
"optional": true
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
|
-
"gitHead": "
|
|
65
|
+
"gitHead": "4fd2c9be786ad9ae2213453276a69723435d5315"
|
|
66
66
|
}
|
package/plugin.d.ts
CHANGED
|
@@ -17,23 +17,52 @@
|
|
|
17
17
|
* search fields for each tsvector column found on a table's codec.
|
|
18
18
|
*
|
|
19
19
|
* ARCHITECTURE NOTE:
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Uses the Grafast meta system (setMeta/getMeta) to pass data between
|
|
21
|
+
* the condition apply phase, the orderBy enum apply, and the output field
|
|
22
|
+
* plan, following the pattern from Benjie's postgraphile-plugin-fulltext-filter.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* the rank
|
|
24
|
+
* 1. Condition apply (runs first): adds ts_rank to the query builder's
|
|
25
|
+
* SELECT list via selectAndReturnIndex, stores { selectIndex, scoreFragment }
|
|
26
|
+
* in meta via qb.setMeta(key, { selectIndex, scoreFragment }).
|
|
27
|
+
* 2. OrderBy enum apply (runs second): reads the scoreFragment from meta
|
|
28
|
+
* and calls qb.orderBy({ fragment, codec, direction }) directly.
|
|
29
|
+
* 3. Output field plan (planning phase): calls $select.getMeta(key) which
|
|
30
|
+
* returns a Grafast Step that resolves at execution time.
|
|
31
|
+
* 4. lambda([$details, $row]) reads the rank from row[details.selectIndex].
|
|
32
32
|
*/
|
|
33
33
|
import 'graphile-build';
|
|
34
34
|
import 'graphile-build-pg';
|
|
35
|
+
import type { PgCodecWithAttributes, PgResource } from '@dataplan/pg';
|
|
35
36
|
import type { GraphileConfig } from 'graphile-config';
|
|
36
37
|
import type { PgSearchPluginOptions } from './types';
|
|
38
|
+
declare global {
|
|
39
|
+
namespace GraphileBuild {
|
|
40
|
+
interface Inflection {
|
|
41
|
+
/** Name for the FullText scalar type */
|
|
42
|
+
fullTextScalarTypeName(this: Inflection): string;
|
|
43
|
+
/** Name for the rank field (e.g. "bodyRank") */
|
|
44
|
+
pgTsvRank(this: Inflection, fieldName: string): string;
|
|
45
|
+
/** Name for orderBy enum value for column rank */
|
|
46
|
+
pgTsvOrderByColumnRankEnum(this: Inflection, codec: PgCodecWithAttributes, attributeName: string, ascending: boolean): string;
|
|
47
|
+
/** Name for orderBy enum value for computed column rank */
|
|
48
|
+
pgTsvOrderByComputedColumnRankEnum(this: Inflection, codec: PgCodecWithAttributes, resource: PgResource, ascending: boolean): string;
|
|
49
|
+
}
|
|
50
|
+
interface ScopeObjectFieldsField {
|
|
51
|
+
isPgTSVRankField?: boolean;
|
|
52
|
+
}
|
|
53
|
+
interface BehaviorStrings {
|
|
54
|
+
'attributeFtsRank:select': true;
|
|
55
|
+
'procFtsRank:select': true;
|
|
56
|
+
'attributeFtsRank:orderBy': true;
|
|
57
|
+
'procFtsRank:orderBy': true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
namespace GraphileConfig {
|
|
61
|
+
interface Plugins {
|
|
62
|
+
PgSearchPlugin: true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
37
66
|
/**
|
|
38
67
|
* Creates the search plugin with the given options.
|
|
39
68
|
*/
|
package/plugin.js
CHANGED
|
@@ -18,18 +18,18 @@
|
|
|
18
18
|
* search fields for each tsvector column found on a table's codec.
|
|
19
19
|
*
|
|
20
20
|
* ARCHITECTURE NOTE:
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Uses the Grafast meta system (setMeta/getMeta) to pass data between
|
|
22
|
+
* the condition apply phase, the orderBy enum apply, and the output field
|
|
23
|
+
* plan, following the pattern from Benjie's postgraphile-plugin-fulltext-filter.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* the rank
|
|
25
|
+
* 1. Condition apply (runs first): adds ts_rank to the query builder's
|
|
26
|
+
* SELECT list via selectAndReturnIndex, stores { selectIndex, scoreFragment }
|
|
27
|
+
* in meta via qb.setMeta(key, { selectIndex, scoreFragment }).
|
|
28
|
+
* 2. OrderBy enum apply (runs second): reads the scoreFragment from meta
|
|
29
|
+
* and calls qb.orderBy({ fragment, codec, direction }) directly.
|
|
30
|
+
* 3. Output field plan (planning phase): calls $select.getMeta(key) which
|
|
31
|
+
* returns a Grafast Step that resolves at execution time.
|
|
32
|
+
* 4. lambda([$details, $row]) reads the rank from row[details.selectIndex].
|
|
33
33
|
*/
|
|
34
34
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
35
|
exports.PgSearchPlugin = void 0;
|
|
@@ -37,22 +37,34 @@ exports.createPgSearchPlugin = createPgSearchPlugin;
|
|
|
37
37
|
require("graphile-build");
|
|
38
38
|
require("graphile-build-pg");
|
|
39
39
|
const pg_1 = require("@dataplan/pg");
|
|
40
|
-
const ftsRankSlots = new WeakMap();
|
|
41
40
|
function isTsvectorCodec(codec) {
|
|
42
41
|
return (codec?.extensions?.pg?.schemaName === 'pg_catalog' &&
|
|
43
42
|
codec?.extensions?.pg?.name === 'tsvector');
|
|
44
43
|
}
|
|
45
44
|
/**
|
|
46
|
-
*
|
|
47
|
-
* Uses
|
|
45
|
+
* Walks from a PgCondition up to the PgSelectQueryBuilder.
|
|
46
|
+
* Uses the .parent property on PgCondition to traverse up the chain,
|
|
47
|
+
* following Benjie's pattern from postgraphile-plugin-fulltext-filter.
|
|
48
|
+
*
|
|
49
|
+
* Returns the query builder if found, or null if the traversal fails.
|
|
48
50
|
*/
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
51
|
+
function getQueryBuilder(build, $condition) {
|
|
52
|
+
const PgCondition = build.dataplanPg?.PgCondition;
|
|
53
|
+
if (!PgCondition)
|
|
54
|
+
return null;
|
|
55
|
+
let current = $condition;
|
|
56
|
+
const { alias } = current;
|
|
57
|
+
// Walk up through nested PgConditions (e.g. and/or/not)
|
|
58
|
+
while (current &&
|
|
59
|
+
current instanceof PgCondition &&
|
|
60
|
+
current.alias === alias) {
|
|
61
|
+
current = current.parent;
|
|
53
62
|
}
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
// Verify we found a query builder with matching alias
|
|
64
|
+
if (current &&
|
|
65
|
+
typeof current.selectAndReturnIndex === 'function' &&
|
|
66
|
+
current.alias === alias) {
|
|
67
|
+
return current;
|
|
56
68
|
}
|
|
57
69
|
return null;
|
|
58
70
|
}
|
|
@@ -66,7 +78,92 @@ function createPgSearchPlugin(options = {}) {
|
|
|
66
78
|
version: '2.0.0',
|
|
67
79
|
description: 'Generates search conditions for tsvector columns in PostGraphile v5',
|
|
68
80
|
after: ['PgAttributesPlugin', 'PgConnectionArgFilterPlugin', 'PgConnectionArgFilterOperatorsPlugin', 'AddConnectionFilterOperatorPlugin'],
|
|
81
|
+
// ─── Custom Inflection Methods ─────────────────────────────────────
|
|
82
|
+
// Makes field naming configurable and overridable by downstream plugins.
|
|
83
|
+
inflection: {
|
|
84
|
+
add: {
|
|
85
|
+
fullTextScalarTypeName() {
|
|
86
|
+
return fullTextScalarName;
|
|
87
|
+
},
|
|
88
|
+
pgTsvRank(_preset, fieldName) {
|
|
89
|
+
return this.camelCase(`${fieldName}-rank`);
|
|
90
|
+
},
|
|
91
|
+
pgTsvOrderByColumnRankEnum(_preset, codec, attributeName, ascending) {
|
|
92
|
+
const columnName = this._attributeName({
|
|
93
|
+
codec,
|
|
94
|
+
attributeName,
|
|
95
|
+
skipRowId: true,
|
|
96
|
+
});
|
|
97
|
+
return this.constantCase(`${columnName}_rank_${ascending ? 'asc' : 'desc'}`);
|
|
98
|
+
},
|
|
99
|
+
pgTsvOrderByComputedColumnRankEnum(_preset, _codec, resource, ascending) {
|
|
100
|
+
const columnName = this.computedAttributeField({
|
|
101
|
+
resource,
|
|
102
|
+
});
|
|
103
|
+
return this.constantCase(`${columnName}_rank_${ascending ? 'asc' : 'desc'}`);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
69
107
|
schema: {
|
|
108
|
+
// ─── Behavior Registry ─────────────────────────────────────────────
|
|
109
|
+
// Declarative control over which columns get FTS features.
|
|
110
|
+
// Users can opt out per-column via `@behavior -attributeFtsRank:select`.
|
|
111
|
+
behaviorRegistry: {
|
|
112
|
+
add: {
|
|
113
|
+
'attributeFtsRank:select': {
|
|
114
|
+
description: 'Should the full text search rank be exposed for this attribute',
|
|
115
|
+
entities: ['pgCodecAttribute'],
|
|
116
|
+
},
|
|
117
|
+
'procFtsRank:select': {
|
|
118
|
+
description: 'Should the full text search rank be exposed for this computed column function',
|
|
119
|
+
entities: ['pgResource'],
|
|
120
|
+
},
|
|
121
|
+
'attributeFtsRank:orderBy': {
|
|
122
|
+
description: 'Should you be able to order by the FTS rank for this attribute',
|
|
123
|
+
entities: ['pgCodecAttribute'],
|
|
124
|
+
},
|
|
125
|
+
'procFtsRank:orderBy': {
|
|
126
|
+
description: 'Should you be able to order by the FTS rank for this computed column function',
|
|
127
|
+
entities: ['pgResource'],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
entityBehavior: {
|
|
132
|
+
pgCodecAttribute: {
|
|
133
|
+
override: {
|
|
134
|
+
provides: ['PgSearchPlugin'],
|
|
135
|
+
after: ['inferred'],
|
|
136
|
+
before: ['override'],
|
|
137
|
+
callback(behavior, [codec, attributeName]) {
|
|
138
|
+
const attr = codec.attributes[attributeName];
|
|
139
|
+
if (isTsvectorCodec(attr.codec)) {
|
|
140
|
+
return [
|
|
141
|
+
behavior,
|
|
142
|
+
'attributeFtsRank:orderBy',
|
|
143
|
+
'attributeFtsRank:select',
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
return behavior;
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
pgResource: {
|
|
151
|
+
override: {
|
|
152
|
+
provides: ['PgSearchPlugin'],
|
|
153
|
+
after: ['inferred'],
|
|
154
|
+
before: ['override'],
|
|
155
|
+
callback(behavior, resource) {
|
|
156
|
+
if (!resource.parameters) {
|
|
157
|
+
return behavior;
|
|
158
|
+
}
|
|
159
|
+
if (!isTsvectorCodec(resource.codec)) {
|
|
160
|
+
return behavior;
|
|
161
|
+
}
|
|
162
|
+
return [behavior, 'procFtsRank:orderBy', 'procFtsRank:select'];
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
70
167
|
hooks: {
|
|
71
168
|
init(_, build) {
|
|
72
169
|
const { sql, graphql: { GraphQLString }, } = build;
|
|
@@ -88,96 +185,172 @@ function createPgSearchPlugin(options = {}) {
|
|
|
88
185
|
return _;
|
|
89
186
|
},
|
|
90
187
|
GraphQLObjectType_fields(fields, build, context) {
|
|
91
|
-
const {
|
|
92
|
-
const { scope: { isPgClassType, pgCodec }, fieldWithHooks, } = context;
|
|
93
|
-
if (!isPgClassType || !
|
|
188
|
+
const { inflection, graphql: { GraphQLFloat }, grafast: { lambda }, } = build;
|
|
189
|
+
const { scope: { isPgClassType, pgCodec: rawPgCodec }, fieldWithHooks, } = context;
|
|
190
|
+
if (!isPgClassType || !rawPgCodec?.attributes) {
|
|
94
191
|
return fields;
|
|
95
192
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
[fieldName]: fieldWithHooks({
|
|
193
|
+
const codec = rawPgCodec;
|
|
194
|
+
const behavior = build.behavior;
|
|
195
|
+
const pgRegistry = build.input?.pgRegistry;
|
|
196
|
+
// Helper to add a rank field for a given base field name
|
|
197
|
+
function addTsvField(baseFieldName, fieldName, origin) {
|
|
198
|
+
const metaKey = `__fts_ranks_${baseFieldName}`;
|
|
199
|
+
fields = build.extend(fields, {
|
|
200
|
+
[fieldName]: fieldWithHooks({
|
|
201
|
+
fieldName,
|
|
202
|
+
isPgTSVRankField: true,
|
|
203
|
+
}, () => ({
|
|
104
204
|
description: `Full-text search ranking when filtered by \`${baseFieldName}\`. Returns null when no search condition is active.`,
|
|
105
205
|
type: GraphQLFloat,
|
|
106
206
|
plan($step) {
|
|
107
|
-
const $
|
|
207
|
+
const $row = $step;
|
|
208
|
+
const $select = typeof $row.getClassStep === 'function'
|
|
209
|
+
? $row.getClassStep()
|
|
210
|
+
: null;
|
|
108
211
|
if (!$select)
|
|
109
|
-
return constant(null);
|
|
212
|
+
return build.grafast.constant(null);
|
|
110
213
|
if (typeof $select.setInliningForbidden === 'function') {
|
|
111
214
|
$select.setInliningForbidden();
|
|
112
215
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
// Return a lambda that reads the rank value from the result
|
|
122
|
-
// row at a dynamically-determined index. The index is set
|
|
123
|
-
// by the condition apply (deferred phase) via the proxy's
|
|
124
|
-
// selectAndReturnIndex, and stored in the WeakMap slot.
|
|
125
|
-
const capturedField = baseFieldName;
|
|
126
|
-
const capturedAlias = alias;
|
|
127
|
-
return lambda($step, (row) => {
|
|
128
|
-
if (row == null)
|
|
129
|
-
return null;
|
|
130
|
-
const slot = ftsRankSlots.get(capturedAlias);
|
|
131
|
-
if (!slot || slot.indices[capturedField] === undefined)
|
|
216
|
+
const $details = $select.getMeta(metaKey);
|
|
217
|
+
return lambda([$details, $row], ([details, row]) => {
|
|
218
|
+
const d = details;
|
|
219
|
+
if (d == null ||
|
|
220
|
+
row == null ||
|
|
221
|
+
d.selectIndex == null) {
|
|
132
222
|
return null;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
223
|
+
}
|
|
224
|
+
const rawValue = row[d.selectIndex];
|
|
225
|
+
return rawValue == null
|
|
226
|
+
? null
|
|
227
|
+
: pg_1.TYPES.float.fromPg(rawValue);
|
|
228
|
+
});
|
|
136
229
|
},
|
|
137
230
|
})),
|
|
138
|
-
},
|
|
231
|
+
}, origin);
|
|
139
232
|
}
|
|
140
|
-
|
|
233
|
+
// ── Direct tsvector columns ──
|
|
234
|
+
for (const [attributeName, attribute] of Object.entries(codec.attributes)) {
|
|
235
|
+
if (!isTsvectorCodec(attribute.codec))
|
|
236
|
+
continue;
|
|
237
|
+
// Check behavior registry — skip if user opted out
|
|
238
|
+
if (behavior &&
|
|
239
|
+
typeof behavior.pgCodecAttributeMatches === 'function' &&
|
|
240
|
+
!behavior.pgCodecAttributeMatches([codec, attributeName], 'attributeFtsRank:select')) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const baseFieldName = inflection.attribute({ codec: codec, attributeName });
|
|
244
|
+
const fieldName = inflection.pgTsvRank(baseFieldName);
|
|
245
|
+
addTsvField(baseFieldName, fieldName, `PgSearchPlugin adding rank field for ${attributeName}`);
|
|
246
|
+
}
|
|
247
|
+
// ── Computed columns (functions returning tsvector) ──
|
|
248
|
+
if (pgRegistry) {
|
|
249
|
+
const tsvProcs = Object.values(pgRegistry.pgResources).filter((r) => {
|
|
250
|
+
if (r.codec !== build.dataplanPg?.TYPES?.tsvector)
|
|
251
|
+
return false;
|
|
252
|
+
if (!r.parameters)
|
|
253
|
+
return false;
|
|
254
|
+
if (!r.parameters[0])
|
|
255
|
+
return false;
|
|
256
|
+
if (r.parameters[0].codec !== codec)
|
|
257
|
+
return false;
|
|
258
|
+
if (behavior &&
|
|
259
|
+
typeof behavior.pgResourceMatches === 'function') {
|
|
260
|
+
if (!behavior.pgResourceMatches(r, 'typeField'))
|
|
261
|
+
return false;
|
|
262
|
+
if (!behavior.pgResourceMatches(r, 'procFtsRank:select'))
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
if (typeof r.from !== 'function')
|
|
266
|
+
return false;
|
|
267
|
+
return true;
|
|
268
|
+
});
|
|
269
|
+
for (const resource of tsvProcs) {
|
|
270
|
+
const baseFieldName = inflection.computedAttributeField({ resource: resource });
|
|
271
|
+
const fieldName = inflection.pgTsvRank(baseFieldName);
|
|
272
|
+
addTsvField(baseFieldName, fieldName, `PgSearchPlugin adding rank field for computed column ${resource.name} on ${context.Self.name}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return fields;
|
|
141
276
|
},
|
|
142
277
|
GraphQLEnumType_values(values, build, context) {
|
|
143
|
-
const { sql, inflection, } = build;
|
|
144
|
-
const { scope: { isPgRowSortEnum, pgCodec }, } = context;
|
|
145
|
-
if (!isPgRowSortEnum || !
|
|
278
|
+
const { sql, inflection, dataplanPg: { TYPES: DP_TYPES }, } = build;
|
|
279
|
+
const { scope: { isPgRowSortEnum, pgCodec: rawPgCodec }, } = context;
|
|
280
|
+
if (!isPgRowSortEnum || !rawPgCodec?.attributes) {
|
|
146
281
|
return values;
|
|
147
282
|
}
|
|
283
|
+
const codec = rawPgCodec;
|
|
284
|
+
const behavior = build.behavior;
|
|
285
|
+
const pgRegistry = build.input?.pgRegistry;
|
|
148
286
|
let newValues = values;
|
|
149
|
-
|
|
287
|
+
// The enum apply runs at PLANNING time (receives PgSelectStep).
|
|
288
|
+
// It stores a direction flag in meta. The condition apply runs at
|
|
289
|
+
// EXECUTION time (receives proxy whose meta was copied from
|
|
290
|
+
// PgSelectStep._meta). The condition apply reads this flag and
|
|
291
|
+
// adds the ORDER BY with the scoreFragment it computes.
|
|
292
|
+
const makeApply = (fieldName, direction) => (queryBuilder) => {
|
|
293
|
+
const orderMetaKey = `__fts_orderBy_${fieldName}`;
|
|
294
|
+
queryBuilder.setMeta(orderMetaKey, {
|
|
295
|
+
direction,
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
const makeSpec = (fieldName, direction) => ({
|
|
299
|
+
extensions: {
|
|
300
|
+
grafast: {
|
|
301
|
+
apply: makeApply(fieldName, direction),
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
// ── Direct tsvector columns ──
|
|
306
|
+
for (const [attributeName, attribute] of Object.entries(codec.attributes)) {
|
|
150
307
|
if (!isTsvectorCodec(attribute.codec))
|
|
151
308
|
continue;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
const ascName = inflection.constantCase(`${attributeName}_rank_asc`);
|
|
164
|
-
const descName = inflection.constantCase(`${attributeName}_rank_desc`);
|
|
309
|
+
// Check behavior registry
|
|
310
|
+
if (behavior &&
|
|
311
|
+
typeof behavior.pgCodecAttributeMatches === 'function' &&
|
|
312
|
+
!behavior.pgCodecAttributeMatches([codec, attributeName], 'attributeFtsRank:orderBy')) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const fieldName = inflection.attribute({ codec: codec, attributeName });
|
|
316
|
+
const ascName = inflection.pgTsvOrderByColumnRankEnum(codec, attributeName, true);
|
|
317
|
+
const descName = inflection.pgTsvOrderByColumnRankEnum(codec, attributeName, false);
|
|
165
318
|
newValues = build.extend(newValues, {
|
|
166
|
-
[ascName]:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
319
|
+
[ascName]: makeSpec(fieldName, 'ASC'),
|
|
320
|
+
[descName]: makeSpec(fieldName, 'DESC'),
|
|
321
|
+
}, `PgSearchPlugin adding rank orderBy for '${attributeName}' on '${codec.name}'`);
|
|
322
|
+
}
|
|
323
|
+
// ── Computed columns returning tsvector ──
|
|
324
|
+
if (pgRegistry) {
|
|
325
|
+
const tsvProcs = Object.values(pgRegistry.pgResources).filter((r) => {
|
|
326
|
+
if (r.codec !== build.dataplanPg?.TYPES?.tsvector)
|
|
327
|
+
return false;
|
|
328
|
+
if (!r.parameters)
|
|
329
|
+
return false;
|
|
330
|
+
if (!r.parameters[0])
|
|
331
|
+
return false;
|
|
332
|
+
if (r.parameters[0].codec !== codec)
|
|
333
|
+
return false;
|
|
334
|
+
if (behavior &&
|
|
335
|
+
typeof behavior.pgResourceMatches === 'function') {
|
|
336
|
+
if (!behavior.pgResourceMatches(r, 'typeField'))
|
|
337
|
+
return false;
|
|
338
|
+
if (!behavior.pgResourceMatches(r, 'procFtsRank:orderBy'))
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
if (typeof r.from !== 'function')
|
|
342
|
+
return false;
|
|
343
|
+
return true;
|
|
344
|
+
});
|
|
345
|
+
for (const resource of tsvProcs) {
|
|
346
|
+
const fieldName = inflection.computedAttributeField({ resource: resource });
|
|
347
|
+
const ascName = inflection.pgTsvOrderByComputedColumnRankEnum(codec, resource, true);
|
|
348
|
+
const descName = inflection.pgTsvOrderByComputedColumnRankEnum(codec, resource, false);
|
|
349
|
+
newValues = build.extend(newValues, {
|
|
350
|
+
[ascName]: makeSpec(fieldName, 'ASC'),
|
|
351
|
+
[descName]: makeSpec(fieldName, 'DESC'),
|
|
352
|
+
}, `PgSearchPlugin adding rank orderBy for computed column '${resource.name}' on '${codec.name}'`);
|
|
353
|
+
}
|
|
181
354
|
}
|
|
182
355
|
return newValues;
|
|
183
356
|
},
|
|
@@ -198,6 +371,7 @@ function createPgSearchPlugin(options = {}) {
|
|
|
198
371
|
for (const [attributeName] of tsvectorAttributes) {
|
|
199
372
|
const fieldName = inflection.camelCase(`${pgSearchPrefix}_${attributeName}`);
|
|
200
373
|
const baseFieldName = inflection.attribute({ codec: pgCodec, attributeName });
|
|
374
|
+
const rankMetaKey = `__fts_ranks_${baseFieldName}`;
|
|
201
375
|
newFields = build.extend(newFields, {
|
|
202
376
|
[fieldName]: fieldWithHooks({
|
|
203
377
|
fieldName,
|
|
@@ -212,38 +386,35 @@ function createPgSearchPlugin(options = {}) {
|
|
|
212
386
|
const columnExpr = sql `${$condition.alias}.${sql.identifier(attributeName)}`;
|
|
213
387
|
// WHERE: column @@ tsquery
|
|
214
388
|
$condition.where(sql `${columnExpr} @@ ${tsquery}`);
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
389
|
+
// Get the query builder (execution-time proxy) via
|
|
390
|
+
// meta-safe traversal.
|
|
391
|
+
const qb = getQueryBuilder(build, $condition);
|
|
392
|
+
if (qb) {
|
|
393
|
+
// Add ts_rank to the SELECT list
|
|
394
|
+
const scoreFragment = sql `ts_rank(${columnExpr}, ${tsquery})`;
|
|
395
|
+
const wrappedRankSql = sql `${sql.parens(scoreFragment)}::text`;
|
|
396
|
+
const rankIndex = qb.selectAndReturnIndex(wrappedRankSql);
|
|
397
|
+
const rankDetails = {
|
|
398
|
+
selectIndex: rankIndex,
|
|
399
|
+
scoreFragment,
|
|
400
|
+
};
|
|
401
|
+
// Store via qb.setMeta for the output field plan.
|
|
402
|
+
// ($select.getMeta() creates a deferred Step that works
|
|
403
|
+
// across the proxy/step boundary at execution time.)
|
|
404
|
+
qb.setMeta(rankMetaKey, rankDetails);
|
|
405
|
+
// Check if the orderBy enum stored a direction flag
|
|
406
|
+
// at planning time. The flag was set on PgSelectStep._meta
|
|
407
|
+
// and copied into this proxy's meta closure.
|
|
408
|
+
const orderMetaKey = `__fts_orderBy_${baseFieldName}`;
|
|
409
|
+
const orderRequest = qb.getMetaRaw(orderMetaKey);
|
|
410
|
+
if (orderRequest) {
|
|
411
|
+
qb.orderBy({
|
|
412
|
+
codec: build.dataplanPg?.TYPES?.float,
|
|
413
|
+
fragment: scoreFragment,
|
|
414
|
+
direction: orderRequest.direction,
|
|
415
|
+
});
|
|
229
416
|
}
|
|
230
417
|
}
|
|
231
|
-
// ORDER BY ts_rank: only add when the user explicitly
|
|
232
|
-
// requested rank ordering via the FULL_TEXT_RANK_ASC/DESC
|
|
233
|
-
// enum values. The enum's apply stores direction in meta
|
|
234
|
-
// during planning; if no meta is set, skip the orderBy
|
|
235
|
-
// entirely so cursors remain stable across pages.
|
|
236
|
-
const metaKey = `fts_order_${baseFieldName}`;
|
|
237
|
-
const explicitDir = typeof $parent.getMetaRaw === 'function'
|
|
238
|
-
? $parent.getMetaRaw(metaKey)
|
|
239
|
-
: undefined;
|
|
240
|
-
if (explicitDir) {
|
|
241
|
-
$parent.orderBy({
|
|
242
|
-
fragment: sql `ts_rank(${columnExpr}, ${tsquery})`,
|
|
243
|
-
codec: pg_1.TYPES.float4,
|
|
244
|
-
direction: explicitDir,
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
418
|
},
|
|
248
419
|
}),
|
|
249
420
|
}, `PgSearchPlugin adding condition field '${fieldName}' for tsvector column '${attributeName}' on '${pgCodec.name}'`);
|