105 lines
4.4 KiB
Python
105 lines
4.4 KiB
Python
"""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")
|