#!/usr/bin/env python3 """ Unified Database Synchronizer with Deep Constraint & Index Support Dynamically imports all SQLAlchemy models, compares metadata with live database, and creates missing tables, columns, unique constraints, and indexes. Safety First: - NEVER drops tables, columns, constraints, or indexes. - Prints planned SQL before execution. - Requires confirmation for destructive operations (none in this script). """ import asyncio import importlib import os import sys from pathlib import Path from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy import inspect, text, UniqueConstraint, Index from sqlalchemy.schema import CreateTable, AddConstraint, CreateIndex from sqlalchemy.sql.ddl import CreateColumn # Add backend to path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from app.database import Base from app.core.config import settings def dynamic_import_models(): """ Dynamically import all .py files in app.models directory to ensure Base.metadata is populated. """ models_dir = Path(__file__).parent.parent / "models" imported = [] for py_file in models_dir.glob("*.py"): if py_file.name == "__init__.py": continue module_name = f"app.models.{py_file.stem}" try: module = importlib.import_module(module_name) imported.append(module_name) print(f"βœ… Imported {module_name}") except Exception as e: print(f"⚠️ Could not import {module_name}: {e}") # Also ensure the __init__ is loaded (it imports many models manually) import app.models print(f"πŸ“¦ Total tables in Base.metadata: {len(Base.metadata.tables)}") return imported async def compare_and_repair(apply: bool = False): """ Compare SQLAlchemy metadata with live database and create missing tables, columns, unique constraints, and indexes. If apply is False, only prints SQL statements without executing. """ print("πŸ”— Connecting to database...") engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI)) def get_diff_and_repair(connection): inspector = inspect(connection) # Get all schemas from models expected_schemas = sorted({t.schema for t in Base.metadata.sorted_tables if t.schema}) print(f"πŸ“‹ Expected schemas: {expected_schemas}") # Ensure enum types exist in marketplace schema if 'marketplace' in expected_schemas: print("\nπŸ”§ Ensuring enum types in marketplace schema...") # moderation_status enum connection.execute(text(""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'moderation_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'marketplace')) THEN CREATE TYPE marketplace.moderation_status AS ENUM ('pending', 'approved', 'rejected'); END IF; END $$; """)) # source_type enum connection.execute(text(""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'source_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'marketplace')) THEN CREATE TYPE marketplace.source_type AS ENUM ('manual', 'ocr', 'import'); END IF; END $$; """)) print("βœ… Enum types ensured.") for schema in expected_schemas: print(f"\n--- πŸ” Checking schema '{schema}' ---") # Check if schema exists db_schemas = inspector.get_schema_names() if schema not in db_schemas: print(f"❌ Schema '{schema}' missing. Creating...") if apply: connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema}"')) print(f"βœ… Schema '{schema}' created.") else: print(f" SQL: CREATE SCHEMA IF NOT EXISTS \"{schema}\"") # Get tables in this schema from models model_tables = [t for t in Base.metadata.sorted_tables if t.schema == schema] db_tables = inspector.get_table_names(schema=schema) for table in model_tables: if table.name not in db_tables: print(f"❌ Missing table: {schema}.{table.name}") # Generate CREATE TABLE statement create_stmt = CreateTable(table) sql_str = str(create_stmt.compile(bind=engine)) print(f" SQL: {sql_str}") if apply: connection.execute(create_stmt) print(f"βœ… Table {schema}.{table.name} created.") continue # Check columns db_columns = {c['name']: c for c in inspector.get_columns(table.name, schema=schema)} model_columns = table.columns missing_cols = [] for col in model_columns: if col.name not in db_columns: missing_cols.append(col) if missing_cols: print(f"⚠️ Table {schema}.{table.name} missing columns: {[c.name for c in missing_cols]}") for col in missing_cols: col_type = col.type.compile(dialect=engine.dialect) sql = f'ALTER TABLE "{schema}"."{table.name}" ADD COLUMN "{col.name}" {col_type}' if col.nullable is False: sql += " NOT NULL" if col.default is not None: sql += f" DEFAULT {col.default.arg}" print(f" SQL: {sql}") if apply: connection.execute(text(sql)) print(f"βœ… Column {col.name} added.") else: print(f"βœ… Table {schema}.{table.name} columns are up‑to‑date.") # Check Unique Constraints db_unique_constraints = inspector.get_unique_constraints(table.name, schema=schema) # Map by column names (since constraint names may differ) db_unique_map = {} for uc in db_unique_constraints: key = tuple(sorted(uc['column_names'])) db_unique_map[key] = uc['name'] # Find unique constraints defined in model model_unique_constraints = [c for c in table.constraints if isinstance(c, UniqueConstraint)] for uc in model_unique_constraints: uc_columns = tuple(sorted([col.name for col in uc.columns])) if uc_columns not in db_unique_map: # Constraint missing constraint_name = uc.name or f"uq_{table.name}_{'_'.join(uc_columns)}" columns_sql = ', '.join([f'"{col}"' for col in uc_columns]) sql = f'ALTER TABLE "{schema}"."{table.name}" ADD CONSTRAINT "{constraint_name}" UNIQUE ({columns_sql})' print(f"⚠️ Missing unique constraint on {schema}.{table.name} columns {uc_columns}") print(f" SQL: {sql}") if apply: connection.execute(text(sql)) print(f"βœ… Unique constraint {constraint_name} added.") else: print(f"βœ… Unique constraint on {uc_columns} exists.") # Check Indexes db_indexes = inspector.get_indexes(table.name, schema=schema) db_index_map = {} for idx in db_indexes: key = tuple(sorted(idx['column_names'])) db_index_map[key] = idx['name'] # Find indexes defined in model (Index objects) model_indexes = [idx for idx in table.indexes] for idx in model_indexes: idx_columns = tuple(sorted([col.name for col in idx.columns])) if idx_columns not in db_index_map: # Index missing index_name = idx.name or f"idx_{table.name}_{'_'.join(idx_columns)}" columns_sql = ', '.join([f'"{col}"' for col in idx_columns]) unique_sql = "UNIQUE " if idx.unique else "" sql = f'CREATE {unique_sql}INDEX "{index_name}" ON "{schema}"."{table.name}" ({columns_sql})' print(f"⚠️ Missing index on {schema}.{table.name} columns {idx_columns}") print(f" SQL: {sql}") if apply: connection.execute(text(sql)) print(f"βœ… Index {index_name} added.") else: print(f"βœ… Index on {idx_columns} exists.") print("\n--- βœ… Schema synchronization complete. ---") async with engine.begin() as conn: await conn.run_sync(get_diff_and_repair) await engine.dispose() async def main(): import argparse parser = argparse.ArgumentParser(description="Unified Database Synchronizer") parser.add_argument('--apply', action='store_true', help='Apply changes to database (otherwise dry‑run)') args = parser.parse_args() print("πŸš€ Unified Database Synchronizer") print("=" * 50) # Step 1: Dynamic import print("\nπŸ“₯ Step 1: Dynamically importing all models...") dynamic_import_models() # Step 2: Compare and repair print("\nπŸ”§ Step 2: Comparing with database and repairing...") await compare_and_repair(apply=args.apply) # Step 3: Final verification print("\nπŸ“Š Step 3: Final verification...") try: from app.tests_internal.diagnostics.compare_schema import compare await compare() except ImportError: print("⚠️ compare_schema module not found, skipping verification.") print("\n✨ Synchronization finished successfully!") if __name__ == "__main__": asyncio.run(main())