From 0f7cc8f12a66b45fb472f2b5319905c8c4af0a55 Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Sun, 21 Jun 2026 08:14:25 +0800 Subject: [PATCH] ci: detect sqlalchemy schema drift --- .gitea/workflows/ci-cd.yml | 7 +- .github/workflows/ci-cd.yml | 7 +- docs/SCHEMA-MAINLINE.md | 15 +- docs/schema-metadata-snapshot.json | 1412 ++++++++++++++++++++++++++++ scripts/check_schema_metadata.py | 94 ++ 5 files changed, 1527 insertions(+), 8 deletions(-) create mode 100644 docs/schema-metadata-snapshot.json create mode 100644 scripts/check_schema_metadata.py diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 869701c6a..b0aa91c10 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -61,9 +61,9 @@ jobs: - name: Run code quality checks run: | - python -m compileall -q alembic apps packages tests - python -m black --check alembic apps packages tests - python -m isort --check-only alembic apps packages tests + python -m compileall -q alembic apps packages tests scripts/check_schema_metadata.py + python -m black --check alembic apps packages tests scripts/check_schema_metadata.py + python -m isort --check-only alembic apps packages tests scripts/check_schema_metadata.py python -m flake8 apps packages tests --count --statistics bandit -r apps packages -q @@ -73,6 +73,7 @@ jobs: python -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql test -s /tmp/alembic-upgrade.sql grep -q "Running upgrade" /tmp/alembic-upgrade.sql + python scripts/check_schema_metadata.py - name: Run tests run: | diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 869701c6a..b0aa91c10 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -61,9 +61,9 @@ jobs: - name: Run code quality checks run: | - python -m compileall -q alembic apps packages tests - python -m black --check alembic apps packages tests - python -m isort --check-only alembic apps packages tests + python -m compileall -q alembic apps packages tests scripts/check_schema_metadata.py + python -m black --check alembic apps packages tests scripts/check_schema_metadata.py + python -m isort --check-only alembic apps packages tests scripts/check_schema_metadata.py python -m flake8 apps packages tests --count --statistics bandit -r apps packages -q @@ -73,6 +73,7 @@ jobs: python -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql test -s /tmp/alembic-upgrade.sql grep -q "Running upgrade" /tmp/alembic-upgrade.sql + python scripts/check_schema_metadata.py - name: Run tests run: | diff --git a/docs/SCHEMA-MAINLINE.md b/docs/SCHEMA-MAINLINE.md index 58697a2d2..299353ec1 100644 --- a/docs/SCHEMA-MAINLINE.md +++ b/docs/SCHEMA-MAINLINE.md @@ -26,6 +26,7 @@ staging / production 不允许依赖 `Base.metadata.create_all()` 建表;表 - staging health endpoint 已确认 healthy - `develop` 部署 job 已确认 succeeded - CI 已增加 Alembic offline upgrade SQL 生成检查,验证 migration 链可加载并能生成 `upgrade head` SQL +- CI 已增加 SQLAlchemy metadata snapshot drift 检查,防止改 models 后忘记同步 Alembic revision / schema snapshot ## 已废弃入口 @@ -52,12 +53,22 @@ staging / production 不允许依赖 `Base.metadata.create_all()` 建表;表 7. staging / production 只允许通过 Alembic 升级 schema。 8. `AUTO_CREATE_SCHEMA` 只能作为开发/测试兜底,staging / production 会被代码级 guard 阻止开启。 +## Schema Drift 检查 + +CI 使用 `scripts/check_schema_metadata.py` 对比当前 SQLAlchemy metadata 和 `docs/schema-metadata-snapshot.json`。 + +如果 schema 变更是有意的,必须同时: + +1. 修改 `packages/adapters/sqlalchemy_impl/models.py`。 +2. 新增 Alembic revision。 +3. 执行 `python scripts/check_schema_metadata.py --write` 刷新 snapshot。 +4. 提交 models、revision、snapshot 三者。 + ## 下一步 后续 schema 工作应继续推进: -1. 新增 revision drift 检查,防止 models 已改但未生成 migration。 -2. 对 production 首次接入 Alembic 前,先执行只读 schema/数据备份检查。 +1. 对 production 首次接入 Alembic 前,先执行只读 schema/数据备份检查。 ## 禁止事项 diff --git a/docs/schema-metadata-snapshot.json b/docs/schema-metadata-snapshot.json new file mode 100644 index 000000000..426d1af41 --- /dev/null +++ b/docs/schema-metadata-snapshot.json @@ -0,0 +1,1412 @@ +{ + "tables": { + "asset_libraries": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "project_id", + "nullable": true, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": false, + "name": "name", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(255)", + "unique": false + }, + { + "index": true, + "name": "kind", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "asset_count", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "total_size", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "updated_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "kind" + ], + "name": "ix_asset_libraries_kind", + "unique": false + }, + { + "columns": [ + "project_id" + ], + "name": "ix_asset_libraries_project_id", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_asset_libraries_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "assets": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "project_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "asset_library_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": false, + "name": "name", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(500)", + "unique": false + }, + { + "index": true, + "name": "file_type", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "file_size", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "file_url", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(1000)", + "unique": false + }, + { + "index": false, + "name": "thumbnail_url", + "nullable": true, + "primary_key": false, + "type": "VARCHAR(1000)", + "unique": false + }, + { + "index": false, + "name": "duration", + "nullable": true, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "width", + "nullable": true, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "height", + "nullable": true, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "fps", + "nullable": true, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "codec", + "nullable": true, + "primary_key": false, + "type": "VARCHAR(50)", + "unique": false + }, + { + "index": true, + "name": "status", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": true, + "name": "classification_status", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "classification_result", + "nullable": true, + "primary_key": false, + "type": "TEXT", + "unique": false + }, + { + "index": false, + "name": "quality_score", + "nullable": true, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "uploaded_by_user_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "updated_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "asset_library_id" + ], + "name": "ix_assets_asset_library_id", + "unique": false + }, + { + "columns": [ + "classification_status" + ], + "name": "ix_assets_classification_status", + "unique": false + }, + { + "columns": [ + "created_at" + ], + "name": "ix_assets_created_at", + "unique": false + }, + { + "columns": [ + "file_type" + ], + "name": "ix_assets_file_type", + "unique": false + }, + { + "columns": [ + "project_id" + ], + "name": "ix_assets_project_id", + "unique": false + }, + { + "columns": [ + "status" + ], + "name": "ix_assets_status", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_assets_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "classification_jobs": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "project_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "asset_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "status", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "classification", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(50)", + "unique": false + }, + { + "index": false, + "name": "confidence", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "error_message", + "nullable": false, + "primary_key": false, + "type": "TEXT", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "updated_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "asset_id" + ], + "name": "ix_classification_jobs_asset_id", + "unique": false + }, + { + "columns": [ + "project_id" + ], + "name": "ix_classification_jobs_project_id", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_classification_jobs_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "generated_videos": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "project_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "generation_task_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "name", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(255)", + "unique": false + }, + { + "index": false, + "name": "file_url", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(1000)", + "unique": false + }, + { + "index": false, + "name": "file_size", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "duration", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "thumbnail_url", + "nullable": true, + "primary_key": false, + "type": "VARCHAR(1000)", + "unique": false + }, + { + "index": false, + "name": "width", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "height", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "fps", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "generated_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "generation_task_id" + ], + "name": "ix_generated_videos_generation_task_id", + "unique": false + }, + { + "columns": [ + "project_id" + ], + "name": "ix_generated_videos_project_id", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_generated_videos_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "generation_tasks": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "project_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "strategy_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "asset_library_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "voice_library_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "status", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "progress", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "result_count", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "error_message", + "nullable": false, + "primary_key": false, + "type": "TEXT", + "unique": false + }, + { + "index": false, + "name": "started_at", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "completed_at", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "created_by_user_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "asset_library_id" + ], + "name": "ix_generation_tasks_asset_library_id", + "unique": false + }, + { + "columns": [ + "project_id" + ], + "name": "ix_generation_tasks_project_id", + "unique": false + }, + { + "columns": [ + "status" + ], + "name": "ix_generation_tasks_status", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_generation_tasks_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "ingest_jobs": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "project_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "library_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "storage_key", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(255)", + "unique": false + }, + { + "index": false, + "name": "status", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "error_message", + "nullable": false, + "primary_key": false, + "type": "TEXT", + "unique": false + }, + { + "index": false, + "name": "result_asset_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "updated_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "library_id" + ], + "name": "ix_ingest_jobs_library_id", + "unique": false + }, + { + "columns": [ + "project_id" + ], + "name": "ix_ingest_jobs_project_id", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_ingest_jobs_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "milestones": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "project_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "name", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(200)", + "unique": false + }, + { + "index": false, + "name": "description", + "nullable": false, + "primary_key": false, + "type": "TEXT", + "unique": false + }, + { + "index": false, + "name": "target_date", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "completed", + "nullable": false, + "primary_key": false, + "type": "BOOLEAN", + "unique": false + }, + { + "index": false, + "name": "completed_at", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "updated_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "project_id" + ], + "name": "ix_milestones_project_id", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_milestones_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "projects": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "name", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(100)", + "unique": false + }, + { + "index": false, + "name": "description", + "nullable": false, + "primary_key": false, + "type": "TEXT", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "workspace_id" + ], + "name": "ix_projects_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "task_issues": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "task_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "project_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "title", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(200)", + "unique": false + }, + { + "index": false, + "name": "description", + "nullable": false, + "primary_key": false, + "type": "TEXT", + "unique": false + }, + { + "index": false, + "name": "resolved", + "nullable": false, + "primary_key": false, + "type": "BOOLEAN", + "unique": false + }, + { + "index": false, + "name": "resolved_at", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "created_by_user_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "updated_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "project_id" + ], + "name": "ix_task_issues_project_id", + "unique": false + }, + { + "columns": [ + "task_id" + ], + "name": "ix_task_issues_task_id", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_task_issues_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "tasks": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "project_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "name", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(200)", + "unique": false + }, + { + "index": false, + "name": "description", + "nullable": false, + "primary_key": false, + "type": "TEXT", + "unique": false + }, + { + "index": true, + "name": "status", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "priority", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": true, + "name": "parent_task_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "assignee_user_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(32)", + "unique": false + }, + { + "index": false, + "name": "progress", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "planned_start_date", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "planned_end_date", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "actual_start_date", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "actual_end_date", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "tags_json", + "nullable": false, + "primary_key": false, + "type": "TEXT", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "updated_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "parent_task_id" + ], + "name": "ix_tasks_parent_task_id", + "unique": false + }, + { + "columns": [ + "project_id" + ], + "name": "ix_tasks_project_id", + "unique": false + }, + { + "columns": [ + "status" + ], + "name": "ix_tasks_status", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_tasks_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "users": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "email", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(255)", + "unique": true + }, + { + "index": true, + "name": "username", + "nullable": true, + "primary_key": false, + "type": "VARCHAR(100)", + "unique": true + }, + { + "index": false, + "name": "display_name", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(255)", + "unique": false + }, + { + "index": false, + "name": "password_hash", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(255)", + "unique": false + }, + { + "index": false, + "name": "email_verified", + "nullable": false, + "primary_key": false, + "type": "BOOLEAN", + "unique": false + }, + { + "index": false, + "name": "email_verification_token", + "nullable": true, + "primary_key": false, + "type": "VARCHAR(255)", + "unique": false + }, + { + "index": false, + "name": "password_reset_token", + "nullable": true, + "primary_key": false, + "type": "VARCHAR(255)", + "unique": false + }, + { + "index": false, + "name": "password_reset_expires_at", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "last_login_at", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "last_login_ip", + "nullable": true, + "primary_key": false, + "type": "VARCHAR(50)", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "email" + ], + "name": "ix_users_email", + "unique": true + }, + { + "columns": [ + "username" + ], + "name": "ix_users_username", + "unique": true + } + ], + "primary_key": [ + "id" + ] + } + } +} diff --git a/scripts/check_schema_metadata.py b/scripts/check_schema_metadata.py new file mode 100644 index 000000000..47cd74737 --- /dev/null +++ b/scripts/check_schema_metadata.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from packages.adapters.sqlalchemy_impl.models import Base + +SNAPSHOT_PATH = Path("docs/schema-metadata-snapshot.json") + + +def column_signature(column) -> dict[str, Any]: + return { + "name": column.name, + "type": str(column.type), + "nullable": column.nullable, + "primary_key": column.primary_key, + "unique": bool(column.unique), + "index": bool(column.index), + } + + +def index_signature(index) -> dict[str, Any]: + return { + "name": index.name, + "columns": [column.name for column in index.columns], + "unique": index.unique, + } + + +def metadata_signature() -> dict[str, Any]: + tables: dict[str, Any] = {} + for table_name in sorted(Base.metadata.tables): + table = Base.metadata.tables[table_name] + tables[table_name] = { + "columns": [column_signature(column) for column in table.columns], + "indexes": sorted( + [index_signature(index) for index in table.indexes], + key=lambda item: item["name"] or "", + ), + "primary_key": [column.name for column in table.primary_key.columns], + } + return {"tables": tables} + + +def write_snapshot(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(metadata_signature(), indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def check_snapshot(path: Path) -> int: + expected = json.loads(path.read_text(encoding="utf-8")) + actual = metadata_signature() + if actual == expected: + return 0 + + print( + "SQLAlchemy metadata drift detected. " + "If this schema change is intentional, add an Alembic revision and refresh " + f"{path} with `python scripts/check_schema_metadata.py --write`." + ) + expected_tables = set(expected.get("tables", {})) + actual_tables = set(actual.get("tables", {})) + if expected_tables != actual_tables: + print( + f"Table diff: missing={sorted(expected_tables - actual_tables)} added={sorted(actual_tables - expected_tables)}" + ) + + for table_name in sorted(expected_tables & actual_tables): + if expected["tables"][table_name] != actual["tables"][table_name]: + print(f"Changed table: {table_name}") + return 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check SQLAlchemy metadata drift against committed snapshot.") + parser.add_argument("--write", action="store_true", help="Refresh the committed metadata snapshot.") + parser.add_argument("--snapshot", type=Path, default=SNAPSHOT_PATH) + args = parser.parse_args() + + if args.write: + write_snapshot(args.snapshot) + return 0 + return check_snapshot(args.snapshot) + + +if __name__ == "__main__": + raise SystemExit(main())