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/README.md
CHANGED
|
@@ -16,44 +16,64 @@
|
|
|
16
16
|
</a>
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
|
-
**`graphile-search-plugin`** enables
|
|
19
|
+
**`graphile-search-plugin`** enables auto-generated full-text search condition fields for all `tsvector` columns in PostGraphile v5 schemas.
|
|
20
20
|
|
|
21
|
-
##
|
|
21
|
+
## Installation
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
24
|
npm install graphile-search-plugin
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
##
|
|
27
|
+
## Features
|
|
28
28
|
|
|
29
|
-
- Adds full-text search
|
|
30
|
-
-
|
|
31
|
-
-
|
|
29
|
+
- Adds full-text search condition fields for `tsvector` columns
|
|
30
|
+
- Uses `websearch_to_tsquery` for natural search syntax
|
|
31
|
+
- Automatic `ORDER BY ts_rank(column, tsquery) DESC` relevance ordering (matching V4 behavior)
|
|
32
|
+
- Cursor-based pagination remains stable โ PostGraphile re-appends unique key columns after the relevance sort
|
|
33
|
+
- Works with PostGraphile v5 preset/plugin pipeline
|
|
32
34
|
|
|
33
|
-
##
|
|
35
|
+
## Usage
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
2. Query `search<YourTsvectorColumn>` in the `conditions` field
|
|
37
|
-
3. Enjoy!
|
|
37
|
+
### With Preset (Recommended)
|
|
38
38
|
|
|
39
|
-
```
|
|
39
|
+
```typescript
|
|
40
|
+
import { PgSearchPreset } from 'graphile-search-plugin';
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
]
|
|
48
|
-
})
|
|
49
|
-
);
|
|
42
|
+
const preset = {
|
|
43
|
+
extends: [
|
|
44
|
+
// ... your other presets
|
|
45
|
+
PgSearchPreset({ pgSearchPrefix: 'fullText' }),
|
|
46
|
+
],
|
|
47
|
+
};
|
|
50
48
|
```
|
|
51
49
|
|
|
52
|
-
|
|
50
|
+
### With Plugin Directly
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { PgSearchPlugin } from 'graphile-search-plugin';
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
const preset = {
|
|
56
|
+
plugins: [
|
|
57
|
+
PgSearchPlugin({ pgSearchPrefix: 'fullText' }),
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### GraphQL Query
|
|
63
|
+
|
|
64
|
+
```graphql
|
|
65
|
+
query SearchGoals($search: String!) {
|
|
66
|
+
goals(condition: { fullTextTsv: $search }) {
|
|
67
|
+
nodes {
|
|
68
|
+
id
|
|
69
|
+
title
|
|
70
|
+
description
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
55
75
|
|
|
56
|
-
##
|
|
76
|
+
## Testing
|
|
57
77
|
|
|
58
78
|
```sh
|
|
59
79
|
# requires a local Postgres available (defaults to postgres/password@localhost:5432)
|
package/esm/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostGraphile v5 Search Plugin
|
|
3
|
+
*
|
|
4
|
+
* Provides full-text search capabilities for tsvector columns.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { PgSearchPlugin, PgSearchPreset } from 'graphile-search-plugin';
|
|
9
|
+
*
|
|
10
|
+
* // Option 1: Use the preset (recommended)
|
|
11
|
+
* const preset = {
|
|
12
|
+
* extends: [
|
|
13
|
+
* PgSearchPreset({
|
|
14
|
+
* pgSearchPrefix: 'fullText',
|
|
15
|
+
* }),
|
|
16
|
+
* ],
|
|
17
|
+
* };
|
|
18
|
+
*
|
|
19
|
+
* // Option 2: Use the plugin directly
|
|
20
|
+
* const plugin = PgSearchPlugin({ pgSearchPrefix: 'fullText' });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export { PgSearchPlugin, createPgSearchPlugin } from './plugin';
|
|
24
|
+
export { PgSearchPreset } from './preset';
|
|
25
|
+
export { TsvectorCodecPlugin, TsvectorCodecPreset, createTsvectorCodecPlugin, } from './tsvector-codec';
|
|
26
|
+
export type { PgSearchPluginOptions } from './types';
|
package/esm/index.js
CHANGED
|
@@ -1,57 +1,25 @@
|
|
|
1
|
-
// plugins/PgSearchPlugin.ts
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
2
|
+
* PostGraphile v5 Search Plugin
|
|
3
|
+
*
|
|
4
|
+
* Provides full-text search capabilities for tsvector columns.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { PgSearchPlugin, PgSearchPreset } from 'graphile-search-plugin';
|
|
9
|
+
*
|
|
10
|
+
* // Option 1: Use the preset (recommended)
|
|
11
|
+
* const preset = {
|
|
12
|
+
* extends: [
|
|
13
|
+
* PgSearchPreset({
|
|
14
|
+
* pgSearchPrefix: 'fullText',
|
|
15
|
+
* }),
|
|
16
|
+
* ],
|
|
17
|
+
* };
|
|
18
|
+
*
|
|
19
|
+
* // Option 2: Use the plugin directly
|
|
20
|
+
* const plugin = PgSearchPlugin({ pgSearchPrefix: 'fullText' });
|
|
21
|
+
* ```
|
|
4
22
|
*/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const { inflection } = build;
|
|
9
|
-
const { scope: { isPgCondition, pgIntrospection: table }, fieldWithHooks } = context;
|
|
10
|
-
if (!isPgCondition || !table || table.kind !== 'class')
|
|
11
|
-
return fields;
|
|
12
|
-
const tsvs = table.attributes.filter((attr) => attr.type.name === 'tsvector');
|
|
13
|
-
if (!tsvs.length)
|
|
14
|
-
return fields;
|
|
15
|
-
return build.extend(fields, tsvs.reduce((memo, attr) => {
|
|
16
|
-
const fieldName = inflection.camelCase(`${pgSearchPrefix}_${attr.name}`);
|
|
17
|
-
memo[fieldName] = fieldWithHooks(fieldName, { type: build.graphql.GraphQLString }, {});
|
|
18
|
-
return memo;
|
|
19
|
-
}, {}));
|
|
20
|
-
});
|
|
21
|
-
builder.hook('GraphQLObjectType:fields:field:args', (args, build, context) => {
|
|
22
|
-
const { pgSql: sql, inflection } = build;
|
|
23
|
-
const { scope: { isPgFieldConnection, isPgFieldSimpleCollection, pgFieldIntrospection: procOrTable, pgFieldIntrospectionTable: tableIfProc, }, addArgDataGenerator, } = context;
|
|
24
|
-
const table = tableIfProc || procOrTable;
|
|
25
|
-
if ((!isPgFieldConnection && !isPgFieldSimpleCollection) ||
|
|
26
|
-
!table ||
|
|
27
|
-
table.kind !== 'class') {
|
|
28
|
-
return args;
|
|
29
|
-
}
|
|
30
|
-
const tsvs = table.attributes.filter((attr) => attr.type.name === 'tsvector');
|
|
31
|
-
if (!tsvs.length)
|
|
32
|
-
return args;
|
|
33
|
-
tsvs.forEach((tsv) => {
|
|
34
|
-
const conditionFieldName = inflection.camelCase(`${pgSearchPrefix}_${tsv.name}`);
|
|
35
|
-
addArgDataGenerator(function addSearchCondition({ condition }) {
|
|
36
|
-
if (!condition || !(conditionFieldName in condition))
|
|
37
|
-
return {};
|
|
38
|
-
const value = condition[conditionFieldName];
|
|
39
|
-
if (value == null)
|
|
40
|
-
return {};
|
|
41
|
-
return {
|
|
42
|
-
pgQuery: (queryBuilder) => {
|
|
43
|
-
const tsquery = sql.fragment `websearch_to_tsquery('english', ${sql.value(value)})`;
|
|
44
|
-
const tableAlias = queryBuilder.getTableAlias();
|
|
45
|
-
// WHERE condition
|
|
46
|
-
queryBuilder.where(sql.fragment `${tableAlias}.${sql.identifier(tsv.name)} @@ ${tsquery}`);
|
|
47
|
-
// Automatically add ordering by relevance (descending)
|
|
48
|
-
queryBuilder.orderBy(sql.fragment `ts_rank(${tableAlias}.${sql.identifier(tsv.name)}, ${tsquery})`, false);
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
return args;
|
|
54
|
-
}, [], ['PgConnectionArgOrderBy']);
|
|
55
|
-
};
|
|
56
|
-
export { PgSearchPlugin };
|
|
57
|
-
export default PgSearchPlugin;
|
|
23
|
+
export { PgSearchPlugin, createPgSearchPlugin } from './plugin';
|
|
24
|
+
export { PgSearchPreset } from './preset';
|
|
25
|
+
export { TsvectorCodecPlugin, TsvectorCodecPreset, createTsvectorCodecPlugin, } from './tsvector-codec';
|
package/esm/plugin.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostGraphile v5 Search Plugin
|
|
3
|
+
*
|
|
4
|
+
* Generates search condition fields for tsvector columns. When a search term
|
|
5
|
+
* is provided via the condition input, this plugin applies a
|
|
6
|
+
* `column @@ websearch_to_tsquery('english', $value)` WHERE clause and
|
|
7
|
+
* automatically orders results by `ts_rank` (descending) for relevance.
|
|
8
|
+
*
|
|
9
|
+
* Additionally provides:
|
|
10
|
+
* - `matches` filter operator for postgraphile-plugin-connection-filter
|
|
11
|
+
* - `fullTextRank` computed fields on output types (null when no search active)
|
|
12
|
+
* - `FULL_TEXT_RANK_ASC/DESC` orderBy enum values
|
|
13
|
+
*
|
|
14
|
+
* Uses the graphile-build hooks API to extend condition input types with
|
|
15
|
+
* search fields for each tsvector column found on a table's codec.
|
|
16
|
+
*
|
|
17
|
+
* ARCHITECTURE NOTE:
|
|
18
|
+
* Condition field apply functions run during a deferred phase (SQL generation)
|
|
19
|
+
* on a queryBuilder proxy โ NOT on the real PgSelectStep. The rank field plan
|
|
20
|
+
* runs earlier, during Grafast's planning phase, on the real PgSelectStep.
|
|
21
|
+
*
|
|
22
|
+
* To bridge these two phases we use a module-level WeakMap keyed by the SQL
|
|
23
|
+
* alias object (shared between proxy and PgSelectStep via reference identity).
|
|
24
|
+
*
|
|
25
|
+
* The rank field plan creates a `lambda` step that reads the row tuple at a
|
|
26
|
+
* dynamically-determined index. The condition apply adds `ts_rank(...)` to
|
|
27
|
+
* the SQL SELECT list via `proxy.selectAndReturnIndex()` and stores the
|
|
28
|
+
* resulting index in the WeakMap slot. At execution time the lambda reads
|
|
29
|
+
* the rank value from that index.
|
|
30
|
+
*/
|
|
31
|
+
import 'graphile-build';
|
|
32
|
+
import 'graphile-build-pg';
|
|
33
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
34
|
+
import type { PgSearchPluginOptions } from './types';
|
|
35
|
+
/**
|
|
36
|
+
* Creates the search plugin with the given options.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createPgSearchPlugin(options?: PgSearchPluginOptions): GraphileConfig.Plugin;
|
|
39
|
+
/**
|
|
40
|
+
* Creates a PgSearchPlugin with the given options.
|
|
41
|
+
* This is the main entry point for using the plugin.
|
|
42
|
+
*/
|
|
43
|
+
export declare const PgSearchPlugin: typeof createPgSearchPlugin;
|
|
44
|
+
export default PgSearchPlugin;
|
package/esm/plugin.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostGraphile v5 Search Plugin
|
|
3
|
+
*
|
|
4
|
+
* Generates search condition fields for tsvector columns. When a search term
|
|
5
|
+
* is provided via the condition input, this plugin applies a
|
|
6
|
+
* `column @@ websearch_to_tsquery('english', $value)` WHERE clause and
|
|
7
|
+
* automatically orders results by `ts_rank` (descending) for relevance.
|
|
8
|
+
*
|
|
9
|
+
* Additionally provides:
|
|
10
|
+
* - `matches` filter operator for postgraphile-plugin-connection-filter
|
|
11
|
+
* - `fullTextRank` computed fields on output types (null when no search active)
|
|
12
|
+
* - `FULL_TEXT_RANK_ASC/DESC` orderBy enum values
|
|
13
|
+
*
|
|
14
|
+
* Uses the graphile-build hooks API to extend condition input types with
|
|
15
|
+
* search fields for each tsvector column found on a table's codec.
|
|
16
|
+
*
|
|
17
|
+
* ARCHITECTURE NOTE:
|
|
18
|
+
* Condition field apply functions run during a deferred phase (SQL generation)
|
|
19
|
+
* on a queryBuilder proxy โ NOT on the real PgSelectStep. The rank field plan
|
|
20
|
+
* runs earlier, during Grafast's planning phase, on the real PgSelectStep.
|
|
21
|
+
*
|
|
22
|
+
* To bridge these two phases we use a module-level WeakMap keyed by the SQL
|
|
23
|
+
* alias object (shared between proxy and PgSelectStep via reference identity).
|
|
24
|
+
*
|
|
25
|
+
* The rank field plan creates a `lambda` step that reads the row tuple at a
|
|
26
|
+
* dynamically-determined index. The condition apply adds `ts_rank(...)` to
|
|
27
|
+
* the SQL SELECT list via `proxy.selectAndReturnIndex()` and stores the
|
|
28
|
+
* resulting index in the WeakMap slot. At execution time the lambda reads
|
|
29
|
+
* the rank value from that index.
|
|
30
|
+
*/
|
|
31
|
+
import 'graphile-build';
|
|
32
|
+
import 'graphile-build-pg';
|
|
33
|
+
import { TYPES } from '@dataplan/pg';
|
|
34
|
+
const ftsRankSlots = new WeakMap();
|
|
35
|
+
/**
|
|
36
|
+
* FinalizationRegistry for defensive cleanup of ftsRankSlots entries.
|
|
37
|
+
* WeakMap entries are already eligible for GC when keys are unreachable,
|
|
38
|
+
* but this provides explicit cleanup and a hook for debugging leaks.
|
|
39
|
+
*/
|
|
40
|
+
const ftsRankCleanup = new FinalizationRegistry((heldValue) => {
|
|
41
|
+
ftsRankSlots.delete(heldValue);
|
|
42
|
+
});
|
|
43
|
+
function isTsvectorCodec(codec) {
|
|
44
|
+
return (codec?.extensions?.pg?.schemaName === 'pg_catalog' &&
|
|
45
|
+
codec?.extensions?.pg?.name === 'tsvector');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Navigates from a PgSelectSingleStep up to the PgSelectStep.
|
|
49
|
+
* Uses duck-typing to avoid dependency on exact class names across rc versions.
|
|
50
|
+
*/
|
|
51
|
+
function getPgSelectStep($someStep) {
|
|
52
|
+
let $step = $someStep;
|
|
53
|
+
if ($step && typeof $step.getClassStep === 'function') {
|
|
54
|
+
$step = $step.getClassStep();
|
|
55
|
+
}
|
|
56
|
+
if ($step && typeof $step.orderBy === 'function' && $step.id !== undefined) {
|
|
57
|
+
return $step;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Creates the search plugin with the given options.
|
|
63
|
+
*/
|
|
64
|
+
export function createPgSearchPlugin(options = {}) {
|
|
65
|
+
const { pgSearchPrefix = 'tsv', fullTextScalarName = 'FullText', tsConfig = 'english' } = options;
|
|
66
|
+
return {
|
|
67
|
+
name: 'PgSearchPlugin',
|
|
68
|
+
version: '2.0.0',
|
|
69
|
+
description: 'Generates search conditions for tsvector columns in PostGraphile v5',
|
|
70
|
+
after: ['PgAttributesPlugin', 'PgConnectionArgFilterPlugin', 'PgConnectionArgFilterOperatorsPlugin', 'AddConnectionFilterOperatorPlugin'],
|
|
71
|
+
schema: {
|
|
72
|
+
hooks: {
|
|
73
|
+
init(_, build) {
|
|
74
|
+
const { sql, graphql: { GraphQLString }, } = build;
|
|
75
|
+
// Register the `matches` filter operator for the FullText scalar.
|
|
76
|
+
// Requires postgraphile-plugin-connection-filter; skip if not loaded.
|
|
77
|
+
const addConnectionFilterOperator = build
|
|
78
|
+
.addConnectionFilterOperator;
|
|
79
|
+
if (typeof addConnectionFilterOperator === 'function') {
|
|
80
|
+
const TYPES = build.dataplanPg?.TYPES;
|
|
81
|
+
addConnectionFilterOperator(fullTextScalarName, 'matches', {
|
|
82
|
+
description: 'Performs a full text search on the field.',
|
|
83
|
+
resolveType: () => GraphQLString,
|
|
84
|
+
resolveInputCodec: TYPES ? () => TYPES.text : undefined,
|
|
85
|
+
resolve(sqlIdentifier, sqlValue, _input, _$where, _details) {
|
|
86
|
+
return sql `${sqlIdentifier} @@ websearch_to_tsquery(${sql.literal(tsConfig)}, ${sqlValue})`;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return _;
|
|
91
|
+
},
|
|
92
|
+
GraphQLObjectType_fields(fields, build, context) {
|
|
93
|
+
const { sql, inflection, graphql: { GraphQLFloat }, grafast: { constant, lambda }, } = build;
|
|
94
|
+
const { scope: { isPgClassType, pgCodec }, fieldWithHooks, } = context;
|
|
95
|
+
if (!isPgClassType || !pgCodec?.attributes) {
|
|
96
|
+
return fields;
|
|
97
|
+
}
|
|
98
|
+
let newFields = fields;
|
|
99
|
+
for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
|
|
100
|
+
if (!isTsvectorCodec(attribute.codec))
|
|
101
|
+
continue;
|
|
102
|
+
const baseFieldName = inflection.attribute({ codec: pgCodec, attributeName });
|
|
103
|
+
const fieldName = inflection.camelCase(`${baseFieldName}-rank`);
|
|
104
|
+
newFields = build.extend(newFields, {
|
|
105
|
+
[fieldName]: fieldWithHooks({ fieldName }, () => ({
|
|
106
|
+
description: `Full-text search ranking when filtered by \`${baseFieldName}\`. Returns null when no search condition is active.`,
|
|
107
|
+
type: GraphQLFloat,
|
|
108
|
+
plan($step) {
|
|
109
|
+
const $select = getPgSelectStep($step);
|
|
110
|
+
if (!$select)
|
|
111
|
+
return constant(null);
|
|
112
|
+
if (typeof $select.setInliningForbidden === 'function') {
|
|
113
|
+
$select.setInliningForbidden();
|
|
114
|
+
}
|
|
115
|
+
// Initialise the WeakMap slot for this query, keyed by the
|
|
116
|
+
// SQL alias (same object ref on PgSelectStep and the proxy).
|
|
117
|
+
const alias = $select.alias;
|
|
118
|
+
if (!ftsRankSlots.has(alias)) {
|
|
119
|
+
ftsRankSlots.set(alias, {
|
|
120
|
+
indices: Object.create(null),
|
|
121
|
+
});
|
|
122
|
+
ftsRankCleanup.register($select, alias);
|
|
123
|
+
}
|
|
124
|
+
// Return a lambda that reads the rank value from the result
|
|
125
|
+
// row at a dynamically-determined index. The index is set
|
|
126
|
+
// by the condition apply (deferred phase) via the proxy's
|
|
127
|
+
// selectAndReturnIndex, and stored in the WeakMap slot.
|
|
128
|
+
const capturedField = baseFieldName;
|
|
129
|
+
const capturedAlias = alias;
|
|
130
|
+
return lambda($step, (row) => {
|
|
131
|
+
if (row == null)
|
|
132
|
+
return null;
|
|
133
|
+
const slot = ftsRankSlots.get(capturedAlias);
|
|
134
|
+
if (!slot || slot.indices[capturedField] === undefined)
|
|
135
|
+
return null;
|
|
136
|
+
const rawValue = row[slot.indices[capturedField]];
|
|
137
|
+
return rawValue == null ? null : parseFloat(rawValue);
|
|
138
|
+
}, true);
|
|
139
|
+
},
|
|
140
|
+
})),
|
|
141
|
+
}, `PgSearchPlugin adding rank field '${fieldName}' for '${attributeName}' on '${pgCodec.name}'`);
|
|
142
|
+
}
|
|
143
|
+
return newFields;
|
|
144
|
+
},
|
|
145
|
+
GraphQLEnumType_values(values, build, context) {
|
|
146
|
+
const { sql, inflection, } = build;
|
|
147
|
+
const { scope: { isPgRowSortEnum, pgCodec }, } = context;
|
|
148
|
+
if (!isPgRowSortEnum || !pgCodec?.attributes) {
|
|
149
|
+
return values;
|
|
150
|
+
}
|
|
151
|
+
let newValues = values;
|
|
152
|
+
for (const [attributeName, attribute] of Object.entries(pgCodec.attributes)) {
|
|
153
|
+
if (!isTsvectorCodec(attribute.codec))
|
|
154
|
+
continue;
|
|
155
|
+
const fieldName = inflection.attribute({ codec: pgCodec, attributeName });
|
|
156
|
+
const metaKey = `fts_order_${fieldName}`;
|
|
157
|
+
const makePlan = (direction) => (step) => {
|
|
158
|
+
// The enum apply runs during the PLANNING phase on PgSelectStep.
|
|
159
|
+
// Store the requested direction in PgSelectStep._meta so that
|
|
160
|
+
// the condition apply (deferred phase) can read it via the
|
|
161
|
+
// proxy's getMetaRaw and add the actual ORDER BY clause.
|
|
162
|
+
if (typeof step.setMeta === 'function') {
|
|
163
|
+
step.setMeta(metaKey, direction);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const ascName = inflection.constantCase(`${attributeName}_rank_asc`);
|
|
167
|
+
const descName = inflection.constantCase(`${attributeName}_rank_desc`);
|
|
168
|
+
newValues = build.extend(newValues, {
|
|
169
|
+
[ascName]: {
|
|
170
|
+
extensions: {
|
|
171
|
+
grafast: {
|
|
172
|
+
apply: makePlan('ASC'),
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
[descName]: {
|
|
177
|
+
extensions: {
|
|
178
|
+
grafast: {
|
|
179
|
+
apply: makePlan('DESC'),
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
}, `PgSearchPlugin adding rank orderBy for '${attributeName}' on '${pgCodec.name}'`);
|
|
184
|
+
}
|
|
185
|
+
return newValues;
|
|
186
|
+
},
|
|
187
|
+
GraphQLInputObjectType_fields(fields, build, context) {
|
|
188
|
+
const { inflection, sql, graphql: { GraphQLString }, } = build;
|
|
189
|
+
const { scope: { isPgCondition, pgCodec }, fieldWithHooks, } = context;
|
|
190
|
+
if (!isPgCondition ||
|
|
191
|
+
!pgCodec ||
|
|
192
|
+
!pgCodec.attributes ||
|
|
193
|
+
pgCodec.isAnonymous) {
|
|
194
|
+
return fields;
|
|
195
|
+
}
|
|
196
|
+
const tsvectorAttributes = Object.entries(pgCodec.attributes).filter(([_name, attr]) => isTsvectorCodec(attr.codec));
|
|
197
|
+
if (tsvectorAttributes.length === 0) {
|
|
198
|
+
return fields;
|
|
199
|
+
}
|
|
200
|
+
let newFields = fields;
|
|
201
|
+
for (const [attributeName] of tsvectorAttributes) {
|
|
202
|
+
const fieldName = inflection.camelCase(`${pgSearchPrefix}_${attributeName}`);
|
|
203
|
+
const baseFieldName = inflection.attribute({ codec: pgCodec, attributeName });
|
|
204
|
+
newFields = build.extend(newFields, {
|
|
205
|
+
[fieldName]: fieldWithHooks({
|
|
206
|
+
fieldName,
|
|
207
|
+
isPgConnectionConditionInputField: true,
|
|
208
|
+
}, {
|
|
209
|
+
description: build.wrapDescription(`Full-text search on the \`${attributeName}\` tsvector column using \`websearch_to_tsquery\`.`, 'field'),
|
|
210
|
+
type: GraphQLString,
|
|
211
|
+
apply: function plan($condition, val) {
|
|
212
|
+
if (val == null)
|
|
213
|
+
return;
|
|
214
|
+
const tsquery = sql `websearch_to_tsquery(${sql.literal(tsConfig)}, ${sql.value(val)})`;
|
|
215
|
+
const columnExpr = sql `${$condition.alias}.${sql.identifier(attributeName)}`;
|
|
216
|
+
// WHERE: column @@ tsquery
|
|
217
|
+
$condition.where(sql `${columnExpr} @@ ${tsquery}`);
|
|
218
|
+
// Add ts_rank to the SELECT list via the proxy's
|
|
219
|
+
// selectAndReturnIndex. This runs during the deferred
|
|
220
|
+
// SQL-generation phase, so the expression goes into
|
|
221
|
+
// info.selects (the live array used for SQL generation).
|
|
222
|
+
const $parent = $condition.dangerouslyGetParent();
|
|
223
|
+
if (typeof $parent.selectAndReturnIndex === 'function') {
|
|
224
|
+
const rankSql = sql `ts_rank(${columnExpr}, ${tsquery})`;
|
|
225
|
+
const wrappedRankSql = sql `${sql.parens(rankSql)}::text`;
|
|
226
|
+
const rankIndex = $parent.selectAndReturnIndex(wrappedRankSql);
|
|
227
|
+
// Store the index in the alias-keyed WeakMap slot so
|
|
228
|
+
// the rank field's lambda can read it at execute time.
|
|
229
|
+
const slot = ftsRankSlots.get($condition.alias);
|
|
230
|
+
if (slot) {
|
|
231
|
+
slot.indices[baseFieldName] = rankIndex;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// ORDER BY ts_rank: check if the user provided an
|
|
235
|
+
// explicit rank orderBy enum (stored in meta during
|
|
236
|
+
// planning). If so, use their direction. Otherwise add
|
|
237
|
+
// automatic DESC ordering for relevance.
|
|
238
|
+
const metaKey = `fts_order_${baseFieldName}`;
|
|
239
|
+
const explicitDir = typeof $parent.getMetaRaw === 'function'
|
|
240
|
+
? $parent.getMetaRaw(metaKey)
|
|
241
|
+
: undefined;
|
|
242
|
+
const orderDirection = explicitDir ?? 'DESC';
|
|
243
|
+
$parent.orderBy({
|
|
244
|
+
fragment: sql `ts_rank(${columnExpr}, ${tsquery})`,
|
|
245
|
+
codec: TYPES.float4,
|
|
246
|
+
direction: orderDirection,
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
}, `PgSearchPlugin adding condition field '${fieldName}' for tsvector column '${attributeName}' on '${pgCodec.name}'`);
|
|
251
|
+
}
|
|
252
|
+
return newFields;
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Creates a PgSearchPlugin with the given options.
|
|
260
|
+
* This is the main entry point for using the plugin.
|
|
261
|
+
*/
|
|
262
|
+
export const PgSearchPlugin = createPgSearchPlugin;
|
|
263
|
+
export default PgSearchPlugin;
|
package/esm/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/esm/preset.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostGraphile v5 Search Preset
|
|
3
|
+
*
|
|
4
|
+
* Provides a convenient preset for including search support in PostGraphile.
|
|
5
|
+
*/
|
|
6
|
+
import { createPgSearchPlugin } from './plugin';
|
|
7
|
+
import { createTsvectorCodecPlugin } from './tsvector-codec';
|
|
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 function PgSearchPreset(options = {}) {
|
|
25
|
+
return {
|
|
26
|
+
plugins: [createTsvectorCodecPlugin(options), createPgSearchPlugin(options)],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export 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;
|