graphile-search 1.1.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/LICENSE +23 -0
- package/README.md +123 -0
- package/adapters/bm25.d.ts +32 -0
- package/adapters/bm25.js +119 -0
- package/adapters/index.d.ts +14 -0
- package/adapters/index.js +17 -0
- package/adapters/pgvector.d.ts +21 -0
- package/adapters/pgvector.js +125 -0
- package/adapters/trgm.d.ts +20 -0
- package/adapters/trgm.js +83 -0
- package/adapters/tsvector.d.ts +20 -0
- package/adapters/tsvector.js +60 -0
- package/codecs/bm25-codec.d.ts +42 -0
- package/codecs/bm25-codec.js +199 -0
- package/codecs/index.d.ts +12 -0
- package/codecs/index.js +22 -0
- package/codecs/operator-factories.d.ts +22 -0
- package/codecs/operator-factories.js +84 -0
- package/codecs/tsvector-codec.d.ts +53 -0
- package/codecs/tsvector-codec.js +162 -0
- package/codecs/vector-codec.d.ts +18 -0
- package/codecs/vector-codec.js +116 -0
- package/esm/adapters/bm25.d.ts +32 -0
- package/esm/adapters/bm25.js +116 -0
- package/esm/adapters/index.d.ts +14 -0
- package/esm/adapters/index.js +10 -0
- package/esm/adapters/pgvector.d.ts +21 -0
- package/esm/adapters/pgvector.js +122 -0
- package/esm/adapters/trgm.d.ts +20 -0
- package/esm/adapters/trgm.js +80 -0
- package/esm/adapters/tsvector.d.ts +20 -0
- package/esm/adapters/tsvector.js +57 -0
- package/esm/codecs/bm25-codec.d.ts +42 -0
- package/esm/codecs/bm25-codec.js +160 -0
- package/esm/codecs/index.d.ts +12 -0
- package/esm/codecs/index.js +10 -0
- package/esm/codecs/operator-factories.d.ts +22 -0
- package/esm/codecs/operator-factories.js +80 -0
- package/esm/codecs/tsvector-codec.d.ts +53 -0
- package/esm/codecs/tsvector-codec.js +155 -0
- package/esm/codecs/vector-codec.d.ts +18 -0
- package/esm/codecs/vector-codec.js +110 -0
- package/esm/index.d.ts +40 -0
- package/esm/index.js +41 -0
- package/esm/plugin.d.ts +50 -0
- package/esm/plugin.js +553 -0
- package/esm/preset.d.ts +79 -0
- package/esm/preset.js +82 -0
- package/esm/types.d.ts +171 -0
- package/esm/types.js +7 -0
- package/index.d.ts +40 -0
- package/index.js +60 -0
- package/package.json +66 -0
- package/plugin.d.ts +50 -0
- package/plugin.js +556 -0
- package/preset.d.ts +79 -0
- package/preset.js +85 -0
- package/types.d.ts +171 -0
- package/types.js +8 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bm25CodecPlugin
|
|
3
|
+
*
|
|
4
|
+
* Teaches PostGraphile v5 how to handle the pg_textsearch `bm25query` type
|
|
5
|
+
* and discovers all BM25 indexes in the database.
|
|
6
|
+
*
|
|
7
|
+
* This plugin:
|
|
8
|
+
* 1. Creates a codec for bm25query via gather.hooks.pgCodecs_findPgCodec
|
|
9
|
+
* 2. Discovers all BM25 indexes via gather.hooks.pgIntrospection_introspection
|
|
10
|
+
* by querying pg_index + pg_am + pg_class + pg_attribute
|
|
11
|
+
* 3. Stores discovered BM25 index info in a module-level Map for use by
|
|
12
|
+
* the BM25 adapter during the schema build phase
|
|
13
|
+
*/
|
|
14
|
+
import 'graphile-build-pg';
|
|
15
|
+
import sql from 'pg-sql2';
|
|
16
|
+
/**
|
|
17
|
+
* Module-level store for discovered BM25 indexes.
|
|
18
|
+
* Populated during the gather phase, read during the schema build phase.
|
|
19
|
+
*
|
|
20
|
+
* Key: "schemaName.tableName.columnName"
|
|
21
|
+
* Value: Bm25IndexInfo
|
|
22
|
+
*/
|
|
23
|
+
export const bm25IndexStore = new Map();
|
|
24
|
+
/**
|
|
25
|
+
* Whether pg_textsearch extension was detected in the database.
|
|
26
|
+
*/
|
|
27
|
+
export let bm25ExtensionDetected = false;
|
|
28
|
+
/**
|
|
29
|
+
* The SQL query that discovers BM25 indexes in the database.
|
|
30
|
+
* Joins pg_index -> pg_class -> pg_am to find all indexes using the 'bm25'
|
|
31
|
+
* access method, then resolves the schema, table, column, and index names.
|
|
32
|
+
*/
|
|
33
|
+
const BM25_DISCOVERY_SQL = `
|
|
34
|
+
SELECT
|
|
35
|
+
n.nspname AS schema_name,
|
|
36
|
+
c.relname AS table_name,
|
|
37
|
+
a.attname AS column_name,
|
|
38
|
+
i.relname AS index_name
|
|
39
|
+
FROM pg_index ix
|
|
40
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
41
|
+
JOIN pg_am am ON am.oid = i.relam
|
|
42
|
+
JOIN pg_class c ON c.oid = ix.indrelid
|
|
43
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
44
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(ix.indkey)
|
|
45
|
+
WHERE am.amname = 'bm25'
|
|
46
|
+
`;
|
|
47
|
+
export const Bm25CodecPlugin = {
|
|
48
|
+
name: 'Bm25CodecPlugin',
|
|
49
|
+
version: '1.0.0',
|
|
50
|
+
description: 'Registers a codec for the pg_textsearch bm25query type and discovers BM25 indexes',
|
|
51
|
+
gather: {
|
|
52
|
+
hooks: {
|
|
53
|
+
/**
|
|
54
|
+
* Register the bm25query codec when detected during type introspection.
|
|
55
|
+
*/
|
|
56
|
+
async pgCodecs_findPgCodec(info, event) {
|
|
57
|
+
if (event.pgCodec)
|
|
58
|
+
return;
|
|
59
|
+
const { pgType: type, serviceName } = event;
|
|
60
|
+
if (type.typname !== 'bm25query')
|
|
61
|
+
return;
|
|
62
|
+
const typeNamespace = await info.helpers.pgIntrospection.getNamespace(serviceName, type.typnamespace);
|
|
63
|
+
if (!typeNamespace)
|
|
64
|
+
return;
|
|
65
|
+
const schemaName = typeNamespace.nspname;
|
|
66
|
+
event.pgCodec = {
|
|
67
|
+
name: 'bm25query',
|
|
68
|
+
sqlType: sql.identifier(schemaName, 'bm25query'),
|
|
69
|
+
// PG sends bm25query as text
|
|
70
|
+
fromPg(value) {
|
|
71
|
+
return value;
|
|
72
|
+
},
|
|
73
|
+
// string -> bm25query text
|
|
74
|
+
toPg(value) {
|
|
75
|
+
return value;
|
|
76
|
+
},
|
|
77
|
+
attributes: undefined,
|
|
78
|
+
executor: null,
|
|
79
|
+
extensions: {
|
|
80
|
+
oid: type._id,
|
|
81
|
+
pg: { serviceName, schemaName, name: 'bm25query' },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
/**
|
|
86
|
+
* After introspection completes, query for all BM25 indexes.
|
|
87
|
+
* Uses the pgService's adaptorSettings to create a direct pg.Pool
|
|
88
|
+
* connection and runs the BM25 discovery query.
|
|
89
|
+
*/
|
|
90
|
+
async pgIntrospection_introspection(info, event) {
|
|
91
|
+
const { serviceName } = event;
|
|
92
|
+
// Get the pgService from the resolved preset
|
|
93
|
+
const pgService = info.resolvedPreset?.pgServices?.find((s) => (s.name ?? 'main') === serviceName);
|
|
94
|
+
if (!pgService)
|
|
95
|
+
return;
|
|
96
|
+
// Clear previous entries for this introspection run
|
|
97
|
+
bm25IndexStore.clear();
|
|
98
|
+
try {
|
|
99
|
+
const adaptorSettings = pgService.adaptorSettings;
|
|
100
|
+
if (!adaptorSettings?.connectionString && !adaptorSettings?.pool) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Import pg dynamically for the discovery query
|
|
104
|
+
const { Pool } = await import('pg');
|
|
105
|
+
const existingPool = adaptorSettings.pool;
|
|
106
|
+
const pool = existingPool ?? new Pool({
|
|
107
|
+
connectionString: adaptorSettings.connectionString,
|
|
108
|
+
max: 1,
|
|
109
|
+
});
|
|
110
|
+
const isOwnPool = !existingPool;
|
|
111
|
+
try {
|
|
112
|
+
const result = await pool.query(BM25_DISCOVERY_SQL);
|
|
113
|
+
if (result.rows && result.rows.length > 0) {
|
|
114
|
+
bm25ExtensionDetected = true;
|
|
115
|
+
for (const row of result.rows) {
|
|
116
|
+
const key = `${row.schema_name}.${row.table_name}.${row.column_name}`;
|
|
117
|
+
bm25IndexStore.set(key, {
|
|
118
|
+
schemaName: row.schema_name,
|
|
119
|
+
tableName: row.table_name,
|
|
120
|
+
columnName: row.column_name,
|
|
121
|
+
indexName: row.index_name,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
if (isOwnPool) {
|
|
128
|
+
await pool.end();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// pg_textsearch not installed or query failed — gracefully skip
|
|
134
|
+
bm25ExtensionDetected = false;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
schema: {
|
|
140
|
+
hooks: {
|
|
141
|
+
init: {
|
|
142
|
+
before: ['PgCodecs'],
|
|
143
|
+
callback(_, build) {
|
|
144
|
+
const { setGraphQLTypeForPgCodec } = build;
|
|
145
|
+
// Map bm25query codec to String for both input and output
|
|
146
|
+
for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
|
|
147
|
+
if (codec.name === 'bm25query') {
|
|
148
|
+
setGraphQLTypeForPgCodec(codec, 'input', build.graphql.GraphQLString.name);
|
|
149
|
+
setGraphQLTypeForPgCodec(codec, 'output', build.graphql.GraphQLString.name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return _;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
export const Bm25CodecPreset = {
|
|
159
|
+
plugins: [Bm25CodecPlugin],
|
|
160
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codec Plugin Exports
|
|
3
|
+
*
|
|
4
|
+
* These plugins teach PostGraphile v5 how to handle custom PostgreSQL types
|
|
5
|
+
* used by the search adapters. They run during the gather phase to discover
|
|
6
|
+
* types and indexes before the schema build phase.
|
|
7
|
+
*/
|
|
8
|
+
export { TsvectorCodecPlugin, TsvectorCodecPreset, createTsvectorCodecPlugin, } from './tsvector-codec';
|
|
9
|
+
export type { TsvectorCodecPluginOptions } from './tsvector-codec';
|
|
10
|
+
export { Bm25CodecPlugin, Bm25CodecPreset, bm25IndexStore, bm25ExtensionDetected, } from './bm25-codec';
|
|
11
|
+
export type { Bm25IndexInfo } from './bm25-codec';
|
|
12
|
+
export { VectorCodecPlugin, VectorCodecPreset, } from './vector-codec';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codec Plugin Exports
|
|
3
|
+
*
|
|
4
|
+
* These plugins teach PostGraphile v5 how to handle custom PostgreSQL types
|
|
5
|
+
* used by the search adapters. They run during the gather phase to discover
|
|
6
|
+
* types and indexes before the schema build phase.
|
|
7
|
+
*/
|
|
8
|
+
export { TsvectorCodecPlugin, TsvectorCodecPreset, createTsvectorCodecPlugin, } from './tsvector-codec';
|
|
9
|
+
export { Bm25CodecPlugin, Bm25CodecPreset, bm25IndexStore, bm25ExtensionDetected, } from './bm25-codec';
|
|
10
|
+
export { VectorCodecPlugin, VectorCodecPreset, } from './vector-codec';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection Filter Operator Factories for Search
|
|
3
|
+
*
|
|
4
|
+
* These factories register filter operators on the connection filter system
|
|
5
|
+
* for tsvector (matches) and pg_trgm (similarTo, wordSimilarTo).
|
|
6
|
+
*
|
|
7
|
+
* They are used in the ConstructivePreset's connectionFilterOperatorFactories
|
|
8
|
+
* array to wire search operators into the declarative filter system.
|
|
9
|
+
*/
|
|
10
|
+
import type { ConnectionFilterOperatorFactory } from 'graphile-connection-filter';
|
|
11
|
+
/**
|
|
12
|
+
* Creates the `matches` filter operator factory for full-text search.
|
|
13
|
+
* Declared here so it's registered via the declarative
|
|
14
|
+
* `connectionFilterOperatorFactories` API.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createMatchesOperatorFactory(fullTextScalarName: string, tsConfig: string): ConnectionFilterOperatorFactory;
|
|
17
|
+
/**
|
|
18
|
+
* Creates the `similarTo` and `wordSimilarTo` filter operator factories
|
|
19
|
+
* for pg_trgm fuzzy text matching. Declared here so they're registered
|
|
20
|
+
* via the declarative `connectionFilterOperatorFactories` API.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createTrgmOperatorFactories(): ConnectionFilterOperatorFactory;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection Filter Operator Factories for Search
|
|
3
|
+
*
|
|
4
|
+
* These factories register filter operators on the connection filter system
|
|
5
|
+
* for tsvector (matches) and pg_trgm (similarTo, wordSimilarTo).
|
|
6
|
+
*
|
|
7
|
+
* They are used in the ConstructivePreset's connectionFilterOperatorFactories
|
|
8
|
+
* array to wire search operators into the declarative filter system.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Creates the `matches` filter operator factory for full-text search.
|
|
12
|
+
* Declared here so it's registered via the declarative
|
|
13
|
+
* `connectionFilterOperatorFactories` API.
|
|
14
|
+
*/
|
|
15
|
+
export function createMatchesOperatorFactory(fullTextScalarName, tsConfig) {
|
|
16
|
+
return (build) => {
|
|
17
|
+
const { sql, graphql: { GraphQLString } } = build;
|
|
18
|
+
const TYPES = build.dataplanPg?.TYPES;
|
|
19
|
+
return [{
|
|
20
|
+
typeNames: fullTextScalarName,
|
|
21
|
+
operatorName: 'matches',
|
|
22
|
+
spec: {
|
|
23
|
+
description: 'Performs a full text search on the field.',
|
|
24
|
+
resolveType: () => GraphQLString,
|
|
25
|
+
resolveInputCodec: TYPES ? () => TYPES.text : undefined,
|
|
26
|
+
resolve(sqlIdentifier, sqlValue, _input, _$where, _details) {
|
|
27
|
+
return sql `${sqlIdentifier} @@ websearch_to_tsquery(${sql.literal(tsConfig)}, ${sqlValue})`;
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}];
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Creates the `similarTo` and `wordSimilarTo` filter operator factories
|
|
35
|
+
* for pg_trgm fuzzy text matching. Declared here so they're registered
|
|
36
|
+
* via the declarative `connectionFilterOperatorFactories` API.
|
|
37
|
+
*/
|
|
38
|
+
export function createTrgmOperatorFactories() {
|
|
39
|
+
return (build) => {
|
|
40
|
+
const { sql } = build;
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
typeNames: 'String',
|
|
44
|
+
operatorName: 'similarTo',
|
|
45
|
+
spec: {
|
|
46
|
+
description: 'Fuzzy matches using pg_trgm trigram similarity. Tolerates typos and misspellings.',
|
|
47
|
+
resolveType: () => build.getTypeByName('TrgmSearchInput'),
|
|
48
|
+
resolve(sqlIdentifier, _sqlValue, input, _$where, _details) {
|
|
49
|
+
if (input == null)
|
|
50
|
+
return null;
|
|
51
|
+
const { value, threshold } = input;
|
|
52
|
+
if (!value || typeof value !== 'string' || value.trim().length === 0) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const th = threshold != null ? threshold : 0.3;
|
|
56
|
+
return sql `similarity(${sqlIdentifier}, ${sql.value(value)}) > ${sql.value(th)}`;
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
typeNames: 'String',
|
|
62
|
+
operatorName: 'wordSimilarTo',
|
|
63
|
+
spec: {
|
|
64
|
+
description: 'Fuzzy matches using pg_trgm word_similarity. Finds the best matching substring within the column value.',
|
|
65
|
+
resolveType: () => build.getTypeByName('TrgmSearchInput'),
|
|
66
|
+
resolve(sqlIdentifier, _sqlValue, input, _$where, _details) {
|
|
67
|
+
if (input == null)
|
|
68
|
+
return null;
|
|
69
|
+
const { value, threshold } = input;
|
|
70
|
+
if (!value || typeof value !== 'string' || value.trim().length === 0) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const th = threshold != null ? threshold : 0.3;
|
|
74
|
+
return sql `word_similarity(${sql.value(value)}, ${sqlIdentifier}) > ${sql.value(th)}`;
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
/**
|
|
17
|
+
* Options for the TsvectorCodecPlugin.
|
|
18
|
+
*/
|
|
19
|
+
export interface TsvectorCodecPluginOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Prefix for tsvector condition fields.
|
|
22
|
+
* @default 'tsv'
|
|
23
|
+
*/
|
|
24
|
+
pgSearchPrefix?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Whether to hide tsvector columns from output types.
|
|
27
|
+
* @default false
|
|
28
|
+
*/
|
|
29
|
+
hideTsvectorColumns?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Name of the custom GraphQL scalar for tsvector columns.
|
|
32
|
+
* @default 'FullText'
|
|
33
|
+
*/
|
|
34
|
+
fullTextScalarName?: string;
|
|
35
|
+
/**
|
|
36
|
+
* PostgreSQL text search configuration used with `websearch_to_tsquery`.
|
|
37
|
+
* @default 'english'
|
|
38
|
+
*/
|
|
39
|
+
tsConfig?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Creates a TsvectorCodecPlugin with the given options.
|
|
43
|
+
*
|
|
44
|
+
* @param options - Plugin configuration
|
|
45
|
+
* @returns GraphileConfig.Plugin
|
|
46
|
+
*/
|
|
47
|
+
export declare function createTsvectorCodecPlugin(options?: TsvectorCodecPluginOptions): GraphileConfig.Plugin;
|
|
48
|
+
/**
|
|
49
|
+
* Default static instance using default options.
|
|
50
|
+
* Maps tsvector to the "FullText" scalar.
|
|
51
|
+
*/
|
|
52
|
+
export declare const TsvectorCodecPlugin: GraphileConfig.Plugin;
|
|
53
|
+
export declare const TsvectorCodecPreset: GraphileConfig.Preset;
|
|
@@ -0,0 +1,155 @@
|
|
|
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 { GraphQLString } from 'graphql';
|
|
16
|
+
import sql from 'pg-sql2';
|
|
17
|
+
/**
|
|
18
|
+
* Creates a TsvectorCodecPlugin with the given options.
|
|
19
|
+
*
|
|
20
|
+
* @param options - Plugin configuration
|
|
21
|
+
* @returns GraphileConfig.Plugin
|
|
22
|
+
*/
|
|
23
|
+
export function createTsvectorCodecPlugin(options = {}) {
|
|
24
|
+
const { fullTextScalarName = 'FullText', hideTsvectorColumns = false, } = options;
|
|
25
|
+
return {
|
|
26
|
+
name: 'TsvectorCodecPlugin',
|
|
27
|
+
version: '1.0.0',
|
|
28
|
+
gather: {
|
|
29
|
+
hooks: {
|
|
30
|
+
async pgCodecs_findPgCodec(info, event) {
|
|
31
|
+
if (event.pgCodec) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const { pgType: type, serviceName } = event;
|
|
35
|
+
const pgCatalog = await info.helpers.pgIntrospection.getNamespaceByName(serviceName, 'pg_catalog');
|
|
36
|
+
if (!pgCatalog) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (type.typnamespace === pgCatalog._id && type.typname === 'tsvector') {
|
|
40
|
+
event.pgCodec = {
|
|
41
|
+
name: 'tsvector',
|
|
42
|
+
sqlType: sql.identifier('pg_catalog', 'tsvector'),
|
|
43
|
+
fromPg: (value) => value,
|
|
44
|
+
toPg: (value) => value,
|
|
45
|
+
attributes: undefined,
|
|
46
|
+
executor: null,
|
|
47
|
+
extensions: {
|
|
48
|
+
oid: type._id,
|
|
49
|
+
pg: {
|
|
50
|
+
serviceName,
|
|
51
|
+
schemaName: 'pg_catalog',
|
|
52
|
+
name: 'tsvector',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (type.typnamespace === pgCatalog._id && type.typname === 'tsquery') {
|
|
59
|
+
event.pgCodec = {
|
|
60
|
+
name: 'tsquery',
|
|
61
|
+
sqlType: sql.identifier('pg_catalog', 'tsquery'),
|
|
62
|
+
fromPg: (value) => value,
|
|
63
|
+
toPg: (value) => value,
|
|
64
|
+
attributes: undefined,
|
|
65
|
+
executor: null,
|
|
66
|
+
extensions: {
|
|
67
|
+
oid: type._id,
|
|
68
|
+
pg: {
|
|
69
|
+
serviceName,
|
|
70
|
+
schemaName: 'pg_catalog',
|
|
71
|
+
name: 'tsquery',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
schema: {
|
|
81
|
+
hooks: {
|
|
82
|
+
// Must run before PgCodecsPlugin's init (to avoid "unknown codec" warning)
|
|
83
|
+
// and before PgConnectionArgFilterPlugin's init (which creates filter
|
|
84
|
+
// types like FullTextFilter based on codec→GraphQL type mappings).
|
|
85
|
+
init: {
|
|
86
|
+
before: ['PgCodecs', 'PgConnectionArgFilterPlugin'],
|
|
87
|
+
callback(_, build) {
|
|
88
|
+
const { setGraphQLTypeForPgCodec } = build;
|
|
89
|
+
// Register a custom scalar type for tsvector columns.
|
|
90
|
+
// This ensures filter operators like `matches` only appear on
|
|
91
|
+
// tsvector filters, not on all String filters.
|
|
92
|
+
build.registerScalarType(fullTextScalarName, {}, () => ({
|
|
93
|
+
description: 'A full-text search tsvector value represented as a string.',
|
|
94
|
+
serialize(value) {
|
|
95
|
+
return String(value);
|
|
96
|
+
},
|
|
97
|
+
parseValue(value) {
|
|
98
|
+
if (typeof value === 'string') {
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`${fullTextScalarName} must be a string`);
|
|
102
|
+
},
|
|
103
|
+
parseLiteral(lit) {
|
|
104
|
+
if (lit.kind === 'NullValue')
|
|
105
|
+
return null;
|
|
106
|
+
if (lit.kind !== 'StringValue') {
|
|
107
|
+
throw new Error(`${fullTextScalarName} must be a string`);
|
|
108
|
+
}
|
|
109
|
+
return lit.value;
|
|
110
|
+
},
|
|
111
|
+
}), `TsvectorCodecPlugin registering ${fullTextScalarName} scalar`);
|
|
112
|
+
for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
|
|
113
|
+
if (codec.name === 'tsvector') {
|
|
114
|
+
setGraphQLTypeForPgCodec(codec, 'input', fullTextScalarName);
|
|
115
|
+
setGraphQLTypeForPgCodec(codec, 'output', fullTextScalarName);
|
|
116
|
+
}
|
|
117
|
+
else if (codec.name === 'tsquery') {
|
|
118
|
+
setGraphQLTypeForPgCodec(codec, 'input', GraphQLString.name);
|
|
119
|
+
setGraphQLTypeForPgCodec(codec, 'output', GraphQLString.name);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return _;
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
...(hideTsvectorColumns
|
|
127
|
+
? {
|
|
128
|
+
entityBehavior: {
|
|
129
|
+
pgCodecAttribute: {
|
|
130
|
+
inferred: {
|
|
131
|
+
after: ['postInferred'],
|
|
132
|
+
provides: ['hideTsvectorColumns'],
|
|
133
|
+
callback(behavior, [codec, attributeName]) {
|
|
134
|
+
const attr = codec.attributes?.[attributeName];
|
|
135
|
+
if (attr?.codec?.name === 'tsvector') {
|
|
136
|
+
return [behavior, '-select'];
|
|
137
|
+
}
|
|
138
|
+
return behavior;
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
: {}),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Default static instance using default options.
|
|
150
|
+
* Maps tsvector to the "FullText" scalar.
|
|
151
|
+
*/
|
|
152
|
+
export const TsvectorCodecPlugin = createTsvectorCodecPlugin();
|
|
153
|
+
export const TsvectorCodecPreset = {
|
|
154
|
+
plugins: [TsvectorCodecPlugin],
|
|
155
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VectorCodecPlugin
|
|
3
|
+
*
|
|
4
|
+
* Teaches PostGraphile v5 how to handle the pgvector `vector` type.
|
|
5
|
+
*
|
|
6
|
+
* Without this:
|
|
7
|
+
* - `vector(n)` columns are silently invisible in the schema
|
|
8
|
+
* - SQL functions with `vector` args are skipped entirely
|
|
9
|
+
*
|
|
10
|
+
* Wire format: PostgreSQL sends vector as text `[0.1,0.2,...,0.768]`
|
|
11
|
+
* JavaScript: number[]
|
|
12
|
+
* GraphQL: `Vector` scalar (serialized as [Float])
|
|
13
|
+
*/
|
|
14
|
+
import 'graphile-build-pg';
|
|
15
|
+
import 'graphile-build';
|
|
16
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
17
|
+
export declare const VectorCodecPlugin: GraphileConfig.Plugin;
|
|
18
|
+
export declare const VectorCodecPreset: GraphileConfig.Preset;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VectorCodecPlugin
|
|
3
|
+
*
|
|
4
|
+
* Teaches PostGraphile v5 how to handle the pgvector `vector` type.
|
|
5
|
+
*
|
|
6
|
+
* Without this:
|
|
7
|
+
* - `vector(n)` columns are silently invisible in the schema
|
|
8
|
+
* - SQL functions with `vector` args are skipped entirely
|
|
9
|
+
*
|
|
10
|
+
* Wire format: PostgreSQL sends vector as text `[0.1,0.2,...,0.768]`
|
|
11
|
+
* JavaScript: number[]
|
|
12
|
+
* GraphQL: `Vector` scalar (serialized as [Float])
|
|
13
|
+
*/
|
|
14
|
+
import 'graphile-build-pg';
|
|
15
|
+
import 'graphile-build';
|
|
16
|
+
import sql from 'pg-sql2';
|
|
17
|
+
export const VectorCodecPlugin = {
|
|
18
|
+
name: 'VectorCodecPlugin',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
description: 'Registers a codec for the pgvector `vector` type',
|
|
21
|
+
gather: {
|
|
22
|
+
hooks: {
|
|
23
|
+
async pgCodecs_findPgCodec(info, event) {
|
|
24
|
+
if (event.pgCodec)
|
|
25
|
+
return;
|
|
26
|
+
const { pgType: type, serviceName } = event;
|
|
27
|
+
if (type.typname !== 'vector')
|
|
28
|
+
return;
|
|
29
|
+
const typeNamespace = await info.helpers.pgIntrospection.getNamespace(serviceName, type.typnamespace);
|
|
30
|
+
if (!typeNamespace)
|
|
31
|
+
return;
|
|
32
|
+
const schemaName = typeNamespace.nspname;
|
|
33
|
+
event.pgCodec = {
|
|
34
|
+
name: 'vector',
|
|
35
|
+
sqlType: sql.identifier(schemaName, 'vector'),
|
|
36
|
+
// PG sends: [0.1,-0.2,...,0.768] -> number[]
|
|
37
|
+
fromPg(value) {
|
|
38
|
+
return value
|
|
39
|
+
.replace(/^\[|\]$/g, '')
|
|
40
|
+
.split(',')
|
|
41
|
+
.map((v) => parseFloat(v.trim()));
|
|
42
|
+
},
|
|
43
|
+
// number[] -> [0.1,-0.2,...,0.768]
|
|
44
|
+
toPg(value) {
|
|
45
|
+
if (!Array.isArray(value))
|
|
46
|
+
throw new Error('vector input must be an array of numbers');
|
|
47
|
+
return `[${value.join(',')}]`;
|
|
48
|
+
},
|
|
49
|
+
attributes: undefined,
|
|
50
|
+
executor: undefined,
|
|
51
|
+
extensions: {
|
|
52
|
+
oid: type._id,
|
|
53
|
+
pg: { serviceName, schemaName, name: 'vector' },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
schema: {
|
|
60
|
+
hooks: {
|
|
61
|
+
init: {
|
|
62
|
+
before: ['PgCodecs'],
|
|
63
|
+
callback(_, build) {
|
|
64
|
+
const { setGraphQLTypeForPgCodec } = build;
|
|
65
|
+
build.registerScalarType('Vector', {}, () => ({
|
|
66
|
+
description: 'A pgvector embedding — array of floats. ' +
|
|
67
|
+
'Dimensions must match the column (e.g. 768 for nomic-embed-text).',
|
|
68
|
+
serialize(value) {
|
|
69
|
+
if (Array.isArray(value))
|
|
70
|
+
return value;
|
|
71
|
+
if (typeof value === 'string')
|
|
72
|
+
return value.replace(/^\[|\]$/g, '').split(',').map((v) => parseFloat(v.trim()));
|
|
73
|
+
throw new Error('Vector must be an array of numbers');
|
|
74
|
+
},
|
|
75
|
+
parseValue(value) {
|
|
76
|
+
if (Array.isArray(value))
|
|
77
|
+
return value;
|
|
78
|
+
throw new Error('Vector must be an array of numbers');
|
|
79
|
+
},
|
|
80
|
+
parseLiteral(ast) {
|
|
81
|
+
if (ast.kind === 'NullValue')
|
|
82
|
+
return null;
|
|
83
|
+
if (ast.kind === 'ListValue')
|
|
84
|
+
return ast.values.map((v) => {
|
|
85
|
+
if (v.kind === 'FloatValue' || v.kind === 'IntValue')
|
|
86
|
+
return parseFloat(v.value);
|
|
87
|
+
throw new Error('Vector elements must be Float values');
|
|
88
|
+
});
|
|
89
|
+
if (ast.kind === 'StringValue')
|
|
90
|
+
return ast.value.replace(/^\[|\]$/g, '').split(',').map((v) => parseFloat(v.trim()));
|
|
91
|
+
throw new Error('Vector must be a list of floats or a string "[f1,f2,...]"');
|
|
92
|
+
},
|
|
93
|
+
}), 'VectorCodecPlugin registering Vector scalar');
|
|
94
|
+
// Wire codec -> scalar for both input (mutations) and output (queries).
|
|
95
|
+
// Without BOTH, PgAttributesPlugin silently drops the column.
|
|
96
|
+
for (const codec of Object.values(build.input.pgRegistry.pgCodecs)) {
|
|
97
|
+
if (codec.name === 'vector') {
|
|
98
|
+
setGraphQLTypeForPgCodec(codec, 'input', 'Vector');
|
|
99
|
+
setGraphQLTypeForPgCodec(codec, 'output', 'Vector');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return _;
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
export const VectorCodecPreset = {
|
|
109
|
+
plugins: [VectorCodecPlugin],
|
|
110
|
+
};
|
package/esm/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graphile-search — Unified PostGraphile v5 Search Plugin
|
|
3
|
+
*
|
|
4
|
+
* Abstracts tsvector, BM25, pg_trgm, and pgvector behind a single
|
|
5
|
+
* adapter-based architecture with a composite `searchScore` field.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { UnifiedSearchPreset } from 'graphile-search';
|
|
10
|
+
*
|
|
11
|
+
* // Use all 4 adapters with defaults:
|
|
12
|
+
* const preset = {
|
|
13
|
+
* extends: [
|
|
14
|
+
* UnifiedSearchPreset(),
|
|
15
|
+
* ],
|
|
16
|
+
* };
|
|
17
|
+
*
|
|
18
|
+
* // Or customize per-adapter:
|
|
19
|
+
* const preset = {
|
|
20
|
+
* extends: [
|
|
21
|
+
* UnifiedSearchPreset({
|
|
22
|
+
* tsvector: { filterPrefix: 'fullText', tsConfig: 'english' },
|
|
23
|
+
* bm25: true,
|
|
24
|
+
* trgm: { defaultThreshold: 0.2 },
|
|
25
|
+
* pgvector: { defaultMetric: 'L2' },
|
|
26
|
+
* searchScoreWeights: { bm25: 0.5, trgm: 0.3, tsv: 0.2 },
|
|
27
|
+
* }),
|
|
28
|
+
* ],
|
|
29
|
+
* };
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export { createUnifiedSearchPlugin } from './plugin';
|
|
33
|
+
export { UnifiedSearchPreset } from './preset';
|
|
34
|
+
export type { UnifiedSearchPresetOptions } from './preset';
|
|
35
|
+
export type { SearchAdapter, SearchableColumn, ScoreSemantics, FilterApplyResult, UnifiedSearchOptions, } from './types';
|
|
36
|
+
export { createTsvectorAdapter, createBm25Adapter, createTrgmAdapter, createPgvectorAdapter, } from './adapters';
|
|
37
|
+
export type { TsvectorAdapterOptions, Bm25AdapterOptions, TrgmAdapterOptions, PgvectorAdapterOptions, } from './adapters';
|
|
38
|
+
export { TsvectorCodecPlugin, TsvectorCodecPreset, createTsvectorCodecPlugin, Bm25CodecPlugin, Bm25CodecPreset, bm25IndexStore, VectorCodecPlugin, VectorCodecPreset, } from './codecs';
|
|
39
|
+
export type { TsvectorCodecPluginOptions, Bm25IndexInfo, } from './codecs';
|
|
40
|
+
export { createMatchesOperatorFactory, createTrgmOperatorFactories, } from './codecs/operator-factories';
|