FUE-T04: add Meter.uuid (stable HA identity anchor) + backfill migration

This commit is contained in:
2026-06-25 20:20:01 +02:00
parent da05fd2f09
commit f663981cdb
5 changed files with 170 additions and 2 deletions
@@ -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")
+10
View File
@@ -11,6 +11,7 @@ Six tables:
from __future__ import annotations from __future__ import annotations
import uuid as _uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
@@ -20,6 +21,10 @@ from sqlalchemy.types import JSON
from app.db import Base from app.db import Base
def _uuid4_str() -> str:
return str(_uuid.uuid4())
class Meter(Base): class Meter(Base):
"""One physical electricity meter's installation epoch. """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) 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). # Human-readable label for this physical meter (e.g. address, serial, tariff zone).
label: Mapped[str] = mapped_column(String(255), nullable=False) label: Mapped[str] = mapped_column(String(255), nullable=False)
+1 -1
View File
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
from app.config import get_settings from app.config import get_settings
APP_BASELINE_REVISION = "20260625_13_meter_table" APP_BASELINE_REVISION = "20260625_14_meter_uuid"
class AppDatabaseAdoptionError(RuntimeError): class AppDatabaseAdoptionError(RuntimeError):
+18 -1
View File
@@ -798,7 +798,7 @@ def test_meter_columns(energy_db):
inspector = inspect(energy_db) inspector = inspect(energy_db)
columns = {col["name"]: col for col in inspector.get_columns("meter")} 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"} nullable = {"ended_at", "note"}
for col_name in non_nullable: 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" 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(): def test_meter_orm_metadata():
"""Meter must be registered in Base.metadata with correct field types.""" """Meter must be registered in Base.metadata with correct field types."""
assert "meter" in Base.metadata.tables, "meter not in Base.metadata" assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
table = Base.metadata.tables["meter"] table = Base.metadata.tables["meter"]
assert "id" in table.columns assert "id" in table.columns
assert "uuid" in table.columns
assert "label" in table.columns assert "label" in table.columns
assert "commodity" in table.columns assert "commodity" in table.columns
assert "started_at" in table.columns assert "started_at" in table.columns
@@ -824,6 +833,14 @@ def test_meter_orm_metadata():
assert "created_at" in table.columns 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): def test_meter_insert_and_retrieve(energy_db):
"""A Meter row can be inserted and retrieved with all fields intact.""" """A Meter row can be inserted and retrieved with all fields intact."""
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
+37
View File
@@ -381,6 +381,43 @@ class TestDeclareMeter:
assert fetched.commodity == "gas" assert fetched.commodity == "gas"
assert fetched.note == "Rotameter serial XYZ" 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 # 5. Different commodities are independent