diff --git a/backend/alembic/versions/0003_revoked_token.py b/backend/alembic/versions/0003_revoked_token.py index b7bcce29..88296542 100644 --- a/backend/alembic/versions/0003_revoked_token.py +++ b/backend/alembic/versions/0003_revoked_token.py @@ -1,7 +1,7 @@ """revoked_token Revision ID: 0003 -Revises: 0002 +Revises: 0002_auth_share Create Date: 2026-06-22 10:00:00.000000 """ @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = "0003" -down_revision = "0002" +down_revision = "0002_auth_share" branch_labels = None depends_on = None diff --git a/backend/alembic/versions/0004_merge_heads.py b/backend/alembic/versions/0004_merge_heads.py new file mode 100644 index 00000000..14a457f9 --- /dev/null +++ b/backend/alembic/versions/0004_merge_heads.py @@ -0,0 +1,28 @@ +"""merge revoked_token and validate_project_space_fk into a single head + +Two migrations branched independently from 0002_auth_share: + - 0003 (revoked_token): creates the revoked_token table + - 0003_validate_project_space_fk: validates a project_space FK +They are unrelated and safe to apply in any order, so this is a no-op merge +that unifies the graph to a single head (``alembic upgrade head`` was ambiguous +with two heads). + +Revision ID: 0004_merge_heads +Revises: 0003, 0003_validate_project_space_fk +Create Date: 2026-07-05 00:00:00.000000 + +""" + +# revision identifiers, used by Alembic. +revision = "0004_merge_heads" +down_revision = ("0003", "0003_validate_project_space_fk") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """No schema change; this revision only merges two heads.""" + + +def downgrade() -> None: + """No schema change; splitting back into two heads requires no work.""" diff --git a/backend/alembic/versions/0005_diagram_view.py b/backend/alembic/versions/0005_diagram_view.py new file mode 100644 index 00000000..53f61b2a --- /dev/null +++ b/backend/alembic/versions/0005_diagram_view.py @@ -0,0 +1,48 @@ +"""diagram_view: saved ERD canvas views + +Revision ID: 0005_diagram_view +Revises: 0004_merge_heads +Create Date: 2026-07-05 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0005_diagram_view" +down_revision = "0004_merge_heads" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "diagram_view", + sa.Column("diagram_view_uuid", sa.Uuid(), primary_key=True, nullable=False), + sa.Column("project_space_uuid", sa.Uuid(), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("layout_json", sa.JSON(), nullable=False), + sa.Column("created_by", sa.Uuid(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["project_space_uuid"], + ["project_space.project_space_uuid"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("diagram_view_uuid"), + ) + op.create_index( + "ix_diagram_view__project_space_uuid", + "diagram_view", + ["project_space_uuid"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_diagram_view__project_space_uuid", table_name="diagram_view" + ) + op.drop_table("diagram_view") diff --git a/backend/alembic/versions/0006_table_annotation.py b/backend/alembic/versions/0006_table_annotation.py new file mode 100644 index 00000000..333e1310 --- /dev/null +++ b/backend/alembic/versions/0006_table_annotation.py @@ -0,0 +1,55 @@ +"""table_annotation: per-project notes attached to tables by name + +Revision ID: 0006_table_annotation +Revises: 0005_diagram_view +Create Date: 2026-07-05 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0006_table_annotation" +down_revision = "0005_diagram_view" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "table_annotation", + sa.Column("table_annotation_uuid", sa.Uuid(), primary_key=True, nullable=False), + sa.Column("project_space_uuid", sa.Uuid(), nullable=False), + sa.Column("schema_name", sa.Text(), nullable=False), + sa.Column("relation_name", sa.Text(), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("created_by", sa.Uuid(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["project_space_uuid"], + ["project_space.project_space_uuid"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("table_annotation_uuid"), + sa.UniqueConstraint( + "project_space_uuid", + "schema_name", + "relation_name", + name="uq_table_annotation__project_table", + ), + ) + op.create_index( + "ix_table_annotation__project_space_uuid", + "table_annotation", + ["project_space_uuid"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_table_annotation__project_space_uuid", table_name="table_annotation" + ) + op.drop_table("table_annotation") diff --git a/backend/app/api/annotations.py b/backend/app/api/annotations.py new file mode 100644 index 00000000..2f47c7af --- /dev/null +++ b/backend/app/api/annotations.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import datetime as dt +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import CurrentUser, get_current_user +from app.db import get_read_session, get_session +from app.models import TableAnnotation +from app.permissions import require_project_member +from app.schemas import TableAnnotationOut, TableAnnotationUpsertIn + +router = APIRouter(prefix="/api/annotations", tags=["annotations"]) + + +def _to_out(ann: TableAnnotation) -> TableAnnotationOut: + return TableAnnotationOut( + table_annotation_uuid=ann.table_annotation_uuid, + schema_name=ann.schema_name, + relation_name=ann.relation_name, + body=ann.body, + created_at=ann.created_at, + updated_at=ann.updated_at, + ) + + +async def _get_authorized_annotation( + session: AsyncSession, + table_annotation_uuid: uuid.UUID, + user: CurrentUser, + minimum_role: str | None = None, +) -> TableAnnotation | None: + """Fetch an annotation only after project membership has been checked. + + Returns ``None`` for both missing and unauthorized so callers can respond + with a uniform 404 (no existence enumeration / IDOR). + """ + project_space_uuid = await session.scalar( + select(TableAnnotation.project_space_uuid).where( + TableAnnotation.table_annotation_uuid == table_annotation_uuid + ) + ) + if project_space_uuid is None: + return None + try: + await require_project_member( + session, + project_space_uuid, + user.user_account_uuid, + minimum_role=minimum_role, + ) + except HTTPException as exc: + if exc.status_code == 403: + return None + raise + return await session.get(TableAnnotation, table_annotation_uuid) + + +@router.get( + "/by-project/{project_space_uuid}", response_model=list[TableAnnotationOut] +) +async def list_annotations( + project_space_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> list[TableAnnotationOut]: + """List table annotations for a project.""" + await require_project_member(session, project_space_uuid, user.user_account_uuid) + rows = await session.execute( + select(TableAnnotation) + .where(TableAnnotation.project_space_uuid == project_space_uuid) + .order_by(TableAnnotation.schema_name, TableAnnotation.relation_name) + ) + return [_to_out(a) for a in rows.scalars().all()] + + +@router.put( + "/by-project/{project_space_uuid}", response_model=TableAnnotationOut +) +async def upsert_annotation( + project_space_uuid: uuid.UUID, + body: TableAnnotationUpsertIn, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> TableAnnotationOut: + """Create or update the note for one table in a project (editor role). + + Keyed by (project, schema_name, relation_name); a unique constraint keeps + it to a single note per table. + """ + await require_project_member( + session, project_space_uuid, user.user_account_uuid, minimum_role="editor" + ) + now = dt.datetime.now(dt.timezone.utc) + existing = await session.scalar( + select(TableAnnotation).where( + TableAnnotation.project_space_uuid == project_space_uuid, + TableAnnotation.schema_name == body.schema_name, + TableAnnotation.relation_name == body.relation_name, + ) + ) + if existing is not None: + existing.body = body.body + existing.updated_at = now + ann = existing + else: + ann = TableAnnotation( + table_annotation_uuid=uuid.uuid4(), + project_space_uuid=project_space_uuid, + schema_name=body.schema_name, + relation_name=body.relation_name, + body=body.body, + created_by=user.user_account_uuid, + created_at=now, + updated_at=now, + ) + session.add(ann) + await session.commit() + return _to_out(ann) + + +@router.delete("/{table_annotation_uuid}") +async def delete_annotation( + table_annotation_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> dict[str, bool]: + """Delete a table annotation (requires editor membership on its project).""" + ann = await _get_authorized_annotation( + session, table_annotation_uuid, user, minimum_role="editor" + ) + if ann is None: + raise HTTPException(status_code=404, detail="annotation not found") + await session.delete(ann) + await session.commit() + return {"ok": True} diff --git a/backend/app/api/connections.py b/backend/app/api/connections.py index 5689ce62..6ef46654 100644 --- a/backend/app/api/connections.py +++ b/backend/app/api/connections.py @@ -3,16 +3,17 @@ import datetime as dt import uuid -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth import CurrentUser, get_current_user from app.db import get_read_session, get_session +from app.db_introspect import probe_database from app.models import DbConnection from app.permissions import require_project_member -from app.schemas import ConnectionCreateIn, ConnectionOut -from app.security import encrypt_text +from app.schemas import ConnectionCreateIn, ConnectionOut, ConnectionTestOut +from app.security import decrypt_text, encrypt_text from app.sanitize import sanitize_for_storage router = APIRouter(prefix="/api/connections", tags=["connections"]) @@ -62,3 +63,44 @@ async def create_connection( session.add(c) await session.commit() return ConnectionOut(db_connection_uuid=c.db_connection_uuid, conn_name=c.conn_name) + + +@router.post("/{db_connection_uuid}/test", response_model=ConnectionTestOut) +async def test_connection( + db_connection_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> ConnectionTestOut: + """Probe a stored connection's DSN and report reachability. + + IDOR-safe: the connection's project is resolved first and membership is + required, with a uniform 404 for missing/unauthorized. The DSN is decrypted + only in memory; the live probe reuses the introspectors' SSRF guard, and any + failure message is DSN-redacted (``ok=false`` rather than an error status, + since an unreachable database is a normal result). + """ + project_space_uuid = await session.scalar( + select(DbConnection.project_space_uuid).where( + DbConnection.db_connection_uuid == db_connection_uuid + ) + ) + if project_space_uuid is None: + raise HTTPException(status_code=404, detail="connection not found") + try: + await require_project_member( + session, project_space_uuid, user.user_account_uuid + ) + except HTTPException as exc: + if exc.status_code == 403: + raise HTTPException(status_code=404, detail="connection not found") + raise + + conn = await session.get(DbConnection, db_connection_uuid) + if conn is None: + raise HTTPException(status_code=404, detail="connection not found") + dsn = decrypt_text(conn.dsn_ciphertext, conn.dsn_nonce) + try: + version = await probe_database(dsn) + return ConnectionTestOut(ok=True, server_version=version, error=None) + except Exception as exc: # noqa: BLE001 - message is already DSN-redacted + return ConnectionTestOut(ok=False, server_version=None, error=str(exc)) diff --git a/backend/app/api/diagram_views.py b/backend/app/api/diagram_views.py new file mode 100644 index 00000000..f57782bc --- /dev/null +++ b/backend/app/api/diagram_views.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import datetime as dt +import json +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import CurrentUser, get_current_user +from app.db import get_read_session, get_session +from app.models import DiagramView +from app.permissions import require_project_member +from app.schemas import ( + DiagramViewCreateIn, + DiagramViewDetailOut, + DiagramViewOut, +) + +router = APIRouter(prefix="/api/diagram-views", tags=["diagram-views"]) + +# Saved layouts are small (positions for a few hundred tables). Bound the +# serialized payload so a client cannot store arbitrarily large blobs. +MAX_LAYOUT_BYTES = 512 * 1024 + + +def _bound_layout_size(layout: dict) -> None: + encoded = json.dumps(layout, separators=(",", ":")).encode("utf-8") + if len(encoded) > MAX_LAYOUT_BYTES: + raise HTTPException(status_code=413, detail="layout payload too large") + + +async def _get_authorized_view( + session: AsyncSession, + diagram_view_uuid: uuid.UUID, + user: CurrentUser, + minimum_role: str | None = None, +) -> DiagramView | None: + """Fetch a view only after project membership has been checked. + + Returns ``None`` for both missing and unauthorized so callers can respond + with a uniform 404 (no existence enumeration). + """ + + project_space_uuid = await session.scalar( + select(DiagramView.project_space_uuid).where( + DiagramView.diagram_view_uuid == diagram_view_uuid + ) + ) + if project_space_uuid is None: + return None + try: + await require_project_member( + session, + project_space_uuid, + user.user_account_uuid, + minimum_role=minimum_role, + ) + except HTTPException as exc: + if exc.status_code == 403: + return None + raise + return await session.get(DiagramView, diagram_view_uuid) + + +@router.post("/by-project/{project_space_uuid}", response_model=DiagramViewOut) +async def create_view( + project_space_uuid: uuid.UUID, + body: DiagramViewCreateIn, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> DiagramViewOut: + """Save a new ERD canvas view for a project.""" + await require_project_member( + session, project_space_uuid, user.user_account_uuid, minimum_role="editor" + ) + _bound_layout_size(body.layout_json) + now = dt.datetime.now(dt.timezone.utc) + view = DiagramView( + diagram_view_uuid=uuid.uuid4(), + project_space_uuid=project_space_uuid, + name=body.name, + layout_json=body.layout_json, + created_by=user.user_account_uuid, + created_at=now, + updated_at=now, + ) + session.add(view) + await session.commit() + return DiagramViewOut( + diagram_view_uuid=view.diagram_view_uuid, + name=view.name, + created_at=view.created_at, + updated_at=view.updated_at, + ) + + +@router.get("/by-project/{project_space_uuid}", response_model=list[DiagramViewOut]) +async def list_views( + project_space_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> list[DiagramViewOut]: + """List saved views for a project (newest first).""" + await require_project_member(session, project_space_uuid, user.user_account_uuid) + rows = await session.execute( + select(DiagramView) + .where(DiagramView.project_space_uuid == project_space_uuid) + .order_by(DiagramView.updated_at.desc()) + ) + return [ + DiagramViewOut( + diagram_view_uuid=v.diagram_view_uuid, + name=v.name, + created_at=v.created_at, + updated_at=v.updated_at, + ) + for v in rows.scalars().all() + ] + + +@router.get("/{diagram_view_uuid}", response_model=DiagramViewDetailOut) +async def get_view( + diagram_view_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> DiagramViewDetailOut: + """Get one saved view including its layout payload.""" + view = await _get_authorized_view(session, diagram_view_uuid, user) + if view is None: + raise HTTPException(status_code=404, detail="diagram view not found") + return DiagramViewDetailOut( + diagram_view_uuid=view.diagram_view_uuid, + name=view.name, + layout_json=view.layout_json, + created_at=view.created_at, + updated_at=view.updated_at, + ) + + +@router.delete("/{diagram_view_uuid}") +async def delete_view( + diagram_view_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> dict[str, bool]: + """Delete a saved view (requires editor membership on its project).""" + view = await _get_authorized_view( + session, diagram_view_uuid, user, minimum_role="editor" + ) + if view is None: + raise HTTPException(status_code=404, detail="diagram view not found") + await session.delete(view) + await session.commit() + return {"ok": True} diff --git a/backend/app/api/snapshots.py b/backend/app/api/snapshots.py index 79f2515f..e20467e2 100644 --- a/backend/app/api/snapshots.py +++ b/backend/app/api/snapshots.py @@ -17,8 +17,15 @@ SchemaSnapshotData, ) from app.permissions import require_project_member -from app.schemas import SnapshotCreateIn, SnapshotDetailOut, SnapshotOut +from app.schemas import ( + SnapshotCreateIn, + SnapshotDetailOut, + SnapshotDiffOut, + SnapshotOut, +) from app.ddl.export import snapshot_json_to_sql +from app.ddl.migration import snapshot_diff_to_migration_sql +from app.diff.schema_diff import diff_snapshots from app.jobs.valkey_queue import enqueue_job_signal from app.spec.llm import ( LlmConfigurationError, @@ -144,6 +151,45 @@ async def get_snapshot( ) +@router.get("/{schema_snapshot_uuid}/diff", response_model=SnapshotDiffOut) +async def diff_snapshot( + schema_snapshot_uuid: uuid.UUID, + against: uuid.UUID = Query( + ..., description="Base snapshot UUID to compare this snapshot against" + ), + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> SnapshotDiffOut: + """Diff this snapshot (target) against another (base). + + Both snapshots are authorized independently via project membership, so a + caller can only diff snapshots they may already read. If either is missing + or unauthorized, a uniform ``not_found`` response is returned so existence + cannot be enumerated. + """ + target_snap = await _get_authorized_snapshot(session, schema_snapshot_uuid, user) + base_snap = await _get_authorized_snapshot(session, against, user) + if target_snap is None or base_snap is None: + return SnapshotDiffOut( + base_snapshot_uuid=against, + target_snapshot_uuid=schema_snapshot_uuid, + status="not_found", + diff=None, + ) + base_data = await session.get(SchemaSnapshotData, against) + target_data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) + diff = diff_snapshots( + base_data.snapshot_json if base_data else None, + target_data.snapshot_json if target_data else None, + ) + return SnapshotDiffOut( + base_snapshot_uuid=against, + target_snapshot_uuid=schema_snapshot_uuid, + status="ok", + diff=diff, + ) + + @router.get("/{schema_snapshot_uuid}/export.sql", response_class=PlainTextResponse) async def export_snapshot_sql( schema_snapshot_uuid: uuid.UUID, @@ -161,6 +207,35 @@ async def export_snapshot_sql( return snapshot_json_to_sql(data.snapshot_json, target_dialect=dialect) +@router.get("/{schema_snapshot_uuid}/migration.sql", response_class=PlainTextResponse) +async def export_migration_sql( + schema_snapshot_uuid: uuid.UUID, + against: uuid.UUID = Query( + ..., description="Base snapshot UUID to migrate from" + ), + dialect: str = Query("postgresql", pattern="^(postgresql|snowflake)$"), + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> str: + """Generate migration SQL moving the base snapshot to this (target) snapshot. + + Both snapshots are authorized independently via project membership; a uniform + not-found marker is returned if either is missing/unauthorized so existence + cannot be enumerated. + """ + target_snap = await _get_authorized_snapshot(session, schema_snapshot_uuid, user) + base_snap = await _get_authorized_snapshot(session, against, user) + if target_snap is None or base_snap is None: + return "-- snapshot not found\n" + base_data = await session.get(SchemaSnapshotData, against) + target_data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) + return snapshot_diff_to_migration_sql( + base_data.snapshot_json if base_data else None, + target_data.snapshot_json if target_data else None, + target_dialect=dialect, + ) + + @router.get( "/{schema_snapshot_uuid}/reversing-spec.md", response_class=PlainTextResponse, diff --git a/backend/app/db_introspect.py b/backend/app/db_introspect.py index 934a5cb5..6b41776e 100644 --- a/backend/app/db_introspect.py +++ b/backend/app/db_introspect.py @@ -4,8 +4,9 @@ from urllib.parse import urlparse from app.dsn_redaction import redact_dsn_error_message -from app.pg_introspect.introspect import introspect_postgres +from app.pg_introspect.introspect import introspect_postgres, probe_postgres from app.snowflake_introspect import introspect_snowflake +from app.snowflake_introspect.introspect import probe_snowflake DatabaseDialect = Literal["postgresql", "snowflake"] @@ -32,3 +33,20 @@ async def introspect_database(dsn: str, schema_filter: str | None) -> dict: except Exception as exc: message = str(exc) or type(exc).__name__ raise RuntimeError(redact_dsn_error_message(message, dsn)) from None + + +async def probe_database(dsn: str) -> str: + """Lightweight connectivity probe; returns the server version string. + + Reuses the dialect introspectors' SSRF-guarded connection setup. Errors are + DSN-redacted so credentials never surface in an API response. + """ + + try: + dialect = detect_dsn_dialect(dsn) + if dialect == "snowflake": + return await probe_snowflake(dsn) + return await probe_postgres(dsn) + except Exception as exc: + message = str(exc) or type(exc).__name__ + raise RuntimeError(redact_dsn_error_message(message, dsn)) from None diff --git a/backend/app/ddl/migration.py b/backend/app/ddl/migration.py new file mode 100644 index 00000000..b936b72a --- /dev/null +++ b/backend/app/ddl/migration.py @@ -0,0 +1,201 @@ +"""Generate migration SQL that transforms one schema snapshot into another. + +This bridges two existing capabilities -- the name-based structural diff +(``app.diff.schema_diff``) and the dialect-aware DDL rendering +(``app.ddl.export``) -- into an actionable output: the ``CREATE``/``ALTER``/ +``DROP`` statements needed to move a database from the *base* snapshot to the +*target* snapshot. + +Design notes +------------ +* Tables/columns/FKs are matched **by name**, never by ``relation_oid`` (which + is reassigned on every introspection run) -- the same correctness rule the + diff enforces. +* PostgreSQL is the reference dialect and is emitted precisely; Snowflake output + maps column types via the DDL exporter and uses Snowflake ALTER syntax. +* The output is advisory: destructive changes (DROP TABLE/COLUMN) are emitted so + a human reviews them before running. Primary-key changes are noted as comments + because they usually require data-aware handling. +""" + +from __future__ import annotations + +from typing import Any + +from app.ddl.export import ( + DdlDialect, + _mapped_data_type, + _normalize_dialect, + _q, + _qname, + _snapshot_source_dialect, +) +from app.diff.schema_diff import _index_snapshot + + +def _col_type(column_name: str, col: dict[str, Any], source: DdlDialect, target: DdlDialect) -> str: + return _mapped_data_type( + {"data_type": col.get("data_type"), "column_name": column_name}, source, target + ) + + +def _column_clause(name: str, col: dict[str, Any], source: DdlDialect, target: DdlDialect) -> str: + clause = f"{_q(name)} {_col_type(name, col, source, target)}" + if col.get("is_not_null"): + clause += " NOT NULL" + return clause + + +def _create_table_sql(tbl: dict[str, Any], source: DdlDialect, target: DdlDialect) -> str: + qname = _qname(tbl["schema_name"], tbl["relation_name"]) + lines = [ + " " + _column_clause(name, col, source, target) + for name, col in tbl["columns"].items() + ] + if tbl.get("pk"): + cols = ", ".join(_q(c) for c in tbl["pk"]) + lines.append(f" PRIMARY KEY ({cols})") + body = ",\n".join(lines) + return f"CREATE TABLE {qname} (\n{body}\n);" + + +def _alter_column_type_sql(qname: str, name: str, type_sql: str, target: DdlDialect) -> str: + if target == "snowflake": + return f"ALTER TABLE {qname} ALTER COLUMN {_q(name)} SET DATA TYPE {type_sql};" + return f"ALTER TABLE {qname} ALTER COLUMN {_q(name)} TYPE {type_sql};" + + +def _alter_table_sql( + base_tbl: dict[str, Any], + target_tbl: dict[str, Any], + source: DdlDialect, + target: DdlDialect, +) -> list[str]: + qname = _qname(target_tbl["schema_name"], target_tbl["relation_name"]) + stmts: list[str] = [] + base_cols = base_tbl["columns"] + target_cols = target_tbl["columns"] + + for name, col in target_cols.items(): + if name not in base_cols: + stmts.append( + f"ALTER TABLE {qname} ADD COLUMN {_column_clause(name, col, source, target)};" + ) + for name in base_cols: + if name not in target_cols: + stmts.append(f"ALTER TABLE {qname} DROP COLUMN {_q(name)};") + for name, col in target_cols.items(): + if name not in base_cols: + continue + old = base_cols[name] + old_type = _col_type(name, old, source, target) + new_type = _col_type(name, col, source, target) + if old_type != new_type: + stmts.append(_alter_column_type_sql(qname, name, new_type, target)) + if bool(old.get("is_not_null")) != bool(col.get("is_not_null")): + action = "SET NOT NULL" if col.get("is_not_null") else "DROP NOT NULL" + stmts.append(f"ALTER TABLE {qname} ALTER COLUMN {_q(name)} {action};") + + if base_tbl.get("pk", []) != target_tbl.get("pk", []): + cols = ", ".join(target_tbl.get("pk", [])) + stmts.append( + f"-- PRIMARY KEY of {qname} changed to ({cols}); review before applying." + ) + if base_tbl.get("comment") != target_tbl.get("comment") and target == "postgresql": + comment = target_tbl.get("comment") + if comment: + escaped = str(comment).replace("'", "''") + stmts.append(f"COMMENT ON TABLE {qname} IS '{escaped}';") + else: + stmts.append(f"COMMENT ON TABLE {qname} IS NULL;") + return stmts + + +def _fk_endpoints(fk: dict[str, Any], tables: dict[str, Any]) -> tuple[str, str] | None: + child = tables.get(fk["child_table"]) + parent = tables.get(fk["parent_table"]) + if child is None or parent is None: + return None + return ( + _qname(child["schema_name"], child["relation_name"]), + _qname(parent["schema_name"], parent["relation_name"]), + ) + + +def _add_fk_sql(fk: dict[str, Any], tables: dict[str, Any]) -> str | None: + ends = _fk_endpoints(fk, tables) + if ends is None: + return None + child_q, parent_q = ends + child_cols = ", ".join(_q(c) for c in fk["child_columns"]) + parent_cols = ", ".join(_q(c) for c in fk["parent_columns"]) + name = fk.get("name") + constraint = f"CONSTRAINT {_q(name)} " if name else "" + return ( + f"ALTER TABLE {child_q} ADD {constraint}FOREIGN KEY ({child_cols}) " + f"REFERENCES {parent_q} ({parent_cols});" + ) + + +def _drop_fk_sql(fk: dict[str, Any], tables: dict[str, Any]) -> str | None: + ends = _fk_endpoints(fk, tables) + if ends is None: + return None + child_q, _ = ends + name = fk.get("name") + if name: + return f"ALTER TABLE {child_q} DROP CONSTRAINT {_q(name)};" + child_cols = ", ".join(_q(c) for c in fk["child_columns"]) + return ( + f"-- FOREIGN KEY on {child_q} ({child_cols}) was removed but has no " + "constraint name; drop it manually." + ) + + +def snapshot_diff_to_migration_sql( + base: dict[str, Any] | None, + target: dict[str, Any] | None, + target_dialect: str = "postgresql", +) -> str: + """Return SQL that migrates the *base* schema to the *target* schema. + + Matching is by name (oid-independent). Returns a ``-- No schema changes.`` + marker when the two snapshots are structurally identical. + """ + dialect = _normalize_dialect(target_dialect) + source = _snapshot_source_dialect(target or {}) + b = _index_snapshot(base) + t = _index_snapshot(target) + b_tables, t_tables = b["tables"], t["tables"] + + stmts: list[str] = [] + + # Drop removed tables first (frees names/constraints), then create/alter. + for key, tbl in b_tables.items(): + if key not in t_tables: + stmts.append( + f"DROP TABLE IF EXISTS {_qname(tbl['schema_name'], tbl['relation_name'])};" + ) + for key, tbl in t_tables.items(): + if key not in b_tables: + stmts.append(_create_table_sql(tbl, source, dialect)) + for key, tbl in t_tables.items(): + if key in b_tables: + stmts.extend(_alter_table_sql(b_tables[key], tbl, source, dialect)) + + base_fks, target_fks = b["fks"], t["fks"] + for sig, fk in base_fks.items(): + if sig not in target_fks: + sql = _drop_fk_sql(fk, b_tables) + if sql: + stmts.append(sql) + for sig, fk in target_fks.items(): + if sig not in base_fks: + sql = _add_fk_sql(fk, t_tables) + if sql: + stmts.append(sql) + + if not stmts: + return "-- No schema changes.\n" + header = f"-- Migration ({dialect}): base -> target\n" + return header + "\n".join(stmts) + "\n" diff --git a/backend/app/diff/__init__.py b/backend/app/diff/__init__.py new file mode 100644 index 00000000..793ebd10 --- /dev/null +++ b/backend/app/diff/__init__.py @@ -0,0 +1 @@ +"""Schema snapshot diffing (pure, DB-independent transforms).""" diff --git a/backend/app/diff/schema_diff.py b/backend/app/diff/schema_diff.py new file mode 100644 index 00000000..b187ef02 --- /dev/null +++ b/backend/app/diff/schema_diff.py @@ -0,0 +1,209 @@ +"""Diff two schema snapshots into a structured change set. + +Snapshots are the JSON payloads captured by reverse-engineering a database +(see ``SchemaSnapshotData.snapshot_json``): ``relations``, ``columns``, +``pk_columns`` and ``fk_edges``, all keyed internally by ``relation_oid``. + +IMPORTANT: ``relation_oid`` (and ``fk_constraint_oid``) are database-internal +identifiers that are re-assigned on every introspection run. Two snapshots of +the *same* database therefore have *different* oids for the *same* table, so we +must never diff by oid. Instead we key tables by ``(schema_name, relation_name)`` +and columns by name, and we resolve every FK's oids to table names *within its +own snapshot* before comparing. Diffing by oid would report every table as +changed on each run — a silent, high-impact bug. +""" + +from __future__ import annotations + +from typing import Any + + +def _table_key(schema_name: object, relation_name: object) -> str: + schema = str(schema_name) if schema_name is not None else "" + name = str(relation_name) if relation_name is not None else "" + return f"{schema}.{name}" if schema else name + + +def _index_snapshot(snapshot: dict[str, Any] | None) -> dict[str, Any]: + """Build name-keyed lookups for one snapshot (oid-independent).""" + + snapshot = snapshot or {} + relations = snapshot.get("relations") or [] + columns = snapshot.get("columns") or [] + pk_columns = snapshot.get("pk_columns") or [] + fk_edges = snapshot.get("fk_edges") or [] + + oid_to_table: dict[Any, str] = {} + tables: dict[str, dict[str, Any]] = {} + for rel in relations: + key = _table_key(rel.get("schema_name"), rel.get("relation_name")) + oid_to_table[rel.get("relation_oid")] = key + tables[key] = { + "schema_name": str(rel.get("schema_name") or ""), + "relation_name": str(rel.get("relation_name") or ""), + "comment": rel.get("relation_comment"), + "kind": rel.get("relation_kind"), + "columns": {}, + "pk": [], + } + + for col in columns: + table = oid_to_table.get(col.get("relation_oid")) + if table is None or table not in tables: + continue + name = col.get("column_name") + if name is None: + continue + tables[table]["columns"][str(name)] = { + "data_type": col.get("data_type"), + "is_not_null": bool(col.get("is_not_null")), + } + + # Preserve primary-key column order via column_ordinal when present. + pk_tmp: dict[str, list[tuple[int, str]]] = {} + for pk in pk_columns: + table = oid_to_table.get(pk.get("relation_oid")) + if table is None or table not in tables: + continue + name = pk.get("column_name") + if name is None: + continue + ordinal = pk.get("column_ordinal") + ordinal = int(ordinal) if isinstance(ordinal, int) else len(pk_tmp.get(table, [])) + pk_tmp.setdefault(table, []).append((ordinal, str(name))) + for table, items in pk_tmp.items(): + items.sort(key=lambda pair: pair[0]) + tables[table]["pk"] = [name for _, name in items] + + # Resolve FK oids to (child_table, child_col -> parent_table, parent_col). + # Group multi-column FKs by constraint via a stable resolved signature. + fk_parts: dict[tuple[str, str, str], list[tuple[int, str, str]]] = {} + for edge in fk_edges: + child = oid_to_table.get(edge.get("child_relation_oid")) + parent = oid_to_table.get(edge.get("parent_relation_oid")) + if child is None or parent is None: + continue + name = str(edge.get("fk_constraint_name") or "") + ordinal = edge.get("column_ordinal") + ordinal = int(ordinal) if isinstance(ordinal, int) else 0 + fk_parts.setdefault((child, parent, name), []).append( + ( + ordinal, + str(edge.get("child_column_name") or ""), + str(edge.get("parent_column_name") or ""), + ) + ) + + fks: dict[str, dict[str, Any]] = {} + for (child, parent, name), parts in fk_parts.items(): + parts.sort(key=lambda p: p[0]) + child_cols = [c for _, c, _ in parts] + parent_cols = [p for _, _, p in parts] + # Signature is oid-independent: names + column pairing, not the FK name + # (constraint names may auto-generate differently between runs). + signature = ( + f"{child}({','.join(child_cols)})->" + f"{parent}({','.join(parent_cols)})" + ) + fks[signature] = { + "name": name or None, + "child_table": child, + "child_columns": child_cols, + "parent_table": parent, + "parent_columns": parent_cols, + } + + return {"tables": tables, "fks": fks} + + +def _diff_columns( + base_cols: dict[str, Any], target_cols: dict[str, Any] +) -> dict[str, Any]: + base_names = set(base_cols) + target_names = set(target_cols) + added = sorted(target_names - base_names) + removed = sorted(base_names - target_names) + changed: list[dict[str, Any]] = [] + for name in sorted(base_names & target_names): + before = base_cols[name] + after = target_cols[name] + if before != after: + changed.append({"column": name, "from": before, "to": after}) + return {"added": added, "removed": removed, "changed": changed} + + +def diff_snapshots( + base: dict[str, Any] | None, target: dict[str, Any] | None +) -> dict[str, Any]: + """Compute a structured diff from ``base`` to ``target`` snapshot JSON. + + Both arguments are snapshot ``snapshot_json`` payloads (or ``None``). The + result reports table/column/primary-key/foreign-key changes plus a summary. + All matching is by name, so it is stable across introspection runs. + """ + + b = _index_snapshot(base) + t = _index_snapshot(target) + + base_tables = set(b["tables"]) + target_tables = set(t["tables"]) + + tables_added = sorted(target_tables - base_tables) + tables_removed = sorted(base_tables - target_tables) + + changed_tables: list[dict[str, Any]] = [] + columns_added = columns_removed = columns_changed = 0 + for table in sorted(base_tables & target_tables): + bt = b["tables"][table] + tt = t["tables"][table] + col_diff = _diff_columns(bt["columns"], tt["columns"]) + pk_changed = bt["pk"] != tt["pk"] + comment_changed = bt["comment"] != tt["comment"] + if ( + col_diff["added"] + or col_diff["removed"] + or col_diff["changed"] + or pk_changed + or comment_changed + ): + columns_added += len(col_diff["added"]) + columns_removed += len(col_diff["removed"]) + columns_changed += len(col_diff["changed"]) + entry: dict[str, Any] = {"table": table, "columns": col_diff} + if pk_changed: + entry["primary_key"] = {"from": bt["pk"], "to": tt["pk"]} + if comment_changed: + entry["comment"] = {"from": bt["comment"], "to": tt["comment"]} + changed_tables.append(entry) + + base_fks = set(b["fks"]) + target_fks = set(t["fks"]) + fks_added = sorted(target_fks - base_fks) + fks_removed = sorted(base_fks - target_fks) + + summary = { + "tables_added": len(tables_added), + "tables_removed": len(tables_removed), + "tables_changed": len(changed_tables), + "columns_added": columns_added, + "columns_removed": columns_removed, + "columns_changed": columns_changed, + "fks_added": len(fks_added), + "fks_removed": len(fks_removed), + } + summary["has_changes"] = any(v for k, v in summary.items() if k != "has_changes") + + return { + "base_table_count": len(base_tables), + "target_table_count": len(target_tables), + "tables": { + "added": tables_added, + "removed": tables_removed, + "changed": changed_tables, + }, + "foreign_keys": { + "added": [t["fks"][s] for s in fks_added], + "removed": [b["fks"][s] for s in fks_removed], + }, + "summary": summary, + } diff --git a/backend/app/main.py b/backend/app/main.py index 41f802c7..c191880a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,8 +8,10 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.api.annotations import router as annotations_router from app.api.connections import router as connections_router from app.api.auth_routes import router as auth_router +from app.api.diagram_views import router as diagram_views_router from app.api.me import router as me_router from app.api.projects import router as projects_router from app.api.share import router as share_router @@ -165,6 +167,8 @@ async def csrf_token() -> dict[str, str]: app.include_router(projects_router) app.include_router(connections_router) app.include_router(snapshots_router) +app.include_router(diagram_views_router) +app.include_router(annotations_router) app.include_router(me_router) app.include_router(share_router) app.include_router(auth_router) diff --git a/backend/app/models.py b/backend/app/models.py index 2a772ac3..9c0fd845 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -3,7 +3,15 @@ import datetime as dt import uuid -from sqlalchemy import DateTime, ForeignKey, Index, Integer, LargeBinary, Text +from sqlalchemy import ( + DateTime, + ForeignKey, + Index, + Integer, + LargeBinary, + Text, + UniqueConstraint, +) from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -185,6 +193,75 @@ class JobQueue(Base): __table_args__ = (Index("ix_job_queue__status_run_after", "status", "run_after"),) +class DiagramView(Base): + """A saved ERD canvas view (node layout + hidden tables) for a project.""" + + __tablename__ = "diagram_view" + + diagram_view_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + project_space_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + index=True, + ) + name: Mapped[str] = mapped_column(Text()) + # Opaque, client-defined layout payload (node positions, hidden tables, + # viewport). Stored as JSONB; the API bounds its size. + layout_json: Mapped[dict] = mapped_column(JSONB()) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), nullable=True + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + updated_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow + ) + + +class TableAnnotation(Base): + """A user note attached to a table within a project. + + Tables are identified by ``(schema_name, relation_name)`` -- never by the + volatile ``relation_oid``, which is reassigned on every introspection run. + At most one annotation exists per (project, schema, table). + """ + + __tablename__ = "table_annotation" + + table_annotation_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + project_space_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + index=True, + ) + schema_name: Mapped[str] = mapped_column(Text()) + relation_name: Mapped[str] = mapped_column(Text()) + body: Mapped[str] = mapped_column(Text()) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), nullable=True + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + updated_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow + ) + + __table_args__ = ( + UniqueConstraint( + "project_space_uuid", + "schema_name", + "relation_name", + name="uq_table_annotation__project_table", + ), + ) + + class ShareLink(Base): """Public share link granting read access to a project's snapshots.""" diff --git a/backend/app/pg_introspect/introspect.py b/backend/app/pg_introspect/introspect.py index 4fb1ebc4..02d9cb5d 100644 --- a/backend/app/pg_introspect/introspect.py +++ b/backend/app/pg_introspect/introspect.py @@ -10,6 +10,29 @@ from app.sanitize import sanitize_for_storage +async def probe_postgres(dsn: str) -> str: + """SSRF-guarded connectivity check: connect and return the server version. + + Reuses ``validate_postgres_dsn_target`` and connects to the pinned IP, so + the same anti-SSRF guarantees as full introspection apply. + """ + target = await validate_postgres_dsn_target(dsn) + connect_host: str | list[str] = ( + target.hosts[0] if len(target.hosts) == 1 else list(target.hosts) + ) + if target.port is not None: + conn = await asyncpg.connect( + dsn, host=connect_host, port=target.port, timeout=10 + ) + else: + conn = await asyncpg.connect(dsn, host=connect_host, timeout=10) + try: + await conn.fetchval("SELECT 1") + return str(await conn.fetchval("SHOW server_version")) + finally: + await conn.close() + + async def introspect_postgres(dsn: str, schema_filter: str | None) -> dict: """Introspect a PostgreSQL database and return a snapshot JSON.""" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0359799c..9540c560 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime as dt import uuid from typing import Literal @@ -58,6 +59,14 @@ class ConnectionOut(BaseModel): conn_name: str +class ConnectionTestOut(BaseModel): + """Result of a connection health probe (DSN-redacted on failure).""" + + ok: bool + server_version: str | None = None + error: str | None = None + + class SnapshotCreateIn(BaseModel): """Request body for creating a schema snapshot.""" @@ -91,6 +100,63 @@ class SnapshotDetailOut(BaseModel): snapshot_json: dict | None +class SnapshotDiffOut(BaseModel): + """Structured diff between two schema snapshots. + + ``status`` is ``"not_found"`` when either snapshot is missing or the caller + is not authorized for it (uniform response avoids existence enumeration); + ``"ok"`` with a populated ``diff`` otherwise. + """ + + base_snapshot_uuid: uuid.UUID + target_snapshot_uuid: uuid.UUID + status: str + diff: dict | None + + +class DiagramViewCreateIn(BaseModel): + """Request body for saving an ERD canvas view.""" + + name: str = Field(min_length=1, max_length=200) + # Opaque client layout (node positions, hidden tables, viewport). The API + # bounds the serialized size in the endpoint to prevent abuse. + layout_json: dict + + +class DiagramViewOut(BaseModel): + """Diagram view summary.""" + + diagram_view_uuid: uuid.UUID + name: str + created_at: dt.datetime + updated_at: dt.datetime + + +class DiagramViewDetailOut(DiagramViewOut): + """Diagram view including its layout payload.""" + + layout_json: dict + + +class TableAnnotationUpsertIn(BaseModel): + """Request body for creating/updating a table annotation.""" + + schema_name: str = Field(min_length=1, max_length=255) + relation_name: str = Field(min_length=1, max_length=255) + body: str = Field(min_length=1, max_length=10_000) + + +class TableAnnotationOut(BaseModel): + """A table annotation.""" + + table_annotation_uuid: uuid.UUID + schema_name: str + relation_name: str + body: str + created_at: dt.datetime + updated_at: dt.datetime + + class MeOut(BaseModel): """Current user payload returned by /me.""" diff --git a/backend/app/snowflake_introspect/introspect.py b/backend/app/snowflake_introspect/introspect.py index dd0e3767..59ec20c7 100644 --- a/backend/app/snowflake_introspect/introspect.py +++ b/backend/app/snowflake_introspect/introspect.py @@ -702,6 +702,25 @@ def _introspect_snowflake_sync_with_config( ) +def _probe_snowflake_sync(config: SnowflakeDsnConfig) -> str: + conn = _connect(**config.connect_kwargs()) + cursor = conn.cursor() + try: + rows = _fetch_dicts(cursor, "SELECT CURRENT_VERSION() AS version") + return str(rows[0].get("version") or "") if rows else "" + finally: + try: + cursor.close() + finally: + conn.close() + + +async def probe_snowflake(dsn: str) -> str: + """SSRF-guarded connectivity check for Snowflake; returns the server version.""" + config = await _parse_snowflake_dsn(dsn) + return await asyncio.to_thread(_probe_snowflake_sync, config) + + async def introspect_snowflake(dsn: str, schema_filter: str | None) -> dict: """Introspect Snowflake metadata into the common snapshot JSON shape.""" diff --git a/backend/tests/test_api_annotations.py b/backend/tests/test_api_annotations.py new file mode 100644 index 00000000..2987a2a7 --- /dev/null +++ b/backend/tests/test_api_annotations.py @@ -0,0 +1,119 @@ +import datetime as dt +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from app.api.annotations import ( + delete_annotation, + list_annotations, + upsert_annotation, +) +from app.auth import CurrentUser +from app.schemas import TableAnnotationUpsertIn + + +def _user(): + return CurrentUser( + user_account_uuid=uuid.uuid4(), subject="test", display_name="Test" + ) + + +@pytest.mark.asyncio +async def test_delete_annotation_returns_404_when_missing_or_unauthorized(): + # Uniform 404 for both missing and unauthorized (no enumeration / IDOR). + session = AsyncMock() + with patch( + "app.api.annotations._get_authorized_annotation", + new_callable=AsyncMock, + return_value=None, + ): + with pytest.raises(HTTPException) as exc: + await delete_annotation( + table_annotation_uuid=uuid.uuid4(), user=_user(), session=session + ) + assert exc.value.status_code == 404 + session.delete.assert_not_called() + + +@pytest.mark.asyncio +async def test_upsert_creates_new_annotation_when_absent(): + session = AsyncMock() + session.scalar = AsyncMock(return_value=None) # no existing row + body = TableAnnotationUpsertIn( + schema_name="public", relation_name="orders", body="핵심 주문 테이블" + ) + with patch( + "app.api.annotations.require_project_member", new_callable=AsyncMock + ): + out = await upsert_annotation( + project_space_uuid=uuid.uuid4(), + body=body, + user=_user(), + session=session, + ) + assert out.schema_name == "public" + assert out.relation_name == "orders" + assert out.body == "핵심 주문 테이블" + session.add.assert_called_once() + session.commit.assert_awaited() + + +@pytest.mark.asyncio +async def test_upsert_updates_existing_annotation_without_insert(): + session = AsyncMock() + now = dt.datetime.now(dt.timezone.utc) + existing = SimpleNamespace( + table_annotation_uuid=uuid.uuid4(), + schema_name="public", + relation_name="orders", + body="old", + created_at=now, + updated_at=now, + ) + session.scalar = AsyncMock(return_value=existing) + body = TableAnnotationUpsertIn( + schema_name="public", relation_name="orders", body="new note" + ) + with patch( + "app.api.annotations.require_project_member", new_callable=AsyncMock + ): + out = await upsert_annotation( + project_space_uuid=uuid.uuid4(), + body=body, + user=_user(), + session=session, + ) + assert out.body == "new note" + assert existing.body == "new note" # updated in place, not re-inserted + session.add.assert_not_called() + session.commit.assert_awaited() + + +@pytest.mark.asyncio +async def test_list_annotations_returns_project_notes(): + session = AsyncMock() + now = dt.datetime.now(dt.timezone.utc) + ann = SimpleNamespace( + table_annotation_uuid=uuid.uuid4(), + schema_name="public", + relation_name="member", + body="회원 마스터", + created_at=now, + updated_at=now, + ) + result = SimpleNamespace( + scalars=lambda: SimpleNamespace(all=lambda: [ann]) + ) + session.execute = AsyncMock(return_value=result) + with patch( + "app.api.annotations.require_project_member", new_callable=AsyncMock + ): + out = await list_annotations( + project_space_uuid=uuid.uuid4(), user=_user(), session=session + ) + assert len(out) == 1 + assert out[0].relation_name == "member" + assert out[0].body == "회원 마스터" diff --git a/backend/tests/test_api_connection_test.py b/backend/tests/test_api_connection_test.py new file mode 100644 index 00000000..5c2104e9 --- /dev/null +++ b/backend/tests/test_api_connection_test.py @@ -0,0 +1,120 @@ +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from app.api.connections import test_connection as run_connection_test +from app.auth import CurrentUser +from app.db_introspect import probe_database + + +def _user(): + return CurrentUser( + user_account_uuid=uuid.uuid4(), subject="t", display_name="T" + ) + + +def _conn(): + return SimpleNamespace(dsn_ciphertext=b"ciphertext", dsn_nonce=b"nonce") + + +@pytest.mark.asyncio +async def test_test_connection_returns_404_when_missing(): + session = AsyncMock() + session.scalar = AsyncMock(return_value=None) # no such connection + with pytest.raises(HTTPException) as exc: + await run_connection_test( + db_connection_uuid=uuid.uuid4(), user=_user(), session=session + ) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_test_connection_masks_forbidden_as_404(): + # A non-member must not learn the connection exists (IDOR). + session = AsyncMock() + session.scalar = AsyncMock(return_value=uuid.uuid4()) + with patch( + "app.api.connections.require_project_member", + new_callable=AsyncMock, + side_effect=HTTPException(status_code=403, detail="denied"), + ): + with pytest.raises(HTTPException) as exc: + await run_connection_test( + db_connection_uuid=uuid.uuid4(), user=_user(), session=session + ) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_test_connection_reports_ok_true_on_success(): + session = AsyncMock() + session.scalar = AsyncMock(return_value=uuid.uuid4()) + session.get = AsyncMock(return_value=_conn()) + with patch( + "app.api.connections.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.connections.decrypt_text", + return_value="postgresql://u@db.example.com/app", + ), patch( + "app.api.connections.probe_database", + new_callable=AsyncMock, + return_value="16.2", + ): + out = await run_connection_test( + db_connection_uuid=uuid.uuid4(), user=_user(), session=session + ) + assert out.ok is True + assert out.server_version == "16.2" + assert out.error is None + + +@pytest.mark.asyncio +async def test_test_connection_reports_ok_false_on_probe_failure(): + session = AsyncMock() + session.scalar = AsyncMock(return_value=uuid.uuid4()) + session.get = AsyncMock(return_value=_conn()) + with patch( + "app.api.connections.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.connections.decrypt_text", + return_value="postgresql://u@db.example.com/app", + ), patch( + "app.api.connections.probe_database", + new_callable=AsyncMock, + side_effect=RuntimeError( + "database host is not in the introspection allowlist" + ), + ): + out = await run_connection_test( + db_connection_uuid=uuid.uuid4(), user=_user(), session=session + ) + assert out.ok is False + assert out.server_version is None + assert "allowlist" in (out.error or "") + + +@pytest.mark.asyncio +async def test_probe_database_routes_to_postgres(): + with patch( + "app.db_introspect.probe_postgres", + new_callable=AsyncMock, + return_value="16.0", + ): + assert await probe_database("postgresql://u@db.example.com/app") == "16.0" + + +@pytest.mark.asyncio +async def test_probe_database_wraps_and_redacts_errors(): + dsn = "postgresql://user:s3cret@db.example.com/app" + with patch( + "app.db_introspect.probe_postgres", + new_callable=AsyncMock, + side_effect=OSError(f"could not connect to {dsn}"), + ): + with pytest.raises(RuntimeError) as exc: + await probe_database(dsn) + # Credentials must never surface in the wrapped error. + assert "s3cret" not in str(exc.value) diff --git a/backend/tests/test_api_diagram_views.py b/backend/tests/test_api_diagram_views.py new file mode 100644 index 00000000..44d29087 --- /dev/null +++ b/backend/tests/test_api_diagram_views.py @@ -0,0 +1,95 @@ +import datetime as dt +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from app.api.diagram_views import create_view, delete_view, get_view +from app.auth import CurrentUser +from app.schemas import DiagramViewCreateIn + + +def _user(): + return CurrentUser( + user_account_uuid=uuid.uuid4(), subject="test", display_name="Test" + ) + + +@pytest.mark.asyncio +async def test_get_view_returns_404_when_missing_or_unauthorized(): + # Uniform 404 for both missing and unauthorized (no enumeration). + session = AsyncMock() + with patch( + "app.api.diagram_views._get_authorized_view", + new_callable=AsyncMock, + return_value=None, + ): + with pytest.raises(HTTPException) as exc: + await get_view( + diagram_view_uuid=uuid.uuid4(), user=_user(), session=session + ) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_view_returns_detail_when_authorized(): + session = AsyncMock() + now = dt.datetime.now(dt.timezone.utc) + view_id = uuid.uuid4() + view = SimpleNamespace( + diagram_view_uuid=view_id, + name="my view", + layout_json={"positions": {"public.member": {"x": 10, "y": 20}}}, + created_at=now, + updated_at=now, + ) + with patch( + "app.api.diagram_views._get_authorized_view", + new_callable=AsyncMock, + return_value=view, + ): + out = await get_view( + diagram_view_uuid=view_id, user=_user(), session=session + ) + assert out.diagram_view_uuid == view_id + assert out.name == "my view" + assert out.layout_json["positions"]["public.member"] == {"x": 10, "y": 20} + + +@pytest.mark.asyncio +async def test_create_view_rejects_oversized_layout(): + session = AsyncMock() + huge = {"blob": "a" * (600 * 1024)} # > 512KB serialized + body = DiagramViewCreateIn(name="big", layout_json=huge) + with patch( + "app.api.diagram_views.require_project_member", new_callable=AsyncMock + ): + with pytest.raises(HTTPException) as exc: + await create_view( + project_space_uuid=uuid.uuid4(), + body=body, + user=_user(), + session=session, + ) + assert exc.value.status_code == 413 + # Nothing should have been persisted. + session.add.assert_not_called() + session.commit.assert_not_called() + + +@pytest.mark.asyncio +async def test_delete_view_returns_404_when_unauthorized(): + session = AsyncMock() + with patch( + "app.api.diagram_views._get_authorized_view", + new_callable=AsyncMock, + return_value=None, + ): + with pytest.raises(HTTPException) as exc: + await delete_view( + diagram_view_uuid=uuid.uuid4(), user=_user(), session=session + ) + assert exc.value.status_code == 404 + session.delete.assert_not_called() diff --git a/backend/tests/test_api_snapshot_diff.py b/backend/tests/test_api_snapshot_diff.py new file mode 100644 index 00000000..0c588cff --- /dev/null +++ b/backend/tests/test_api_snapshot_diff.py @@ -0,0 +1,104 @@ +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.api.snapshots import diff_snapshot +from app.auth import CurrentUser + + +def _user(): + return CurrentUser( + user_account_uuid=uuid.uuid4(), subject="test", display_name="Test" + ) + + +@pytest.mark.asyncio +async def test_diff_returns_not_found_when_a_snapshot_is_unauthorized(): + # If either snapshot is missing/unauthorized, respond uniformly (no + # existence enumeration) — mirrors get_snapshot's not_found contract. + session = AsyncMock() + target = uuid.uuid4() + base = uuid.uuid4() + + with patch( + "app.api.snapshots._get_authorized_snapshot", + new_callable=AsyncMock, + return_value=None, + ): + out = await diff_snapshot( + schema_snapshot_uuid=target, against=base, user=_user(), session=session + ) + + assert out.status == "not_found" + assert out.diff is None + assert out.base_snapshot_uuid == base + assert out.target_snapshot_uuid == target + # Must not read snapshot data for an unauthorized request. + session.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_diff_computes_changes_when_both_authorized(): + session = AsyncMock() + target = uuid.uuid4() + base = uuid.uuid4() + + base_json = { + "relations": [ + {"relation_oid": 1, "schema_name": "public", "relation_name": "member"} + ], + "columns": [ + { + "relation_oid": 1, + "column_name": "email", + "data_type": "varchar(100)", + "is_not_null": False, + } + ], + "pk_columns": [], + "fk_edges": [], + } + # Same table (different oid), email widened + NOT NULL, new table added. + target_json = { + "relations": [ + {"relation_oid": 50, "schema_name": "public", "relation_name": "member"}, + {"relation_oid": 51, "schema_name": "public", "relation_name": "orders"}, + ], + "columns": [ + { + "relation_oid": 50, + "column_name": "email", + "data_type": "varchar(255)", + "is_not_null": True, + }, + { + "relation_oid": 51, + "column_name": "order_id", + "data_type": "bigint", + "is_not_null": True, + }, + ], + "pk_columns": [], + "fk_edges": [], + } + + # Both snapshots authorized. + authorized = AsyncMock(return_value=SimpleNamespace(schema_snapshot_uuid=target)) + # session.get(SchemaSnapshotData, against) then (…, target). + session.get.side_effect = [ + SimpleNamespace(snapshot_json=base_json), + SimpleNamespace(snapshot_json=target_json), + ] + + with patch("app.api.snapshots._get_authorized_snapshot", authorized): + out = await diff_snapshot( + schema_snapshot_uuid=target, against=base, user=_user(), session=session + ) + + assert out.status == "ok" + assert out.diff is not None + assert out.diff["tables"]["added"] == ["public.orders"] + assert out.diff["summary"]["columns_changed"] == 1 + assert out.diff["summary"]["has_changes"] is True diff --git a/backend/tests/test_dsn_guard.py b/backend/tests/test_dsn_guard.py index c8d2b3ee..cfb7bc4a 100644 --- a/backend/tests/test_dsn_guard.py +++ b/backend/tests/test_dsn_guard.py @@ -274,3 +274,44 @@ def raise_gaierror(*args, **kwargs): with pytest.raises(DsnTargetError, match="database host could not be resolved"): await validate_postgres_dsn_target("postgresql://user:pass@db.example.com/app") + + +def test_unique_hosts_deduplicates_repeated_hosts() -> None: + assert _unique_hosts(["1.2.3.4", "1.2.3.4", "5.6.7.8"]) == ( + "1.2.3.4", + "5.6.7.8", + ) + + +@pytest.mark.asyncio +async def test_dsn_guard_rejects_non_integer_query_port( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + settings, "db_introspection_allowed_hosts", "db.example.com" + ) + with pytest.raises(DsnTargetError, match="query port is invalid"): + await validate_postgres_dsn_target( + "postgresql://user:pass@db.example.com/app?port=abc" + ) + + +@pytest.mark.asyncio +async def test_dsn_guard_rejects_host_resolving_to_no_usable_address( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # getaddrinfo yields an entry whose sockaddr is empty -> skipped -> no IPs left. + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *_args, **_kwargs: [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ())], + ) + monkeypatch.setattr( + settings, "db_introspection_allowed_hosts", "db.example.com" + ) + with pytest.raises( + DsnTargetError, match="did not resolve to an IP address" + ): + await validate_postgres_dsn_target( + "postgresql://user:pass@db.example.com/app" + ) diff --git a/backend/tests/test_migration.py b/backend/tests/test_migration.py new file mode 100644 index 00000000..18b4fb57 --- /dev/null +++ b/backend/tests/test_migration.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from app.ddl.migration import snapshot_diff_to_migration_sql + + +def _snap(relations, columns, pk_columns=None, fk_edges=None): + return { + "relations": relations, + "columns": columns, + "pk_columns": pk_columns or [], + "fk_edges": fk_edges or [], + } + + +def _member_table(oid=1): + return _snap( + relations=[ + {"relation_oid": oid, "schema_name": "public", "relation_name": "member"} + ], + columns=[ + {"relation_oid": oid, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": oid, "column_name": "email", "data_type": "varchar(100)", "is_not_null": False}, + ], + pk_columns=[{"relation_oid": oid, "column_name": "member_id", "column_ordinal": 1}], + ) + + +def test_identical_snapshots_report_no_changes_even_with_different_oids(): + sql = snapshot_diff_to_migration_sql(_member_table(1), _member_table(999)) + assert "No schema changes" in sql + + +def test_added_table_emits_create_table_with_pk(): + base = _snap(relations=[], columns=[]) + sql = snapshot_diff_to_migration_sql(base, _member_table()) + assert 'CREATE TABLE "public"."member"' in sql + assert '"member_id" bigint NOT NULL' in sql + assert '"email" varchar(100)' in sql + assert 'PRIMARY KEY ("member_id")' in sql + + +def test_dropped_table_emits_drop_table(): + base = _member_table() + target = _snap(relations=[], columns=[]) + sql = snapshot_diff_to_migration_sql(base, target) + assert 'DROP TABLE IF EXISTS "public"."member";' in sql + + +def test_added_and_dropped_columns(): + base = _member_table() + target = _snap( + relations=[{"relation_oid": 2, "schema_name": "public", "relation_name": "member"}], + columns=[ + {"relation_oid": 2, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + # email dropped, nickname added + {"relation_oid": 2, "column_name": "nickname", "data_type": "text", "is_not_null": False}, + ], + pk_columns=[{"relation_oid": 2, "column_name": "member_id", "column_ordinal": 1}], + ) + sql = snapshot_diff_to_migration_sql(base, target) + assert 'ALTER TABLE "public"."member" ADD COLUMN "nickname" text;' in sql + assert 'ALTER TABLE "public"."member" DROP COLUMN "email";' in sql + + +def test_column_type_and_nullability_changes(): + base = _member_table() + target = _snap( + relations=[{"relation_oid": 3, "schema_name": "public", "relation_name": "member"}], + columns=[ + {"relation_oid": 3, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + # email: varchar(100) -> text, and now NOT NULL + {"relation_oid": 3, "column_name": "email", "data_type": "text", "is_not_null": True}, + ], + pk_columns=[{"relation_oid": 3, "column_name": "member_id", "column_ordinal": 1}], + ) + sql = snapshot_diff_to_migration_sql(base, target) + assert 'ALTER TABLE "public"."member" ALTER COLUMN "email" TYPE text;' in sql + assert 'ALTER TABLE "public"."member" ALTER COLUMN "email" SET NOT NULL;' in sql + + +def test_foreign_key_add_and_drop(): + orders_rel = {"relation_oid": 2, "schema_name": "public", "relation_name": "orders"} + orders_cols = [ + {"relation_oid": 2, "column_name": "order_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 2, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + ] + fk = { + "fk_constraint_name": "fk_orders_member", + "child_relation_oid": 2, + "parent_relation_oid": 1, + "child_column_name": "member_id", + "parent_column_name": "member_id", + "column_ordinal": 1, + } + base = _snap( + relations=[ + {"relation_oid": 1, "schema_name": "public", "relation_name": "member"}, + orders_rel, + ], + columns=[ + {"relation_oid": 1, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + *orders_cols, + ], + ) + target = _snap( + relations=base["relations"], + columns=base["columns"], + fk_edges=[fk], + ) + add_sql = snapshot_diff_to_migration_sql(base, target) + assert ( + 'ALTER TABLE "public"."orders" ADD CONSTRAINT "fk_orders_member" ' + 'FOREIGN KEY ("member_id") REFERENCES "public"."member" ("member_id");' + in add_sql + ) + drop_sql = snapshot_diff_to_migration_sql(target, base) + assert 'ALTER TABLE "public"."orders" DROP CONSTRAINT "fk_orders_member";' in drop_sql + + +def test_snowflake_dialect_uses_set_data_type(): + base = _member_table() + target = _snap( + relations=[{"relation_oid": 5, "schema_name": "public", "relation_name": "member"}], + columns=[ + {"relation_oid": 5, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 5, "column_name": "email", "data_type": "text", "is_not_null": False}, + ], + pk_columns=[{"relation_oid": 5, "column_name": "member_id", "column_ordinal": 1}], + ) + sql = snapshot_diff_to_migration_sql(base, target, target_dialect="snowflake") + assert "SET DATA TYPE" in sql + assert "-- Migration (snowflake)" in sql diff --git a/backend/tests/test_sanitize.py b/backend/tests/test_sanitize.py index 6f8002c3..54f577f9 100644 --- a/backend/tests/test_sanitize.py +++ b/backend/tests/test_sanitize.py @@ -13,3 +13,19 @@ def test_strip_nul_removes_nul_characters_from_strings() -> None: assert strip_nul("hello\x00world") == "helloworld" assert strip_nul("\x00") == "" assert strip_nul("no nulls here") == "no nulls here" + + +def test_sanitize_converts_memoryview_and_bytes_to_text() -> None: + import base64 + + assert sanitize_for_storage(memoryview(b"hi")) == "hi" + assert sanitize_for_storage(b"ok\x00") == "ok" # utf-8 decode + NUL strip + assert sanitize_for_storage(bytearray(b"a")) == "a" + raw = b"\xff\xfe" # invalid utf-8 -> base64 fallback + assert sanitize_for_storage(raw) == base64.b64encode(raw).decode("ascii") + + +def test_sanitize_preserves_tuples_and_passes_through_scalars() -> None: + assert sanitize_for_storage(("a\x00", ["b\x00"])) == ("a", ["b"]) + assert sanitize_for_storage(5) == 5 + assert sanitize_for_storage(None) is None diff --git a/backend/tests/test_schema_diff.py b/backend/tests/test_schema_diff.py new file mode 100644 index 00000000..57765916 --- /dev/null +++ b/backend/tests/test_schema_diff.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from app.diff.schema_diff import diff_snapshots + + +def _snap(relations, columns, pk_columns=None, fk_edges=None): + return { + "relations": relations, + "columns": columns, + "pk_columns": pk_columns or [], + "fk_edges": fk_edges or [], + } + + +def _base(): + return _snap( + relations=[ + {"relation_oid": 1, "schema_name": "public", "relation_name": "member"}, + {"relation_oid": 2, "schema_name": "public", "relation_name": "orders"}, + ], + columns=[ + {"relation_oid": 1, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 1, "column_name": "email", "data_type": "varchar(100)", "is_not_null": False}, + {"relation_oid": 2, "column_name": "order_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 2, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + ], + pk_columns=[ + {"relation_oid": 1, "column_name": "member_id", "column_ordinal": 1}, + {"relation_oid": 2, "column_name": "order_id", "column_ordinal": 1}, + ], + fk_edges=[ + { + "fk_constraint_oid": 10, + "fk_constraint_name": "fk_orders_member", + "child_relation_oid": 2, + "parent_relation_oid": 1, + "child_column_name": "member_id", + "parent_column_name": "member_id", + "column_ordinal": 1, + } + ], + ) + + +def test_identical_snapshots_report_no_changes_even_with_different_oids(): + # Same logical schema, but re-introspection assigned different oids. + # A correct diff keys by name and must report zero changes. + base = _base() + target = _snap( + relations=[ + {"relation_oid": 900, "schema_name": "public", "relation_name": "member"}, + {"relation_oid": 901, "schema_name": "public", "relation_name": "orders"}, + ], + columns=[ + {"relation_oid": 900, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 900, "column_name": "email", "data_type": "varchar(100)", "is_not_null": False}, + {"relation_oid": 901, "column_name": "order_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 901, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + ], + pk_columns=[ + {"relation_oid": 900, "column_name": "member_id", "column_ordinal": 1}, + {"relation_oid": 901, "column_name": "order_id", "column_ordinal": 1}, + ], + fk_edges=[ + { + "fk_constraint_oid": 77, + "fk_constraint_name": "fk_orders_member", + "child_relation_oid": 901, + "parent_relation_oid": 900, + "child_column_name": "member_id", + "parent_column_name": "member_id", + "column_ordinal": 1, + } + ], + ) + d = diff_snapshots(base, target) + assert d["summary"]["has_changes"] is False + assert d["tables"]["added"] == [] + assert d["tables"]["removed"] == [] + assert d["tables"]["changed"] == [] + assert d["foreign_keys"]["added"] == [] + assert d["foreign_keys"]["removed"] == [] + + +def test_table_added_and_removed(): + base = _base() + target = _snap( + relations=[ + {"relation_oid": 1, "schema_name": "public", "relation_name": "member"}, + {"relation_oid": 3, "schema_name": "public", "relation_name": "audit_log"}, + ], + columns=[ + {"relation_oid": 1, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 1, "column_name": "email", "data_type": "varchar(100)", "is_not_null": False}, + {"relation_oid": 3, "column_name": "id", "data_type": "bigint", "is_not_null": True}, + ], + pk_columns=[{"relation_oid": 1, "column_name": "member_id", "column_ordinal": 1}], + ) + d = diff_snapshots(base, target) + assert d["tables"]["added"] == ["public.audit_log"] + assert d["tables"]["removed"] == ["public.orders"] + assert d["summary"]["tables_added"] == 1 + assert d["summary"]["tables_removed"] == 1 + # The removed FK (only existed in base) is reported. + assert d["summary"]["fks_removed"] == 1 + + +def test_column_added_removed_type_and_nullability_changed(): + base = _base() + target = _snap( + relations=[ + {"relation_oid": 1, "schema_name": "public", "relation_name": "member"}, + {"relation_oid": 2, "schema_name": "public", "relation_name": "orders"}, + ], + columns=[ + {"relation_oid": 1, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + # email: type widened AND became NOT NULL (a change) + {"relation_oid": 1, "column_name": "email", "data_type": "varchar(255)", "is_not_null": True}, + # new column added + {"relation_oid": 1, "column_name": "phone", "data_type": "varchar(20)", "is_not_null": False}, + {"relation_oid": 2, "column_name": "order_id", "data_type": "bigint", "is_not_null": True}, + # orders.member_id removed + ], + pk_columns=[ + {"relation_oid": 1, "column_name": "member_id", "column_ordinal": 1}, + {"relation_oid": 2, "column_name": "order_id", "column_ordinal": 1}, + ], + ) + d = diff_snapshots(base, target) + changed = {c["table"]: c for c in d["tables"]["changed"]} + assert set(changed) == {"public.member", "public.orders"} + + member = changed["public.member"] + assert member["columns"]["added"] == ["phone"] + email_change = next(c for c in member["columns"]["changed"] if c["column"] == "email") + assert email_change["from"] == {"data_type": "varchar(100)", "is_not_null": False} + assert email_change["to"] == {"data_type": "varchar(255)", "is_not_null": True} + + orders = changed["public.orders"] + assert orders["columns"]["removed"] == ["member_id"] + assert d["summary"]["columns_added"] == 1 + assert d["summary"]["columns_removed"] == 1 + assert d["summary"]["columns_changed"] == 1 + + +def test_primary_key_change_detected(): + base = _base() + target = _snap( + relations=[{"relation_oid": 1, "schema_name": "public", "relation_name": "member"}], + columns=[ + {"relation_oid": 1, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 1, "column_name": "email", "data_type": "varchar(100)", "is_not_null": False}, + ], + # PK moved from member_id to email + pk_columns=[{"relation_oid": 1, "column_name": "email", "column_ordinal": 1}], + ) + d = diff_snapshots(base, target) + member = next(c for c in d["tables"]["changed"] if c["table"] == "public.member") + assert member["primary_key"] == {"from": ["member_id"], "to": ["email"]} + + +def test_fk_added(): + base = _snap( + relations=[ + {"relation_oid": 1, "schema_name": "public", "relation_name": "member"}, + {"relation_oid": 2, "schema_name": "public", "relation_name": "orders"}, + ], + columns=[ + {"relation_oid": 1, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 2, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + ], + ) + target = _snap( + relations=[ + {"relation_oid": 1, "schema_name": "public", "relation_name": "member"}, + {"relation_oid": 2, "schema_name": "public", "relation_name": "orders"}, + ], + columns=[ + {"relation_oid": 1, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 2, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + ], + fk_edges=[ + { + "fk_constraint_name": "fk_orders_member", + "child_relation_oid": 2, + "parent_relation_oid": 1, + "child_column_name": "member_id", + "parent_column_name": "member_id", + "column_ordinal": 1, + } + ], + ) + d = diff_snapshots(base, target) + assert d["summary"]["fks_added"] == 1 + added = d["foreign_keys"]["added"][0] + assert added["child_table"] == "public.orders" + assert added["parent_table"] == "public.member" + assert added["child_columns"] == ["member_id"] + + +def test_none_and_empty_snapshots_are_safe(): + assert diff_snapshots(None, None)["summary"]["has_changes"] is False + d = diff_snapshots(None, _base()) + assert d["summary"]["tables_added"] == 2 + assert diff_snapshots(_base(), None)["summary"]["tables_removed"] == 2 + + +def test_diff_skips_orphan_nameless_rows_and_detects_comment_change(): + # Rows referencing an unknown relation_oid or missing a name must be skipped, + # not treated as spurious changes; a table-comment change is a real change. + base = _snap( + relations=[ + { + "relation_oid": 1, + "schema_name": "public", + "relation_name": "t", + "relation_comment": "old note", + } + ], + columns=[ + {"relation_oid": 1, "column_name": "id", "data_type": "int", "is_not_null": True}, + {"relation_oid": 999, "column_name": "ghost", "data_type": "int"}, # orphan oid + {"relation_oid": 1, "column_name": None, "data_type": "int"}, # no name + ], + pk_columns=[ + {"relation_oid": 999, "column_name": "id", "column_ordinal": 1}, # orphan oid + {"relation_oid": 1, "column_name": None, "column_ordinal": 1}, # no name + ], + fk_edges=[ + { + "fk_constraint_name": "x", + "child_relation_oid": 999, # orphan child -> skipped + "parent_relation_oid": 1, + "child_column_name": "a", + "parent_column_name": "b", + "column_ordinal": 1, + } + ], + ) + target = _snap( + relations=[ + { + "relation_oid": 1, + "schema_name": "public", + "relation_name": "t", + "relation_comment": "new note", # comment changed + } + ], + columns=[ + {"relation_oid": 1, "column_name": "id", "data_type": "int", "is_not_null": True} + ], + ) + d = diff_snapshots(base, target) + changed = d["tables"]["changed"] + assert len(changed) == 1 + assert changed[0]["table"] == "public.t" + assert changed[0]["comment"] == {"from": "old note", "to": "new note"} + # orphan/nameless rows and the orphan FK produced no spurious changes + assert changed[0]["columns"]["added"] == [] + assert changed[0]["columns"]["removed"] == [] + assert d["summary"]["fks_added"] == 0 + assert d["summary"]["fks_removed"] == 0 diff --git a/docs/valuation-gap-analysis.md b/docs/valuation-gap-analysis.md new file mode 100644 index 00000000..537f84b0 --- /dev/null +++ b/docs/valuation-gap-analysis.md @@ -0,0 +1,60 @@ +# pg-erd-cloud — Value Gap Analysis & Roadmap + +_Grounded in a full code-knowledge-graph pass (147 files, 1,202 nodes, 17 communities, +61 flows) plus the live test suites (backend 258 pytest, frontend 136 vitest, `tsc` clean)._ + +## 1. What exists today (the moat we already have) + +The codebase is a **schema-intelligence platform**, not just an ERD drawer. The graph +communities map cleanly to real capabilities: + +| Capability | Module (community) | State | +| --- | --- | --- | +| Multi-dialect introspection | `pg_introspect`, `snowflake_introspect` | Solid, SSRF-guarded, 100% guard coverage | +| Structural diff (oid-independent) | `diff/schema_diff` | Solid, name-keyed | +| Dialect-aware DDL export | `ddl/export` | PG + Snowflake, type-mapped | +| **Migration SQL from diff** | `ddl/migration` | **NEW — this PR** | +| Reverse-engineering / index & cardinality advice | `spec/*` | LLM-assisted | +| AuthN/Z, RBAC, CSRF, DSN encryption, IDOR guards | `app` (auth) | Security-reviewed | +| Snapshots, saved views, table annotations, connection test, share links | `api/*` | Complete backend+frontend | +| ERD editor UI | `src/*`, `modals/*` | React + xyflow | + +The security posture (SSRF host-pinning, IDOR uniform-404, encrypted DSN at rest, +DSN-redacted errors) is genuinely strong and is a differentiator for enterprise buyers. + +## 2. Value thesis + +A schema tool becomes a *platform teams pay for* when it moves up the value ladder: +**visualize → diff → _act_ → govern → automate**. We are strong on visualize/diff and +now cross into **act** (migration SQL). The gaps below are ordered by value-per-effort. + +## 3. Prioritized gaps + +### P0 — Actionability (turns insight into change teams pay for) +- **✅ Migration SQL from diff** _(delivered in this PR)_ — `GET /api/snapshots/{uuid}/migration.sql?against=…&dialect=…`. Diff two snapshots and get the `CREATE`/`ALTER`/`DROP` + FK statements to apply. Bridges the previously-disconnected `diff` and `ddl` modules the graph flagged. +- **Migration safety review** — classify each statement as safe/destructive/lock-heavy; emit a summary header. (Foundation is in place: destructive ops already emitted with review comments.) +- **CI drift check** — a documented `GET .../migration.sql` recipe + exit-code semantics so teams can gate deploys on "no unexpected drift." + +### P1 — Breadth & documentation (expands addressable market) +- **More dialects** — MySQL / SQL Server / BigQuery / Redshift introspection. Each ~doubles the reachable market; needs a live instance per dialect for integration tests (flag as infra-gated). +- **Data dictionary export** — Markdown/HTML doc of schema + **annotations** (now that annotations exist) + PK/FK/index. High value for the "living documentation" buyer; pure-logic, testable. +- **Relationship inference** — suggest implicit FKs by naming/type heuristics (`*_id` → `id`). Composes with existing cardinality logic. + +### P2 — Scale, governance, monetization (justifies a valuation, not just a product) +- **Robust async jobs** — `jobs` is DB-queue MVP; harden the valkey worker path (retries/backoff/idempotency) for large-schema introspection at scale. +- **Audit log + org multi-tenancy + usage quotas** — table-stakes for enterprise/SOC2 and for billing. +- **Observability SLOs** — `observability.py` exists; add per-tenant metrics + alerting. +- **Public API keys / SDK** — programmatic snapshots & diffs enable CI/CD integrations (the stickiest use case). + +## 4. Delivered in this PR + +`ddl/migration.py` — `snapshot_diff_to_migration_sql(base, target, dialect)`: +- name-based matching (never volatile `relation_oid`), +- reuses `diff/schema_diff` indexing + `ddl/export` type-mapping/quoting, +- emits CREATE / DROP / ADD·DROP·ALTER COLUMN / SET·DROP NOT NULL / ADD·DROP FOREIGN KEY, +- destructive & PK changes emitted with review comments, +- PostgreSQL-precise, Snowflake `SET DATA TYPE` handling, +- endpoint `GET /api/snapshots/{uuid}/migration.sql` (IDOR-safe, dialect param), +- 7 unit tests incl. oid-independence. + +Next (Loop): data-dictionary export (P1) and CI drift recipe (P0). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e99700c5..6d8675ce 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,10 +17,13 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { AddTableModal, CardinalityModal, + AnnotationsModal, + DiffModal, EditEdgeModal, EditTableModal, ExportModal, GroupModal, + ViewsModal, } from "./components/modals"; import { @@ -29,11 +32,21 @@ import { createConnection, createProject, createSnapshot, + createView, + deleteAnnotation, + deleteView, + diffSnapshots, getSnapshot, + getView, + listAnnotations, listConnections, listProjects, listSnapshots, + listViews, + testConnection, + upsertAnnotation, } from "./api"; +import { applyLayout, captureLayout } from "./diagramViews"; import TableNode from "./erd/TableNode"; import { BUSINESS_GROUP_COLORS, @@ -58,7 +71,7 @@ import { } from "./erd/export"; import { exportMermaid } from "./erd/mermaid"; import { GRID_COLUMNS, GRID_X_GAP, GRID_Y_GAP } from "./erd/layoutConstants"; -import type { Connection, Project, Snapshot, SnapshotDetail } from "./types"; +import type { Connection, ConnectionTestResult, DiagramView, Project, SchemaDiff, Snapshot, SnapshotDetail, TableAnnotation } from "./types"; const TERMINAL_SNAPSHOT_STATUSES = new Set([ "succeeded", @@ -150,6 +163,27 @@ export default function App() { const [isShareLinkCopied, setIsShareLinkCopied] = useState(false); const [shareLinkError, setShareLinkError] = useState(null); + const [isDiffModalOpen, setIsDiffModalOpen] = useState(false); + const [diffBaseId, setDiffBaseId] = useState(null); + const [diffResult, setDiffResult] = useState(null); + const [diffNotFound, setDiffNotFound] = useState(false); + const [isDiffLoading, setIsDiffLoading] = useState(false); + const [diffError, setDiffError] = useState(null); + + const [isViewsModalOpen, setIsViewsModalOpen] = useState(false); + const [savedViews, setSavedViews] = useState([]); + const [newViewName, setNewViewName] = useState(""); + const [isSavingView, setIsSavingView] = useState(false); + const [viewsError, setViewsError] = useState(null); + + const [isAnnotationsModalOpen, setIsAnnotationsModalOpen] = useState(false); + const [annotations, setAnnotations] = useState([]); + const [isSavingAnnotation, setIsSavingAnnotation] = useState(false); + const [annotationsError, setAnnotationsError] = useState(null); + + const [connTestResult, setConnTestResult] = useState(null); + const [isTestingConn, setIsTestingConn] = useState(false); + const [editingEdge, setEditingEdge] = useState(null); const [editingNode, setEditingNode] = useState | null>(null); const [isEditTableModalOpen, setIsEditTableModalOpen] = useState(false); @@ -537,6 +571,153 @@ export default function App() { setIsExportModalOpen(true); } + function onOpenDiff() { + setDiffBaseId(null); + setDiffResult(null); + setDiffNotFound(false); + setDiffError(null); + setIsDiffModalOpen(true); + } + + function onCloseDiff() { + setIsDiffModalOpen(false); + } + + function onOpenViews() { + setViewsError(null); + setNewViewName(""); + setIsViewsModalOpen(true); + if (!selectedProjectId) return; + listViews(selectedProjectId) + .then(setSavedViews) + .catch(() => setViewsError("저장된 뷰를 불러오지 못했습니다.")); + } + + function onCloseViews() { + setIsViewsModalOpen(false); + } + + function onSaveCurrentView() { + if (!selectedProjectId || nodes.length === 0) return; + const name = newViewName.trim(); + if (!name) return; + setIsSavingView(true); + setViewsError(null); + createView(selectedProjectId, name, captureLayout(nodes)) + .then((view) => { + setSavedViews((prev) => [view, ...prev]); + setNewViewName(""); + }) + .catch(() => setViewsError("뷰 저장에 실패했습니다.")) + .finally(() => setIsSavingView(false)); + } + + function onLoadView(viewId: string) { + setViewsError(null); + getView(viewId) + .then((detail) => { + setNodes((prev) => applyLayout(prev, detail.layout_json)); + setIsViewsModalOpen(false); + }) + .catch(() => setViewsError("뷰를 불러오지 못했습니다.")); + } + + function onDeleteView(viewId: string) { + setViewsError(null); + deleteView(viewId) + .then(() => { + setSavedViews((prev) => + prev.filter((v) => v.diagram_view_uuid !== viewId), + ); + }) + .catch(() => setViewsError("뷰 삭제에 실패했습니다.")); + } + + function onOpenAnnotations() { + setAnnotationsError(null); + setIsAnnotationsModalOpen(true); + if (!selectedProjectId) return; + listAnnotations(selectedProjectId) + .then(setAnnotations) + .catch(() => setAnnotationsError("주석을 불러오지 못했습니다.")); + } + + function onCloseAnnotations() { + setIsAnnotationsModalOpen(false); + } + + function onSaveAnnotation( + schemaName: string, + relationName: string, + body: string, + ) { + if (!selectedProjectId) return; + setIsSavingAnnotation(true); + setAnnotationsError(null); + upsertAnnotation(selectedProjectId, schemaName, relationName, body) + .then((saved) => { + setAnnotations((prev) => [ + saved, + ...prev.filter( + (a) => + !( + a.schema_name === saved.schema_name && + a.relation_name === saved.relation_name + ), + ), + ]); + }) + .catch(() => setAnnotationsError("주석 저장에 실패했습니다.")) + .finally(() => setIsSavingAnnotation(false)); + } + + function onDeleteAnnotation(annotationId: string) { + if (!selectedProjectId) return; + setAnnotationsError(null); + deleteAnnotation(selectedProjectId, annotationId) + .then(() => { + setAnnotations((prev) => + prev.filter((a) => a.table_annotation_uuid !== annotationId), + ); + }) + .catch(() => setAnnotationsError("주석 삭제에 실패했습니다.")); + } + + function onTestConnection() { + if (!selectedConnId) return; + setIsTestingConn(true); + setConnTestResult(null); + testConnection(selectedConnId) + .then(setConnTestResult) + .catch(() => + setConnTestResult({ + ok: false, + server_version: null, + error: "연결 테스트에 실패했습니다.", + }), + ) + .finally(() => setIsTestingConn(false)); + } + + function onSelectDiffBase(baseSnapshotId: string) { + setDiffBaseId(baseSnapshotId); + if (!snapshotId) return; + setIsDiffLoading(true); + setDiffError(null); + setDiffNotFound(false); + setDiffResult(null); + diffSnapshots(snapshotId, baseSnapshotId) + .then((res) => { + if (res.status === "not_found") { + setDiffNotFound(true); + } else { + setDiffResult(res.diff); + } + }) + .catch(() => setDiffError("스키마 비교를 불러오지 못했습니다.")) + .finally(() => setIsDiffLoading(false)); + } + function onCloseExport() { setIsExportModalOpen(false); setIsCopied(false); @@ -1078,6 +1259,25 @@ export default function App() { ))} + + {connTestResult ? ( + + {connTestResult.ok + ? `연결 성공${connTestResult.server_version ? ` · ${connTestResult.server_version}` : ""}` + : `연결 실패${connTestResult.error ? ` · ${connTestResult.error}` : ""}`} + + ) : null}
@@ -1423,6 +1623,47 @@ export default function App() { > ↗ + + + +
+ +
+
+ setSchemaName(e.target.value)} + placeholder="스키마" + /> + setRelationName(e.target.value)} + placeholder="테이블" + /> +
+