"""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")