feat(repositories): expose the spatial cache repo on the Unit of Work

Slice 3c.3. Ingestion writes the OS spatial reference cache through the same
unit it persists the EPC/solar enrichments with, so `UnitOfWork` declares a
`spatial` repo, `PostgresUnitOfWork` binds a `SpatialPostgresRepository` to the
session, and `FakeUnitOfWork` gains a `FakeSpatialRepo` (seedable for read
tests, recording writes for ingestion-side assertions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 17:20:39 +00:00
parent a1c60d2fba
commit 234c4ae947
4 changed files with 40 additions and 0 deletions

View file

@ -21,6 +21,7 @@ from repositories.scenario.scenario_postgres_repository import (
ScenarioPostgresRepository,
)
from repositories.solar.solar_postgres_repository import SolarPostgresRepository
from repositories.spatial.spatial_postgres_repository import SpatialPostgresRepository
from repositories.unit_of_work import UnitOfWork
@ -42,6 +43,7 @@ class PostgresUnitOfWork(UnitOfWork):
self.property = PropertyPostgresRepository(self._session, epc_repo)
self.epc = epc_repo
self.solar = SolarPostgresRepository(self._session)
self.spatial = SpatialPostgresRepository(self._session)
self.property_baseline = PropertyBaselinePostgresRepository(self._session)
self.scenario = ScenarioPostgresRepository(self._session)
self.product = ProductPostgresRepository(self._session)

View file

@ -11,6 +11,7 @@ from repositories.product.product_repository import ProductRepository
from repositories.property.property_repository import PropertyRepository
from repositories.scenario.scenario_repository import ScenarioRepository
from repositories.solar.solar_repository import SolarRepository
from repositories.spatial.spatial_repository import SpatialRepository
class UnitOfWork(ABC):
@ -28,6 +29,9 @@ class UnitOfWork(ABC):
property: PropertyRepository
epc: EpcRepository
solar: SolarRepository
# Per-UPRN cache of the OS spatial reference data, written in Ingestion
# alongside the EPC/solar enrichments (ADR-0020).
spatial: SpatialRepository
property_baseline: PropertyBaselineRepository
# Modelling-stage repos (ADR-0017): read the Scenario, read the Product
# catalogue, write the Plan + its Plan Measures — all on the one session.

View file

@ -10,6 +10,8 @@ from types import TracebackType
from typing import Any, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.geospatial.spatial_reference import SpatialReference
from domain.modelling.plan import Plan
from domain.modelling.scenario import Scenario
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
@ -22,6 +24,7 @@ from repositories.epc.epc_repository import EpcRepository
from repositories.property.property_repository import PropertyRepository
from repositories.scenario.scenario_repository import ScenarioRepository
from repositories.solar.solar_repository import SolarRepository
from repositories.spatial.spatial_repository import SpatialRepository
from repositories.unit_of_work import UnitOfWork
from domain.modelling.product import Product
@ -101,6 +104,24 @@ class FakeSolarRepo(SolarRepository):
raise NotImplementedError
class FakeSpatialRepo(SpatialRepository):
"""In-memory per-UPRN spatial cache. Seed `by_uprn` to hydrate Properties in
a read test; `saved` records writes for an Ingestion-side assertion."""
def __init__(
self, by_uprn: Optional[dict[int, PlanningRestrictions]] = None
) -> None:
self._by_uprn: dict[int, PlanningRestrictions] = dict(by_uprn or {})
self.saved: list[tuple[int, SpatialReference]] = []
def save(self, uprn: int, reference: SpatialReference) -> None:
self.saved.append((uprn, reference))
self._by_uprn[uprn] = reference.restrictions
def get_for_uprns(self, uprns: list[int]) -> dict[int, PlanningRestrictions]:
return {uprn: self._by_uprn[uprn] for uprn in uprns if uprn in self._by_uprn}
class FakePropertyBaselineRepo(PropertyBaselineRepository):
def __init__(self) -> None:
self.saved: list[tuple[PropertyBaselinePerformance, int]] = []
@ -170,6 +191,7 @@ class FakeUnitOfWork(UnitOfWork):
property: FakePropertyRepo,
epc: Optional[FakeEpcRepo] = None,
solar: Optional[FakeSolarRepo] = None,
spatial: Optional[FakeSpatialRepo] = None,
property_baseline: Optional[FakePropertyBaselineRepo] = None,
scenario: Optional[FakeScenarioRepository] = None,
product: Optional[ProductRepository] = None,
@ -178,6 +200,7 @@ class FakeUnitOfWork(UnitOfWork):
self.property = property
self.epc = epc or FakeEpcRepo()
self.solar = solar or FakeSolarRepo()
self.spatial = spatial or FakeSpatialRepo()
self.property_baseline = property_baseline or FakePropertyBaselineRepo()
self.scenario = scenario or FakeScenarioRepository()
self.product = product or _UnsetProductRepo()

View file

@ -13,6 +13,7 @@ from repositories.plan.plan_repository import PlanRepository
from repositories.postgres_unit_of_work import PostgresUnitOfWork
from repositories.product.product_repository import ProductRepository
from repositories.scenario.scenario_repository import ScenarioRepository
from repositories.spatial.spatial_repository import SpatialRepository
def _session_factory(db_engine: Engine) -> Callable[[], Session]:
@ -75,6 +76,16 @@ def test_unit_exposes_the_modelling_repos_bound_to_its_session(
assert isinstance(uow.plan, PlanRepository)
def test_unit_exposes_the_spatial_cache_repo_bound_to_its_session(
db_engine: Engine,
) -> None:
# Arrange / Act
with PostgresUnitOfWork(_session_factory(db_engine)) as uow:
# Assert — Ingestion writes the OS spatial reference cache through the
# same unit it persists the EPC/solar with (ADR-0020).
assert isinstance(uow.spatial, SpatialRepository)
def test_leaving_the_block_without_commit_persists_nothing(db_engine: Engine) -> None:
# Arrange
new_unit = lambda: PostgresUnitOfWork(_session_factory(db_engine))