Skip to content

Commit ba6035e

Browse files
authored
Merge pull request #950 from constructive-io/fix/meta-geometry-v5
Add PostGraphile v5 geometry subtype metadata to _meta
2 parents 3d13781 + ecc7af5 commit ba6035e

9 files changed

Lines changed: 270 additions & 1 deletion

File tree

graphile/graphile-postgis/__tests__/codec.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { PgCodec } from '@dataplan/pg';
22
import { PostgisCodecPlugin } from '../src/plugins/codec';
33
import type { GisFieldValue } from '../src/types';
4+
import { GisSubtype } from '../src/constants';
5+
import { getGISTypeModifier } from '../src/utils';
46

57
// Test event shape matching the GatherHooks.pgCodecs_findPgCodec event
68
interface MockEvent {
@@ -242,4 +244,83 @@ describe('PostgisCodecPlugin', () => {
242244
});
243245
});
244246
});
247+
248+
describe('pgCodecs_attribute hook', () => {
249+
const attributeHook = (PostgisCodecPlugin as { gather: { hooks: { pgCodecs_attribute: Function } } })
250+
.gather.hooks.pgCodecs_attribute;
251+
252+
async function runAttributeHook({
253+
codecName,
254+
typmod,
255+
withExtensions = true,
256+
}: {
257+
codecName: string;
258+
typmod: number | null;
259+
withExtensions?: boolean;
260+
}) {
261+
const attribute: Record<string, any> = { codec: { name: codecName } };
262+
if (withExtensions) {
263+
attribute.extensions = {};
264+
}
265+
const event = { pgAttribute: { atttypmod: typmod }, attribute };
266+
267+
await attributeHook({}, event);
268+
return attribute;
269+
}
270+
271+
it.each([
272+
['geometry', GisSubtype.Polygon, 'Polygon'],
273+
['geometry', GisSubtype.Point, 'Point'],
274+
['geography', GisSubtype.MultiPolygon, 'MultiPolygon'],
275+
])('stores geometrySubtype for %s subtype %s', async (codecName, subtype, expected) => {
276+
const attribute = await runAttributeHook({
277+
codecName,
278+
typmod: getGISTypeModifier(subtype, false, false, 4326),
279+
});
280+
281+
expect(attribute.extensions.geometrySubtype).toBe(expected);
282+
});
283+
284+
it('should skip unconstrained geometry (atttypmod = -1)', async () => {
285+
const attribute = await runAttributeHook({
286+
codecName: 'geometry',
287+
typmod: -1,
288+
});
289+
expect(attribute.extensions.geometrySubtype).toBeUndefined();
290+
});
291+
292+
it('should skip when atttypmod is null', async () => {
293+
const attribute = await runAttributeHook({
294+
codecName: 'geometry',
295+
typmod: null,
296+
});
297+
expect(attribute.extensions.geometrySubtype).toBeUndefined();
298+
});
299+
300+
it('should not store subtype for base Geometry (subtype=0)', async () => {
301+
const attribute = await runAttributeHook({
302+
codecName: 'geometry',
303+
typmod: getGISTypeModifier(GisSubtype.Geometry, false, false, 4326),
304+
});
305+
expect(attribute.extensions.geometrySubtype).toBeUndefined();
306+
});
307+
308+
it('should skip non-geometry codec types', async () => {
309+
const attribute = await runAttributeHook({
310+
codecName: 'text',
311+
typmod: getGISTypeModifier(GisSubtype.Point, false, false, 4326),
312+
});
313+
expect(attribute.extensions.geometrySubtype).toBeUndefined();
314+
});
315+
316+
it('should create extensions object if not present', async () => {
317+
const attribute = await runAttributeHook({
318+
codecName: 'geometry',
319+
typmod: getGISTypeModifier(GisSubtype.LineString, false, false, 4326),
320+
withExtensions: false,
321+
});
322+
expect(attribute.extensions).toBeDefined();
323+
expect(attribute.extensions.geometrySubtype).toBe('LineString');
324+
});
325+
});
245326
});

graphile/graphile-postgis/src/plugins/codec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import type { PgCodec } from '@dataplan/pg';
33
import type { GraphileConfig } from 'graphile-config';
44
import type { SQL } from 'pg-sql2';
55
import sql from 'pg-sql2';
6+
import { GIS_SUBTYPE_NAME } from '../constants';
67
import type { GisFieldValue } from '../types';
8+
import { getGISTypeDetails } from '../utils';
79

810
/**
911
* Map from PostGIS uppercase geometry type names (from geometrytype()) to
@@ -52,6 +54,12 @@ function normalizeGisType(raw: string): string {
5254
return GIS_TYPE_NORMALIZE[raw] ?? raw;
5355
}
5456

57+
function getGeometrySubtypeName(typmod: number): string | null {
58+
const { subtype } = getGISTypeDetails(typmod);
59+
const subtypeName = GIS_SUBTYPE_NAME[subtype];
60+
return subtypeName && subtypeName !== 'Geometry' ? subtypeName : null;
61+
}
62+
5563
/**
5664
* Scalar PgCodec for PostGIS geometry/geography types.
5765
*
@@ -226,6 +234,41 @@ export const PostgisCodecPlugin: GraphileConfig.Plugin = {
226234
);
227235
return;
228236
}
237+
},
238+
239+
/**
240+
* Annotate geometry/geography attributes with their subtype (Point,
241+
* Polygon, LineString, etc.) decoded from the PostgreSQL type modifier.
242+
*
243+
* The atttypmod encodes subtype + SRID + Z/M flags. We decode it with
244+
* getGISTypeDetails() and store the human-readable subtype name on
245+
* attribute.extensions.geometrySubtype so the _meta plugin can expose it.
246+
*/
247+
async pgCodecs_attribute(_info, event) {
248+
const { pgAttribute, attribute } = event;
249+
const codecName = attribute.codec?.name;
250+
if (codecName !== 'geometry' && codecName !== 'geography') {
251+
return;
252+
}
253+
254+
const typmod = pgAttribute.atttypmod;
255+
// atttypmod of -1 or null means no modifier (unconstrained geometry)
256+
if (typmod == null || typmod === -1) {
257+
return;
258+
}
259+
260+
try {
261+
const subtypeName = getGeometrySubtypeName(typmod);
262+
if (subtypeName) {
263+
if (!attribute.extensions) {
264+
attribute.extensions = {};
265+
}
266+
(attribute.extensions as Record<string, unknown>).geometrySubtype = subtypeName;
267+
}
268+
} catch {
269+
// If the modifier can't be decoded, silently skip — the column
270+
// will still work, just without subtype info in _meta.
271+
}
229272
}
230273
}
231274
}

0 commit comments

Comments
 (0)