Skip to content

Commit bee6ced

Browse files
jirhikerclaude
andcommitted
feat(ogc): expose last_observation_date on the Group A layers
The 11 thing-type layers carried construction and location detail but no signal of data recency: nothing distinguished a well measured last month from one last visited in 1994 without querying a second layer. This adds last_observation_date to the shared thing-view template -- the UTC date of the most recent observation recorded against the thing, or NULL where it has none -- and rebuilds all 11 public views and their ogc_internal_ counterparts from that one template so both mounts stay identical. Scope is the observation table, reached through the sample -> field_activity -> field_event chain every other observation-backed view here uses. Continuous transducer readings are deliberately excluded: they hang off deployments rather than field events, they exist for a handful of instrumented wells rather than for Group A generally, and a max() over the largest table in the schema would need an index of its own to stay cheap. Wells with logger data are served by actively_monitored_wells and the water-elevation layers. Per-thing lookup is a LEFT JOIN LATERAL so a paginated or single-feature request touches only the observations of the rows it returns. That join path had no indexes at all, so the four it needs come with the migration. The behave harness needed one fix to go with this: pygeoapi caches reflected table models process-wide, so the scenarios that downgrade the schema under a running app were building SELECTs naming a column the downgraded views no longer have. The cache is now cleared wherever those scenarios move the schema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4e95758 commit bee6ced

5 files changed

