Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions migrations/1770299199726_area_geom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type Kysely, sql } from "kysely";

/**
* Add a generated `area.geom` column that is the `geometry` version
* of the source `geography` column. At the time of writing, this column
* is only used for one query (repositories/Area.ts/findAreasByPoint), as
* it is *slightly* less accurate to use geometry columns when working with
* geographical data, so should be avoided. However, this function is the
* bottleneck when geocoding data records, and using geometry leads to a
* 10x speedup, so the accuracy/performance tradeoff works out.
*/

export async function up(db: Kysely<any>): Promise<void> {
await sql`
ALTER TABLE area
ADD COLUMN geom geometry(MultiPolygon, 4326) NOT NULL
GENERATED ALWAYS AS (geography::geometry) STORED
`.execute(db);

await sql`CREATE INDEX area_area_set_id_geom_gist ON area USING GIST (area_set_id, geom)`.execute(
db,
);
}

export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE area DROP COLUMN geom`.execute(db);
await sql`DROP INDEX IF EXISTS area_area_set_id_geom_gist`.execute(db);
}
4 changes: 3 additions & 1 deletion src/server/repositories/Area.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ const applyAreaWithPointsSelect = (
]);
};

// Uses the generated `geom` column for 10x performance improvement,
// at the loss of some (potentially negligible) accuracy.
export async function findAreasByPoint({
point,
excludeAreaSetCode,
Expand All @@ -93,7 +95,7 @@ export async function findAreasByPoint({
query = query.where("areaSet.code", "=", includeAreaSetCode);
}
return query
.where(sql<boolean>`ST_Intersects(geography, ST_GeomFromGeoJson(${point}))`)
.where(sql<boolean>`ST_Covers(geom, ST_GeomFromGeoJson(${point}))`)
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The function has changed from using ST_Intersects on a geography column to ST_Covers on a geometry column. While both functions test spatial relationships, they have different semantics:

  • ST_Intersects returns true if the geometries/geographies share any space (including boundaries touching)
  • ST_Covers returns true only if every point of the second geometry is within or on the boundary of the first geometry

This means ST_Covers is more restrictive than ST_Intersects. A point that was previously found by ST_Intersects (e.g., on the boundary of an area) might not be found by ST_Covers, potentially changing the behavior of geocoding operations. This semantic difference should be verified to ensure it doesn't affect the correctness of the geocoding logic, particularly for edge cases where points fall on area boundaries.

Suggested change
.where(sql<boolean>`ST_Covers(geom, ST_GeomFromGeoJson(${point}))`)
.where(sql<boolean>`ST_Intersects(geom, ST_GeomFromGeoJson(${point}))`)

Copilot uses AI. Check for mistakes.
.select([
"area.id",
"area.code",
Expand Down
9 changes: 6 additions & 3 deletions src/server/services/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This file represents the complete database schema as defined by all migrations.
* It includes all tables, columns, types, constraints, and relationships.
*
* Last updated: 2026-01-15
* Last updated: 2026-02-05
* Based on migrations up to: 1764611637231_map_view_inspector_config.ts
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The schema documentation comment states "Based on migrations up to: 1764611637231_map_view_inspector_config.ts", but this PR adds a new migration file 1770299199726_area_geom.ts (with a later timestamp). The comment should be updated to reference the latest migration that this schema now reflects.

Suggested change
* Based on migrations up to: 1764611637231_map_view_inspector_config.ts
* Based on migrations up to: 1770299199726_area_geom.ts

Copilot uses AI. Check for mistakes.
*/

Expand Down Expand Up @@ -39,13 +39,16 @@ export interface Area {
name: string; // text, NOT NULL
geography: unknown; // geography (PostGIS), NOT NULL
areaSetId: number; // bigint, NOT NULL
geom: unknown; // geometry(MultiPolygon,4326), GENERATED ALWAYS AS ((geography)::geometry) STORED, NOT NULL

// CONSTRAINTS:
// - UNIQUE (code, areaSetId)
// FOREIGN KEYS:
// - areaSetId -> areaSet.id (CASCADE DELETE, CASCADE UPDATE)
// INDEXES:
// - area_geography_gist USING GIST (geography)
// - area_area_set_id_geography_gist USING GIST (area_set_id, geography)
// - area_area_set_id_geom_gist USING GIST (area_set_id, geom)
}

// ============================================================================
Expand Down Expand Up @@ -82,8 +85,8 @@ export interface Organisation {
*/
export interface OrganisationUser {
id: number; // bigserial, NOT NULL
organisationId: string; // uuid, NOT NULL
userId: string; // uuid, NOT NULL
organisationId: string | null; // uuid, NULL
userId: string | null; // uuid, NULL
Comment on lines +88 to +89
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The organisationId and userId columns in the OrganisationUser interface have been changed from NOT NULL to NULL. This change appears unrelated to the PR's stated purpose of adding the area.geom column for geocoding performance.

The original migration (migrations/1744117982427_organisation_user.ts) defines both columns as NOT NULL, and there's no migration file included in this PR that would alter these columns to be nullable. This suggests the schema documentation may be incorrect, or there's a missing migration that should be included in this PR.

Copilot uses AI. Check for mistakes.

// CONSTRAINTS:
// - UNIQUE (organisationId, userId)
Expand Down
Loading