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
115 lines
4.5 KiB
Python
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")
|