graphile-search-plugin 1.1.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -23
- package/esm/index.d.ts +26 -0
- package/esm/index.js +23 -55
- package/esm/plugin.d.ts +44 -0
- package/esm/plugin.js +263 -0
- package/esm/preset.d.ts +25 -0
- package/esm/preset.js +29 -0
- package/esm/tsvector-codec.d.ts +29 -0
- package/esm/tsvector-codec.js +155 -0
- package/esm/types.d.ts +37 -0
- package/esm/types.js +6 -0
- package/index.d.ts +24 -8
- package/index.js +31 -57
- package/package.json +28 -14
- package/plugin.d.ts +44 -0
- package/plugin.js +267 -0
- package/preset.d.ts +25 -0
- package/preset.js +32 -0
- package/tsvector-codec.d.ts +29 -0
- package/tsvector-codec.js +162 -0
- package/types.d.ts +37 -0
- package/types.js +7 -0
package/plugin.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* PostGraphile v5 Search Plugin
|
|
4
|
+
*
|
|
5
|
+
* Generates search condition fields for tsvector columns. When a search term
|
|
6
|
+
* is provided via the condition input, this plugin applies a
|
|
7
|
+
* `column @@ websearch_to_tsquery('english', $value)` WHERE clause and
|
|
8
|
+
* automatically orders results by `ts_rank` (descending) for relevance.
|
|
9
|
+
*
|
|
10
|
+
* Additionally provides:
|
|
11
|
+
* - `matches` filter operator for postgraphile-plugin-connection-filter
|
|
12
|
+
* - `fullTextRank` computed fields on output types (null when no search active)
|
|
13
|
+
* - `FULL_TEXT_RANK_ASC/DESC` orderBy enum values
|
|
14
|
+
*
|
|
15
|
+
* Uses the graphile-build hooks API to extend condition input types with
|
|
16
|
+
* search fields for each tsvector column found on a table's codec.
|
|
17
|
+
*
|
|
18
|
+
* ARCHITECTURE NOTE:
|
|
19
|
+
* Condition field apply functions run during a deferred phase (SQL generation)
|
|
20
|
+
* on a queryBuilder proxy — NOT on the real PgSelectStep. The rank field plan
|
|
21
|
+
* runs earlier, during Grafast's planning phase, on the real PgSelectStep.
|
|
22
|
+
*
|
|
23
|
+
* To bridge these two phases we use a module-level WeakMap keyed by the SQL
|
|
24
|
+
* alias object (shared between proxy and PgSelectStep via reference identity).
|
|
25
|
+
*
|
|
26
|
+
* The rank field plan creates a `lambda` step that reads the row tuple at a
|
|
27
|
+
* dynamically-determined index. The condition apply adds `ts_rank(...)` to
|
|
28
|
+
* the SQL SELECT list via `proxy.selectAndReturnIndex()` and stores the
|
|
29
|
+
* resulting index in the WeakMap slot. At execution time the lambda reads
|
|
30
|
+
* the rank value from that index.
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.PgSearchPlugin = void 0;
|
|
34
|
+
exports.createPgSearchPlugin = createPgSearchPlugin;
|
|
35
|
+
require("graphile-build");
|
|
36
|
+
require("graphile-build-pg");
|
|
37
|
+
const pg_1 = require("@dataplan/pg");
|
|
38
|
+
const ftsRankSlots = new WeakMap();
|
|
39
|
+
/**
|
|
40
|
+
* FinalizationRegistry for defensive cleanup of ftsRankSlots entries.
|
|
41
|
+
* WeakMap entries are already eligible for GC when keys are unreachable,
|
|
42
|
+
* but this provides explicit cleanup and a hook for debugging leaks.
|
|
43
|
+
*/
|
|
44
|
+
const ftsRankCleanup = new FinalizationRegistry((heldValue) => {
|
|
45
|
+
ftsRankSlots.delete(heldValue);
|
|
46
|
+
});
|
|
47
|
+
function isTsvectorCodec(codec) {
|
|
48
|
+
return (codec?.extensions?.pg?.schemaName === 'pg_catalog' &&
|
|
49
|
+
codec?.extensions?.pg?.name === 'tsvector');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Navigates from a PgSelectSingleStep up to the PgSelectStep.
|
|
53
|
+
* Uses duck-typing to avoid dependency on exact class names across rc versions.
|
|
54
|
+
*/
|
|
55
|
+
function getPgSelectStep($someStep) {
|
|
56
|
+
let $step = $someStep;
|
|
57
|
+
if ($step && typeof $step.getClassStep === 'function') {
|
|
58
|
+
$step = $step.getClassStep();
|
|
59
|
+
}
|
|
60
|
+
if ($step && typeof $step.orderBy === 'function' && $step.id !== undefined) {
|
|
61
|
+
return $step;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Creates the search plugin with the given options.
|
|
67
|
+
*/
|
|
68
|
+
function createPgSearchPlugin(options = {}) {
|
|
69
|
+
const { pgSearchPrefix = 'tsv', fullTextScalarName = 'FullText', tsConfig = 'english' } = options;
|
|
70
|
+
return {
|
|
71
|
+
name: 'PgSearchPlugin',
|
|
72
|
+
version: '2.0.0',
|
|
73
|
+
description: 'Generates search conditions for tsvector columns in PostGraphile v5',
|
|
74
|
+
after: ['PgAttributesPlugin', 'PgConnectionArgFilterPlugin', 'PgConnectionArgFilterOperatorsPlugin', 'AddConnectionFilterOperatorPlugin'],
|
|
75
|
+
schema: {
|
|
76
|
+
hooks: {
|
|
77
|
+
init(_, build) {
|
|
78
|
+
const { sql, graphql: { GraphQLString }, } = build;
|
|
79
|
+
// Register the `matches` filter operator for the FullText scalar.
|
|
80
|
+
// Requires postgraphile-plugin-connection-filter; skip if not loaded.
|
|
81
|
+
const addConnectionFilterOperator = build
|
|
82
|
+
.addConnectionFilterOperator;
|
|
83
|
+
if (typeof addConnectionFilterOperator === 'function') {
|
|
84
|
+
const TYPES = build.dataplanPg?.TYPES;
|
|
85
|
+
addConnectionFilterOperator(fullTextScalarName, 'matches', {
|
|
86
|
+
description: 'Performs a full text search on the field.',
|
|
87
|
+
resolveType: () => GraphQLString,
|
|
88
|
+
resolveInputCodec: TYPES ? () => TYPES.text : undefined,
|
|
89
|
+
resolve(sqlIdentifier, sqlValue, _input, _$where, _details) {
|
|
90
|
+
return sql `${sqlIdentifier} @@ websearch_to_tsquery(${sql.literal(tsConfig)}, ${sqlValue})`;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return _;
|
|
95
|
+
},
|
|
96
|
+
GraphQLObjectType_fields(fields, build, context) {
|
|
97
|
+
const { sql, inflection, graphql: { GraphQLFloat }, grafast: { constant, lambda }, } = build;
|
|
98
|
+
const { scope: { isPgClassType, pgCodec }, fieldWithHooks, } = context;
|
|
99
|
+
if (!isPgClassType || !pgCodec?.attributes) {
|
|
100
|
+
return fields;
|
|
101
|
+
}
|
|
102
|
+
let newFields = fields;
|
|
103
|
+
for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
|
|
104
|
+
if (!isTsvectorCodec(attribute.codec))
|
|
105
|
+
continue;
|
|
106
|
+
const baseFieldName = inflection.attribute({ codec: pgCodec, attributeName });
|
|
107
|
+
const fieldName = inflection.camelCase(`${baseFieldName}-rank`);
|
|
108
|
+
newFields = build.extend(newFields, {
|
|
109
|
+
[fieldName]: fieldWithHooks({ fieldName }, () => ({
|
|
110
|
+
description: `Full-text search ranking when filtered by \`${baseFieldName}\`. Returns null when no search condition is active.`,
|
|
111
|
+
type: GraphQLFloat,
|
|
112
|
+
plan($step) {
|
|
113
|
+
const $select = getPgSelectStep($step);
|
|
114
|
+
if (!$select)
|
|
115
|
+
return constant(null);
|
|
116
|
+
if (typeof $select.setInliningForbidden === 'function') {
|
|
117
|
+
$select.setInliningForbidden();
|
|
118
|
+
}
|
|
119
|
+
// Initialise the WeakMap slot for this query, keyed by the
|
|
120
|
+
// SQL alias (same object ref on PgSelectStep and the proxy).
|
|
121
|
+
const alias = $select.alias;
|
|
122
|
+
if (!ftsRankSlots.has(alias)) {
|
|
123
|
+
ftsRankSlots.set(alias, {
|
|
124
|
+
indices: Object.create(null),
|
|
125
|
+
});
|
|
126
|
+
ftsRankCleanup.register($select, alias);
|
|
127
|
+
}
|
|
128
|
+
// Return a lambda that reads the rank value from the result
|
|
129
|
+
// row at a dynamically-determined index. The index is set
|
|
130
|
+
// by the condition apply (deferred phase) via the proxy's
|
|
131
|
+
// selectAndReturnIndex, and stored in the WeakMap slot.
|
|
132
|
+
const capturedField = baseFieldName;
|
|
133
|
+
const capturedAlias = alias;
|
|
134
|
+
return lambda($step, (row) => {
|
|
135
|
+
if (row == null)
|
|
136
|
+
return null;
|
|
137
|
+
const slot = ftsRankSlots.get(capturedAlias);
|
|
138
|
+
if (!slot || slot.indices[capturedField] === undefined)
|
|
139
|
+
return null;
|
|
140
|
+
const rawValue = row[slot.indices[capturedField]];
|
|
141
|
+
return rawValue == null ? null : parseFloat(rawValue);
|
|
142
|
+
}, true);
|
|
143
|
+
},
|
|
144
|
+
})),
|
|
145
|
+
}, `PgSearchPlugin adding rank field '${fieldName}' for '${attributeName}' on '${pgCodec.name}'`);
|
|
146
|
+
}
|
|
147
|
+
return newFields;
|
|
148
|
+
},
|
|
149
|
+
GraphQLEnumType_values(values, build, context) {
|
|
150
|
+
const { sql, inflection, } = build;
|
|
151
|
+
const { scope: { isPgRowSortEnum, pgCodec }, } = context;
|
|
152
|
+
if (!isPgRowSortEnum || !pgCodec?.attributes) {
|
|
153
|
+
return values;
|
|
154
|
+
}
|
|
155
|
+
let newValues = values;
|
|
156
|
+
for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
|
|
157
|
+
if (!isTsvectorCodec(attribute.codec))
|
|
158
|
+
continue;
|
|
159
|
+
const fieldName = inflection.attribute({ codec: pgCodec, attributeName });
|
|
160
|
+
const metaKey = `fts_order_${fieldName}`;
|
|
161
|
+
const makePlan = (direction) => (step) => {
|
|
162
|
+
// The enum apply runs during the PLANNING phase on PgSelectStep.
|
|
163
|
+
// Store the requested direction in PgSelectStep._meta so that
|
|
164
|
+
// the condition apply (deferred phase) can read it via the
|
|
165
|
+
// proxy's getMetaRaw and add the actual ORDER BY clause.
|
|
166
|
+
if (typeof step.setMeta === 'function') {
|
|
167
|
+
step.setMeta(metaKey, direction);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const ascName = inflection.constantCase(`${attributeName}_rank_asc`);
|
|
171
|
+
const descName = inflection.constantCase(`${attributeName}_rank_desc`);
|
|
172
|
+
newValues = build.extend(newValues, {
|
|
173
|
+
[ascName]: {
|
|
174
|
+
extensions: {
|
|
175
|
+
grafast: {
|
|
176
|
+
apply: makePlan('ASC'),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
[descName]: {
|
|
181
|
+
extensions: {
|
|
182
|
+
grafast: {
|
|
183
|
+
apply: makePlan('DESC'),
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}, `PgSearchPlugin adding rank orderBy for '${attributeName}' on '${pgCodec.name}'`);
|
|
188
|
+
}
|
|
189
|
+
return newValues;
|
|
190
|
+
},
|
|
191
|
+
GraphQLInputObjectType_fields(fields, build, context) {
|
|
192
|
+
const { inflection, sql, graphql: { GraphQLString }, } = build;
|
|
193
|
+
const { scope: { isPgCondition, pgCodec }, fieldWithHooks, } = context;
|
|
194
|
+
if (!isPgCondition ||
|
|
195
|
+
!pgCodec ||
|
|
196
|
+
!pgCodec.attributes ||
|
|
197
|
+
pgCodec.isAnonymous) {
|
|
198
|
+
return fields;
|
|
199
|
+
}
|
|
200
|
+
const tsvectorAttributes = Object.entries(pgCodec.attributes).filter(([_name, attr]) => isTsvectorCodec(attr.codec));
|
|
201
|
+
if (tsvectorAttributes.length === 0) {
|
|
202
|
+
return fields;
|
|
203
|
+
}
|
|
204
|
+
let newFields = fields;
|
|
205
|
+
for (const [attributeName] of tsvectorAttributes) {
|
|
206
|
+
const fieldName = inflection.camelCase(`${pgSearchPrefix}_${attributeName}`);
|
|
207
|
+
const baseFieldName = inflection.attribute({ codec: pgCodec, attributeName });
|
|
208
|
+
newFields = build.extend(newFields, {
|
|
209
|
+
[fieldName]: fieldWithHooks({
|
|
210
|
+
fieldName,
|
|
211
|
+
isPgConnectionConditionInputField: true,
|
|
212
|
+
}, {
|
|
213
|
+
description: build.wrapDescription(`Full-text search on the \`${attributeName}\` tsvector column using \`websearch_to_tsquery\`.`, 'field'),
|
|
214
|
+
type: GraphQLString,
|
|
215
|
+
apply: function plan($condition, val) {
|
|
216
|
+
if (val == null)
|
|
217
|
+
return;
|
|
218
|
+
const tsquery = sql `websearch_to_tsquery(${sql.literal(tsConfig)}, ${sql.value(val)})`;
|
|
219
|
+
const columnExpr = sql `${$condition.alias}.${sql.identifier(attributeName)}`;
|
|
220
|
+
// WHERE: column @@ tsquery
|
|
221
|
+
$condition.where(sql `${columnExpr} @@ ${tsquery}`);
|
|
222
|
+
// Add ts_rank to the SELECT list via the proxy's
|
|
223
|
+
// selectAndReturnIndex. This runs during the deferred
|
|
224
|
+
// SQL-generation phase, so the expression goes into
|
|
225
|
+
// info.selects (the live array used for SQL generation).
|
|
226
|
+
const $parent = $condition.dangerouslyGetParent();
|
|
227
|
+
if (typeof $parent.selectAndReturnIndex === 'function') {
|
|
228
|
+
const rankSql = sql `ts_rank(${columnExpr}, ${tsquery})`;
|
|
229
|
+
const wrappedRankSql = sql `${sql.parens(rankSql)}::text`;
|
|
230
|
+
const rankIndex = $parent.selectAndReturnIndex(wrappedRankSql);
|
|
231
|
+
// Store the index in the alias-keyed WeakMap slot so
|
|
232
|
+
// the rank field's lambda can read it at execute time.
|
|
233
|
+
const slot = ftsRankSlots.get($condition.alias);
|
|
234
|
+
if (slot) {
|
|
235
|
+
slot.indices[baseFieldName] = rankIndex;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// ORDER BY ts_rank: check if the user provided an
|
|
239
|
+
// explicit rank orderBy enum (stored in meta during
|
|
240
|
+
// planning). If so, use their direction. Otherwise add
|
|
241
|
+
// automatic DESC ordering for relevance.
|
|
242
|
+
const metaKey = `fts_order_${baseFieldName}`;
|
|
243
|
+
const explicitDir = typeof $parent.getMetaRaw === 'function'
|
|
244
|
+
? $parent.getMetaRaw(metaKey)
|
|
245
|
+
: undefined;
|
|
246
|
+
const orderDirection = explicitDir ?? 'DESC';
|
|
247
|
+
$parent.orderBy({
|
|
248
|
+
fragment: sql `ts_rank(${columnExpr}, ${tsquery})`,
|
|
249
|
+
codec: pg_1.TYPES.float4,
|
|
250
|
+
direction: orderDirection,
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
}, `PgSearchPlugin adding condition field '${fieldName}' for tsvector column '${attributeName}' on '${pgCodec.name}'`);
|
|
255
|
+
}
|
|
256
|
+
return newFields;
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Creates a PgSearchPlugin with the given options.
|
|
264
|
+
* This is the main entry point for using the plugin.
|
|
265
|
+
*/
|
|
266
|
+
exports.PgSearchPlugin = createPgSearchPlugin;
|
|
267
|
+
exports.default = exports.PgSearchPlugin;
|
package/preset.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostGraphile v5 Search Preset
|
|
3
|
+
*
|
|
4
|
+
* Provides a convenient preset for including search support in PostGraphile.
|
|
5
|
+
*/
|
|
6
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
7
|
+
import type { PgSearchPluginOptions } from './types';
|
|
8
|
+
/**
|
|
9
|
+
* Creates a preset that includes the search plugin with the given options.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* import { PgSearchPreset } from 'graphile-search-plugin';
|
|
14
|
+
*
|
|
15
|
+
* const preset = {
|
|
16
|
+
* extends: [
|
|
17
|
+
* PgSearchPreset({
|
|
18
|
+
* pgSearchPrefix: 'fullText',
|
|
19
|
+
* }),
|
|
20
|
+
* ],
|
|
21
|
+
* };
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function PgSearchPreset(options?: PgSearchPluginOptions): GraphileConfig.Preset;
|
|
25
|
+
export default PgSearchPreset;
|
package/preset.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* PostGraphile v5 Search Preset
|
|
4
|
+
*
|
|
5
|
+
* Provides a convenient preset for including search support in PostGraphile.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.PgSearchPreset = PgSearchPreset;
|
|
9
|
+
const plugin_1 = require("./plugin");
|
|
10
|
+
const tsvector_codec_1 = require("./tsvector-codec");
|
|
11
|
+
/**
|
|
12
|
+
* Creates a preset that includes the search plugin with the given options.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { PgSearchPreset } from 'graphile-search-plugin';
|
|
17
|
+
*
|
|
18
|
+
* const preset = {
|
|
19
|
+
* extends: [
|
|
20
|
+
* PgSearchPreset({
|
|
21
|
+
* pgSearchPrefix: 'fullText',
|
|
22
|
+
* }),
|
|
23
|
+
* ],
|
|
24
|
+
* };
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
function PgSearchPreset(options = {}) {
|
|
28
|
+
return {
|
|
29
|
+
plugins: [(0, tsvector_codec_1.createTsvectorCodecPlugin)(options), (0, plugin_1.createPgSearchPlugin)(options)],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
exports.default = PgSearchPreset;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TsvectorCodecPlugin
|
|
3
|
+
*
|
|
4
|
+
* Teaches PostGraphile v5 how to handle PostgreSQL's tsvector and tsquery types.
|
|
5
|
+
* Without this, tsvector columns are invisible to the schema builder and the
|
|
6
|
+
* search plugin cannot generate condition fields.
|
|
7
|
+
*
|
|
8
|
+
* This plugin:
|
|
9
|
+
* 1. Creates codecs for tsvector/tsquery via gather.hooks.pgCodecs_findPgCodec
|
|
10
|
+
* 2. Registers a custom "FullText" scalar type for tsvector columns
|
|
11
|
+
* 3. Maps tsvector codec to the FullText scalar (isolating filter operators)
|
|
12
|
+
* 4. Maps tsquery codec to GraphQL String
|
|
13
|
+
* 5. Optionally hides tsvector columns from output types
|
|
14
|
+
*/
|
|
15
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
16
|
+
import type { PgSearchPluginOptions } from './types';
|
|
17
|
+
/**
|
|
18
|
+
* Creates a TsvectorCodecPlugin with the given options.
|
|
19
|
+
*
|
|
20
|
+
* @param options - Plugin configuration
|
|
21
|
+
* @returns GraphileConfig.Plugin
|
|
22
|
+
*/
|
|
23
|
+
export declare function createTsvectorCodecPlugin(options?: PgSearchPluginOptions): GraphileConfig.Plugin;
|
|
24
|
+
/**
|
|
25
|
+
* Default static instance using default options.
|
|
26
|
+
* Maps tsvector to the "FullText" scalar.
|
|
27
|
+
*/
|
|
28
|
+
export declare const TsvectorCodecPlugin: GraphileConfig.Plugin;
|
|
29
|
+
export declare const TsvectorCodecPreset: GraphileConfig.Preset;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* TsvectorCodecPlugin
|
|
4
|
+
*
|
|
5
|
+
* Teaches PostGraphile v5 how to handle PostgreSQL's tsvector and tsquery types.
|
|
6
|
+
* Without this, tsvector columns are invisible to the schema builder and the
|
|
7
|
+
* search plugin cannot generate condition fields.
|
|
8
|
+
*
|
|
9
|
+
* This plugin:
|
|
10
|
+
* 1. Creates codecs for tsvector/tsquery via gather.hooks.pgCodecs_findPgCodec
|
|
11
|
+
* 2. Registers a custom "FullText" scalar type for tsvector columns
|
|
12
|
+
* 3. Maps tsvector codec to the FullText scalar (isolating filter operators)
|
|
13
|
+
* 4. Maps tsquery codec to GraphQL String
|
|
14
|
+
* 5. Optionally hides tsvector columns from output types
|
|
15
|
+
*/
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.TsvectorCodecPreset = exports.TsvectorCodecPlugin = void 0;
|
|
21
|
+
exports.createTsvectorCodecPlugin = createTsvectorCodecPlugin;
|
|
22
|
+
const graphql_1 = require("graphql");
|
|
23
|
+
const pg_sql2_1 = __importDefault(require("pg-sql2"));
|
|
24
|
+
/**
|
|
25
|
+
* Creates a TsvectorCodecPlugin with the given options.
|
|
26
|
+
*
|
|
27
|
+
* @param options - Plugin configuration
|
|
28
|
+
* @returns GraphileConfig.Plugin
|
|
29
|
+
*/
|
|
30
|
+
function createTsvectorCodecPlugin(options = {}) {
|
|
31
|
+
const { fullTextScalarName = 'FullText', hideTsvectorColumns = false, } = options;
|
|
32
|
+
return {
|
|
33
|
+
name: 'TsvectorCodecPlugin',
|
|
34
|
+
version: '1.0.0',
|
|
35
|
+
gather: {
|
|
36
|
+
hooks: {
|
|
37
|
+
async pgCodecs_findPgCodec(info, event) {
|
|
38
|
+
if (event.pgCodec) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const { pgType: type, serviceName } = event;
|
|
42
|
+
const pgCatalog = await info.helpers.pgIntrospection.getNamespaceByName(serviceName, 'pg_catalog');
|
|
43
|
+
if (!pgCatalog) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (type.typnamespace === pgCatalog._id && type.typname === 'tsvector') {
|
|
47
|
+
event.pgCodec = {
|
|
48
|
+
name: 'tsvector',
|
|
49
|
+
sqlType: pg_sql2_1.default.identifier('pg_catalog', 'tsvector'),
|
|
50
|
+
fromPg: (value) => value,
|
|
51
|
+
toPg: (value) => value,
|
|
52
|
+
attributes: undefined,
|
|
53
|
+
executor: null,
|
|
54
|
+
extensions: {
|
|
55
|
+
oid: type._id,
|
|
56
|
+
pg: {
|
|
57
|
+
serviceName,
|
|
58
|
+
schemaName: 'pg_catalog',
|
|
59
|
+
name: 'tsvector',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (type.typnamespace === pgCatalog._id && type.typname === 'tsquery') {
|
|
66
|
+
event.pgCodec = {
|
|
67
|
+
name: 'tsquery',
|
|
68
|
+
sqlType: pg_sql2_1.default.identifier('pg_catalog', 'tsquery'),
|
|
69
|
+
fromPg: (value) => value,
|
|
70
|
+
toPg: (value) => value,
|
|
71
|
+
attributes: undefined,
|
|
72
|
+
executor: null,
|
|
73
|
+
extensions: {
|
|
74
|
+
oid: type._id,
|
|
75
|
+
pg: {
|
|
76
|
+
serviceName,
|
|
77
|
+
schemaName: 'pg_catalog',
|
|
78
|
+
name: 'tsquery',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
schema: {
|
|
88
|
+
hooks: {
|
|
89
|
+
// Must run before PgCodecsPlugin's init (to avoid "unknown codec" warning)
|
|
90
|
+
// and before PgConnectionArgFilterPlugin's init (which creates filter
|
|
91
|
+
// types like FullTextFilter based on codec→GraphQL type mappings).
|
|
92
|
+
init: {
|
|
93
|
+
before: ['PgCodecs', 'PgConnectionArgFilterPlugin'],
|
|
94
|
+
callback(_, build) {
|
|
95
|
+
const { setGraphQLTypeForPgCodec } = build;
|
|
96
|
+
// Register a custom scalar type for tsvector columns.
|
|
97
|
+
// This ensures filter operators like `matches` only appear on
|
|
98
|
+
// tsvector filters, not on all String filters.
|
|
99
|
+
build.registerScalarType(fullTextScalarName, {}, () => ({
|
|
100
|
+
description: 'A full-text search tsvector value represented as a string.',
|
|
101
|
+
serialize(value) {
|
|
102
|
+
return String(value);
|
|
103
|
+
},
|
|
104
|
+
parseValue(value) {
|
|
105
|
+
if (typeof value === 'string') {
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`${fullTextScalarName} must be a string`);
|
|
109
|
+
},
|
|
110
|
+
parseLiteral(lit) {
|
|
111
|
+
if (lit.kind === 'NullValue')
|
|
112
|
+
return null;
|
|
113
|
+
if (lit.kind !== 'StringValue') {
|
|
114
|
+
throw new Error(`${fullTextScalarName} must be a string`);
|
|
115
|
+
}
|
|
116
|
+
return lit.value;
|
|
117
|
+
},
|
|
118
|
+
}), `TsvectorCodecPlugin registering ${fullTextScalarName} scalar`);
|
|
119
|
+
for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
|
|
120
|
+
if (codec.name === 'tsvector') {
|
|
121
|
+
setGraphQLTypeForPgCodec(codec, 'input', fullTextScalarName);
|
|
122
|
+
setGraphQLTypeForPgCodec(codec, 'output', fullTextScalarName);
|
|
123
|
+
}
|
|
124
|
+
else if (codec.name === 'tsquery') {
|
|
125
|
+
setGraphQLTypeForPgCodec(codec, 'input', graphql_1.GraphQLString.name);
|
|
126
|
+
setGraphQLTypeForPgCodec(codec, 'output', graphql_1.GraphQLString.name);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return _;
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
...(hideTsvectorColumns
|
|
134
|
+
? {
|
|
135
|
+
entityBehavior: {
|
|
136
|
+
pgCodecAttribute: {
|
|
137
|
+
inferred: {
|
|
138
|
+
after: ['postInferred'],
|
|
139
|
+
provides: ['hideTsvectorColumns'],
|
|
140
|
+
callback(behavior, [codec, attributeName]) {
|
|
141
|
+
const attr = codec.attributes?.[attributeName];
|
|
142
|
+
if (attr?.codec?.name === 'tsvector') {
|
|
143
|
+
return [behavior, '-select'];
|
|
144
|
+
}
|
|
145
|
+
return behavior;
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
: {}),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Default static instance using default options.
|
|
157
|
+
* Maps tsvector to the "FullText" scalar.
|
|
158
|
+
*/
|
|
159
|
+
exports.TsvectorCodecPlugin = createTsvectorCodecPlugin();
|
|
160
|
+
exports.TsvectorCodecPreset = {
|
|
161
|
+
plugins: [exports.TsvectorCodecPlugin],
|
|
162
|
+
};
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PgSearch Plugin Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for the PostGraphile v5 search plugin configuration.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Plugin configuration options.
|
|
8
|
+
*/
|
|
9
|
+
export interface PgSearchPluginOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Prefix for tsvector condition fields.
|
|
12
|
+
* For example, with prefix 'fullText' and a column named 'tsv',
|
|
13
|
+
* the generated condition field will be 'fullTextTsv'.
|
|
14
|
+
* @default 'tsv'
|
|
15
|
+
*/
|
|
16
|
+
pgSearchPrefix?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Whether to hide tsvector columns from output types.
|
|
19
|
+
* When true, tsvector columns won't appear as fields on the GraphQL object type.
|
|
20
|
+
* @default false
|
|
21
|
+
*/
|
|
22
|
+
hideTsvectorColumns?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Name of the custom GraphQL scalar for tsvector columns.
|
|
25
|
+
* This scalar isolates filter operators (like `matches`) to tsvector columns
|
|
26
|
+
* rather than all String fields.
|
|
27
|
+
* @default 'FullText'
|
|
28
|
+
*/
|
|
29
|
+
fullTextScalarName?: string;
|
|
30
|
+
/**
|
|
31
|
+
* PostgreSQL text search configuration used with `websearch_to_tsquery`.
|
|
32
|
+
* Must match the configuration used when building your tsvector columns
|
|
33
|
+
* (e.g., `'english'`, `'simple'`, `'spanish'`).
|
|
34
|
+
* @default 'english'
|
|
35
|
+
*/
|
|
36
|
+
tsConfig?: string;
|
|
37
|
+
}
|