Lines changed: 494 additions & 10 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""add last_observation_date to the Group A thing views
2+
3+
Ticket A13. The 11 thing-type layers (Group A) carry construction and location
4+
detail but no signal of data recency: a consumer could not tell a well measured
5+
last month from one last visited in 1994 without querying a second layer.
6+
7+
This adds `last_observation_date` to the shared thing-view template -- the date
8+
of the most recent observation recorded against the thing, or NULL where the
9+
thing has no observations at all. All 11 public views and their 11
10+
`ogc_internal_` counterparts are rebuilt from the same template here, so the
11+
two mounts stay column-for-column identical.
12+
13+
Scope of "observation": rows in the `observation` table, reached through the
14+
sample -> field_activity -> field_event chain that every other observation-
15+
backed view in this schema uses. Continuous transducer readings
16+
(`transducer_observation`) are deliberately *not* folded in: they live on a
17+
different chain (deployment -> thing), they exist for a handful of instrumented
18+
water wells rather than for Group A generally, and a max() over the largest
19+
table in the schema would need its own index on
20+
(deployment_id, observation_datetime) to stay cheap. Wells with logger data are
21+
served by ogc_actively_monitored_wells and the water-elevation layers. If
22+
Group A currency should later include instrument readings, that is a separate
23+
ticket and a separate index.
24+
25+
The date is the UTC calendar date of the observation timestamp -- same
26+
convention as transducer_daily_data (v0w1x2y3z4a5) -- rather than a
27+
session-timezone cast, so the value does not depend on who is querying.
28+
29+
Public views count only observations with release_status='public', matching how
30+
the public mount filters everything else; the internal views count all of them.
31+
A public well whose only observations are private therefore reads NULL on
32+
/ogcapi and carries a date on /ogcapi-internal.
33+
34+
Per-thing lookup is a LEFT JOIN LATERAL rather than a grouped CTE so that a
35+
paginated or single-feature request touches only the observations of the rows
36+
it returns. That path had no indexes at all (Postgres does not index foreign
37+
keys on its own), so the four it needs are created here.
38+
39+
The view bodies below are otherwise character-for-character the templates from
40+
f4a5b6c7d8e9 (public) and 2d3c3a268652 (internal); downgrade() restores them.
41+
42+
Revision ID: b8c9d0e1f2a3
43+
Revises: 986e0eb85ab3
44+
Create Date: 2026-08-24 00:00:00.000000
45+
"""
46+
47+
import re
48+
from typing import Sequence, Union
49+
50+
from alembic import op
51+
from sqlalchemy import inspect, text
52+
53+
# revision identifiers, used by Alembic.
54+
revision: str = "b8c9d0e1f2a3"
55+
down_revision: Union[str, Sequence[str], None] = "986e0eb85ab3"
56+
branch_labels: Union[str, Sequence[str], None] = None
57+
depends_on: Union[str, Sequence[str], None] = None
58+
59+
REQUIRED_TABLES = {
60+
"thing",
61+
"location",
62+
"location_thing_association",
63+
"observation",
64+
"sample",
65+
"field_activity",
66+
"field_event",
67+
}
68+
69+
LATEST_LOCATION_CTE = """
70+
SELECT DISTINCT ON (lta.thing_id)
71+
lta.thing_id,
72+
lta.location_id,
73+
lta.effective_start
74+
FROM location_thing_association AS lta
75+
WHERE lta.effective_end IS NULL
76+
ORDER BY lta.thing_id, lta.effective_start DESC
77+
""".strip()
78+
79+
# Same 11 thing-type views as f4a5b6c7d8e9's THING_VIEWS.
80+
THING_VIEWS = [
81+
("water_wells", "water well"),
82+
("springs", "spring"),
83+
("diversions_surface_water", "diversion of surface water, etc."),
84+
("ephemeral_streams", "ephemeral stream"),
85+
("lakes_ponds_reservoirs", "lake, pond or reservoir"),
86+
("meteorological_stations", "meteorological station"),
87+
("other_things", "other"),
88+
("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"),
89+
("perennial_streams", "perennial stream"),
90+
("rock_sample_locations", "rock sample location"),
91+
("soil_gas_sample_locations", "soil gas sample location"),
92+
]
93+
94+
# (name, table, columns) for the observation chain the lateral walks
95+
# thing -> field_event -> field_activity -> sample -> observation.
96+
SUPPORTING_INDEXES = [
97+
("ix_field_event_thing_id", "field_event", "thing_id"),
98+
("ix_field_activity_field_event_id", "field_activity", "field_event_id"),
99+
("ix_sample_field_activity_id", "sample", "field_activity_id"),
100+
(
101+
"ix_observation_sample_id_observation_datetime",
102+
"observation",
103+
"sample_id, observation_datetime",
104+
),
105+
]
106+
107+
108+
def _safe_view_id(view_id: str) -> str:
109+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", view_id):
110+
raise ValueError(f"Unsafe view id: {view_id!r}")
111+
return view_id
112+
113+
114+
def _check_required_tables() -> None:
115+
bind = op.get_bind()
116+
inspector = inspect(bind)
117+
existing_tables = set(inspector.get_table_names(schema="public"))
118+
missing = REQUIRED_TABLES - existing_tables
119+
if missing:
120+
raise RuntimeError(
121+
"Cannot add last_observation_date to the OGC thing views. "
122+
f"Missing required tables: {', '.join(sorted(missing))}"
123+
)
124+
125+
126+
def _create_thing_view(
127+
view_id: str, thing_type: str, public_only: bool, table_prefix: str
128+
) -> str:
129+
"""The Group A view template, with last_observation_date."""
130+
safe_view_id = _safe_view_id(f"{table_prefix}{view_id}")
131+
escaped_thing_type = thing_type.replace("'", "''")
132+
release_filter = " AND t.release_status = 'public'" if public_only else ""
133+
observation_release_filter = (
134+
"\n AND o.release_status = 'public'" if public_only else ""
135+
)
136+
return f"""
137+
CREATE VIEW {safe_view_id} AS
138+
WITH latest_location AS (
139+
{LATEST_LOCATION_CTE}
140+
)
141+
SELECT
142+
t.id,
143+
t.name,
144+
t.first_visit_date,
145+
(
146+
last_obs.last_observation_datetime AT TIME ZONE 'UTC'
147+
)::date AS last_observation_date,
148+
t.nma_pk_welldata,
149+
t.well_depth,
150+
t.hole_depth,
151+
t.well_casing_diameter,
152+
t.well_casing_depth,
153+
t.well_completion_date,
154+
t.well_driller_name,
155+
t.well_construction_method,
156+
t.well_pump_type,
157+
t.well_pump_depth,
158+
t.formation_completion_code,
159+
t.nma_formation_zone,
160+
t.release_status,
161+
l.elevation,
162+
l.point
163+
FROM thing AS t
164+
JOIN latest_location AS ll ON ll.thing_id = t.id
165+
JOIN location AS l ON l.id = ll.location_id
166+
LEFT JOIN LATERAL (
167+
SELECT MAX(o.observation_datetime) AS last_observation_datetime
168+
FROM observation AS o
169+
JOIN sample AS s ON s.id = o.sample_id
170+
JOIN field_activity AS fa ON fa.id = s.field_activity_id
171+
JOIN field_event AS fe ON fe.id = fa.field_event_id
172+
WHERE fe.thing_id = t.id{observation_release_filter}
173+
) AS last_obs ON TRUE
174+
WHERE t.thing_type = '{escaped_thing_type}'{release_filter}
175+
"""
176+
177+
178+
def _create_thing_view_pre_a13(
179+
view_id: str, thing_type: str, public_only: bool, table_prefix: str
180+
) -> str:
181+
"""The template as it stood in f4a5b6c7d8e9/2d3c3a268652, for downgrade."""
182+
safe_view_id = _safe_view_id(f"{table_prefix}{view_id}")
183+
escaped_thing_type = thing_type.replace("'", "''")
184+
release_filter = " AND t.release_status = 'public'" if public_only else ""
185+
return f"""
186+
CREATE VIEW {safe_view_id} AS
187+
WITH latest_location AS (
188+
{LATEST_LOCATION_CTE}
189+
)
190+
SELECT
191+
t.id,
192+
t.name,
193+
t.first_visit_date,
194+
t.nma_pk_welldata,
195+
t.well_depth,
196+
t.hole_depth,
197+
t.well_casing_diameter,
198+
t.well_casing_depth,
199+
t.well_completion_date,
200+
t.well_driller_name,
201+
t.well_construction_method,
202+
t.well_pump_type,
203+
t.well_pump_depth,
204+
t.formation_completion_code,
205+
t.nma_formation_zone,
206+
t.release_status,
207+
l.elevation,
208+
l.point
209+
FROM thing AS t
210+
JOIN latest_location AS ll ON ll.thing_id = t.id
211+
JOIN location AS l ON l.id = ll.location_id
212+
WHERE t.thing_type = '{escaped_thing_type}'{release_filter}
213+
"""
214+
215+
216+
def _rebuild_thing_views(builder) -> None:
217+
for table_prefix, public_only in (("ogc_", True), ("ogc_internal_", False)):
218+
for view_id, thing_type in THING_VIEWS:
219+
view_name = _safe_view_id(f"{table_prefix}{view_id}")
220+
op.execute(text(f"DROP VIEW IF EXISTS {view_name}"))
221+
op.execute(text(builder(view_id, thing_type, public_only, table_prefix)))
222+
223+
224+
def upgrade() -> None:
225+
_check_required_tables()
226+
227+
for index_name, table_name, columns in SUPPORTING_INDEXES:
228+
op.execute(
229+
text(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table_name} ({columns})")
230+
)
231+
232+
_rebuild_thing_views(_create_thing_view)
233+
234+
235+
def downgrade() -> None:
236+
_rebuild_thing_views(_create_thing_view_pre_a13)
237+
238+
for index_name, _table_name, _columns in SUPPORTING_INDEXES:
239+
op.execute(text(f"DROP INDEX IF EXISTS {index_name}"))

core/ogc-field-descriptions.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ _defaults:
4040
first_visit_date:
4141
title: First visit date
4242
description: Date of the earliest Bureau visit on record for this feature.
43+
last_observation_date:
44+
title: Last observation date
45+
description: >-
46+
Date of the most recent measurement recorded against this feature, as a
47+
UTC calendar date. Null where no measurement is on record for it. Counts
48+
readings and laboratory results held in the observation record; continuous
49+
instrument readings from a deployed logger are not included, so an
50+
instrumented well can carry newer data than this date shows. On the public
51+
mount only measurements released to the public are counted.
4352
nma_pk_welldata:
4453
title: Legacy NM_Aquifer well key
4554
description: >-

tests/features/environment.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,20 @@ def _alembic_config() -> Config:
605605
return cfg
606606

607607

608+
def reset_pygeoapi_reflection() -> None:
609+
"""Drop pygeoapi's process-wide cache of reflected table models.
610+
611+
pygeoapi.provider.sql.get_table_model is functools.cache'd, so a provider
612+
keeps serving the column list it reflected the first time a collection was
613+
queried. Scenarios that move the schema under a running app (the
614+
@migration-mutates-schema ones) would otherwise build SELECTs naming
615+
columns the downgraded views no longer have.
616+
"""
617+
from pygeoapi.provider.sql import get_table_model
618+
619+
get_table_model.cache_clear()
620+
621+
608622
def _initialize_test_schema() -> None:
609623
with session_ctx() as session:
610624
recreate_public_schema(session)
@@ -836,6 +850,7 @@ def before_scenario(context, scenario):
836850
# Defense in depth against a previous, unrelated failure having
837851
# already left the database below head.
838852
command.upgrade(_alembic_config(), "head")
853+
reset_pygeoapi_reflection()
839854

840855

841856
def after_scenario(context, scenario):
@@ -845,6 +860,7 @@ def after_scenario(context, scenario):
845860
# this database. Deliberately not gated on DROP_AND_REBUILD_DB,
846861
# since these scenarios mutate schema regardless of that flag.
847862
command.upgrade(_alembic_config(), "head")
863+
reset_pygeoapi_reflection()
848864

849865
if not get_bool_env("DROP_AND_REBUILD_DB"):
850866
return

tests/features/ogc-cleanup-sprint1.feature

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
203203
# A13 — Add last_observation_date column to Group A view template
204204
# ---------------------------------------------------------------------------
205205

206-
@backend @ogc-data-currency @sprint-1 @medium-priority @A13
206+
@backend @ogc-data-currency @sprint-1 @medium-priority @A13 @production
207207
Scenario: last_observation_date column is present in all Group A layers
208208
When a client requests items from each of the following layers:
209209
| layer-id |
@@ -221,7 +221,7 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
221221
# other_things is not listed: it is in the Group A view template, but A18
222222
# took it off the public catalog — it is only reachable on /ogcapi-internal.
223223

224-
@backend @ogc-data-currency @sprint-1 @medium-priority @A13
224+
@backend @ogc-data-currency @sprint-1 @medium-priority @A13 @production
225225
Scenario: last_observation_date is NULL for things with no associated observations
226226
Given monitoring locations with no linked observations exist in each of the following layers:
227227
| layer-id |
@@ -240,7 +240,7 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
240240
# other_things is not listed: it is in the Group A view template, but A18
241241
# took it off the public catalog — it is only reachable on /ogcapi-internal.
242242

243-
@backend @ogc-data-currency @sprint-1 @medium-priority @A13
243+
@backend @ogc-data-currency @sprint-1 @medium-priority @A13 @production
244244
Scenario: Consumers can filter Group A layers by last_observation_date
245245
Given each of the following Group A layers has features with last_observation_date values "2019-06-01" and "2023-06-01":
246246
| layer-id |

0 commit comments

Comments
 (0)