From 972972c3c2e9edc85845418d958127a23e6754fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 6 Jul 2026 09:52:21 +0900 Subject: [PATCH] feat(stats): schema overview statistics Independent feature against main. compute_schema_stats(snapshot) returns object counts by kind (table/view/matview), column stats (total/nullable/avg-&-max per table), PK/FK/index coverage, tables-without-PK, top-5 widest tables, and top-10 data types. For a dashboard header or sizing a migration. GET /api/snapshots/{uuid}/stats, IDOR-safe. +4 tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AxU2xaupAjp912oDNFuWyd --- backend/app/api/snapshots.py | 31 ++++++++++- backend/app/schemas.py | 8 +++ backend/app/spec/schema_stats.py | 89 ++++++++++++++++++++++++++++++ backend/tests/test_schema_stats.py | 56 +++++++++++++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 backend/app/spec/schema_stats.py create mode 100644 backend/tests/test_schema_stats.py diff --git a/backend/app/api/snapshots.py b/backend/app/api/snapshots.py index 79f2515f..e353e0bf 100644 --- a/backend/app/api/snapshots.py +++ b/backend/app/api/snapshots.py @@ -17,8 +17,14 @@ SchemaSnapshotData, ) from app.permissions import require_project_member -from app.schemas import SnapshotCreateIn, SnapshotDetailOut, SnapshotOut +from app.schemas import ( + SchemaStatsOut, + SnapshotCreateIn, + SnapshotDetailOut, + SnapshotOut, +) from app.ddl.export import snapshot_json_to_sql +from app.spec.schema_stats import compute_schema_stats from app.jobs.valkey_queue import enqueue_job_signal from app.spec.llm import ( LlmConfigurationError, @@ -161,6 +167,29 @@ async def export_snapshot_sql( return snapshot_json_to_sql(data.snapshot_json, target_dialect=dialect) +@router.get("/{schema_snapshot_uuid}/stats", response_model=SchemaStatsOut) +async def schema_stats( + schema_snapshot_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> SchemaStatsOut: + """Overview statistics for a snapshot (object counts, column & type + distribution, widest tables, PK/FK/index coverage). + + IDOR-safe (uniform not-found for missing/unauthorized snapshots). + """ + snap = await _get_authorized_snapshot(session, schema_snapshot_uuid, user) + if snap is None: + return SchemaStatsOut( + schema_snapshot_uuid=schema_snapshot_uuid, status="not_found", stats=None + ) + data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) + stats = compute_schema_stats(data.snapshot_json if data else None) + return SchemaStatsOut( + schema_snapshot_uuid=schema_snapshot_uuid, status="ok", stats=stats + ) + + @router.get( "/{schema_snapshot_uuid}/reversing-spec.md", response_class=PlainTextResponse, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0359799c..9b0b5061 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -91,6 +91,14 @@ class SnapshotDetailOut(BaseModel): snapshot_json: dict | None +class SchemaStatsOut(BaseModel): + """Overview statistics for a schema snapshot.""" + + schema_snapshot_uuid: uuid.UUID + status: str + stats: dict | None + + class MeOut(BaseModel): """Current user payload returned by /me.""" diff --git a/backend/app/spec/schema_stats.py b/backend/app/spec/schema_stats.py new file mode 100644 index 00000000..76f4b9fd --- /dev/null +++ b/backend/app/spec/schema_stats.py @@ -0,0 +1,89 @@ +"""Compute overview statistics for a schema snapshot. + +A single call that answers "how big and how shaped is this schema?": object +counts, column distribution, the widest tables, PK/FK/index coverage, and the +most common data types. Useful for a dashboard header or an API consumer sizing +a migration effort. + +Pure and dialect-agnostic. +""" + +from __future__ import annotations + +from collections import Counter +from typing import Any + +_KIND_LABELS = { + "r": "table", + "p": "table", # partitioned table + "v": "view", + "m": "materialized_view", + "f": "foreign_table", + "t": "toast", +} + + +def compute_schema_stats(snapshot: dict[str, Any] | None) -> dict[str, Any]: + """Return counts and distributions describing the snapshot.""" + 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 [] + indexes = snapshot.get("indexes") or [] + + rel_by_oid = {r.get("relation_oid"): r for r in relations} + + kinds: Counter[str] = Counter() + for r in relations: + kinds[_KIND_LABELS.get(r.get("relation_kind") or "r", "other")] += 1 + + cols_per_oid: Counter[Any] = Counter() + nullable = 0 + type_counts: Counter[str] = Counter() + for c in columns: + cols_per_oid[c.get("relation_oid")] += 1 + if not c.get("is_not_null"): + nullable += 1 + dtype = str(c.get("data_type") or "").strip().lower() + if dtype: + type_counts[dtype] += 1 + + table_oids = [ + r.get("relation_oid") + for r in relations + if (r.get("relation_kind") or "r") in ("r", "p") + ] + pk_oids = {pk.get("relation_oid") for pk in pk_columns} + without_pk = sum(1 for oid in table_oids if oid not in pk_oids) + + total_columns = len(columns) + table_total = len(table_oids) + + def _qname(oid: Any) -> str: + rel = rel_by_oid.get(oid) or {} + return f"{rel.get('schema_name')}.{rel.get('relation_name')}" + + widest = [ + {"table": _qname(oid), "columns": n} + for oid, n in cols_per_oid.most_common(5) + ] + + return { + "relations": {**dict(kinds), "total": len(relations)}, + "columns": { + "total": total_columns, + "nullable": nullable, + "not_null": total_columns - nullable, + "avg_per_table": round(total_columns / table_total, 1) if table_total else 0.0, + "max_per_table": max(cols_per_oid.values(), default=0), + }, + "constraints": { + "primary_keys": len(pk_oids), + "foreign_keys": len(fk_edges), + "indexes": len(indexes), + }, + "tables_without_primary_key": without_pk, + "widest_tables": widest, + "data_types": dict(type_counts.most_common(10)), + } diff --git a/backend/tests/test_schema_stats.py b/backend/tests/test_schema_stats.py new file mode 100644 index 00000000..37023eb2 --- /dev/null +++ b/backend/tests/test_schema_stats.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from app.spec.schema_stats import compute_schema_stats + + +def _snap(): + return { + "relations": [ + {"relation_oid": 1, "relation_kind": "r", "schema_name": "public", "relation_name": "member"}, + {"relation_oid": 2, "relation_kind": "r", "schema_name": "public", "relation_name": "orders"}, + {"relation_oid": 3, "relation_kind": "v", "schema_name": "public", "relation_name": "v_report"}, + ], + "columns": [ + {"relation_oid": 1, "column_name": "member_id", "data_type": "bigint", "is_not_null": True}, + {"relation_oid": 1, "column_name": "email", "data_type": "text", "is_not_null": True}, + {"relation_oid": 1, "column_name": "nickname", "data_type": "text", "is_not_null": False}, + {"relation_oid": 2, "column_name": "order_id", "data_type": "bigint", "is_not_null": True}, + ], + "pk_columns": [{"relation_oid": 1, "column_name": "member_id"}], + "fk_edges": [{"child_relation_oid": 2, "parent_relation_oid": 1, "child_column_name": "member_id", "parent_column_name": "member_id"}], + "indexes": [{"relation_oid": 1, "index_name": "pk_member"}], + } + + +def test_counts_relations_by_kind(): + s = compute_schema_stats(_snap()) + assert s["relations"]["table"] == 2 + assert s["relations"]["view"] == 1 + assert s["relations"]["total"] == 3 + + +def test_column_stats_and_widest_table(): + s = compute_schema_stats(_snap()) + assert s["columns"]["total"] == 4 + assert s["columns"]["nullable"] == 1 + assert s["columns"]["not_null"] == 3 + assert s["columns"]["avg_per_table"] == 2.0 # 4 columns / 2 tables + assert s["columns"]["max_per_table"] == 3 # member has 3 + assert s["widest_tables"][0] == {"table": "public.member", "columns": 3} + + +def test_constraint_and_pk_coverage(): + s = compute_schema_stats(_snap()) + assert s["constraints"]["primary_keys"] == 1 + assert s["constraints"]["foreign_keys"] == 1 + assert s["constraints"]["indexes"] == 1 + assert s["tables_without_primary_key"] == 1 # orders has no PK + + +def test_data_type_distribution_and_empty(): + s = compute_schema_stats(_snap()) + assert s["data_types"]["bigint"] == 2 + assert s["data_types"]["text"] == 2 + empty = compute_schema_stats({}) + assert empty["relations"]["total"] == 0 + assert empty["columns"]["avg_per_table"] == 0.0