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 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
- * Condition field apply functions run during a deferred phase (SQL generation)
21
- * on a queryBuilder proxy NOT on the real PgSelectStep. The rank field plan
22
- * runs earlier, during Grafast's planning phase, on the real PgSelectStep.
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
- * To bridge these two phases we use a module-level WeakMap keyed by the SQL
25
- * alias object (shared between proxy and PgSelectStep via reference identity).
26
- *
27
- * The rank field plan creates a `lambda` step that reads the row tuple at a
28
- * dynamically-determined index. The condition apply adds `ts_rank(...)` to
29
- * the SQL SELECT list via `proxy.selectAndReturnIndex()` and stores the
30
- * resulting index in the WeakMap slot. At execution time the lambda reads
31
- * the rank value from that index.
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
- * Condition field apply functions run during a deferred phase (SQL generation)
21
- * on a queryBuilder proxy NOT on the real PgSelectStep. The rank field plan
22
- * runs earlier, during Grafast's planning phase, on the real PgSelectStep.
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
- * To bridge these two phases we use a module-level WeakMap keyed by the SQL
25
- * alias object (shared between proxy and PgSelectStep via reference identity).
26
- *
27
- * The rank field plan creates a `lambda` step that reads the row tuple at a
28
- * dynamically-determined index. The condition apply adds `ts_rank(...)` to
29
- * the SQL SELECT list via `proxy.selectAndReturnIndex()` and stores the
30
- * resulting index in the WeakMap slot. At execution time the lambda reads
31
- * the rank value from that index.
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
- * Navigates from a PgSelectSingleStep up to the PgSelectStep.
43
- * Uses duck-typing to avoid dependency on exact class names across rc versions.
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 getPgSelectStep($someStep) {
46
- let $step = $someStep;
47
- if ($step && typeof $step.getClassStep === 'function') {
48
- $step = $step.getClassStep();
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
- if ($step && typeof $step.orderBy === 'function' && $step.id !== undefined) {
51
- return $step;
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 { sql, inflection, graphql: { GraphQLFloat }, grafast: { constant, lambda }, } = build;
88
- const { scope: { isPgClassType, pgCodec }, fieldWithHooks, } = context;
89
- if (!isPgClassType || !pgCodec?.attributes) {
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
- let newFields = fields;
93
- for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
94
- if (!isTsvectorCodec(attribute.codec))
95
- continue;
96
- const baseFieldName = inflection.attribute({ codec: pgCodec, attributeName });
97
- const fieldName = inflection.camelCase(`${baseFieldName}-rank`);
98
- newFields = build.extend(newFields, {
99
- [fieldName]: fieldWithHooks({ fieldName }, () => ({
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 $select = getPgSelectStep($step);
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
- // Initialise the WeakMap slot for this query, keyed by the
110
- // SQL alias (same object ref on PgSelectStep and the proxy).
111
- const alias = $select.alias;
112
- if (!ftsRankSlots.has(alias)) {
113
- ftsRankSlots.set(alias, {
114
- indices: Object.create(null),
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
- const rawValue = row[slot.indices[capturedField]];
130
- return rawValue == null ? null : parseFloat(rawValue);
131
- }, true);
219
+ }
220
+ const rawValue = row[d.selectIndex];
221
+ return rawValue == null
222
+ ? null
223
+ : TYPES.float.fromPg(rawValue);
224
+ });
132
225
  },
133
226
  })),
134
- }, `PgSearchPlugin adding rank field '${fieldName}' for '${attributeName}' on '${pgCodec.name}'`);
227
+ }, origin);
135
228
  }
136
- return newFields;
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 || !pgCodec?.attributes) {
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
- for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
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
- const fieldName = inflection.attribute({ codec: pgCodec, attributeName });
149
- const metaKey = `fts_order_${fieldName}`;
150
- const makePlan = (direction) => (step) => {
151
- // The enum apply runs during the PLANNING phase on PgSelectStep.
152
- // Store the requested direction in PgSelectStep._meta so that
153
- // the condition apply (deferred phase) can read it via the
154
- // proxy's getMetaRaw and add the actual ORDER BY clause.
155
- if (typeof step.setMeta === 'function') {
156
- step.setMeta(metaKey, direction);
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
- extensions: {
164
- grafast: {
165
- apply: makePlan('ASC'),
166
- },
167
- },
168
- },
169
- [descName]: {
170
- extensions: {
171
- grafast: {
172
- apply: makePlan('DESC'),
173
- },
174
- },
175
- },
176
- }, `PgSearchPlugin adding rank orderBy for '${attributeName}' on '${pgCodec.name}'`);
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
- // Add ts_rank to the SELECT list via the proxy's
212
- // selectAndReturnIndex. This runs during the deferred
213
- // SQL-generation phase, so the expression goes into
214
- // info.selects (the live array used for SQL generation).
215
- const $parent = $condition.dangerouslyGetParent();
216
- if (typeof $parent.selectAndReturnIndex === 'function') {
217
- const rankSql = sql `ts_rank(${columnExpr}, ${tsquery})`;
218
- const wrappedRankSql = sql `${sql.parens(rankSql)}::text`;
219
- const rankIndex = $parent.selectAndReturnIndex(wrappedRankSql);
220
- // Store the index in the alias-keyed WeakMap slot so
221
- // the rank field's lambda can read it at execute time.
222
- const slot = ftsRankSlots.get($condition.alias);
223
- if (slot) {
224
- slot.indices[baseFieldName] = rankIndex;
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.1",
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.1",
45
- "graphile-test": "^4.3.1",
44
+ "@types/node": "^22.19.11",
45
+ "graphile-test": "^4.5.2",
46
46
  "makage": "^0.1.10",
47
- "pgsql-test": "^4.3.1",
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": "1c9efe4ecd5a6b8daa14fe42214ecf8c5b664198"
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
- * Condition field apply functions run during a deferred phase (SQL generation)
21
- * on a queryBuilder proxy NOT on the real PgSelectStep. The rank field plan
22
- * runs earlier, during Grafast's planning phase, on the real PgSelectStep.
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
- * To bridge these two phases we use a module-level WeakMap keyed by the SQL
25
- * alias object (shared between proxy and PgSelectStep via reference identity).
26
- *
27
- * The rank field plan creates a `lambda` step that reads the row tuple at a
28
- * dynamically-determined index. The condition apply adds `ts_rank(...)` to
29
- * the SQL SELECT list via `proxy.selectAndReturnIndex()` and stores the
30
- * resulting index in the WeakMap slot. At execution time the lambda reads
31
- * the rank value from that index.
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
- * Condition field apply functions run during a deferred phase (SQL generation)
22
- * on a queryBuilder proxy NOT on the real PgSelectStep. The rank field plan
23
- * runs earlier, during Grafast's planning phase, on the real PgSelectStep.
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
- * To bridge these two phases we use a module-level WeakMap keyed by the SQL
26
- * alias object (shared between proxy and PgSelectStep via reference identity).
27
- *
28
- * The rank field plan creates a `lambda` step that reads the row tuple at a
29
- * dynamically-determined index. The condition apply adds `ts_rank(...)` to
30
- * the SQL SELECT list via `proxy.selectAndReturnIndex()` and stores the
31
- * resulting index in the WeakMap slot. At execution time the lambda reads
32
- * the rank value from that index.
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
- * Navigates from a PgSelectSingleStep up to the PgSelectStep.
47
- * Uses duck-typing to avoid dependency on exact class names across rc versions.
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 getPgSelectStep($someStep) {
50
- let $step = $someStep;
51
- if ($step && typeof $step.getClassStep === 'function') {
52
- $step = $step.getClassStep();
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
- if ($step && typeof $step.orderBy === 'function' && $step.id !== undefined) {
55
- return $step;
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 { sql, inflection, graphql: { GraphQLFloat }, grafast: { constant, lambda }, } = build;
92
- const { scope: { isPgClassType, pgCodec }, fieldWithHooks, } = context;
93
- if (!isPgClassType || !pgCodec?.attributes) {
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
- let newFields = fields;
97
- for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
98
- if (!isTsvectorCodec(attribute.codec))
99
- continue;
100
- const baseFieldName = inflection.attribute({ codec: pgCodec, attributeName });
101
- const fieldName = inflection.camelCase(`${baseFieldName}-rank`);
102
- newFields = build.extend(newFields, {
103
- [fieldName]: fieldWithHooks({ fieldName }, () => ({
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 $select = getPgSelectStep($step);
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
- // Initialise the WeakMap slot for this query, keyed by the
114
- // SQL alias (same object ref on PgSelectStep and the proxy).
115
- const alias = $select.alias;
116
- if (!ftsRankSlots.has(alias)) {
117
- ftsRankSlots.set(alias, {
118
- indices: Object.create(null),
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
- const rawValue = row[slot.indices[capturedField]];
134
- return rawValue == null ? null : parseFloat(rawValue);
135
- }, true);
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
- }, `PgSearchPlugin adding rank field '${fieldName}' for '${attributeName}' on '${pgCodec.name}'`);
231
+ }, origin);
139
232
  }
140
- return newFields;
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 || !pgCodec?.attributes) {
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
- for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
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
- const fieldName = inflection.attribute({ codec: pgCodec, attributeName });
153
- const metaKey = `fts_order_${fieldName}`;
154
- const makePlan = (direction) => (step) => {
155
- // The enum apply runs during the PLANNING phase on PgSelectStep.
156
- // Store the requested direction in PgSelectStep._meta so that
157
- // the condition apply (deferred phase) can read it via the
158
- // proxy's getMetaRaw and add the actual ORDER BY clause.
159
- if (typeof step.setMeta === 'function') {
160
- step.setMeta(metaKey, direction);
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
- extensions: {
168
- grafast: {
169
- apply: makePlan('ASC'),
170
- },
171
- },
172
- },
173
- [descName]: {
174
- extensions: {
175
- grafast: {
176
- apply: makePlan('DESC'),
177
- },
178
- },
179
- },
180
- }, `PgSearchPlugin adding rank orderBy for '${attributeName}' on '${pgCodec.name}'`);
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
- // Add ts_rank to the SELECT list via the proxy's
216
- // selectAndReturnIndex. This runs during the deferred
217
- // SQL-generation phase, so the expression goes into
218
- // info.selects (the live array used for SQL generation).
219
- const $parent = $condition.dangerouslyGetParent();
220
- if (typeof $parent.selectAndReturnIndex === 'function') {
221
- const rankSql = sql `ts_rank(${columnExpr}, ${tsquery})`;
222
- const wrappedRankSql = sql `${sql.parens(rankSql)}::text`;
223
- const rankIndex = $parent.selectAndReturnIndex(wrappedRankSql);
224
- // Store the index in the alias-keyed WeakMap slot so
225
- // the rank field's lambda can read it at execute time.
226
- const slot = ftsRankSlots.get($condition.alias);
227
- if (slot) {
228
- slot.indices[baseFieldName] = rankIndex;
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}'`);