Skip to content

feat: add v5-native graphile-connection-filter plugin#797

Open
pyramation wants to merge 10 commits intomainfrom
devin/1773292005-graphile-connection-filter-v5
Open

feat: add v5-native graphile-connection-filter plugin#797
pyramation wants to merge 10 commits intomainfrom
devin/1773292005-graphile-connection-filter-v5

Conversation

@pyramation
Copy link
Contributor

@pyramation pyramation commented Mar 12, 2026

feat: add v5-native graphile-connection-filter plugin

Summary

Replaces the upstream postgraphile-plugin-connection-filter@3.0.0-rc.1 npm dependency with a from-scratch, PostGraphile v5-native implementation written in TypeScript within the monorepo at graphile/graphile-connection-filter/.

Why: The upstream plugin carried v4 legacy patterns, had a broken connectionFilterRelations: false config option (requiring a disablePlugins workaround), and wasn't maintainable as an opaque npm dependency.

Architecture — 10 focused plugins:

  1. ConnectionFilterInflectionPlugin — filter type naming (UserFilter, StringFilter, etc.)
  2. ConnectionFilterTypesPlugin — registers per-table and per-scalar filter input types, behavior registry
  3. ConnectionFilterArgPlugin — injects filter arg on connections/lists via applyPlan
  4. ConnectionFilterAttributesPlugin — per-column filter fields with PgCondition
  5. ConnectionFilterOperatorsPlugin (~1000 lines) — standard, sort, pattern, hstore, jsonb, inet, array, range, enum operators
  6. ConnectionFilterCustomOperatorsPluginaddConnectionFilterOperator API for satellite plugins
  7. ConnectionFilterLogicalOperatorsPluginand/or/not composition
  8. ConnectionFilterComputedAttributesPlugin — computed column filter fields (opt-in, disabled by default)
  9. ConnectionFilterForwardRelationsPlugin — filter by FK parent relations (e.g. clientByClientId: { name: { startsWith: "Acme" } })
  10. ConnectionFilterBackwardRelationsPlugin — filter by backward relations: one-to-one direct + one-to-many with some/every/none

Relation filters are controlled by the connectionFilterRelations toggle (default: false). Plugins are always loaded but check build.options.connectionFilterRelations at runtime, so the toggle can be set by any preset in the chain.

Integration changes:

  • graphile-settings/constructive-preset.ts swaps PostGraphileConnectionFilterPresetConnectionFilterPreset()
  • Removes the disablePlugins hack (no relation filter plugins to disable)
  • graphile-settings/package.json now uses "graphile-connection-filter": "workspace:^"

Satellite plugin cleanup:

  • Removed phantom postgraphile-plugin-connection-filter dependency from graphile-pgvector-plugin (never imported or used)
  • Removed phantom postgraphile-plugin-connection-filter dependency from graphile-pg-textsearch-plugin (never imported or used)
  • Updated graphile-plugin-connection-filter-postgis to depend on graphile-connection-filter: workspace:^ with properly typed imports (no more (build as any).addConnectionFilterOperator)
  • Updated graphile-search-plugin to depend on graphile-connection-filter: workspace:^ with properly typed imports

Updates since last revision

  • Added relation filter plugins (forward + backward). The connectionFilterRelations toggle (default: false) enables filtering via foreign key relationships:

    • Forward: allOrders(filter: { clientByClientId: { name: { startsWith: "Acme" } } }) — filter child by parent properties
    • Backward one-to-one: allClients(filter: { profileByClientId: { bio: { includes: "engineer" } } }) — filter parent by unique child
    • Backward one-to-many: allClients(filter: { ordersByClientId: { some: { total: { greaterThan: 1000 } } } }) — filter parent by child collection with some/every/none operators
  • Un-skipped relation filter tests in graphile-search-plugin. Two tests now run with connectionFilterRelations: true and verify that the matches operator works through relation filters (e.g. clientByClientId: { tsv: { matches: "apple" } }). These tests execute real GraphQL queries against a test database and verify result counts, confirming the SQL generation is correct.

  • Architectural fix for runtime toggle. Relation plugins are always included in the plugin list but check build.options.connectionFilterRelations at runtime and early-return if disabled. This allows the toggle to be set by any preset in the chain, not just the ConnectionFilterPreset() call itself.

  • CI is fully green — 40/40 checks passing (2 skipped are expected: publish-constructive-manifest, build-push-constructive). All tests including relation filter tests pass.

  • Previous updates (still apply):

    • Added graphile-connection-filter as devDependency to graphile-pgvector-plugin (test imports ConnectionFilterPreset)
    • Fixed test file imports across 3 packages (PostGraphileConnectionFilterPresetConnectionFilterPreset())
    • Fixed Unicode smart quotes in filter descriptions to match existing snapshots
    • Added ConnectionFilterComputedAttributesPlugin for feature parity (opt-in, disabled by default)
    • Cleaned up satellite plugin dependencies with fully typed build.addConnectionFilterOperator(...)
    • Fixed inflection mismatch: inflection.filterType()inflection.filterFieldType()

