Skip to content

Commit ddedfa9

Browse files
committed
wip: add geometry subtype metadata to _meta
1 parent f7bfaab commit ddedfa9

7 files changed

Lines changed: 234 additions & 0 deletions

File tree

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

Lines changed: 78 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,80 @@ 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+
it('should store geometrySubtype for Polygon geometry column', async () => {
253+
const typmod = getGISTypeModifier(GisSubtype.Polygon, false, false, 4326);
254+
const attribute: Record<string, any> = { codec: { name: 'geometry' }, extensions: {} };
255+
const event = { pgAttribute: { atttypmod: typmod }, attribute };
256+
257+
await attributeHook({}, event);
258+
expect(attribute.extensions.geometrySubtype).toBe('Polygon');
259+
});
260+
261+
it('should store geometrySubtype for Point geometry column', async () => {
262+
const typmod = getGISTypeModifier(GisSubtype.Point, false, false, 4326);
263+
const attribute: Record<string, any> = { codec: { name: 'geometry' }, extensions: {} };
264+
const event = { pgAttribute: { atttypmod: typmod }, attribute };
265+
266+
await attributeHook({}, event);
267+
expect(attribute.extensions.geometrySubtype).toBe('Point');
268+
});
269+
270+
it('should store geometrySubtype for MultiPolygon geography column', async () => {
271+
const typmod = getGISTypeModifier(GisSubtype.MultiPolygon, false, false, 4326);
272+
const attribute: Record<string, any> = { codec: { name: 'geography' }, extensions: {} };
273+
const event = { pgAttribute: { atttypmod: typmod }, attribute };
274+
275+
await attributeHook({}, event);
276+
expect(attribute.extensions.geometrySubtype).toBe('MultiPolygon');
277+
});
278+
279+
it('should skip unconstrained geometry (atttypmod = -1)', async () => {
280+
const attribute: Record<string, any> = { codec: { name: 'geometry' }, extensions: {} };
281+
const event = { pgAttribute: { atttypmod: -1 }, attribute };
282+
283+
await attributeHook({}, event);
284+
expect(attribute.extensions.geometrySubtype).toBeUndefined();
285+
});
286+
287+
it('should skip when atttypmod is null', async () => {
288+
const attribute: Record<string, any> = { codec: { name: 'geometry' }, extensions: {} };
289+
const event = { pgAttribute: { atttypmod: null as number | null }, attribute };
290+
291+
await attributeHook({}, event);
292+
expect(attribute.extensions.geometrySubtype).toBeUndefined();
293+
});
294+
295+
it('should not store subtype for base Geometry (subtype=0)', async () => {
296+
const typmod = getGISTypeModifier(GisSubtype.Geometry, false, false, 4326);
297+
const attribute: Record<string, any> = { codec: { name: 'geometry' }, extensions: {} };
298+
const event = { pgAttribute: { atttypmod: typmod }, attribute };
299+
300+
await attributeHook({}, event);
301+
expect(attribute.extensions.geometrySubtype).toBeUndefined();
302+
});
303+
304+
it('should skip non-geometry codec types', async () => {
305+
const typmod = getGISTypeModifier(GisSubtype.Point, false, false, 4326);
306+
const attribute: Record<string, any> = { codec: { name: 'text' }, extensions: {} };
307+
const event = { pgAttribute: { atttypmod: typmod }, attribute };
308+
309+
await attributeHook({}, event);
310+
expect(attribute.extensions.geometrySubtype).toBeUndefined();
311+
});
312+
313+
it('should create extensions object if not present', async () => {
314+
const typmod = getGISTypeModifier(GisSubtype.LineString, false, false, 4326);
315+
const attribute: Record<string, any> = { codec: { name: 'geometry' } };
316+
const event = { pgAttribute: { atttypmod: typmod }, attribute };
317+
318+
await attributeHook({}, event);
319+
expect(attribute.extensions).toBeDefined();
320+
expect(attribute.extensions.geometrySubtype).toBe('LineString');
321+
});
322+
});
245323
});

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

Lines changed: 38 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
@@ -226,6 +228,42 @@ export const PostgisCodecPlugin: GraphileConfig.Plugin = {
226228
);
227229
return;
228230
}
231+
},
232+
233+
/**
234+
* Annotate geometry/geography attributes with their subtype (Point,
235+
* Polygon, LineString, etc.) decoded from the PostgreSQL type modifier.
236+
*
237+
* The atttypmod encodes subtype + SRID + Z/M flags. We decode it with
238+
* getGISTypeDetails() and store the human-readable subtype name on
239+
* attribute.extensions.geometrySubtype so the _meta plugin can expose it.
240+
*/
241+
async pgCodecs_attribute(_info, event) {
242+
const { pgAttribute, attribute } = event;
243+
const codecName = attribute.codec?.name;
244+
if (codecName !== 'geometry' && codecName !== 'geography') {
245+
return;
246+
}
247+
248+
const typmod = pgAttribute.atttypmod;
249+
// atttypmod of -1 or null means no modifier (unconstrained geometry)
250+
if (typmod == null || typmod === -1) {
251+
return;
252+
}
253+
254+
try {
255+
const details = getGISTypeDetails(typmod);
256+
const subtypeName = GIS_SUBTYPE_NAME[details.subtype];
257+
if (subtypeName && subtypeName !== 'Geometry') {
258+
if (!attribute.extensions) {
259+
attribute.extensions = {};
260+
}
261+
(attribute.extensions as Record<string, unknown>).geometrySubtype = subtypeName;
262+
}
263+
} catch {
264+
// If the modifier can't be decoded, silently skip — the column
265+
// will still work, just without subtype info in _meta.
266+
}
229267
}
230268
}
231269
}

0 commit comments

Comments
 (0)