You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

schema_manager.py 19 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. import os
  2. import shutil
  3. from pathlib import Path
  4. from typing import List, Optional, Tuple
  5. import sqlmodel
  6. from alembic import command
  7. from alembic.autogenerate import compare_metadata
  8. from alembic.config import Config
  9. from alembic.runtime.migration import MigrationContext
  10. from alembic.script import ScriptDirectory
  11. from alembic.util.exc import CommandError
  12. from loguru import logger
  13. from sqlalchemy import Engine, text
  14. from sqlmodel import SQLModel
  15. class SchemaManager:
  16. """
  17. Manages database schema validation and migrations using Alembic.
  18. Operations are initiated explicitly by DatabaseManager.
  19. """
  20. def __init__(
  21. self,
  22. engine: Engine,
  23. base_dir: Optional[Path] = None,
  24. ):
  25. """
  26. Initialize configuration only - no filesystem or DB operations.
  27. Args:
  28. engine: SQLAlchemy engine instance
  29. base_dir: Base directory for Alembic files. If None, uses current working directory
  30. """
  31. # Convert string path to Path object if necessary
  32. if isinstance(base_dir, str):
  33. base_dir = Path(base_dir)
  34. self.engine = engine
  35. self.base_dir = base_dir or Path(__file__).parent
  36. self.alembic_dir = self.base_dir / "alembic"
  37. self.alembic_ini_path = self.base_dir / "alembic.ini"
  38. def initialize_migrations(self, force: bool = False) -> bool:
  39. try:
  40. if force:
  41. logger.info("Force reinitialization of migrations...")
  42. self._cleanup_existing_alembic()
  43. if not self._initialize_alembic():
  44. return False
  45. else:
  46. try:
  47. self._validate_alembic_setup()
  48. logger.info("Using existing Alembic configuration")
  49. self._update_configuration()
  50. except FileNotFoundError:
  51. logger.info("Initializing new Alembic configuration")
  52. if not self._initialize_alembic():
  53. return False
  54. # Only generate initial revision if alembic is properly initialized
  55. logger.info("Creating initial migration...")
  56. return self.generate_revision("Initial schema") is not None
  57. except Exception as e:
  58. logger.error(f"Failed to initialize migrations: {e}")
  59. return False
  60. def _update_configuration(self) -> None:
  61. """Updates existing Alembic configuration with current settings."""
  62. logger.info("Updating existing Alembic configuration...")
  63. # Update alembic.ini
  64. config_content = self._generate_alembic_ini_content()
  65. with open(self.alembic_ini_path, "w") as f:
  66. f.write(config_content)
  67. # Update env.py
  68. env_path = self.alembic_dir / "env.py"
  69. if env_path.exists():
  70. self._update_env_py(env_path)
  71. else:
  72. self._create_minimal_env_py(env_path)
  73. def _cleanup_existing_alembic(self) -> None:
  74. """
  75. Completely remove existing Alembic configuration including versions.
  76. For fresh initialization, we don't need to preserve anything.
  77. """
  78. logger.info("Cleaning up existing Alembic configuration...")
  79. # Remove entire alembic directory if it exists
  80. if self.alembic_dir.exists():
  81. import shutil
  82. shutil.rmtree(self.alembic_dir)
  83. logger.info(f"Removed alembic directory: {self.alembic_dir}")
  84. # Remove alembic.ini if it exists
  85. if self.alembic_ini_path.exists():
  86. self.alembic_ini_path.unlink()
  87. logger.info("Removed alembic.ini")
  88. def _ensure_alembic_setup(self, *, force: bool = False) -> None:
  89. """
  90. Ensures Alembic is properly set up, initializing if necessary.
  91. Args:
  92. force: If True, removes existing configuration and reinitializes
  93. """
  94. try:
  95. self._validate_alembic_setup()
  96. if force:
  97. logger.info("Force initialization requested. Cleaning up existing configuration...")
  98. self._cleanup_existing_alembic()
  99. self._initialize_alembic()
  100. except FileNotFoundError:
  101. logger.info("Alembic configuration not found. Initializing...")
  102. if self.alembic_dir.exists():
  103. logger.warning("Found existing alembic directory but missing configuration")
  104. self._cleanup_existing_alembic()
  105. self._initialize_alembic()
  106. logger.info("Alembic initialization complete")
  107. def _initialize_alembic(self) -> bool:
  108. """Initialize alembic structure and configuration"""
  109. try:
  110. # Ensure parent directory exists
  111. self.alembic_dir.parent.mkdir(exist_ok=True)
  112. # Run alembic init to create fresh directory structure
  113. logger.info("Initializing alembic directory structure...")
  114. # Create initial config file for alembic init
  115. config_content = self._generate_alembic_ini_content()
  116. with open(self.alembic_ini_path, "w") as f:
  117. f.write(config_content)
  118. # Use the config we just created
  119. config = Config(str(self.alembic_ini_path))
  120. command.init(config, str(self.alembic_dir))
  121. # Update script template after initialization
  122. self.update_script_template()
  123. # Update env.py with our customizations
  124. self._update_env_py(self.alembic_dir / "env.py")
  125. logger.info("Alembic initialization complete")
  126. return True
  127. except Exception as e:
  128. # Explicitly convert error to string
  129. logger.error(f"Failed to initialize alembic: {str(e)}")
  130. return False
  131. def _create_minimal_env_py(self, env_path: Path) -> None:
  132. """Creates a minimal env.py file for Alembic."""
  133. content = """
  134. from logging.config import fileConfig
  135. from sqlalchemy import engine_from_config
  136. from sqlalchemy import pool
  137. from alembic import context
  138. from sqlmodel import SQLModel
  139. config = context.config
  140. if config.config_file_name is not None:
  141. fileConfig(config.config_file_name)
  142. target_metadata = SQLModel.metadata
  143. def run_migrations_offline() -> None:
  144. url = config.get_main_option("sqlalchemy.url")
  145. context.configure(
  146. url=url,
  147. target_metadata=target_metadata,
  148. literal_binds=True,
  149. dialect_opts={"paramstyle": "named"},
  150. compare_type=True
  151. )
  152. with context.begin_transaction():
  153. context.run_migrations()
  154. def run_migrations_online() -> None:
  155. connectable = engine_from_config(
  156. config.get_section(config.config_ini_section),
  157. prefix="sqlalchemy.",
  158. poolclass=pool.NullPool,
  159. )
  160. with connectable.connect() as connection:
  161. context.configure(
  162. connection=connection,
  163. target_metadata=target_metadata,
  164. compare_type=True
  165. )
  166. with context.begin_transaction():
  167. context.run_migrations()
  168. if context.is_offline_mode():
  169. run_migrations_offline()
  170. else:
  171. run_migrations_online()"""
  172. with open(env_path, "w") as f:
  173. f.write(content)
  174. def _generate_alembic_ini_content(self) -> str:
  175. """
  176. Generates content for alembic.ini file.
  177. """
  178. return f"""
  179. [alembic]
  180. script_location = {self.alembic_dir}
  181. sqlalchemy.url = {self.engine.url}
  182. [loggers]
  183. keys = root,sqlalchemy,alembic
  184. [handlers]
  185. keys = console
  186. [formatters]
  187. keys = generic
  188. [logger_root]
  189. level = WARN
  190. handlers = console
  191. qualname =
  192. [logger_sqlalchemy]
  193. level = WARN
  194. handlers =
  195. qualname = sqlalchemy.engine
  196. [logger_alembic]
  197. level = INFO
  198. handlers =
  199. qualname = alembic
  200. [handler_console]
  201. class = StreamHandler
  202. args = (sys.stderr,)
  203. level = NOTSET
  204. formatter = generic
  205. [formatter_generic]
  206. format = %(levelname)-5.5s [%(name)s] %(message)s
  207. datefmt = %H:%M:%S
  208. """.strip()
  209. def update_script_template(self):
  210. """Update the Alembic script template to include SQLModel."""
  211. template_path = self.alembic_dir / "script.py.mako"
  212. try:
  213. with open(template_path, "r") as f:
  214. content = f.read()
  215. # Add sqlmodel import to imports section
  216. import_section = "from alembic import op\nimport sqlalchemy as sa"
  217. new_imports = "from alembic import op\nimport sqlalchemy as sa\nimport sqlmodel"
  218. content = content.replace(import_section, new_imports)
  219. with open(template_path, "w") as f:
  220. f.write(content)
  221. logger.info("Updated script template")
  222. return True
  223. except Exception as e:
  224. logger.error(f"Failed to update script template: {e}")
  225. return False
  226. def _update_env_py(self, env_path: Path) -> None:
  227. """
  228. Updates the env.py file to use SQLModel metadata.
  229. """
  230. if not env_path.exists():
  231. self._create_minimal_env_py(env_path)
  232. return
  233. try:
  234. with open(env_path, "r") as f:
  235. content = f.read()
  236. # Add SQLModel import if not present
  237. if "from sqlmodel import SQLModel" not in content:
  238. content = "from sqlmodel import SQLModel\n" + content
  239. # Replace target_metadata
  240. content = content.replace("target_metadata = None", "target_metadata = SQLModel.metadata")
  241. # Update both configure blocks properly
  242. content = content.replace(
  243. """context.configure(
  244. url=url,
  245. target_metadata=target_metadata,
  246. literal_binds=True,
  247. dialect_opts={"paramstyle": "named"},
  248. )""",
  249. """context.configure(
  250. url=url,
  251. target_metadata=target_metadata,
  252. literal_binds=True,
  253. dialect_opts={"paramstyle": "named"},
  254. compare_type=True,
  255. )""",
  256. )
  257. content = content.replace(
  258. """ context.configure(
  259. connection=connection, target_metadata=target_metadata
  260. )""",
  261. """ context.configure(
  262. connection=connection,
  263. target_metadata=target_metadata,
  264. compare_type=True,
  265. )""",
  266. )
  267. with open(env_path, "w") as f:
  268. f.write(content)
  269. logger.info("Updated env.py with SQLModel metadata")
  270. except Exception as e:
  271. logger.error(f"Failed to update env.py: {e}")
  272. raise
  273. # Fixed: use keyword-only argument
  274. def _ensure_alembic_setup(self, *, force: bool = False) -> None:
  275. """
  276. Ensures Alembic is properly set up, initializing if necessary.
  277. Args:
  278. force: If True, removes existing configuration and reinitializes
  279. """
  280. try:
  281. self._validate_alembic_setup()
  282. if force:
  283. logger.info("Force initialization requested. Cleaning up existing configuration...")
  284. self._cleanup_existing_alembic()
  285. self._initialize_alembic()
  286. except FileNotFoundError:
  287. logger.info("Alembic configuration not found. Initializing...")
  288. if self.alembic_dir.exists():
  289. logger.warning("Found existing alembic directory but missing configuration")
  290. self._cleanup_existing_alembic()
  291. self._initialize_alembic()
  292. logger.info("Alembic initialization complete")
  293. def _validate_alembic_setup(self) -> None:
  294. """Validates that Alembic is properly configured."""
  295. required_files = [self.alembic_ini_path, self.alembic_dir / "env.py", self.alembic_dir / "versions"]
  296. missing = [f for f in required_files if not f.exists()]
  297. if missing:
  298. raise FileNotFoundError(f"Alembic configuration incomplete. Missing: {', '.join(str(f) for f in missing)}")
  299. def get_alembic_config(self) -> Config:
  300. """
  301. Gets Alembic configuration.
  302. Returns:
  303. Config: Alembic Config object
  304. Raises:
  305. FileNotFoundError: If alembic.ini cannot be found
  306. """
  307. if not self.alembic_ini_path.exists():
  308. raise FileNotFoundError("Could not find alembic.ini")
  309. return Config(str(self.alembic_ini_path))
  310. def get_current_revision(self) -> Optional[str]:
  311. """
  312. Gets the current database revision.
  313. Returns:
  314. str: Current revision string or None if no revision
  315. """
  316. with self.engine.connect() as conn:
  317. context = MigrationContext.configure(conn)
  318. return context.get_current_revision()
  319. def get_head_revision(self) -> str:
  320. """
  321. Gets the latest available revision.
  322. Returns:
  323. str: Head revision string
  324. """
  325. config = self.get_alembic_config()
  326. script = ScriptDirectory.from_config(config)
  327. return script.get_current_head()
  328. def get_schema_differences(self) -> List[tuple]:
  329. """
  330. Detects differences between current database and models.
  331. Returns:
  332. List[tuple]: List of differences found
  333. """
  334. with self.engine.connect() as conn:
  335. context = MigrationContext.configure(conn)
  336. diff = compare_metadata(context, SQLModel.metadata)
  337. return list(diff)
  338. def check_schema_status(self) -> Tuple[bool, str]:
  339. """
  340. Checks if database schema matches current models and migrations.
  341. Returns:
  342. Tuple[bool, str]: (needs_upgrade, status_message)
  343. """
  344. try:
  345. current_rev = self.get_current_revision()
  346. head_rev = self.get_head_revision()
  347. if current_rev != head_rev:
  348. return True, f"Database needs upgrade: {current_rev} -> {head_rev}"
  349. differences = self.get_schema_differences()
  350. if differences:
  351. changes_desc = "\n".join(str(diff) for diff in differences)
  352. return True, f"Unmigrated changes detected:\n{changes_desc}"
  353. return False, "Database schema is up to date"
  354. except Exception as e:
  355. logger.error(f"Error checking schema status: {str(e)}")
  356. return True, f"Error checking schema: {str(e)}"
  357. def upgrade_schema(self, revision: str = "head") -> bool:
  358. """
  359. Upgrades database schema to specified revision.
  360. Args:
  361. revision: Target revision (default: "head")
  362. Returns:
  363. bool: True if upgrade successful
  364. """
  365. try:
  366. config = self.get_alembic_config()
  367. command.upgrade(config, revision)
  368. logger.info(f"Schema upgraded successfully to {revision}")
  369. return True
  370. except Exception as e:
  371. logger.error(f"Schema upgrade failed: {str(e)}")
  372. return False
  373. def check_and_upgrade(self) -> Tuple[bool, str]:
  374. """
  375. Checks schema status and upgrades if necessary.
  376. Returns:
  377. Tuple[bool, str]: (action_taken, status_message)
  378. """
  379. needs_upgrade, status = self.check_schema_status()
  380. if needs_upgrade:
  381. # Remove the auto_upgrade check since we explicitly called this method
  382. if self.upgrade_schema():
  383. return True, "Schema was automatically upgraded"
  384. else:
  385. return False, "Automatic schema upgrade failed"
  386. return False, status
  387. def generate_revision(self, message: str = "auto") -> Optional[str]:
  388. """
  389. Generates new migration revision for current schema changes.
  390. Args:
  391. message: Revision message
  392. Returns:
  393. str: Revision ID if successful, None otherwise
  394. """
  395. try:
  396. config = self.get_alembic_config()
  397. command.revision(config, message=message, autogenerate=True)
  398. return self.get_head_revision()
  399. except Exception as e:
  400. logger.error(f"Failed to generate revision: {str(e)}")
  401. return None
  402. def get_pending_migrations(self) -> List[str]:
  403. """
  404. Gets list of pending migrations that need to be applied.
  405. Returns:
  406. List[str]: List of pending migration revision IDs
  407. """
  408. config = self.get_alembic_config()
  409. script = ScriptDirectory.from_config(config)
  410. current = self.get_current_revision()
  411. head = self.get_head_revision()
  412. if current == head:
  413. return []
  414. pending = []
  415. for rev in script.iterate_revisions(current, head):
  416. pending.append(rev.revision)
  417. return pending
  418. def print_status(self) -> None:
  419. """Prints current migration status information to logger."""
  420. current = self.get_current_revision()
  421. head = self.get_head_revision()
  422. differences = self.get_schema_differences()
  423. pending = self.get_pending_migrations()
  424. logger.info("=== Database Schema Status ===")
  425. logger.info(f"Current revision: {current}")
  426. logger.info(f"Head revision: {head}")
  427. logger.info(f"Pending migrations: {len(pending)}")
  428. for rev in pending:
  429. logger.info(f" - {rev}")
  430. logger.info(f"Unmigrated changes: {len(differences)}")
  431. for diff in differences:
  432. logger.info(f" - {diff}")
  433. def ensure_schema_up_to_date(self) -> bool:
  434. """
  435. Reset migrations and create fresh migration for current schema state.
  436. """
  437. try:
  438. logger.info("Resetting migrations and updating to current schema...")
  439. # 1. Clear the entire alembic directory
  440. if self.alembic_dir.exists():
  441. shutil.rmtree(self.alembic_dir)
  442. logger.info("Cleared alembic directory")
  443. # 2. Clear alembic_version table
  444. with self.engine.connect() as connection:
  445. connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
  446. connection.commit()
  447. logger.info("Reset alembic version")
  448. # 3. Reinitialize alembic from scratch
  449. if not self._initialize_alembic():
  450. logger.error("Failed to reinitialize alembic")
  451. return False
  452. # 4. Generate fresh migration from current schema
  453. revision = self.generate_revision("current_schema")
  454. if not revision:
  455. logger.error("Failed to generate new migration")
  456. return False
  457. logger.info(f"Generated fresh migration: {revision}")
  458. # 5. Apply the migration
  459. if not self.upgrade_schema():
  460. logger.error("Failed to apply migration")
  461. return False
  462. logger.info("Successfully applied migration")
  463. return True
  464. except Exception as e:
  465. logger.error(f"Failed to ensure schema is up to date: {e}")
  466. return False