From 78610ae9750f93c322e834c4751a170a5fdfc19d Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 18 Apr 2026 10:32:44 +0000 Subject: [PATCH] fix(graphile-postgis): camelCase @spatialRelation field + PascalCase filter type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated GraphQL for a @spatialRelation tag like '@spatialRelation inside_neighborhood neighborhoods.geom st_within' used to produce a field and type whose casing passed through the raw tag identifier verbatim: inside_neighborhood?: PlaceSpatialInside_neighborhoodFilter; Normalize the relation name through the standard Graphile inflectors so the GraphQL identifiers follow the same conventions as every other generated field/type: insideNeighborhood?: PlaceSpatialInsideNeighborhoodFilter; Also camelCase the parametric arg name (e.g. 'travel_distance' → 'travelDistance') on parametric ops (st_dwithin). Added a unit test that pins this behavior. While here, re-export graphile-connection-filter's declaration-merge augmentations from its package entry so that satellite plugins (like graphile-postgis) that type-only import the package actually pick up the Inflection.filterType/filterManyType augmentations the file already declared — the satellite plugin was previously relying on an augmentation chain that was never loaded via the public barrel, which produced TS2339 errors during typecheck. --- .../graphile-connection-filter/src/index.ts | 7 +++++ .../__tests__/spatial-relations.test.ts | 29 +++++++++++++++++++ .../src/plugins/spatial-relations.ts | 19 ++++++++++-- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/graphile/graphile-connection-filter/src/index.ts b/graphile/graphile-connection-filter/src/index.ts index 30c95ea35..44faf101f 100644 --- a/graphile/graphile-connection-filter/src/index.ts +++ b/graphile/graphile-connection-filter/src/index.ts @@ -49,6 +49,13 @@ * ``` */ +// Load the global type augmentations (inflection methods, build/scope +// properties) so that downstream satellite plugins which `import +// 'graphile-connection-filter'` pick up the `filterType`/`filterManyType`/ +// etc. type extensions without having to reach into the package's internal +// file layout. +import './augmentations'; + export { ConnectionFilterPreset } from './preset'; // Re-export all plugins for granular use diff --git a/graphile/graphile-postgis/__tests__/spatial-relations.test.ts b/graphile/graphile-postgis/__tests__/spatial-relations.test.ts index 63165312b..d9fe6b1e8 100644 --- a/graphile/graphile-postgis/__tests__/spatial-relations.test.ts +++ b/graphile/graphile-postgis/__tests__/spatial-relations.test.ts @@ -67,8 +67,16 @@ function buildMockRegistry( } function makeBuild(registry: any): any { + // Minimal inflection double — only `camelCase` is consulted by + // `collectSpatialRelations` (to normalize the parametric arg name). + const inflection = { + camelCase(str: string): string { + return str.replace(/[-_](.)/g, (_, c: string) => c.toUpperCase()); + }, + }; return { input: { pgRegistry: registry }, + inflection, }; } @@ -289,6 +297,27 @@ describe('collectSpatialRelations', () => { expect(rel.paramFieldName).toBe('distance'); }); + it('camelCases snake_case parametric arg names', () => { + // The @spatialRelation tag grammar accepts any [A-Za-z_][A-Za-z0-9_]* + // identifier for the parametric arg; the GraphQL field we expose for + // it must follow the same camelCase convention as every other field. + const registry = buildMockRegistry({ + clinics: { + pk: ['id'], + attributes: { + id: { base: 'int4' }, + location: { + base: 'geometry', + spatialRelation: + 'nearbyClinic clinics.location st_dwithin travel_distance', + }, + }, + }, + }); + const [rel] = collectSpatialRelations(makeBuild(registry)); + expect(rel.paramFieldName).toBe('travelDistance'); + }); + it('supports multiple tags on the same column (string[] form)', () => { const registry = buildMockRegistry({ counties: { diff --git a/graphile/graphile-postgis/src/plugins/spatial-relations.ts b/graphile/graphile-postgis/src/plugins/spatial-relations.ts index daf4984ea..ba0f5f3aa 100644 --- a/graphile/graphile-postgis/src/plugins/spatial-relations.ts +++ b/graphile/graphile-postgis/src/plugins/spatial-relations.ts @@ -322,6 +322,13 @@ export function collectSpatialRelations(build: any): SpatialRelationInfo[] { const pgRegistry = build.input?.pgRegistry; if (!pgRegistry) return []; + // Inflection is used to normalize user-supplied identifiers (the + // parametric arg name, e.g. `travel_distance` → `travelDistance`) into the + // GraphQL casing conventions. Fall back to identity if not available + // (e.g. when invoked from unit tests with a stub build). + const camelCase: (s: string) => string = + build.inflection?.camelCase?.bind(build.inflection) ?? ((s: string) => s); + const relations: SpatialRelationInfo[] = []; for (const resource of Object.values(pgRegistry.pgResources) as any[]) { @@ -400,7 +407,7 @@ export function collectSpatialRelations(build: any): SpatialRelationInfo[] { targetResource: target.resource, targetAttributeName: target.attributeName, operator: OPERATOR_REGISTRY[parsed.operator], - paramFieldName: parsed.paramName, + paramFieldName: parsed.paramName ? camelCase(parsed.paramName) : null, isSelfRelation, ownerPkAttributes, targetPkAttributes, @@ -431,7 +438,10 @@ export function collectSpatialRelations(build: any): SpatialRelationInfo[] { function spatialFilterTypeName(build: any, rel: SpatialRelationInfo): string { const { inflection } = build; const ownerTypeName = inflection.tableType(rel.ownerCodec); - const rel0 = rel.relationName.charAt(0).toUpperCase() + rel.relationName.slice(1); + // Normalize the user-supplied relation name (which may be snake_case, + // kebab-case, or mixed) into PascalCase so the type name is consistent + // with every other generated GraphQL type name. + const rel0 = inflection.upperCamelCase(rel.relationName); return `${ownerTypeName}Spatial${rel0}Filter`; } @@ -638,7 +648,10 @@ export const PostgisSpatialRelationsPlugin: GraphileConfig.Plugin = { const FilterType = build.getTypeByName(filterTypeName); if (!FilterType) continue; - const fieldName = rel.relationName; + // Normalize the user-supplied relation name (which may be + // snake_case, kebab-case, or mixed) into camelCase so the GraphQL + // field name matches the casing of every other generated field. + const fieldName = inflection.camelCase(rel.relationName); // Avoid clobbering fields an upstream plugin may have registered // (e.g. an FK-derived relation with the same name). if (fields[fieldName]) {