Review & Testing Checklist for Human

This is a high-risk PR — a complete rewrite of a core schema plugin plus dependency rewiring of 4 satellite plugins, now including relation filter support. TypeScript compiles and CI passes (including relation filter tests against a real database), but this only verifies basic functionality, not all edge cases or production scenarios.

  • Verify relation filter SQL generation in production scenarios. The tests cover basic forward relation filters (clientByClientId), but backward one-to-many filters with some/every/none are untested against real data. Test queries like:

    allClients(filter: { 
      ordersByClientId: { 
        some: { total: { greaterThan: 1000 } } 
      }
    })

    Inspect the generated SQL — it should use EXISTS subqueries with proper join conditions.

  • Test the existsPlan / notPlan API contract. The relation plugins assume $where.existsPlan({ tableExpression, alias, equals }) and $subQuery.notPlan() exist on PgCondition. These are internal Grafast APIs that may not work as expected. Verify with logging or breakpoints.

  • Run against a real database and inspect the generated schema. Verify filter types (e.g. UserFilter, StringFilter) appear on connections, have the expected operator fields, logical operators (and/or/not), and relation filter fields (if connectionFilterRelations: true). Compare with the schema generated by the old plugin.

  • Test satellite plugin compatibility end-to-end. The PostGIS filter, search (matches operator), pgvector, and textsearch plugins previously depended on the upstream plugin. Verify these still work:

    • PostGIS spatial filter operators (intersects, contains, stDWithinGeography, etc.)
    • Search plugin's matches operator on tsvector columns, both standalone and through relation filters
    • pgvector and textsearch plugins should be unaffected
  • Execute actual filter queries in the GraphQL playground. Test standard operators, pattern matching, logical operators, type-specific operators (JSONB, array, inet, range), and relation filters with some/every/none. Inspect the generated SQL for correctness.

Recommended Test Plan

  1. Schema generation:

    cd ~/repos/constructive
    pnpm run graphql-codegen # or however you generate schema
    # Inspect the generated schema.graphql — confirm filter types and relation fields
  2. Relation filter queries (if enabled):

    query TestForwardRelation {
      allOrders(filter: { 
        clientByClientId: { name: { startsWith: "A" } }
      }) {
        nodes { id comment }
      }
    }
    
    query TestBackwardMany {
      allClients(filter: {
        ordersByClientId: { some: { total: { greaterThan: 100 } } }
      }) {
        nodes { id name }
      }
    }

    Verify SQL uses EXISTS subqueries with correct join conditions.

  3. Satellite plugin verification:

    • Start the GraphQL server locally
    • Test a PostGIS spatial filter (e.g. stIntersects)
    • Test the full-text search matches operator, including through relations if enabled
    • Verify pgvector/textsearch operators if applicable

Notes

  • Link to Devin Session: https://app.devin.ai/sessions/cf88f3fd383b4421a5169ed01612899d
  • Requested by: @pyramation
  • The plugin follows v5 best practices: declarative plugin objects, behavior registry, EXPORTABLE pattern, codec-aware type resolution, and PgCondition API.
  • Computed columns and relation filters are opt-in (both disabled by default). Enable via connectionFilterComputedColumns: true or connectionFilterRelations: true in preset options.
  • No setof function filters are implemented (can be added later if needed).
  • The addConnectionFilterOperator API signature matches the upstream plugin exactly to preserve satellite plugin compatibility.
  • TypeScript any usage is extensive throughout (codec as any, queryBuilder: any, sql: any in EXPORTABLE closures, etc.). While pragmatic for a rewrite, this undermines type safety. Consider tightening in follow-up work.
  • Relation filter SQL generation uses internal Grafast APIs (existsPlan, notPlan) and custom runtime tagging ($where._manyRelation = {...}). These patterns are tested but may be fragile.

Open with Devin

Implements a from-scratch PostGraphile v5 native connection filter plugin,
replacing the upstream postgraphile-plugin-connection-filter dependency.

New package: graphile/graphile-connection-filter/

Plugin architecture (7 plugins):
- ConnectionFilterInflectionPlugin: filter type naming conventions
- ConnectionFilterTypesPlugin: registers per-table and per-scalar filter types
- ConnectionFilterArgPlugin: injects filter arg on connections via applyPlan
- ConnectionFilterAttributesPlugin: adds per-column filter fields
- ConnectionFilterOperatorsPlugin: standard/sort/pattern/jsonb/inet/array/range operators
- ConnectionFilterCustomOperatorsPlugin: addConnectionFilterOperator API for satellite plugins
- ConnectionFilterLogicalOperatorsPlugin: and/or/not logical composition

