Files
portfolio-hub/migrations/versions/20260511_0015_portfolio_uuid_migration.py
T
genichin 829629fa8f feat: implement portfolio UUID migration and v1 API finalization (BAT-006, BAT-001 re-verified)
Core Changes:
- Migrate portfolio_id from string to UUID in schema and repository layer
- Update portfolio CLI commands to use name+user combination for identification
- Update HTTP API to accept portfolio_id as UUID path parameter
- Implement portfolio ownership validation in repository queries

Requirements Completed (REQ-016 to REQ-019):
- REQ-016: portfolios table UUID schema migration with data migration script
- REQ-017: CLI portfolio commands with name+user identification
- REQ-018: HTTP API portfolio path parameters and v1 asset_id UUID returns
- REQ-019: Portfolio UUID baseline documentation updates

BAT-001 Re-verification:
- REQ-004~010 marked as Implemented after verification evidence validation
- All v1 API endpoints verified with comprehensive test coverage

Documentation Updates:
- Update data-model.md with portfolio UUID structure
- Update interface-contract.md with UUID path parameters
- Update runtime-flows.md with ownership-based resolution flows
- Update project-structure.md with database layer changes
- Add migration script for portfolio UUID transition

Testing:
- Add test_portfolio_uuid.py for migration validation
- Update test_cli.py and test_portfolio_ownership.py for ownership checks
2026-05-11 09:27:57 +00:00

115 lines
4.5 KiB
Python

"""portfolios.portfolio_id UUID 전환 및 (owner_user_id, name) UNIQUE 제약 추가
Revision ID: 20260511_0015
Revises: 20260502_0014
Create Date: 2026-05-11 00:00:00
"""
from __future__ import annotations
import uuid as _uuid
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260511_0015"
down_revision = "20260502_0014"
branch_labels = None
depends_on = None
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if table_name not in inspector.get_table_names():
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def _constraint_exists(inspector: sa.engine.Inspector, table_name: str, constraint_name: str) -> bool:
if table_name not in inspector.get_table_names():
return False
return any(c["name"] == constraint_name for c in inspector.get_unique_constraints(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# Step 1: Add name column IF NOT EXISTS (already present via ensure_schema in most envs)
if not _column_exists(inspector, "portfolios", "name"):
op.add_column("portfolios", sa.Column("name", sa.Text(), nullable=True))
bind.execute(sa.text("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''"))
op.alter_column("portfolios", "name", nullable=False)
# Step 2: Guard — skip UUID replacement if already migrated (UNIQUE constraint present)
already_migrated = _constraint_exists(inspector, "portfolios", "uq_portfolios_owner_name")
if not already_migrated:
# Collect all existing portfolio_ids
result = bind.execute(sa.text("SELECT portfolio_id FROM portfolios"))
old_pids = [row[0] for row in result.fetchall()]
for old_pid in old_pids:
new_uuid = str(_uuid.uuid4())
# Copy old portfolio_id to name (overwrites display name with identifier)
bind.execute(
sa.text("UPDATE portfolios SET name = :old_pid WHERE portfolio_id = :old_pid"),
{"old_pid": old_pid},
)
# Update FK reference tables before changing portfolios PK
bind.execute(
sa.text("UPDATE portfolio_ledger SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
{"new_uuid": new_uuid, "old_pid": old_pid},
)
bind.execute(
sa.text("UPDATE portfolio_comments SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
{"new_uuid": new_uuid, "old_pid": old_pid},
)
bind.execute(
sa.text("UPDATE portfolio_tags SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
{"new_uuid": new_uuid, "old_pid": old_pid},
)
bind.execute(
sa.text("UPDATE ledger_trades SET asset_id = :new_uuid WHERE asset_id = :old_pid"),
{"new_uuid": new_uuid, "old_pid": old_pid},
)
# Update portfolios PK last
bind.execute(
sa.text("UPDATE portfolios SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
{"new_uuid": new_uuid, "old_pid": old_pid},
)
# Ensure no name nulls remain before adding UNIQUE constraint
bind.execute(
sa.text("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")
)
# Step 3: Add (owner_user_id, name) UNIQUE constraint
op.create_unique_constraint("uq_portfolios_owner_name", "portfolios", ["owner_user_id", "name"])
# Step 4: Verify no orphan records in FK reference tables
for table, col in [
("portfolio_ledger", "portfolio_id"),
("portfolio_comments", "portfolio_id"),
("portfolio_tags", "portfolio_id"),
]:
result = bind.execute(
sa.text(
f"SELECT COUNT(*) FROM {table}" # noqa: S608
f" WHERE {col} NOT IN (SELECT portfolio_id FROM portfolios)"
)
)
count = result.scalar()
if count and count > 0:
raise RuntimeError(
f"Migration 20260511_0015: {count} orphan record(s) found in {table}.{col}"
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _constraint_exists(inspector, "portfolios", "uq_portfolios_owner_name"):
op.drop_constraint("uq_portfolios_owner_name", "portfolios", type_="unique")