feat: asset catalog API, 태그 DB 기초, 마이그레이션 3건 및 도메인/테스트 갱신 (2026-05-19)
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""Create tags master table (REQ-001)
|
||||
|
||||
Revision ID: 20260519_0019
|
||||
Revises: 20260519_0018
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260519_0019"
|
||||
down_revision = "20260519_0018"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "tags"):
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("tag_id", sa.UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.Text(), nullable=False),
|
||||
sa.Column("is_admin_tag", sa.Boolean(), nullable=False, server_default=sa.text("FALSE")),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("TRUE")),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("tag_id"),
|
||||
sa.UniqueConstraint("name", name="uq_tags_name"),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_tags_created_by_user_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "tags"):
|
||||
op.drop_table("tags")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Add portfolios.tag_ids UUID[] column and GIN index (REQ-002, REQ-003)
|
||||
|
||||
Revision ID: 20260519_0020
|
||||
Revises: 20260519_0019
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260519_0020"
|
||||
down_revision = "20260519_0019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _column_exists(inspector, "portfolios", "tag_ids"):
|
||||
op.add_column(
|
||||
"portfolios",
|
||||
sa.Column(
|
||||
"tag_ids",
|
||||
sa.ARRAY(sa.UUID()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::uuid[]"),
|
||||
),
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _column_exists(inspector, "portfolios", "tag_ids") and not _index_exists(
|
||||
inspector, "portfolios", "idx_portfolios_tag_ids_gin"
|
||||
):
|
||||
op.create_index(
|
||||
"idx_portfolios_tag_ids_gin",
|
||||
"portfolios",
|
||||
["tag_ids"],
|
||||
postgresql_using="gin",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _index_exists(inspector, "portfolios", "idx_portfolios_tag_ids_gin"):
|
||||
op.drop_index("idx_portfolios_tag_ids_gin", table_name="portfolios")
|
||||
|
||||
if _column_exists(inspector, "portfolios", "tag_ids"):
|
||||
op.drop_column("portfolios", "tag_ids")
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Migrate portfolio_tags data to tags master, fill tag_ids, drop portfolio_tags (REQ-006)
|
||||
|
||||
Revision ID: 20260519_0021
|
||||
Revises: 20260519_0020
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
|
||||
IMPORTANT: Set MIGRATION_ADMIN_ID to a valid user ID that exists in the users table
|
||||
before running this migration in a live environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "20260519_0021"
|
||||
down_revision = "20260519_0020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Operator must confirm this user ID exists in the users table before running.
|
||||
MIGRATION_ADMIN_ID = "migration-admin"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def run_migration(connection, admin_user_id: str) -> None:
|
||||
"""Core migration logic — extracted for testability."""
|
||||
result = connection.execute(
|
||||
text("SELECT COUNT(*) FROM users WHERE id = :uid"),
|
||||
{"uid": admin_user_id},
|
||||
)
|
||||
count = result.scalar()
|
||||
if not count:
|
||||
raise ValueError(
|
||||
f"Migration admin user not found in users table: {admin_user_id}. "
|
||||
"Set MIGRATION_ADMIN_ID to a valid user ID before running."
|
||||
)
|
||||
|
||||
# Migrate distinct (lowercased) tag_values from portfolio_tags → tags master
|
||||
connection.execute(
|
||||
text("""
|
||||
INSERT INTO tags (name, created_by_user_id, is_admin_tag)
|
||||
SELECT LOWER(tag_value), :admin_id, FALSE
|
||||
FROM portfolio_tags
|
||||
GROUP BY LOWER(tag_value)
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
"""),
|
||||
{"admin_id": admin_user_id},
|
||||
)
|
||||
|
||||
# Fill portfolios.tag_ids from the migrated tags
|
||||
connection.execute(text("""
|
||||
UPDATE portfolios p
|
||||
SET tag_ids = COALESCE((
|
||||
SELECT ARRAY_AGG(t.tag_id ORDER BY t.name)
|
||||
FROM portfolio_tags pt
|
||||
JOIN tags t ON t.name = LOWER(pt.tag_value)
|
||||
WHERE pt.portfolio_id = p.portfolio_id
|
||||
), '{}'::uuid[])
|
||||
"""))
|
||||
|
||||
# Drop portfolio_tags table (data is now in tags + portfolios.tag_ids)
|
||||
connection.execute(text("DROP TABLE IF EXISTS portfolio_tags"))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "portfolio_tags"):
|
||||
# portfolio_tags already gone — migration was already applied or skipped
|
||||
return
|
||||
|
||||
run_migration(bind, MIGRATION_ADMIN_ID)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Re-create portfolio_tags table
|
||||
if not _table_exists(inspector, "portfolio_tags"):
|
||||
op.create_table(
|
||||
"portfolio_tags",
|
||||
sa.Column("portfolio_id", sa.Text(), nullable=False),
|
||||
sa.Column("tag_value", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("portfolio_id", "tag_value", name="pk_portfolio_tags"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["portfolio_id"], ["portfolios.portfolio_id"],
|
||||
name="fk_portfolio_tags_portfolio",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_portfolio_tags_tag_portfolio",
|
||||
"portfolio_tags",
|
||||
["tag_value", "portfolio_id"],
|
||||
)
|
||||
|
||||
# Restore portfolio_tags from tags + portfolios.tag_ids (best-effort reverse)
|
||||
bind.execute(text("""
|
||||
INSERT INTO portfolio_tags (portfolio_id, tag_value)
|
||||
SELECT p.portfolio_id, t.name
|
||||
FROM portfolios p
|
||||
CROSS JOIN LATERAL UNNEST(p.tag_ids) AS tag_id_val
|
||||
JOIN tags t ON t.tag_id = tag_id_val
|
||||
ON CONFLICT DO NOTHING
|
||||
"""))
|
||||
|
||||
# Clear tag_ids arrays and drop tags data added by this migration
|
||||
# (cannot fully reverse is_admin_tag=FALSE origin without original data)
|
||||
bind.execute(text("UPDATE portfolios SET tag_ids = '{}'::uuid[]"))
|
||||
Reference in New Issue
Block a user