From f663981cdb3f2e847755cea93abd993c4b27d9e7 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Thu, 25 Jun 2026 20:20:01 +0200 Subject: [PATCH] FUE-T04: add Meter.uuid (stable HA identity anchor) + backfill migration --- .../versions/20260625_14_meter_uuid.py | 104 ++++++++++++++++++ app/models/energy.py | 10 ++ scripts/app_db_adopt.py | 2 +- tests/test_energy_models.py | 19 +++- tests/test_meters.py | 37 +++++++ 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 alembic_app/versions/20260625_14_meter_uuid.py diff --git a/alembic_app/versions/20260625_14_meter_uuid.py b/alembic_app/versions/20260625_14_meter_uuid.py new file mode 100644 index 0000000..ecf1f9f --- /dev/null +++ b/alembic_app/versions/20260625_14_meter_uuid.py @@ -0,0 +1,104 @@ +"""add uuid column to meter table + +Adds a stable ``uuid`` (UUID v4 string) column to the ``meter`` table so that +each meter epoch has a durable identity anchor suitable for use as an HA +Discovery ``unique_id``. + +**Migration strategy (SQLite-safe)**: + +SQLite does not support adding a NOT NULL + UNIQUE column to a non-empty table +in a single ``ALTER TABLE ADD COLUMN`` statement (adding a NOT NULL column +without a default value is rejected if the table already has rows). The +safe approach used here is: + + 1. Add ``uuid`` as a **nullable** column (SQLite allows this). + 2. **Back-fill** every existing ``meter`` row with a distinct ``str(uuid4())`` + value. Each row gets its *own* random UUID — not a shared value — so the + subsequent UNIQUE constraint is satisfied. + 3. Use ``batch_alter_table`` (which re-creates the table under the hood in + SQLite) to alter the column to ``NOT NULL`` and add a UNIQUE constraint. + +**Idempotency**: only rows where ``uuid IS NULL`` are back-filled; rows that +already have a uuid (e.g. from a repeated upgrade after a partial failure) are +left untouched. + +**Audit**: after back-fill, the count of rows with ``uuid IS NULL`` must be +exactly zero; if not, the migration raises ``RuntimeError`` and rolls back. + +**Data safety**: this migration is additive only — no existing rows are deleted +or overwritten; it only adds a new column and fills it in. + +Revision ID: 20260625_14_meter_uuid +Revises: 20260625_13_meter_table +Create Date: 2026-06-25 00:00:00.000000 +""" + +import uuid as _uuid +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260625_14_meter_uuid" +down_revision: Union[str, None] = "20260625_13_meter_table" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + # ------------------------------------------------------------------ # + # 1. Add uuid as a nullable column. # + # ------------------------------------------------------------------ # + with op.batch_alter_table("meter", schema=None) as batch_op: + batch_op.add_column( + sa.Column("uuid", sa.String(length=36), nullable=True) + ) + + # ------------------------------------------------------------------ # + # 2. Back-fill: assign a distinct UUID to every row that has # + # uuid IS NULL. Each row gets its own random value so that the # + # subsequent UNIQUE constraint is satisfied. # + # ------------------------------------------------------------------ # + rows = conn.execute(sa.text("SELECT id FROM meter WHERE uuid IS NULL")).fetchall() + for (meter_id,) in rows: + new_uuid = str(_uuid.uuid4()) + conn.execute( + sa.text("UPDATE meter SET uuid = :uuid WHERE id = :mid"), + {"uuid": new_uuid, "mid": meter_id}, + ) + + # ------------------------------------------------------------------ # + # 3. Audit: verify no rows remain with uuid IS NULL. # + # ------------------------------------------------------------------ # + null_count_row = conn.execute( + sa.text("SELECT COUNT(*) FROM meter WHERE uuid IS NULL") + ).fetchone() + null_count: int = null_count_row[0] if null_count_row else 0 + + if null_count != 0: + raise RuntimeError( + f"meter.uuid back-fill audit failed: {null_count} meter row(s) still have " + "uuid IS NULL after back-fill. Migration aborted to protect data integrity." + ) + + # ------------------------------------------------------------------ # + # 4. Alter column to NOT NULL + UNIQUE (requires batch on SQLite). # + # batch_alter_table re-creates the table, so the UNIQUE constraint # + # and NOT NULL are applied atomically. # + # ------------------------------------------------------------------ # + with op.batch_alter_table("meter", schema=None) as batch_op: + batch_op.alter_column( + "uuid", + existing_type=sa.String(length=36), + nullable=False, + ) + batch_op.create_unique_constraint("uq_meter_uuid", ["uuid"]) + + +def downgrade() -> None: + # Drop the UNIQUE constraint and the uuid column (batch on SQLite). + with op.batch_alter_table("meter", schema=None) as batch_op: + batch_op.drop_constraint("uq_meter_uuid", type_="unique") + batch_op.drop_column("uuid") diff --git a/app/models/energy.py b/app/models/energy.py index 2fd8947..2813e1d 100644 --- a/app/models/energy.py +++ b/app/models/energy.py @@ -11,6 +11,7 @@ Six tables: from __future__ import annotations +import uuid as _uuid from datetime import datetime from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String @@ -20,6 +21,10 @@ from sqlalchemy.types import JSON from app.db import Base +def _uuid4_str() -> str: + return str(_uuid.uuid4()) + + class Meter(Base): """One physical electricity meter's installation epoch. @@ -47,6 +52,11 @@ class Meter(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # Stable internal identity — used as HA Discovery unique_id anchor. + uuid: Mapped[str] = mapped_column( + String(36), unique=True, nullable=False, default=_uuid4_str + ) + # Human-readable label for this physical meter (e.g. address, serial, tariff zone). label: Mapped[str] = mapped_column(String(255), nullable=False) diff --git a/scripts/app_db_adopt.py b/scripts/app_db_adopt.py index 4daa477..abc785b 100644 --- a/scripts/app_db_adopt.py +++ b/scripts/app_db_adopt.py @@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path: from app.config import get_settings -APP_BASELINE_REVISION = "20260625_13_meter_table" +APP_BASELINE_REVISION = "20260625_14_meter_uuid" class AppDatabaseAdoptionError(RuntimeError): diff --git a/tests/test_energy_models.py b/tests/test_energy_models.py index d889de7..9bffaaf 100644 --- a/tests/test_energy_models.py +++ b/tests/test_energy_models.py @@ -798,7 +798,7 @@ def test_meter_columns(energy_db): inspector = inspect(energy_db) columns = {col["name"]: col for col in inspector.get_columns("meter")} - non_nullable = {"id", "label", "commodity", "started_at", "reason", "created_at"} + non_nullable = {"id", "uuid", "label", "commodity", "started_at", "reason", "created_at"} nullable = {"ended_at", "note"} for col_name in non_nullable: @@ -810,11 +810,20 @@ def test_meter_columns(energy_db): assert columns[col_name]["nullable"], f"{col_name} should be nullable" +def test_meter_uuid_unique_constraint(energy_db): + """meter.uuid must have a unique constraint.""" + inspector = inspect(energy_db) + unique_constraints = inspector.get_unique_constraints("meter") + unique_cols = [col for uc in unique_constraints for col in uc["column_names"]] + assert "uuid" in unique_cols, "meter.uuid must have a unique constraint" + + def test_meter_orm_metadata(): """Meter must be registered in Base.metadata with correct field types.""" assert "meter" in Base.metadata.tables, "meter not in Base.metadata" table = Base.metadata.tables["meter"] assert "id" in table.columns + assert "uuid" in table.columns assert "label" in table.columns assert "commodity" in table.columns assert "started_at" in table.columns @@ -824,6 +833,14 @@ def test_meter_orm_metadata(): assert "created_at" in table.columns +def test_meter_uuid_unique_in_metadata(): + """Meter.uuid must be declared unique and not nullable in ORM metadata.""" + table = Base.metadata.tables["meter"] + col = table.columns["uuid"] + assert col.unique, "Meter.uuid must be declared unique in ORM metadata" + assert not col.nullable, "Meter.uuid must be NOT NULL in ORM metadata" + + def test_meter_insert_and_retrieve(energy_db): """A Meter row can be inserted and retrieved with all fields intact.""" now = datetime.now(tz=timezone.utc) diff --git a/tests/test_meters.py b/tests/test_meters.py index 81f539b..d4e04ee 100644 --- a/tests/test_meters.py +++ b/tests/test_meters.py @@ -381,6 +381,43 @@ class TestDeclareMeter: assert fetched.commodity == "gas" assert fetched.note == "Rotameter serial XYZ" + def test_declare_meter_generates_uuid(self, session: Session): + """declare_meter must auto-generate a non-empty uuid via ORM default.""" + import re + UUID4_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + re.IGNORECASE, + ) + + m = declare_meter( + session, + label="Meter with UUID", + started_at=_T0, + reason="initial", + ) + session.commit() + + fetched = session.get(Meter, m.id) + assert fetched is not None + assert fetched.uuid is not None, "uuid must not be None after declare_meter" + assert fetched.uuid != "", "uuid must not be empty" + assert UUID4_RE.match(fetched.uuid), ( + f"uuid {fetched.uuid!r} does not look like a valid UUID v4" + ) + + def test_declare_meter_each_gets_distinct_uuid(self, session: Session): + """Each declared meter must receive a distinct UUID (not duplicated).""" + m1 = declare_meter(session, label="M1", started_at=_T0, reason="initial") + session.commit() + + t1 = _T0 + timedelta(days=10) + m2 = declare_meter(session, label="M2", started_at=t1, reason="meter_swap") + session.commit() + + assert m1.uuid != m2.uuid, ( + f"Two declared meters must have distinct UUIDs; both got {m1.uuid!r}" + ) + # --------------------------------------------------------------------------- # 5. Different commodities are independent