diff --git a/graphile/graphile-search/src/codecs/operator-factories.ts b/graphile/graphile-search/src/codecs/operator-factories.ts index 2ad3c6079..a8e8a80b0 100644 --- a/graphile/graphile-search/src/codecs/operator-factories.ts +++ b/graphile/graphile-search/src/codecs/operator-factories.ts @@ -49,6 +49,12 @@ export function createMatchesOperatorFactory( * Creates the `similarTo` and `wordSimilarTo` filter operator factories * for pg_trgm fuzzy text matching. Declared here so they're registered * via the declarative `connectionFilterOperatorFactories` API. + * + * These operators target 'StringTrgm' (resolved to 'StringTrgmFilter'), + * NOT the global 'String' type. The unified search plugin registers + * 'StringTrgmFilter' and selectively assigns it to string columns on + * tables that qualify for trgm (via intentional search or @trgmSearch tag). + * This prevents trgm operators from appearing on every string field. */ export function createTrgmOperatorFactories(): ConnectionFilterOperatorFactory { return (build) => { @@ -56,7 +62,7 @@ export function createTrgmOperatorFactories(): ConnectionFilterOperatorFactory { return [ { - typeNames: 'String', + typeNames: 'StringTrgm', operatorName: 'similarTo', spec: { description: @@ -81,7 +87,7 @@ export function createTrgmOperatorFactories(): ConnectionFilterOperatorFactory { }, }, { - typeNames: 'String', + typeNames: 'StringTrgm', operatorName: 'wordSimilarTo', spec: { description: diff --git a/graphile/graphile-search/src/plugin.ts b/graphile/graphile-search/src/plugin.ts index 0758705d9..db66ddb6c 100644 --- a/graphile/graphile-search/src/plugin.ts +++ b/graphile/graphile-search/src/plugin.ts @@ -122,10 +122,18 @@ export function createUnifiedSearchPlugin( } } - // Phase 2: Only run supplementary adapters if at least one primary - // adapter with isIntentionalSearch found columns on this codec. + // Phase 2: Run supplementary adapters if intentional search exists + // OR if the table/column has a @trgmSearch smart tag. // pgvector (isIntentionalSearch: false) alone won't trigger trgm. - if (hasIntentionalSearch) { + const hasTrgmSearchTag = + // Table-level tag + (codec.extensions as any)?.tags?.trgmSearch || + // Column-level tag + (codec.attributes && Object.values(codec.attributes as Record).some( + (attr: any) => attr?.extensions?.tags?.trgmSearch + )); + + if (hasIntentionalSearch || hasTrgmSearchTag) { for (const adapter of supplementaryAdapters) { const columns = adapter.detectColumns(codec, build); if (columns.length > 0) { @@ -149,6 +157,8 @@ export function createUnifiedSearchPlugin( 'PgConnectionArgFilterAttributesPlugin', 'PgConnectionArgFilterOperatorsPlugin', 'AddConnectionFilterOperatorPlugin', + 'ConnectionFilterTypesPlugin', + 'ConnectionFilterCustomOperatorsPlugin', // Allow individual codec plugins to load first (e.g. Bm25CodecPlugin) 'Bm25CodecPlugin', 'VectorCodecPlugin', @@ -229,6 +239,34 @@ export function createUnifiedSearchPlugin( for (const adapter of adapters) { adapter.registerTypes(build); } + + // Register StringTrgmFilter — a variant of StringFilter that includes + // trgm operators (similarTo, wordSimilarTo). Only string columns on + // tables that qualify for trgm will use this type instead of StringFilter. + const hasTrgmAdapter = adapters.some((a) => a.name === 'trgm'); + if (hasTrgmAdapter) { + const DPTYPES = (build as any).dataplanPg?.TYPES; + const textCodec = DPTYPES?.text ?? TYPES.text; + build.registerInputObjectType( + 'StringTrgmFilter', + { + pgConnectionFilterOperators: { + isList: false, + pgCodecs: [textCodec], + inputTypeName: 'String', + rangeElementInputTypeName: null, + domainBaseTypeName: null, + }, + }, + () => ({ + description: + 'A filter to be used against String fields with pg_trgm support. ' + + 'All fields are combined with a logical \u2018and.\u2019', + }), + 'UnifiedSearchPlugin (StringTrgmFilter)' + ); + } + return _; }, @@ -610,6 +648,26 @@ export function createUnifiedSearchPlugin( let newFields = fields; + // ── StringFilter → StringTrgmFilter type swapping ── + // For tables that qualify for trgm, swap the type of string attribute + // filter fields so they get similarTo/wordSimilarTo operators. + const hasTrgm = adapterColumns.some((ac) => ac.adapter.name === 'trgm'); + if (hasTrgm) { + const StringTrgmFilterType = build.getTypeByName('StringTrgmFilter'); + const StringFilterType = build.getTypeByName('StringFilter'); + if (StringTrgmFilterType && StringFilterType) { + const swapped: Record = {}; + for (const [key, field] of Object.entries(newFields)) { + if (field && typeof field === 'object' && (field as any).type === StringFilterType) { + swapped[key] = Object.assign({}, field, { type: StringTrgmFilterType }); + } else { + swapped[key] = field; + } + } + newFields = swapped; + } + } + for (const { adapter, columns } of adapterColumns) { for (const column of columns) { const fieldName = inflection.camelCase( diff --git a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap index b70e8d045..5de8a96a9 100644 --- a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap +++ b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap @@ -666,29 +666,6 @@ input StringFilter { """Greater than or equal to the specified value (case-insensitive).""" greaterThanOrEqualToInsensitive: String - - """ - Fuzzy matches using pg_trgm trigram similarity. Tolerates typos and misspellings. - """ - similarTo: TrgmSearchInput - - """ - Fuzzy matches using pg_trgm word_similarity. Finds the best matching substring within the column value. - """ - wordSimilarTo: TrgmSearchInput -} - -""" -Input for pg_trgm fuzzy text matching. Provide a search value and optional similarity threshold. -""" -input TrgmSearchInput { - """The text to fuzzy-match against. Typos and misspellings are tolerated.""" - value: String! - - """ - Minimum similarity threshold (0.0 to 1.0). Higher = stricter matching. Default is 0.3. - """ - threshold: Float } """ diff --git a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap index d2fae7909..d3c1f0f58 100644 --- a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap +++ b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap @@ -1121,77 +1121,12 @@ based pagination. May not be used with \`last\`.", "ofType": null, }, }, - { - "defaultValue": null, - "description": "Fuzzy matches using pg_trgm trigram similarity. Tolerates typos and misspellings.", - "name": "similarTo", - "type": { - "kind": "INPUT_OBJECT", - "name": "TrgmSearchInput", - "ofType": null, - }, - }, - { - "defaultValue": null, - "description": "Fuzzy matches using pg_trgm word_similarity. Finds the best matching substring within the column value.", - "name": "wordSimilarTo", - "type": { - "kind": "INPUT_OBJECT", - "name": "TrgmSearchInput", - "ofType": null, - }, - }, ], "interfaces": null, "kind": "INPUT_OBJECT", "name": "StringFilter", "possibleTypes": null, }, - { - "description": "Input for pg_trgm fuzzy text matching. Provide a search value and optional similarity threshold.", - "enumValues": null, - "fields": null, - "inputFields": [ - { - "defaultValue": null, - "description": "The text to fuzzy-match against. Typos and misspellings are tolerated.", - "name": "value", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null, - }, - }, - }, - { - "defaultValue": null, - "description": "Minimum similarity threshold (0.0 to 1.0). Higher = stricter matching. Default is 0.3.", - "name": "threshold", - "type": { - "kind": "SCALAR", - "name": "Float", - "ofType": null, - }, - }, - ], - "interfaces": null, - "kind": "INPUT_OBJECT", - "name": "TrgmSearchInput", - "possibleTypes": null, - }, - { - "description": "The \`Float\` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", - "enumValues": null, - "fields": null, - "inputFields": null, - "interfaces": null, - "kind": "SCALAR", - "name": "Float", - "possibleTypes": null, - }, { "description": "Methods to use when ordering \`User\`.", "enumValues": [