Key features:
- Full v5 native: uses Grafast planning, PgCondition, codec system, behavior registry
- EXPORTABLE pattern for schema caching
- Preserves addConnectionFilterOperator API for PostGIS, search, pgvector, textsearch plugins
- No relation filter plugins (simplifies configuration vs upstream)
- Preset factory: ConnectionFilterPreset(options)

Also updates graphile-settings to use the new workspace package.
@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 7 additional findings in Devin Review.

Open in Devin Review

: [typeNameOrNames];

for (const typeName of typeNames) {
const filterTypeName = inflection.filterType(typeName);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 addConnectionFilterOperator uses filterType inflection instead of filterFieldType, causing mismatch for custom inflections

In ConnectionFilterCustomOperatorsPlugin.ts:60, addConnectionFilterOperator stores custom operators under the key inflection.filterType(typeName). However, the operator types (e.g. StringFilter, GeoJSONFilter) are registered in ConnectionFilterTypesPlugin.ts:162-164 using inflection.filterFieldType(namedTypeName). The lookup in the GraphQLInputObjectType_fields hook at line 96 uses Self.name, which matches the filterFieldType-derived name.

Currently both filterType and filterFieldType produce the same ${name}Filter output (ConnectionFilterInflectionPlugin.ts:19-23), so this works by coincidence. But these inflections have different semantic purposes (filterType = table filter types like UserFilter, filterFieldType = scalar operator types like StringFilter). If a consumer overrides one inflection but not the other, custom operator registration from satellite plugins (PostGIS, search) will silently fail — build[$$filters].get(Self.name) at line 96 would return undefined and operators would be quietly skipped.

Suggested change
const filterTypeName = inflection.filterType(typeName);
const filterTypeName = inflection.filterFieldType(typeName);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…Operator

filterType is for table-level filter types (UserFilter), while filterFieldType
is for scalar operator types (StringFilter). Satellite plugins pass scalar type
names, so the lookup must use filterFieldType to match the registration in
ConnectionFilterTypesPlugin. Previously worked by coincidence since both
inflections produce the same output, but would silently fail if a consumer
overrode one inflection but not the other.
Adds computed column filter support — allows filtering on PostgreSQL functions
that take a table row as their first argument and return a scalar.

Controlled by connectionFilterComputedColumns schema option. The preset factory
includes the plugin only when the option is truthy (default in preset: true,
but constructive-preset sets it to false).
- Remove phantom postgraphile-plugin-connection-filter dep from graphile-pgvector-plugin (never used)
- Remove phantom postgraphile-plugin-connection-filter dep from graphile-pg-textsearch-plugin (never used)
- Update graphile-plugin-connection-filter-postgis to use graphile-connection-filter workspace dep with typed imports
- Update graphile-search-plugin to use graphile-connection-filter workspace dep with typed imports
- Replace (build as any).addConnectionFilterOperator casts with properly typed build.addConnectionFilterOperator
…on-filter

- Update search plugin, pgvector, and postgis test files to import from
  graphile-connection-filter instead of postgraphile-plugin-connection-filter
- Use ConnectionFilterPreset() factory instead of PostGraphileConnectionFilterPreset
- Import ConnectionFilterOperatorSpec type from graphile-connection-filter
- Fix smart quote characters in filter descriptions to match existing snapshots
…ion filter tests

- Add graphile-connection-filter as devDependency in graphile-pgvector-plugin
  (test file imports ConnectionFilterPreset but package had no dependency)
- Skip connectionFilterRelations tests in search plugin (relation filters
  are intentionally not included in the v5-native plugin; they were disabled
  in production via disablePlugins with the old plugin)
…toggle

- ConnectionFilterForwardRelationsPlugin: filter by FK parent relations
- ConnectionFilterBackwardRelationsPlugin: filter by backward relations (one-to-one + one-to-many with some/every/none)
- connectionFilterRelations toggle in preset (default: false)
- Un-skip relation filter tests in search plugin
- Updated augmentations, types, and exports
… at runtime

The preset factory now always includes relation plugins in the plugin list.
Each plugin checks build.options.connectionFilterRelations at runtime and
early-returns if disabled. This allows the toggle to be set by any preset
in the chain, not just the ConnectionFilterPreset() call.
Enables relation filter fields in the production schema:
- Forward: filter by FK parent (e.g. clientByClientId on OrderFilter)
- Backward: filter by children with some/every/none
- Codegen will pick up the new filter fields automatically
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant