graphile-search-plugin
Version:
Generate search conditions for your tsvector columns (PostGraphile v5)
434 lines (433 loc) • 24.4 kB
JavaScript
;
/**
* PostGraphile v5 Search Plugin
*
* Generates search condition fields for tsvector columns. When a search term
* is provided via the condition input, this plugin applies a
* `column @@ websearch_to_tsquery('english', $value)` WHERE clause.
* Results are ordered by `ts_rank` only when explicitly requested via
* the `FULL_TEXT_RANK_ASC/DESC` orderBy enum values (not automatically),
* ensuring cursor pagination digests remain stable across pages.
*
* Additionally provides:
* - `matches` filter operator for postgraphile-plugin-connection-filter
* - `fullTextRank` computed fields on output types (null when no search active)
* - `FULL_TEXT_RANK_ASC/DESC` orderBy enum values
*
* Uses the graphile-build hooks API to extend condition input types with
* search fields for each tsvector column found on a table's codec.
*
* ARCHITECTURE NOTE:
* Uses the Grafast meta system (setMeta/getMeta) to pass data between
* the condition apply phase, the orderBy enum apply, and the output field
* plan, following the pattern from Benjie's postgraphile-plugin-fulltext-filter.
*
* 1. Condition apply (runs first): adds ts_rank to the query builder's
* SELECT list via selectAndReturnIndex, stores { selectIndex, scoreFragment }
* in meta via qb.setMeta(key, { selectIndex, scoreFragment }).
* 2. OrderBy enum apply (runs second): reads the scoreFragment from meta
* and calls qb.orderBy({ fragment, codec, direction }) directly.
* 3. Output field plan (planning phase): calls $select.getMeta(key) which
* returns a Grafast Step that resolves at execution time.
* 4. lambda([$details, $row]) reads the rank from row[details.selectIndex].
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.PgSearchPlugin = void 0;
exports.createPgSearchPlugin = createPgSearchPlugin;
require("graphile-build");
require("graphile-build-pg");
const pg_1 = require("@dataplan/pg");
function isTsvectorCodec(codec) {
return (codec?.extensions?.pg?.schemaName === 'pg_catalog' &&
codec?.extensions?.pg?.name === 'tsvector');
}
/**
* Walks from a PgCondition up to the PgSelectQueryBuilder.
* Uses the .parent property on PgCondition to traverse up the chain,
* following Benjie's pattern from postgraphile-plugin-fulltext-filter.
*
* Returns the query builder if found, or null if the traversal fails.
*/
function getQueryBuilder(build, $condition) {
const PgCondition = build.dataplanPg?.PgCondition;
if (!PgCondition)
return null;
let current = $condition;
const { alias } = current;
// Walk up through nested PgConditions (e.g. and/or/not)
while (current &&
current instanceof PgCondition &&
current.alias === alias) {
current = current.parent;
}
// Verify we found a query builder with matching alias
if (current &&
typeof current.selectAndReturnIndex === 'function' &&
current.alias === alias) {
return current;
}
return null;
}
/**
* Creates the search plugin with the given options.
*/
function createPgSearchPlugin(options = {}) {
const { pgSearchPrefix = 'tsv', fullTextScalarName = 'FullText', tsConfig = 'english' } = options;
return {
name: 'PgSearchPlugin',
version: '2.0.0',
description: 'Generates search conditions for tsvector columns in PostGraphile v5',
after: ['PgAttributesPlugin', 'PgConnectionArgFilterPlugin', 'PgConnectionArgFilterOperatorsPlugin', 'AddConnectionFilterOperatorPlugin'],
// ─── Custom Inflection Methods ─────────────────────────────────────
// Makes field naming configurable and overridable by downstream plugins.
inflection: {
add: {
fullTextScalarTypeName() {
return fullTextScalarName;
},
pgTsvRank(_preset, fieldName) {
return this.camelCase(`${fieldName}-rank`);
},
pgTsvOrderByColumnRankEnum(_preset, codec, attributeName, ascending) {
const columnName = this._attributeName({
codec,
attributeName,
skipRowId: true,
});
return this.constantCase(`${columnName}_rank_${ascending ? 'asc' : 'desc'}`);
},
pgTsvOrderByComputedColumnRankEnum(_preset, _codec, resource, ascending) {
const columnName = this.computedAttributeField({
resource,
});
return this.constantCase(`${columnName}_rank_${ascending ? 'asc' : 'desc'}`);
},
},
},
schema: {
// ─── Behavior Registry ─────────────────────────────────────────────
// Declarative control over which columns get FTS features.
// Users can opt out per-column via `@behavior -attributeFtsRank:select`.
behaviorRegistry: {
add: {
'attributeFtsRank:select': {
description: 'Should the full text search rank be exposed for this attribute',
entities: ['pgCodecAttribute'],
},
'procFtsRank:select': {
description: 'Should the full text search rank be exposed for this computed column function',
entities: ['pgResource'],
},
'attributeFtsRank:orderBy': {
description: 'Should you be able to order by the FTS rank for this attribute',
entities: ['pgCodecAttribute'],
},
'procFtsRank:orderBy': {
description: 'Should you be able to order by the FTS rank for this computed column function',
entities: ['pgResource'],
},
},
},
entityBehavior: {
pgCodecAttribute: {
override: {
provides: ['PgSearchPlugin'],
after: ['inferred'],
before: ['override'],
callback(behavior, [codec, attributeName]) {
const attr = codec.attributes[attributeName];
if (isTsvectorCodec(attr.codec)) {
return [
behavior,
'attributeFtsRank:orderBy',
'attributeFtsRank:select',
];
}
return behavior;
},
},
},
pgResource: {
override: {
provides: ['PgSearchPlugin'],
after: ['inferred'],
before: ['override'],
callback(behavior, resource) {
if (!resource.parameters) {
return behavior;
}
if (!isTsvectorCodec(resource.codec)) {
return behavior;
}
return [behavior, 'procFtsRank:orderBy', 'procFtsRank:select'];
},
},
},
},
hooks: {
init(_, build) {
const { sql, graphql: { GraphQLString }, } = build;
// Register the `matches` filter operator for the FullText scalar.
// Requires postgraphile-plugin-connection-filter; skip if not loaded.
const addConnectionFilterOperator = build
.addConnectionFilterOperator;
if (typeof addConnectionFilterOperator === 'function') {
const TYPES = build.dataplanPg?.TYPES;
addConnectionFilterOperator(fullTextScalarName, 'matches', {
description: 'Performs a full text search on the field.',
resolveType: () => GraphQLString,
resolveInputCodec: TYPES ? () => TYPES.text : undefined,
resolve(sqlIdentifier, sqlValue, _input, _$where, _details) {
return sql `${sqlIdentifier} @@ websearch_to_tsquery(${sql.literal(tsConfig)}, ${sqlValue})`;
},
});
}
return _;
},
GraphQLObjectType_fields(fields, build, context) {
const { inflection, graphql: { GraphQLFloat }, grafast: { lambda }, } = build;
const { scope: { isPgClassType, pgCodec: rawPgCodec }, fieldWithHooks, } = context;
if (!isPgClassType || !rawPgCodec?.attributes) {
return fields;
}
const codec = rawPgCodec;
const behavior = build.behavior;
const pgRegistry = build.input?.pgRegistry;
// Helper to add a rank field for a given base field name
function addTsvField(baseFieldName, fieldName, origin) {
const metaKey = `__fts_ranks_${baseFieldName}`;
fields = build.extend(fields, {
[fieldName]: fieldWithHooks({
fieldName,
isPgTSVRankField: true,
}, () => ({
description: `Full-text search ranking when filtered by \`${baseFieldName}\`. Returns null when no search condition is active.`,
type: GraphQLFloat,
plan($step) {
const $row = $step;
const $select = typeof $row.getClassStep === 'function'
? $row.getClassStep()
: null;
if (!$select)
return build.grafast.constant(null);
if (typeof $select.setInliningForbidden === 'function') {
$select.setInliningForbidden();
}
const $details = $select.getMeta(metaKey);
return lambda([$details, $row], ([details, row]) => {
const d = details;
if (d == null ||
row == null ||
d.selectIndex == null) {
return null;
}
const rawValue = row[d.selectIndex];
return rawValue == null
? null
: pg_1.TYPES.float.fromPg(rawValue);
});
},
})),
}, origin);
}
// ── Direct tsvector columns ──
for (const [attributeName, attribute] of Object.entries(codec.attributes)) {
if (!isTsvectorCodec(attribute.codec))
continue;
// Check behavior registry — skip if user opted out
if (behavior &&
typeof behavior.pgCodecAttributeMatches === 'function' &&
!behavior.pgCodecAttributeMatches([codec, attributeName], 'attributeFtsRank:select')) {
continue;
}
const baseFieldName = inflection.attribute({ codec: codec, attributeName });
const fieldName = inflection.pgTsvRank(baseFieldName);
addTsvField(baseFieldName, fieldName, `PgSearchPlugin adding rank field for ${attributeName}`);
}
// ── Computed columns (functions returning tsvector) ──
if (pgRegistry) {
const tsvProcs = Object.values(pgRegistry.pgResources).filter((r) => {
if (r.codec !== build.dataplanPg?.TYPES?.tsvector)
return false;
if (!r.parameters)
return false;
if (!r.parameters[0])
return false;
if (r.parameters[0].codec !== codec)
return false;
if (behavior &&
typeof behavior.pgResourceMatches === 'function') {
if (!behavior.pgResourceMatches(r, 'typeField'))
return false;
if (!behavior.pgResourceMatches(r, 'procFtsRank:select'))
return false;
}
if (typeof r.from !== 'function')
return false;
return true;
});
for (const resource of tsvProcs) {
const baseFieldName = inflection.computedAttributeField({ resource: resource });
const fieldName = inflection.pgTsvRank(baseFieldName);
addTsvField(baseFieldName, fieldName, `PgSearchPlugin adding rank field for computed column ${resource.name} on ${context.Self.name}`);
}
}
return fields;
},
GraphQLEnumType_values(values, build, context) {
const { sql, inflection, dataplanPg: { TYPES: DP_TYPES }, } = build;
const { scope: { isPgRowSortEnum, pgCodec: rawPgCodec }, } = context;
if (!isPgRowSortEnum || !rawPgCodec?.attributes) {
return values;
}
const codec = rawPgCodec;
const behavior = build.behavior;
const pgRegistry = build.input?.pgRegistry;
let newValues = values;
// The enum apply runs at PLANNING time (receives PgSelectStep).
// It stores a direction flag in meta. The condition apply runs at
// EXECUTION time (receives proxy whose meta was copied from
// PgSelectStep._meta). The condition apply reads this flag and
// adds the ORDER BY with the scoreFragment it computes.
const makeApply = (fieldName, direction) => (queryBuilder) => {
const orderMetaKey = `__fts_orderBy_${fieldName}`;
queryBuilder.setMeta(orderMetaKey, {
direction,
});
};
const makeSpec = (fieldName, direction) => ({
extensions: {
grafast: {
apply: makeApply(fieldName, direction),
},
},
});
// ── Direct tsvector columns ──
for (const [attributeName, attribute] of Object.entries(codec.attributes)) {
if (!isTsvectorCodec(attribute.codec))
continue;
// Check behavior registry
if (behavior &&
typeof behavior.pgCodecAttributeMatches === 'function' &&
!behavior.pgCodecAttributeMatches([codec, attributeName], 'attributeFtsRank:orderBy')) {
continue;
}
const fieldName = inflection.attribute({ codec: codec, attributeName });
const ascName = inflection.pgTsvOrderByColumnRankEnum(codec, attributeName, true);
const descName = inflection.pgTsvOrderByColumnRankEnum(codec, attributeName, false);
newValues = build.extend(newValues, {
[ascName]: makeSpec(fieldName, 'ASC'),
[descName]: makeSpec(fieldName, 'DESC'),
}, `PgSearchPlugin adding rank orderBy for '${attributeName}' on '${codec.name}'`);
}
// ── Computed columns returning tsvector ──
if (pgRegistry) {
const tsvProcs = Object.values(pgRegistry.pgResources).filter((r) => {
if (r.codec !== build.dataplanPg?.TYPES?.tsvector)
return false;
if (!r.parameters)
return false;
if (!r.parameters[0])
return false;
if (r.parameters[0].codec !== codec)
return false;
if (behavior &&
typeof behavior.pgResourceMatches === 'function') {
if (!behavior.pgResourceMatches(r, 'typeField'))
return false;
if (!behavior.pgResourceMatches(r, 'procFtsRank:orderBy'))
return false;
}
if (typeof r.from !== 'function')
return false;
return true;
});
for (const resource of tsvProcs) {
const fieldName = inflection.computedAttributeField({ resource: resource });
const ascName = inflection.pgTsvOrderByComputedColumnRankEnum(codec, resource, true);
const descName = inflection.pgTsvOrderByComputedColumnRankEnum(codec, resource, false);
newValues = build.extend(newValues, {
[ascName]: makeSpec(fieldName, 'ASC'),
[descName]: makeSpec(fieldName, 'DESC'),
}, `PgSearchPlugin adding rank orderBy for computed column '${resource.name}' on '${codec.name}'`);
}
}
return newValues;
},
GraphQLInputObjectType_fields(fields, build, context) {
const { inflection, sql, graphql: { GraphQLString }, } = build;
const { scope: { isPgCondition, pgCodec }, fieldWithHooks, } = context;
if (!isPgCondition ||
!pgCodec ||
!pgCodec.attributes ||
pgCodec.isAnonymous) {
return fields;
}
const tsvectorAttributes = Object.entries(pgCodec.attributes).filter(([_name, attr]) => isTsvectorCodec(attr.codec));
if (tsvectorAttributes.length === 0) {
return fields;
}
let newFields = fields;
for (const [attributeName] of tsvectorAttributes) {
const fieldName = inflection.camelCase(`${pgSearchPrefix}_${attributeName}`);
const baseFieldName = inflection.attribute({ codec: pgCodec, attributeName });
const rankMetaKey = `__fts_ranks_${baseFieldName}`;
newFields = build.extend(newFields, {
[fieldName]: fieldWithHooks({
fieldName,
isPgConnectionConditionInputField: true,
}, {
description: build.wrapDescription(`Full-text search on the \`${attributeName}\` tsvector column using \`websearch_to_tsquery\`.`, 'field'),
type: GraphQLString,
apply: function plan($condition, val) {
if (val == null)
return;
const tsquery = sql `websearch_to_tsquery(${sql.literal(tsConfig)}, ${sql.value(val)})`;
const columnExpr = sql `${$condition.alias}.${sql.identifier(attributeName)}`;
// WHERE: column @@ tsquery
$condition.where(sql `${columnExpr} @@ ${tsquery}`);
// Get the query builder (execution-time proxy) via
// meta-safe traversal.
const qb = getQueryBuilder(build, $condition);
if (qb) {
// Add ts_rank to the SELECT list
const scoreFragment = sql `ts_rank(${columnExpr}, ${tsquery})`;
const wrappedRankSql = sql `${sql.parens(scoreFragment)}::text`;
const rankIndex = qb.selectAndReturnIndex(wrappedRankSql);
const rankDetails = {
selectIndex: rankIndex,
scoreFragment,
};
// Store via qb.setMeta for the output field plan.
// ($select.getMeta() creates a deferred Step that works
// across the proxy/step boundary at execution time.)
qb.setMeta(rankMetaKey, rankDetails);
// Check if the orderBy enum stored a direction flag
// at planning time. The flag was set on PgSelectStep._meta
// and copied into this proxy's meta closure.
const orderMetaKey = `__fts_orderBy_${baseFieldName}`;
const orderRequest = qb.getMetaRaw(orderMetaKey);
if (orderRequest) {
qb.orderBy({
codec: build.dataplanPg?.TYPES?.float,
fragment: scoreFragment,
direction: orderRequest.direction,
});
}
}
},
}),
}, `PgSearchPlugin adding condition field '${fieldName}' for tsvector column '${attributeName}' on '${pgCodec.name}'`);
}
return newFields;
},
},
},
};
}
/**
* Creates a PgSearchPlugin with the given options.
* This is the main entry point for using the plugin.
*/
exports.PgSearchPlugin = createPgSearchPlugin;
exports.default = exports.PgSearchPlugin;