feat: asset catalog API, 태그 DB 기초, 마이그레이션 3건 및 도메인/테스트 갱신 (2026-05-19)

This commit is contained in:
2026-05-19 22:15:55 +00:00
parent e2226e28b6
commit 158929b2d9
33 changed files with 2575 additions and 102 deletions
@@ -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[]"))