mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
landlord overrids moved into one repo
This commit is contained in:
parent
3845ac10b0
commit
8a9d14a45c
10 changed files with 288 additions and 591 deletions
|
|
@ -18,17 +18,20 @@ from infrastructure.chatgpt.chatgpt import ChatGPT
|
||||||
from infrastructure.chatgpt.chatgpt_column_classifier import ChatGptColumnClassifier
|
from infrastructure.chatgpt.chatgpt_column_classifier import ChatGptColumnClassifier
|
||||||
from infrastructure.postgres.config import PostgresConfig
|
from infrastructure.postgres.config import PostgresConfig
|
||||||
from infrastructure.postgres.engine import commit_scope, make_engine, make_session
|
from infrastructure.postgres.engine import commit_scope, make_engine, make_session
|
||||||
from infrastructure.postgres.landlord_built_form_type_override_postgres_repository import (
|
from infrastructure.postgres.landlord_built_form_type_override_table import (
|
||||||
LandlordBuiltFormTypeOverridePostgresRepository,
|
LandlordBuiltFormTypeOverrideRow,
|
||||||
)
|
)
|
||||||
from infrastructure.postgres.landlord_property_type_override_postgres_repository import (
|
from infrastructure.postgres.landlord_overrides_postgres_repository import (
|
||||||
LandlordPropertyTypeOverridePostgresRepository,
|
LandlordOverridesRepository,
|
||||||
)
|
)
|
||||||
from infrastructure.postgres.landlord_roof_type_override_postgres_repository import (
|
from infrastructure.postgres.landlord_property_type_override_table import (
|
||||||
LandlordRoofTypeOverridePostgresRepository,
|
LandlordPropertyTypeOverrideRow,
|
||||||
)
|
)
|
||||||
from infrastructure.postgres.landlord_wall_type_override_postgres_repository import (
|
from infrastructure.postgres.landlord_roof_type_override_table import (
|
||||||
LandlordWallTypeOverridePostgresRepository,
|
LandlordRoofTypeOverrideRow,
|
||||||
|
)
|
||||||
|
from infrastructure.postgres.landlord_wall_type_override_table import (
|
||||||
|
LandlordWallTypeOverrideRow,
|
||||||
)
|
)
|
||||||
from infrastructure.s3.csv_s3_client import CsvS3Client
|
from infrastructure.s3.csv_s3_client import CsvS3Client
|
||||||
from infrastructure.s3.s3_uri import parse_s3_uri
|
from infrastructure.s3.s3_uri import parse_s3_uri
|
||||||
|
|
@ -62,7 +65,9 @@ def _build_columns(
|
||||||
classifier=ChatGptColumnClassifier(
|
classifier=ChatGptColumnClassifier(
|
||||||
chat_gpt, PropertyType, PropertyType.UNKNOWN
|
chat_gpt, PropertyType, PropertyType.UNKNOWN
|
||||||
),
|
),
|
||||||
repo=LandlordPropertyTypeOverridePostgresRepository(session),
|
repo=LandlordOverridesRepository[PropertyType](
|
||||||
|
session, LandlordPropertyTypeOverrideRow
|
||||||
|
),
|
||||||
),
|
),
|
||||||
"built_form_type": lambda src: ClassifiableColumn(
|
"built_form_type": lambda src: ClassifiableColumn(
|
||||||
name="built_form_type",
|
name="built_form_type",
|
||||||
|
|
@ -70,7 +75,9 @@ def _build_columns(
|
||||||
classifier=ChatGptColumnClassifier(
|
classifier=ChatGptColumnClassifier(
|
||||||
chat_gpt, BuiltFormType, BuiltFormType.UNKNOWN
|
chat_gpt, BuiltFormType, BuiltFormType.UNKNOWN
|
||||||
),
|
),
|
||||||
repo=LandlordBuiltFormTypeOverridePostgresRepository(session),
|
repo=LandlordOverridesRepository[BuiltFormType](
|
||||||
|
session, LandlordBuiltFormTypeOverrideRow
|
||||||
|
),
|
||||||
),
|
),
|
||||||
"wall_type": lambda src: ClassifiableColumn(
|
"wall_type": lambda src: ClassifiableColumn(
|
||||||
name="wall_type",
|
name="wall_type",
|
||||||
|
|
@ -81,7 +88,9 @@ def _build_columns(
|
||||||
WallType.UNKNOWN,
|
WallType.UNKNOWN,
|
||||||
extra_instructions=wall_type_construction_date_prompt_hint(),
|
extra_instructions=wall_type_construction_date_prompt_hint(),
|
||||||
),
|
),
|
||||||
repo=LandlordWallTypeOverridePostgresRepository(session),
|
repo=LandlordOverridesRepository[WallType](
|
||||||
|
session, LandlordWallTypeOverrideRow
|
||||||
|
),
|
||||||
),
|
),
|
||||||
"roof_type": lambda src: ClassifiableColumn(
|
"roof_type": lambda src: ClassifiableColumn(
|
||||||
name="roof_type",
|
name="roof_type",
|
||||||
|
|
@ -89,7 +98,9 @@ def _build_columns(
|
||||||
classifier=ChatGptColumnClassifier(
|
classifier=ChatGptColumnClassifier(
|
||||||
chat_gpt, RoofType, RoofType.UNKNOWN
|
chat_gpt, RoofType, RoofType.UNKNOWN
|
||||||
),
|
),
|
||||||
repo=LandlordRoofTypeOverridePostgresRepository(session),
|
repo=LandlordOverridesRepository[RoofType](
|
||||||
|
session, LandlordRoofTypeOverrideRow
|
||||||
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ The Model service (specifically `applications/landlord_description_overrides/han
|
||||||
|
|
||||||
Transaction boundaries live in `infrastructure/postgres/engine.transactional_session` — a context manager that commits on clean exit and rolls back on exception. The application layer (`handler.py`) never calls `.commit()` or `.rollback()` itself; it only opens the context. Orchestration and repository code likewise never commits — keeping transaction semantics confined to one infrastructure helper.
|
Transaction boundaries live in `infrastructure/postgres/engine.transactional_session` — a context manager that commits on clean exit and rolls back on exception. The application layer (`handler.py`) never calls `.commit()` or `.rollback()` itself; it only opens the context. Orchestration and repository code likewise never commits — keeping transaction semantics confined to one infrastructure helper.
|
||||||
|
|
||||||
The conflict policy lives in SQL and is identical for every adapter implementation:
|
The conflict policy lives in SQL and is identical for every override category. A single generic adapter, `LandlordOverridesRepository[E]`, implements it once; the target table is selected by the SQLModel `…Row` class passed at construction. Each category (property / built-form / wall / roof type) is that same adapter parameterised by its row class:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
INSERT INTO landlord_property_type_overrides (portfolio_id, description, value, source)
|
INSERT INTO landlord_property_type_overrides (portfolio_id, description, value, source)
|
||||||
|
|
@ -59,7 +59,7 @@ The convention going forward is:
|
||||||
- **Postgres adapter (concrete):** `infrastructure/postgres/<thing>_postgres_repository.py`
|
- **Postgres adapter (concrete):** `infrastructure/postgres/<thing>_postgres_repository.py`
|
||||||
- **SQLModel row class:** `infrastructure/postgres/<thing>_table.py`
|
- **SQLModel row class:** `infrastructure/postgres/<thing>_table.py`
|
||||||
|
|
||||||
The new `LandlordOverrideRepository` family follows this convention.
|
The `LandlordOverridesRepository` adapter follows this convention: the concrete class lives at `infrastructure/postgres/landlord_overrides_postgres_repository.py`, with one `…_table.py` per category alongside it. The `…Row` classes stay one-per-table — each mirrors a genuinely distinct Drizzle table and `value` pgEnum, so they are schema mirrors, not duplicated logic.
|
||||||
|
|
||||||
**Existing outliers to relocate in a follow-up:**
|
**Existing outliers to relocate in a follow-up:**
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ Both moves are mechanical (import-path updates only). They are intentionally out
|
||||||
## Out of scope (deferred to follow-up work)
|
## Out of scope (deferred to follow-up work)
|
||||||
|
|
||||||
- Relocating `task_postgres_repository.py` and `subtask_postgres_repository.py` into `infrastructure/postgres/` per the convention above.
|
- Relocating `task_postgres_repository.py` and `subtask_postgres_repository.py` into `infrastructure/postgres/` per the convention above.
|
||||||
- Extracting a shared upsert helper / base class once a third `landlord_*_overrides` column lands — until then the two adapters' 95%-identical bodies are kept side-by-side for direct comparison.
|
- ~~Extracting a shared upsert helper / base class once a third `landlord_*_overrides` column lands — until then the per-category adapters' 95%-identical bodies are kept side-by-side for direct comparison.~~ **Done.** The per-category adapter bodies were byte-identical (varying only in their row class), so they were consolidated into one generic `LandlordOverridesRepository[E]` parameterised by row class rather than waiting for a third column.
|
||||||
- Switching `applications/landlord_description_overrides/handler.py` to acquire its `Session` via a `@subtask_handler()`-style decorator instead of building its own engine.
|
- Switching `applications/landlord_description_overrides/handler.py` to acquire its `Session` via a `@subtask_handler()`-style decorator instead of building its own engine.
|
||||||
- A cross-repo PR amending ADR-0002 to point at this ADR.
|
- A cross-repo PR amending ADR-0002 to point at this ADR.
|
||||||
- A CI check (or codegen) that diffs the Drizzle pgEnum literals against the Python `Enum.value` strings.
|
- A CI check (or codegen) that diffs the Drizzle pgEnum literals against the Python `Enum.value` strings.
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,60 @@
|
||||||
"""Postgres adapter for ``LandlordOverrideRepository[BuiltFormType]``.
|
"""One Postgres adapter for every ``landlord_<category>_overrides`` table.
|
||||||
|
|
||||||
Writes to ``landlord_built_form_type_overrides`` (Drizzle-managed; mirrored by
|
The four override categories (property / built-form / wall / roof type) share
|
||||||
``LandlordBuiltFormTypeOverrideRow``). The conflict policy lives in the SQL --
|
an identical write path: the same ``(portfolio_id, description) -> value`` row
|
||||||
see ADR-0003 §Decision. Shape mirrors
|
shape and the same source-aware conflict policy (ADR-0003 §Decision fixes one
|
||||||
``LandlordPropertyTypeOverridePostgresRepository``; the duplication is
|
policy for all of them). The only thing that varies per category is the target
|
||||||
deliberate while there are only three columns -- if a fourth lands and the
|
table, which is selected by the SQLModel row class handed to the constructor.
|
||||||
duplication becomes painful, extract a shared upsert helper then.
|
|
||||||
|
So a single generic adapter serves all four -- callers parameterise it by the
|
||||||
|
category enum ``E`` and pass the matching ``…Row`` class:
|
||||||
|
|
||||||
|
LandlordOverridesRepository[PropertyType](session, LandlordPropertyTypeOverrideRow)
|
||||||
|
|
||||||
|
Per ADR-0003 §File layout, Postgres adapters live in ``infrastructure/postgres/``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import cast
|
from enum import Enum
|
||||||
|
from typing import TypeVar, cast
|
||||||
|
|
||||||
from sqlalchemy import Table
|
from sqlalchemy import Table
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session, SQLModel
|
||||||
|
|
||||||
from domain.epc.built_form_type import BuiltFormType
|
|
||||||
from infrastructure.postgres.landlord_built_form_type_override_table import (
|
|
||||||
LandlordBuiltFormTypeOverrideRow,
|
|
||||||
)
|
|
||||||
from infrastructure.postgres.landlord_override_enums import OverrideSource
|
from infrastructure.postgres.landlord_override_enums import OverrideSource
|
||||||
from repositories.landlord_overrides.landlord_override_repository import (
|
from repositories.landlord_overrides.landlord_override_repository import (
|
||||||
LandlordOverrideRepository,
|
LandlordOverrideRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
E = TypeVar("E", bound=Enum)
|
||||||
|
|
||||||
class LandlordBuiltFormTypeOverridePostgresRepository(
|
|
||||||
LandlordOverrideRepository[BuiltFormType]
|
class LandlordOverridesRepository(LandlordOverrideRepository[E]):
|
||||||
):
|
"""Writes classifier overrides to the ``landlord_<category>_overrides`` table
|
||||||
def __init__(self, session: Session) -> None:
|
backing ``row_type``.
|
||||||
|
|
||||||
|
``row_type`` is the SQLModel mirror of the target table (e.g.
|
||||||
|
``LandlordPropertyTypeOverrideRow``); its category enum must match the type
|
||||||
|
argument ``E`` the caller binds. The pairing is a convention, not a type
|
||||||
|
constraint -- the row classes are not generic over their value enum -- so
|
||||||
|
keep the two in step at the call site.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session, row_type: type[SQLModel]) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
|
# SQLModel's class-level ``__table__`` is injected at runtime on
|
||||||
|
# ``table=True`` classes but isn't exposed by the stubs; pin it to
|
||||||
|
# ``Table`` via ``getattr`` so the dialect insert helper carries
|
||||||
|
# through with strict types.
|
||||||
|
self._table: Table = cast(Table, getattr(row_type, "__table__"))
|
||||||
|
|
||||||
def upsert_all(
|
def upsert_all(
|
||||||
self,
|
self,
|
||||||
portfolio_id: int,
|
portfolio_id: int,
|
||||||
descriptions_to_values: dict[str, BuiltFormType],
|
descriptions_to_values: dict[str, E],
|
||||||
) -> None:
|
) -> None:
|
||||||
if not descriptions_to_values:
|
if not descriptions_to_values:
|
||||||
return
|
return
|
||||||
|
|
@ -54,14 +72,7 @@ class LandlordBuiltFormTypeOverridePostgresRepository(
|
||||||
for description, value in descriptions_to_values.items()
|
for description, value in descriptions_to_values.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
# SQLModel's class-level ``__table__`` is injected at runtime on
|
stmt = pg_insert(self._table).values(rows)
|
||||||
# ``table=True`` classes but isn't exposed by the stubs; pin it to
|
|
||||||
# ``Table`` via ``getattr`` so the dialect insert helper below
|
|
||||||
# carries through with strict types.
|
|
||||||
table: Table = cast(
|
|
||||||
Table, getattr(LandlordBuiltFormTypeOverrideRow, "__table__")
|
|
||||||
)
|
|
||||||
stmt = pg_insert(table).values(rows)
|
|
||||||
|
|
||||||
# The classifier may refresh its own past output, but must never
|
# The classifier may refresh its own past output, but must never
|
||||||
# overwrite a user correction -- the ``WHERE existing.source =
|
# overwrite a user correction -- the ``WHERE existing.source =
|
||||||
|
|
@ -73,7 +84,7 @@ class LandlordBuiltFormTypeOverridePostgresRepository(
|
||||||
"source": stmt.excluded.source,
|
"source": stmt.excluded.source,
|
||||||
"updated_at": stmt.excluded.updated_at,
|
"updated_at": stmt.excluded.updated_at,
|
||||||
},
|
},
|
||||||
where=table.c.source == OverrideSource.CLASSIFIER,
|
where=self._table.c.source == OverrideSource.CLASSIFIER,
|
||||||
)
|
)
|
||||||
|
|
||||||
# SQLModel re-exports SQLAlchemy's ``Session.execute``; one of the
|
# SQLModel re-exports SQLAlchemy's ``Session.execute``; one of the
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
"""Postgres adapter for ``LandlordOverrideRepository[PropertyType]``.
|
|
||||||
|
|
||||||
Writes to ``landlord_property_type_overrides`` (Drizzle-managed; mirrored by
|
|
||||||
``LandlordPropertyTypeOverrideRow``). The conflict policy lives in the SQL --
|
|
||||||
see ADR-0003 §Decision.
|
|
||||||
|
|
||||||
Per the convention this ADR fixes, Postgres adapters live in
|
|
||||||
``infrastructure/postgres/``. The existing ``task_postgres_repository.py`` /
|
|
||||||
``subtask_postgres_repository.py`` are outliers still under ``repositories/``;
|
|
||||||
relocating them is tracked as a follow-up in ADR-0003 §"File layout".
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from sqlalchemy import Table
|
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from domain.epc.property_type import PropertyType
|
|
||||||
from infrastructure.postgres.landlord_override_enums import OverrideSource
|
|
||||||
from infrastructure.postgres.landlord_property_type_override_table import (
|
|
||||||
LandlordPropertyTypeOverrideRow,
|
|
||||||
)
|
|
||||||
from repositories.landlord_overrides.landlord_override_repository import (
|
|
||||||
LandlordOverrideRepository,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LandlordPropertyTypeOverridePostgresRepository(
|
|
||||||
LandlordOverrideRepository[PropertyType]
|
|
||||||
):
|
|
||||||
def __init__(self, session: Session) -> None:
|
|
||||||
self._session = session
|
|
||||||
|
|
||||||
def upsert_all(
|
|
||||||
self,
|
|
||||||
portfolio_id: int,
|
|
||||||
descriptions_to_values: dict[str, PropertyType],
|
|
||||||
) -> None:
|
|
||||||
if not descriptions_to_values:
|
|
||||||
return
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"portfolio_id": portfolio_id,
|
|
||||||
"description": description,
|
|
||||||
"value": value.value,
|
|
||||||
"source": OverrideSource.CLASSIFIER,
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
for description, value in descriptions_to_values.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
# SQLModel's class-level ``__table__`` is injected at runtime on
|
|
||||||
# ``table=True`` classes but isn't exposed by the stubs; pin it to
|
|
||||||
# ``Table`` via ``getattr`` so the dialect insert helper below
|
|
||||||
# carries through with strict types.
|
|
||||||
table: Table = cast(Table, getattr(LandlordPropertyTypeOverrideRow, "__table__"))
|
|
||||||
stmt = pg_insert(table).values(rows)
|
|
||||||
|
|
||||||
# The classifier may refresh its own past output, but must never
|
|
||||||
# overwrite a user correction -- the ``WHERE existing.source =
|
|
||||||
# 'classifier'`` guard enforces that. See ADR-0003 §Decision.
|
|
||||||
stmt = stmt.on_conflict_do_update(
|
|
||||||
index_elements=["portfolio_id", "description"],
|
|
||||||
set_={
|
|
||||||
"value": stmt.excluded.value,
|
|
||||||
"source": stmt.excluded.source,
|
|
||||||
"updated_at": stmt.excluded.updated_at,
|
|
||||||
},
|
|
||||||
where=table.c.source == OverrideSource.CLASSIFIER,
|
|
||||||
)
|
|
||||||
|
|
||||||
# SQLModel re-exports SQLAlchemy's ``Session.execute``; one of the
|
|
||||||
# overload signatures is marked deprecated in stubs, which fires
|
|
||||||
# here even though our INSERT path is the supported one.
|
|
||||||
self._session.execute(stmt) # pyright: ignore[reportDeprecated]
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"""Postgres adapter for ``LandlordOverrideRepository[RoofType]``.
|
|
||||||
|
|
||||||
Writes to ``landlord_roof_type_overrides`` (Drizzle-managed; mirrored by
|
|
||||||
``LandlordRoofTypeOverrideRow``). The conflict policy lives in the SQL --
|
|
||||||
see ADR-0003 §Decision. Shape mirrors
|
|
||||||
``LandlordPropertyTypeOverridePostgresRepository``; the duplication is
|
|
||||||
deliberate while there are only a handful of override columns -- if the
|
|
||||||
duplication becomes painful, extract a shared upsert helper then.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from sqlalchemy import Table
|
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from domain.epc.roof_type import RoofType
|
|
||||||
from infrastructure.postgres.landlord_override_enums import OverrideSource
|
|
||||||
from infrastructure.postgres.landlord_roof_type_override_table import (
|
|
||||||
LandlordRoofTypeOverrideRow,
|
|
||||||
)
|
|
||||||
from repositories.landlord_overrides.landlord_override_repository import (
|
|
||||||
LandlordOverrideRepository,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LandlordRoofTypeOverridePostgresRepository(
|
|
||||||
LandlordOverrideRepository[RoofType]
|
|
||||||
):
|
|
||||||
def __init__(self, session: Session) -> None:
|
|
||||||
self._session = session
|
|
||||||
|
|
||||||
def upsert_all(
|
|
||||||
self,
|
|
||||||
portfolio_id: int,
|
|
||||||
descriptions_to_values: dict[str, RoofType],
|
|
||||||
) -> None:
|
|
||||||
if not descriptions_to_values:
|
|
||||||
return
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"portfolio_id": portfolio_id,
|
|
||||||
"description": description,
|
|
||||||
"value": value.value,
|
|
||||||
"source": OverrideSource.CLASSIFIER,
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
for description, value in descriptions_to_values.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
# SQLModel's class-level ``__table__`` is injected at runtime on
|
|
||||||
# ``table=True`` classes but isn't exposed by the stubs; pin it to
|
|
||||||
# ``Table`` via ``getattr`` so the dialect insert helper below
|
|
||||||
# carries through with strict types.
|
|
||||||
table: Table = cast(Table, getattr(LandlordRoofTypeOverrideRow, "__table__"))
|
|
||||||
stmt = pg_insert(table).values(rows)
|
|
||||||
|
|
||||||
# The classifier may refresh its own past output, but must never
|
|
||||||
# overwrite a user correction -- the ``WHERE existing.source =
|
|
||||||
# 'classifier'`` guard enforces that. See ADR-0003 §Decision.
|
|
||||||
stmt = stmt.on_conflict_do_update(
|
|
||||||
index_elements=["portfolio_id", "description"],
|
|
||||||
set_={
|
|
||||||
"value": stmt.excluded.value,
|
|
||||||
"source": stmt.excluded.source,
|
|
||||||
"updated_at": stmt.excluded.updated_at,
|
|
||||||
},
|
|
||||||
where=table.c.source == OverrideSource.CLASSIFIER,
|
|
||||||
)
|
|
||||||
|
|
||||||
# SQLModel re-exports SQLAlchemy's ``Session.execute``; one of the
|
|
||||||
# overload signatures is marked deprecated in stubs, which fires
|
|
||||||
# here even though our INSERT path is the supported one.
|
|
||||||
self._session.execute(stmt) # pyright: ignore[reportDeprecated]
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"""Postgres adapter for ``LandlordOverrideRepository[WallType]``.
|
|
||||||
|
|
||||||
Writes to ``landlord_wall_type_overrides`` (Drizzle-managed; mirrored by
|
|
||||||
``LandlordWallTypeOverrideRow``). The conflict policy lives in the SQL --
|
|
||||||
see ADR-0003 §Decision. Shape mirrors
|
|
||||||
``LandlordPropertyTypeOverridePostgresRepository``; the duplication is
|
|
||||||
deliberate while there are only two columns -- if a third lands and the
|
|
||||||
duplication becomes painful, extract a shared upsert helper then.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from sqlalchemy import Table
|
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from domain.epc.wall_type import WallType
|
|
||||||
from infrastructure.postgres.landlord_override_enums import OverrideSource
|
|
||||||
from infrastructure.postgres.landlord_wall_type_override_table import (
|
|
||||||
LandlordWallTypeOverrideRow,
|
|
||||||
)
|
|
||||||
from repositories.landlord_overrides.landlord_override_repository import (
|
|
||||||
LandlordOverrideRepository,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LandlordWallTypeOverridePostgresRepository(
|
|
||||||
LandlordOverrideRepository[WallType]
|
|
||||||
):
|
|
||||||
def __init__(self, session: Session) -> None:
|
|
||||||
self._session = session
|
|
||||||
|
|
||||||
def upsert_all(
|
|
||||||
self,
|
|
||||||
portfolio_id: int,
|
|
||||||
descriptions_to_values: dict[str, WallType],
|
|
||||||
) -> None:
|
|
||||||
if not descriptions_to_values:
|
|
||||||
return
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
"portfolio_id": portfolio_id,
|
|
||||||
"description": description,
|
|
||||||
"value": value.value,
|
|
||||||
"source": OverrideSource.CLASSIFIER,
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
for description, value in descriptions_to_values.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
# SQLModel's class-level ``__table__`` is injected at runtime on
|
|
||||||
# ``table=True`` classes but isn't exposed by the stubs; pin it to
|
|
||||||
# ``Table`` via ``getattr`` so the dialect insert helper below
|
|
||||||
# carries through with strict types.
|
|
||||||
table: Table = cast(Table, getattr(LandlordWallTypeOverrideRow, "__table__"))
|
|
||||||
stmt = pg_insert(table).values(rows)
|
|
||||||
|
|
||||||
# The classifier may refresh its own past output, but must never
|
|
||||||
# overwrite a user correction -- the ``WHERE existing.source =
|
|
||||||
# 'classifier'`` guard enforces that. See ADR-0003 §Decision.
|
|
||||||
stmt = stmt.on_conflict_do_update(
|
|
||||||
index_elements=["portfolio_id", "description"],
|
|
||||||
set_={
|
|
||||||
"value": stmt.excluded.value,
|
|
||||||
"source": stmt.excluded.source,
|
|
||||||
"updated_at": stmt.excluded.updated_at,
|
|
||||||
},
|
|
||||||
where=table.c.source == OverrideSource.CLASSIFIER,
|
|
||||||
)
|
|
||||||
|
|
||||||
# SQLModel re-exports SQLAlchemy's ``Session.execute``; one of the
|
|
||||||
# overload signatures is marked deprecated in stubs, which fires
|
|
||||||
# here even though our INSERT path is the supported one.
|
|
||||||
self._session.execute(stmt) # pyright: ignore[reportDeprecated]
|
|
||||||
|
|
@ -15,8 +15,10 @@ class LandlordOverrideRepository(ABC, Generic[E]):
|
||||||
which table the adapter writes to; the orchestrator depends only on this
|
which table the adapter writes to; the orchestrator depends only on this
|
||||||
interface and never names a concrete table.
|
interface and never names a concrete table.
|
||||||
|
|
||||||
Concrete adapters live in ``infrastructure/`` (see ADR-0003): for example
|
A single concrete adapter,
|
||||||
``infrastructure/postgres/landlord_property_type_override_postgres_repository.py``.
|
``infrastructure/postgres/landlord_overrides_postgres_repository.LandlordOverridesRepository``,
|
||||||
|
serves every category (see ADR-0003) -- it is parameterised by the
|
||||||
|
SQLModel row class for the target table.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
"""Integration tests for the consolidated landlord-overrides upsert adapter.
|
||||||
|
|
||||||
|
``LandlordOverridesRepository`` serves every ``landlord_<category>_overrides``
|
||||||
|
table; the table is selected by the ``…Row`` class passed at construction. The
|
||||||
|
source-aware conflict policy lives entirely in SQL (``INSERT ... ON CONFLICT
|
||||||
|
... DO UPDATE ... WHERE existing.source = 'classifier'``), so the only way to
|
||||||
|
verify it correctly distinguishes ``EXCLUDED.source`` from the qualified
|
||||||
|
``<table>.source`` is against a real Postgres -- the ``db_engine`` fixture in
|
||||||
|
``tests/conftest.py`` spins one up per test.
|
||||||
|
|
||||||
|
Each scenario is parametrised across two distinct categories (property type and
|
||||||
|
wall type). Running the shared body against two different ``…Row`` classes
|
||||||
|
proves the adapter writes to whichever table it was handed -- not just that one
|
||||||
|
table happens to work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import Engine, Table
|
||||||
|
from sqlmodel import Session, SQLModel, select
|
||||||
|
|
||||||
|
from domain.epc.property_type import PropertyType
|
||||||
|
from domain.epc.wall_type import WallType
|
||||||
|
from infrastructure.postgres.landlord_override_enums import OverrideSource
|
||||||
|
from infrastructure.postgres.landlord_overrides_postgres_repository import (
|
||||||
|
LandlordOverridesRepository,
|
||||||
|
)
|
||||||
|
from infrastructure.postgres.landlord_property_type_override_table import (
|
||||||
|
LandlordPropertyTypeOverrideRow,
|
||||||
|
)
|
||||||
|
from infrastructure.postgres.landlord_wall_type_override_table import (
|
||||||
|
LandlordWallTypeOverrideRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Category:
|
||||||
|
"""One ``landlord_<category>_overrides`` table plus a few of its enum values.
|
||||||
|
|
||||||
|
``first``/``second`` are two distinct classifier values for re-upsert
|
||||||
|
tests; ``user_correction`` stands in for a value only a user would pick;
|
||||||
|
``unknown`` is the category's ``UNKNOWN`` member.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
row_type: type[SQLModel]
|
||||||
|
first: Enum
|
||||||
|
second: Enum
|
||||||
|
user_correction: Enum
|
||||||
|
unknown: Enum
|
||||||
|
|
||||||
|
|
||||||
|
CATEGORIES = [
|
||||||
|
Category(
|
||||||
|
id="property_type",
|
||||||
|
row_type=LandlordPropertyTypeOverrideRow,
|
||||||
|
first=PropertyType.FLAT,
|
||||||
|
second=PropertyType.HOUSE,
|
||||||
|
user_correction=PropertyType.BUNGALOW,
|
||||||
|
unknown=PropertyType.UNKNOWN,
|
||||||
|
),
|
||||||
|
Category(
|
||||||
|
id="wall_type",
|
||||||
|
row_type=LandlordWallTypeOverrideRow,
|
||||||
|
first=WallType.CAVITY_FILLED,
|
||||||
|
second=WallType.SOLID_BRICK_AS_BUILT_NO_INSULATION_ASSUMED,
|
||||||
|
user_correction=WallType.SANDSTONE_AS_BUILT_NO_INSULATION_ASSUMED,
|
||||||
|
unknown=WallType.UNKNOWN,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session(db_engine: Engine) -> Iterator[Session]:
|
||||||
|
with Session(db_engine) as s:
|
||||||
|
yield s
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=CATEGORIES, ids=lambda c: c.id)
|
||||||
|
def category(request: pytest.FixtureRequest) -> Category:
|
||||||
|
return request.param
|
||||||
|
|
||||||
|
|
||||||
|
def _select_row(
|
||||||
|
session: Session, row_type: type[SQLModel], portfolio_id: int, description: str
|
||||||
|
) -> SQLModel:
|
||||||
|
# The row class is only known as ``type[SQLModel]`` here, so its column
|
||||||
|
# descriptors aren't typed; filter via the Core ``Table`` columns instead.
|
||||||
|
table = cast(Table, getattr(row_type, "__table__"))
|
||||||
|
rows = session.exec(
|
||||||
|
select(row_type).where(
|
||||||
|
table.c.portfolio_id == portfolio_id,
|
||||||
|
table.c.description == description,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
assert len(rows) == 1, f"expected exactly one row, got {len(rows)}"
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_inserts_a_fresh_row_with_source_classifier(
|
||||||
|
session: Session, category: Category
|
||||||
|
) -> None:
|
||||||
|
# arrange
|
||||||
|
repo = LandlordOverridesRepository[Enum](session, category.row_type)
|
||||||
|
|
||||||
|
# act
|
||||||
|
repo.upsert_all(portfolio_id=1, descriptions_to_values={"cosy": category.first})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# assert
|
||||||
|
row = _select_row(session, category.row_type, portfolio_id=1, description="cosy")
|
||||||
|
assert getattr(row, "value") is category.first
|
||||||
|
assert getattr(row, "source") == OverrideSource.CLASSIFIER
|
||||||
|
|
||||||
|
|
||||||
|
def test_reupsert_overwrites_a_classifier_row(
|
||||||
|
session: Session, category: Category
|
||||||
|
) -> None:
|
||||||
|
# arrange: a stale classifier row exists.
|
||||||
|
repo = LandlordOverridesRepository[Enum](session, category.row_type)
|
||||||
|
repo.upsert_all(portfolio_id=1, descriptions_to_values={"cosy": category.first})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# act: re-classify with a different value.
|
||||||
|
repo.upsert_all(portfolio_id=1, descriptions_to_values={"cosy": category.second})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# assert: the new classification wins.
|
||||||
|
row = _select_row(session, category.row_type, portfolio_id=1, description="cosy")
|
||||||
|
assert getattr(row, "value") is category.second
|
||||||
|
assert getattr(row, "source") == OverrideSource.CLASSIFIER
|
||||||
|
|
||||||
|
|
||||||
|
def test_reupsert_does_not_overwrite_a_user_row(
|
||||||
|
session: Session, category: Category
|
||||||
|
) -> None:
|
||||||
|
# arrange: a user has corrected the row. The classifier path never produces
|
||||||
|
# ``source = 'user'``; we install the row directly to mimic the override
|
||||||
|
# frontend.
|
||||||
|
user_row = category.row_type(
|
||||||
|
portfolio_id=1,
|
||||||
|
description="cosy",
|
||||||
|
value=category.user_correction,
|
||||||
|
source=OverrideSource.USER,
|
||||||
|
)
|
||||||
|
session.add(user_row)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# act: the classifier re-runs and tries to reclassify the same description.
|
||||||
|
# Under the source-aware conflict policy, this must be silently skipped --
|
||||||
|
# user edits beat classifier reruns.
|
||||||
|
repo = LandlordOverridesRepository[Enum](session, category.row_type)
|
||||||
|
repo.upsert_all(portfolio_id=1, descriptions_to_values={"cosy": category.second})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# assert: the user row is unchanged.
|
||||||
|
row = _select_row(session, category.row_type, portfolio_id=1, description="cosy")
|
||||||
|
assert getattr(row, "value") is category.user_correction
|
||||||
|
assert getattr(row, "source") == OverrideSource.USER
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_keeps_other_portfolios_descriptions_independent(
|
||||||
|
session: Session, category: Category
|
||||||
|
) -> None:
|
||||||
|
# arrange / act: the unique key is ``(portfolio_id, description)``, so the
|
||||||
|
# same description for two different portfolios must coexist as two rows.
|
||||||
|
repo = LandlordOverridesRepository[Enum](session, category.row_type)
|
||||||
|
repo.upsert_all(portfolio_id=1, descriptions_to_values={"cosy": category.first})
|
||||||
|
repo.upsert_all(portfolio_id=2, descriptions_to_values={"cosy": category.second})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# assert: both rows survive with their own values.
|
||||||
|
assert (
|
||||||
|
getattr(_select_row(session, category.row_type, 1, "cosy"), "value")
|
||||||
|
is category.first
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
getattr(_select_row(session, category.row_type, 2, "cosy"), "value")
|
||||||
|
is category.second
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_persists_unknown_so_a_user_can_resolve_it_later(
|
||||||
|
session: Session, category: Category
|
||||||
|
) -> None:
|
||||||
|
# arrange / act: a description the classifier couldn't resolve still lands
|
||||||
|
# -- per ADR-0002 §5 / ADR-0003 §Decision, so a future user override can
|
||||||
|
# upgrade it to a real value.
|
||||||
|
repo = LandlordOverridesRepository[Enum](session, category.row_type)
|
||||||
|
repo.upsert_all(
|
||||||
|
portfolio_id=1,
|
||||||
|
descriptions_to_values={"unparseable nonsense": category.unknown},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# assert: the row exists with value=UNKNOWN, source=classifier.
|
||||||
|
row = _select_row(
|
||||||
|
session, category.row_type, portfolio_id=1, description="unparseable nonsense"
|
||||||
|
)
|
||||||
|
assert getattr(row, "value") is category.unknown
|
||||||
|
assert getattr(row, "source") == OverrideSource.CLASSIFIER
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_all_with_empty_mapping_is_a_no_op(
|
||||||
|
session: Session, category: Category
|
||||||
|
) -> None:
|
||||||
|
# arrange / act
|
||||||
|
repo = LandlordOverridesRepository[Enum](session, category.row_type)
|
||||||
|
repo.upsert_all(portfolio_id=1, descriptions_to_values={})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# assert: nothing was inserted.
|
||||||
|
rows = session.exec(select(category.row_type)).all()
|
||||||
|
assert rows == []
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
"""Integration tests for the source-aware upsert policy.
|
|
||||||
|
|
||||||
The conflict policy lives entirely in SQL (``INSERT ... ON CONFLICT
|
|
||||||
... DO UPDATE ... WHERE existing.source = 'classifier'``). The only way to
|
|
||||||
verify it correctly distinguishes ``EXCLUDED.source`` from the qualified
|
|
||||||
``landlord_property_type_overrides.source`` is against a real Postgres --
|
|
||||||
the ``db_engine`` fixture in ``tests/conftest.py`` spins one up per test.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterator
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import Engine
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from domain.epc.property_type import PropertyType
|
|
||||||
from infrastructure.postgres.landlord_override_enums import OverrideSource
|
|
||||||
from infrastructure.postgres.landlord_property_type_override_postgres_repository import (
|
|
||||||
LandlordPropertyTypeOverridePostgresRepository,
|
|
||||||
)
|
|
||||||
from infrastructure.postgres.landlord_property_type_override_table import (
|
|
||||||
LandlordPropertyTypeOverrideRow,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def session(db_engine: Engine) -> Iterator[Session]:
|
|
||||||
with Session(db_engine) as s:
|
|
||||||
yield s
|
|
||||||
|
|
||||||
|
|
||||||
def _select_row(
|
|
||||||
session: Session, portfolio_id: int, description: str
|
|
||||||
) -> LandlordPropertyTypeOverrideRow:
|
|
||||||
rows = session.exec(
|
|
||||||
select(LandlordPropertyTypeOverrideRow).where(
|
|
||||||
LandlordPropertyTypeOverrideRow.portfolio_id == portfolio_id,
|
|
||||||
LandlordPropertyTypeOverrideRow.description == description,
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
assert len(rows) == 1, f"expected exactly one row, got {len(rows)}"
|
|
||||||
return rows[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_inserts_a_fresh_row_with_source_classifier(session: Session) -> None:
|
|
||||||
# arrange
|
|
||||||
repo = LandlordPropertyTypeOverridePostgresRepository(session)
|
|
||||||
|
|
||||||
# act
|
|
||||||
repo.upsert_all(portfolio_id=1, descriptions_to_values={"cosy": PropertyType.HOUSE})
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert
|
|
||||||
row = _select_row(session, portfolio_id=1, description="cosy")
|
|
||||||
assert row.value is PropertyType.HOUSE
|
|
||||||
assert row.source == OverrideSource.CLASSIFIER
|
|
||||||
|
|
||||||
|
|
||||||
def test_reupsert_overwrites_a_classifier_row(session: Session) -> None:
|
|
||||||
# arrange: a stale classifier row exists.
|
|
||||||
repo = LandlordPropertyTypeOverridePostgresRepository(session)
|
|
||||||
repo.upsert_all(portfolio_id=1, descriptions_to_values={"cosy": PropertyType.FLAT})
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# act: re-classify with a different category.
|
|
||||||
repo.upsert_all(portfolio_id=1, descriptions_to_values={"cosy": PropertyType.HOUSE})
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert: the new classification wins.
|
|
||||||
row = _select_row(session, portfolio_id=1, description="cosy")
|
|
||||||
assert row.value is PropertyType.HOUSE
|
|
||||||
assert row.source == OverrideSource.CLASSIFIER
|
|
||||||
|
|
||||||
|
|
||||||
def test_reupsert_does_not_overwrite_a_user_row(session: Session) -> None:
|
|
||||||
# arrange: a user has corrected the row to ``BUNGALOW``. The classifier
|
|
||||||
# path never produces ``source = 'user'``; we install the row directly
|
|
||||||
# to mimic the override frontend.
|
|
||||||
user_row = LandlordPropertyTypeOverrideRow(
|
|
||||||
portfolio_id=1,
|
|
||||||
description="cosy",
|
|
||||||
value=PropertyType.BUNGALOW,
|
|
||||||
source=OverrideSource.USER,
|
|
||||||
)
|
|
||||||
session.add(user_row)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# act: the classifier re-runs and tries to classify the same description
|
|
||||||
# as a ``HOUSE``. Under the source-aware conflict policy, this must be
|
|
||||||
# silently skipped -- user edits beat classifier reruns.
|
|
||||||
repo = LandlordPropertyTypeOverridePostgresRepository(session)
|
|
||||||
repo.upsert_all(portfolio_id=1, descriptions_to_values={"cosy": PropertyType.HOUSE})
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert: the user row is unchanged.
|
|
||||||
row = _select_row(session, portfolio_id=1, description="cosy")
|
|
||||||
assert row.value is PropertyType.BUNGALOW
|
|
||||||
assert row.source == OverrideSource.USER
|
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_keeps_other_portfolios_descriptions_independent(
|
|
||||||
session: Session,
|
|
||||||
) -> None:
|
|
||||||
# arrange: the unique key is ``(portfolio_id, description)``, so the same
|
|
||||||
# description for two different portfolios must coexist as two rows.
|
|
||||||
repo = LandlordPropertyTypeOverridePostgresRepository(session)
|
|
||||||
|
|
||||||
# act
|
|
||||||
repo.upsert_all(portfolio_id=1, descriptions_to_values={"cosy": PropertyType.HOUSE})
|
|
||||||
repo.upsert_all(portfolio_id=2, descriptions_to_values={"cosy": PropertyType.FLAT})
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert: both rows survive with their own values.
|
|
||||||
assert _select_row(session, 1, "cosy").value is PropertyType.HOUSE
|
|
||||||
assert _select_row(session, 2, "cosy").value is PropertyType.FLAT
|
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_persists_unknown_so_a_user_can_resolve_it_later(
|
|
||||||
session: Session,
|
|
||||||
) -> None:
|
|
||||||
# arrange / act: a description the classifier couldn't resolve still
|
|
||||||
# lands -- per ADR-0002 §5 / ADR-0003 §Decision, so a future user
|
|
||||||
# override can upgrade it to a real value.
|
|
||||||
repo = LandlordPropertyTypeOverridePostgresRepository(session)
|
|
||||||
repo.upsert_all(
|
|
||||||
portfolio_id=1,
|
|
||||||
descriptions_to_values={"unparseable nonsense": PropertyType.UNKNOWN},
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert: the row exists with value=UNKNOWN, source=classifier.
|
|
||||||
row = _select_row(session, portfolio_id=1, description="unparseable nonsense")
|
|
||||||
assert row.value is PropertyType.UNKNOWN
|
|
||||||
assert row.source == OverrideSource.CLASSIFIER
|
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_all_with_empty_mapping_is_a_no_op(session: Session) -> None:
|
|
||||||
# arrange / act
|
|
||||||
repo = LandlordPropertyTypeOverridePostgresRepository(session)
|
|
||||||
repo.upsert_all(portfolio_id=1, descriptions_to_values={})
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert: nothing was inserted.
|
|
||||||
rows = session.exec(select(LandlordPropertyTypeOverrideRow)).all()
|
|
||||||
assert rows == []
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
"""Integration tests for the source-aware upsert policy on the WallType table.
|
|
||||||
|
|
||||||
Mirror of ``test_landlord_property_type_override_postgres_repository.py`` --
|
|
||||||
the SQL is structurally identical, but the conflict policy lives in two
|
|
||||||
separate concrete adapters and so warrants two parallel test suites until
|
|
||||||
(if) the adapters are factored through a shared upsert helper.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterator
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import Engine
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from domain.epc.wall_type import WallType
|
|
||||||
from infrastructure.postgres.landlord_override_enums import OverrideSource
|
|
||||||
from infrastructure.postgres.landlord_wall_type_override_postgres_repository import (
|
|
||||||
LandlordWallTypeOverridePostgresRepository,
|
|
||||||
)
|
|
||||||
from infrastructure.postgres.landlord_wall_type_override_table import (
|
|
||||||
LandlordWallTypeOverrideRow,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def session(db_engine: Engine) -> Iterator[Session]:
|
|
||||||
with Session(db_engine) as s:
|
|
||||||
yield s
|
|
||||||
|
|
||||||
|
|
||||||
def _select_row(
|
|
||||||
session: Session, portfolio_id: int, description: str
|
|
||||||
) -> LandlordWallTypeOverrideRow:
|
|
||||||
rows = session.exec(
|
|
||||||
select(LandlordWallTypeOverrideRow).where(
|
|
||||||
LandlordWallTypeOverrideRow.portfolio_id == portfolio_id,
|
|
||||||
LandlordWallTypeOverrideRow.description == description,
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
assert len(rows) == 1, f"expected exactly one row, got {len(rows)}"
|
|
||||||
return rows[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_inserts_a_fresh_row_with_source_classifier(session: Session) -> None:
|
|
||||||
# arrange
|
|
||||||
repo = LandlordWallTypeOverridePostgresRepository(session)
|
|
||||||
|
|
||||||
# act
|
|
||||||
repo.upsert_all(
|
|
||||||
portfolio_id=1, descriptions_to_values={"cavity insulated": WallType.CAVITY_FILLED}
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert
|
|
||||||
row = _select_row(session, portfolio_id=1, description="cavity insulated")
|
|
||||||
assert row.value is WallType.CAVITY_FILLED
|
|
||||||
assert row.source == OverrideSource.CLASSIFIER
|
|
||||||
|
|
||||||
|
|
||||||
def test_reupsert_overwrites_a_classifier_row(session: Session) -> None:
|
|
||||||
# arrange: a stale classifier row exists.
|
|
||||||
repo = LandlordWallTypeOverridePostgresRepository(session)
|
|
||||||
repo.upsert_all(
|
|
||||||
portfolio_id=1, descriptions_to_values={"old red brick": WallType.CAVITY_FILLED}
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# act: re-classify with a different category.
|
|
||||||
repo.upsert_all(
|
|
||||||
portfolio_id=1, descriptions_to_values={"old red brick": WallType.SOLID_BRICK_AS_BUILT_NO_INSULATION_ASSUMED}
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert: the new classification wins.
|
|
||||||
row = _select_row(session, portfolio_id=1, description="old red brick")
|
|
||||||
assert row.value is WallType.SOLID_BRICK_AS_BUILT_NO_INSULATION_ASSUMED
|
|
||||||
assert row.source == OverrideSource.CLASSIFIER
|
|
||||||
|
|
||||||
|
|
||||||
def test_reupsert_does_not_overwrite_a_user_row(session: Session) -> None:
|
|
||||||
# arrange: a user has corrected the row to ``SANDSTONE``. The classifier
|
|
||||||
# path never produces ``source = 'user'``; we install the row directly
|
|
||||||
# to mimic the override frontend.
|
|
||||||
user_row = LandlordWallTypeOverrideRow(
|
|
||||||
portfolio_id=1,
|
|
||||||
description="old red brick",
|
|
||||||
value=WallType.SANDSTONE_AS_BUILT_NO_INSULATION_ASSUMED,
|
|
||||||
source=OverrideSource.USER,
|
|
||||||
)
|
|
||||||
session.add(user_row)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# act: the classifier re-runs and tries to classify the same description
|
|
||||||
# as ``SOLID_BRICK``. Under the source-aware conflict policy, this must
|
|
||||||
# be silently skipped -- user edits beat classifier reruns.
|
|
||||||
repo = LandlordWallTypeOverridePostgresRepository(session)
|
|
||||||
repo.upsert_all(
|
|
||||||
portfolio_id=1, descriptions_to_values={"old red brick": WallType.SOLID_BRICK_AS_BUILT_NO_INSULATION_ASSUMED}
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert: the user row is unchanged.
|
|
||||||
row = _select_row(session, portfolio_id=1, description="old red brick")
|
|
||||||
assert row.value is WallType.SANDSTONE_AS_BUILT_NO_INSULATION_ASSUMED
|
|
||||||
assert row.source == OverrideSource.USER
|
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_keeps_other_portfolios_descriptions_independent(
|
|
||||||
session: Session,
|
|
||||||
) -> None:
|
|
||||||
# arrange / act: the unique key is ``(portfolio_id, description)``, so the
|
|
||||||
# same description for two different portfolios must coexist as two rows.
|
|
||||||
repo = LandlordWallTypeOverridePostgresRepository(session)
|
|
||||||
repo.upsert_all(
|
|
||||||
portfolio_id=1, descriptions_to_values={"old red brick": WallType.CAVITY_FILLED}
|
|
||||||
)
|
|
||||||
repo.upsert_all(
|
|
||||||
portfolio_id=2, descriptions_to_values={"old red brick": WallType.SOLID_BRICK_AS_BUILT_NO_INSULATION_ASSUMED}
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert: both rows survive with their own values.
|
|
||||||
assert _select_row(session, 1, "old red brick").value is WallType.CAVITY_FILLED
|
|
||||||
assert _select_row(session, 2, "old red brick").value is WallType.SOLID_BRICK_AS_BUILT_NO_INSULATION_ASSUMED
|
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_persists_unknown_so_a_user_can_resolve_it_later(
|
|
||||||
session: Session,
|
|
||||||
) -> None:
|
|
||||||
# arrange / act: a description the classifier couldn't resolve still
|
|
||||||
# lands -- per ADR-0002 §5 / ADR-0003 §Decision, so a future user
|
|
||||||
# override can upgrade it to a real value.
|
|
||||||
repo = LandlordWallTypeOverridePostgresRepository(session)
|
|
||||||
repo.upsert_all(
|
|
||||||
portfolio_id=1,
|
|
||||||
descriptions_to_values={"unparseable wall description": WallType.UNKNOWN},
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert: the row exists with value=UNKNOWN, source=classifier.
|
|
||||||
row = _select_row(
|
|
||||||
session, portfolio_id=1, description="unparseable wall description"
|
|
||||||
)
|
|
||||||
assert row.value is WallType.UNKNOWN
|
|
||||||
assert row.source == OverrideSource.CLASSIFIER
|
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_all_with_empty_mapping_is_a_no_op(session: Session) -> None:
|
|
||||||
# arrange / act
|
|
||||||
repo = LandlordWallTypeOverridePostgresRepository(session)
|
|
||||||
repo.upsert_all(portfolio_id=1, descriptions_to_values={})
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# assert: nothing was inserted.
|
|
||||||
rows = session.exec(select(LandlordWallTypeOverrideRow)).all()
|
|
||||||
assert rows == []
|
|
||||||
Loading…
Add table
Reference in a new issue