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 17 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import os
  2. from pathlib import Path
  3. import shutil
  4. from typing import Optional, Tuple, List
  5. from loguru import logger
  6. from alembic import command
  7. from alembic.config import Config
  8. from alembic.runtime.migration import MigrationContext
  9. from alembic.script import ScriptDirectory
  10. from alembic.autogenerate import compare_metadata
  11. from sqlalchemy import Engine
  12. from sqlmodel import SQLModel
  13. class SchemaManager:
  14. """
  15. Manages database schema validation and migrations using Alembic.
  16. Provides automatic schema validation, migrations, and safe upgrades.
  17. Args:
  18. engine: SQLAlchemy engine instance
  19. auto_upgrade: Whether to automatically upgrade schema when differences found
  20. init_mode: Controls initialization behavior:
  21. - "none": No automatic initialization (raises error if not set up)
  22. - "auto": Initialize if not present (default)
  23. - "force": Always reinitialize, removing existing configuration
  24. """
  25. def __init__(
  26. self,
  27. engine: Engine,
  28. auto_upgrade: bool = True,
  29. init_mode: str = "auto"
  30. ):
  31. if init_mode not in ["none", "auto", "force"]:
  32. raise ValueError("init_mode must be one of: none, auto, force")
  33. self.engine = engine
  34. self.auto_upgrade = auto_upgrade
  35. # Set up paths relative to this file
  36. self.base_dir = Path(__file__).parent
  37. self.alembic_dir = self.base_dir / 'alembic'
  38. self.alembic_ini_path = self.base_dir / 'alembic.ini'
  39. # Handle initialization based on mode
  40. if init_mode == "none":
  41. self._validate_alembic_setup()
  42. else:
  43. self._ensure_alembic_setup(force=init_mode == "force")
  44. def _cleanup_existing_alembic(self) -> None:
  45. """
  46. Safely removes existing Alembic configuration while preserving versions directory.
  47. """
  48. logger.info(
  49. "Cleaning up existing Alembic configuration while preserving versions...")
  50. # Create a backup of versions directory if it exists
  51. if self.alembic_dir.exists() and (self.alembic_dir / 'versions').exists():
  52. logger.info("Preserving existing versions directory")
  53. # Remove alembic directory contents EXCEPT versions
  54. if self.alembic_dir.exists():
  55. for item in self.alembic_dir.iterdir():
  56. if item.name != 'versions':
  57. try:
  58. if item.is_dir():
  59. shutil.rmtree(item)
  60. logger.info(f"Removed directory: {item}")
  61. else:
  62. item.unlink()
  63. logger.info(f"Removed file: {item}")
  64. except Exception as e:
  65. logger.error(f"Failed to remove {item}: {e}")
  66. # Remove alembic.ini if it exists
  67. if self.alembic_ini_path.exists():
  68. try:
  69. self.alembic_ini_path.unlink()
  70. logger.info(
  71. f"Removed existing alembic.ini: {self.alembic_ini_path}")
  72. except Exception as e:
  73. logger.error(f"Failed to remove alembic.ini: {e}")
  74. def _ensure_alembic_setup(self, *, force: bool = False) -> None:
  75. """
  76. Ensures Alembic is properly set up, initializing if necessary.
  77. Args:
  78. force: If True, removes existing configuration and reinitializes
  79. """
  80. try:
  81. self._validate_alembic_setup()
  82. if force:
  83. logger.info(
  84. "Force initialization requested. Cleaning up existing configuration...")
  85. self._cleanup_existing_alembic()
  86. self._initialize_alembic()
  87. except FileNotFoundError:
  88. logger.info("Alembic configuration not found. Initializing...")
  89. if self.alembic_dir.exists():
  90. logger.warning(
  91. "Found existing alembic directory but missing configuration")
  92. self._cleanup_existing_alembic()
  93. self._initialize_alembic()
  94. logger.info("Alembic initialization complete")
  95. def _initialize_alembic(self) -> str:
  96. """Initializes Alembic configuration in the local directory."""
  97. logger.info("Initializing Alembic configuration...")
  98. # Check if versions exists
  99. has_versions = (self.alembic_dir / 'versions').exists()
  100. logger.info(f"Existing versions directory found: {has_versions}")
  101. # Create base directories
  102. self.alembic_dir.mkdir(exist_ok=True)
  103. if not has_versions:
  104. (self.alembic_dir / 'versions').mkdir(exist_ok=True)
  105. # Write alembic.ini
  106. ini_content = self._generate_alembic_ini_content()
  107. with open(self.alembic_ini_path, 'w') as f:
  108. f.write(ini_content)
  109. logger.info("Created alembic.ini")
  110. if not has_versions:
  111. # Only run init if no versions directory
  112. config = self.get_alembic_config()
  113. command.init(config, str(self.alembic_dir))
  114. logger.info("Initialized new Alembic directory structure")
  115. else:
  116. # Create minimal env.py if it doesn't exist
  117. env_path = self.alembic_dir / 'env.py'
  118. if not env_path.exists():
  119. self._create_minimal_env_py(env_path)
  120. logger.info("Created minimal env.py")
  121. else:
  122. # Update existing env.py
  123. self._update_env_py(env_path)
  124. logger.info("Updated existing env.py")
  125. logger.info(f"Alembic setup completed at {self.base_dir}")
  126. return str(self.alembic_ini_path)
  127. def _create_minimal_env_py(self, env_path: Path) -> None:
  128. """Creates a minimal env.py file for Alembic."""
  129. content = '''
  130. from logging.config import fileConfig
  131. from sqlalchemy import engine_from_config
  132. from sqlalchemy import pool
  133. from alembic import context
  134. from sqlmodel import SQLModel
  135. config = context.config
  136. if config.config_file_name is not None:
  137. fileConfig(config.config_file_name)
  138. target_metadata = SQLModel.metadata
  139. def run_migrations_offline() -> None:
  140. url = config.get_main_option("sqlalchemy.url")
  141. context.configure(
  142. url=url,
  143. target_metadata=target_metadata,
  144. literal_binds=True,
  145. dialect_opts={"paramstyle": "named"},
  146. compare_type=True
  147. )
  148. with context.begin_transaction():
  149. context.run_migrations()
  150. def run_migrations_online() -> None:
  151. connectable = engine_from_config(
  152. config.get_section(config.config_ini_section),
  153. prefix="sqlalchemy.",
  154. poolclass=pool.NullPool,
  155. )
  156. with connectable.connect() as connection:
  157. context.configure(
  158. connection=connection,
  159. target_metadata=target_metadata,
  160. compare_type=True
  161. )
  162. with context.begin_transaction():
  163. context.run_migrations()
  164. if context.is_offline_mode():
  165. run_migrations_offline()
  166. else:
  167. run_migrations_online()'''
  168. with open(env_path, 'w') as f:
  169. f.write(content)
  170. def _generate_alembic_ini_content(self) -> str:
  171. """
  172. Generates content for alembic.ini file.
  173. """
  174. return f"""
  175. [alembic]
  176. script_location = {self.alembic_dir}
  177. sqlalchemy.url = {self.engine.url}
  178. [loggers]
  179. keys = root,sqlalchemy,alembic
  180. [handlers]
  181. keys = console
  182. [formatters]
  183. keys = generic
  184. [logger_root]
  185. level = WARN
  186. handlers = console
  187. qualname =
  188. [logger_sqlalchemy]
  189. level = WARN
  190. handlers =
  191. qualname = sqlalchemy.engine
  192. [logger_alembic]
  193. level = INFO
  194. handlers =
  195. qualname = alembic
  196. [handler_console]
  197. class = StreamHandler
  198. args = (sys.stderr,)
  199. level = NOTSET
  200. formatter = generic
  201. [formatter_generic]
  202. format = %(levelname)-5.5s [%(name)s] %(message)s
  203. datefmt = %H:%M:%S
  204. """.strip()
  205. def _update_env_py(self, env_path: Path) -> None:
  206. """
  207. Updates the env.py file to use SQLModel metadata.
  208. """
  209. try:
  210. with open(env_path, 'r') as f:
  211. content = f.read()
  212. # Add SQLModel import
  213. if "from sqlmodel import SQLModel" not in content:
  214. content = "from sqlmodel import SQLModel\n" + content
  215. # Replace target_metadata
  216. content = content.replace(
  217. "target_metadata = None",
  218. "target_metadata = SQLModel.metadata"
  219. )
  220. # Add compare_type=True to context.configure
  221. if "context.configure(" in content and "compare_type=True" not in content:
  222. content = content.replace(
  223. "context.configure(",
  224. "context.configure(compare_type=True,"
  225. )
  226. with open(env_path, 'w') as f:
  227. f.write(content)
  228. logger.info("Updated env.py with SQLModel metadata")
  229. except Exception as e:
  230. logger.error(f"Failed to update env.py: {e}")
  231. raise
  232. # Fixed: use keyword-only argument
  233. def _ensure_alembic_setup(self, *, force: bool = False) -> None:
  234. """
  235. Ensures Alembic is properly set up, initializing if necessary.
  236. Args:
  237. force: If True, removes existing configuration and reinitializes
  238. """
  239. try:
  240. self._validate_alembic_setup()
  241. if force:
  242. logger.info(
  243. "Force initialization requested. Cleaning up existing configuration...")
  244. self._cleanup_existing_alembic()
  245. self._initialize_alembic()
  246. except FileNotFoundError:
  247. logger.info("Alembic configuration not found. Initializing...")
  248. if self.alembic_dir.exists():
  249. logger.warning(
  250. "Found existing alembic directory but missing configuration")
  251. self._cleanup_existing_alembic()
  252. self._initialize_alembic()
  253. logger.info("Alembic initialization complete")
  254. def _validate_alembic_setup(self) -> None:
  255. """Validates that Alembic is properly configured."""
  256. if not self.alembic_ini_path.exists():
  257. raise FileNotFoundError("Alembic configuration not found")
  258. def get_alembic_config(self) -> Config:
  259. """
  260. Gets Alembic configuration.
  261. Returns:
  262. Config: Alembic Config object
  263. Raises:
  264. FileNotFoundError: If alembic.ini cannot be found
  265. """
  266. if not self.alembic_ini_path.exists():
  267. raise FileNotFoundError("Could not find alembic.ini")
  268. return Config(str(self.alembic_ini_path))
  269. def get_current_revision(self) -> Optional[str]:
  270. """
  271. Gets the current database revision.
  272. Returns:
  273. str: Current revision string or None if no revision
  274. """
  275. with self.engine.connect() as conn:
  276. context = MigrationContext.configure(conn)
  277. return context.get_current_revision()
  278. def get_head_revision(self) -> str:
  279. """
  280. Gets the latest available revision.
  281. Returns:
  282. str: Head revision string
  283. """
  284. config = self.get_alembic_config()
  285. script = ScriptDirectory.from_config(config)
  286. return script.get_current_head()
  287. def get_schema_differences(self) -> List[tuple]:
  288. """
  289. Detects differences between current database and models.
  290. Returns:
  291. List[tuple]: List of differences found
  292. """
  293. with self.engine.connect() as conn:
  294. context = MigrationContext.configure(conn)
  295. diff = compare_metadata(context, SQLModel.metadata)
  296. return list(diff)
  297. def check_schema_status(self) -> Tuple[bool, str]:
  298. """
  299. Checks if database schema matches current models and migrations.
  300. Returns:
  301. Tuple[bool, str]: (needs_upgrade, status_message)
  302. """
  303. try:
  304. current_rev = self.get_current_revision()
  305. head_rev = self.get_head_revision()
  306. if current_rev != head_rev:
  307. return True, f"Database needs upgrade: {current_rev} -> {head_rev}"
  308. differences = self.get_schema_differences()
  309. if differences:
  310. changes_desc = "\n".join(str(diff) for diff in differences)
  311. return True, f"Unmigrated changes detected:\n{changes_desc}"
  312. return False, "Database schema is up to date"
  313. except Exception as e:
  314. logger.error(f"Error checking schema status: {str(e)}")
  315. return True, f"Error checking schema: {str(e)}"
  316. def upgrade_schema(self, revision: str = "head") -> bool:
  317. """
  318. Upgrades database schema to specified revision.
  319. Args:
  320. revision: Target revision (default: "head")
  321. Returns:
  322. bool: True if upgrade successful
  323. """
  324. try:
  325. config = self.get_alembic_config()
  326. command.upgrade(config, revision)
  327. logger.info(f"Schema upgraded successfully to {revision}")
  328. return True
  329. except Exception as e:
  330. logger.error(f"Schema upgrade failed: {str(e)}")
  331. return False
  332. def check_and_upgrade(self) -> Tuple[bool, str]:
  333. """
  334. Checks schema status and upgrades if necessary (and auto_upgrade is True).
  335. Returns:
  336. Tuple[bool, str]: (action_taken, status_message)
  337. """
  338. needs_upgrade, status = self.check_schema_status()
  339. if needs_upgrade:
  340. if self.auto_upgrade:
  341. if self.upgrade_schema():
  342. return True, "Schema was automatically upgraded"
  343. else:
  344. return False, "Automatic schema upgrade failed"
  345. else:
  346. return False, f"Schema needs upgrade but auto_upgrade is disabled. Status: {status}"
  347. return False, status
  348. def generate_revision(self, message: str = "auto") -> Optional[str]:
  349. """
  350. Generates new migration revision for current schema changes.
  351. Args:
  352. message: Revision message
  353. Returns:
  354. str: Revision ID if successful, None otherwise
  355. """
  356. try:
  357. config = self.get_alembic_config()
  358. command.revision(
  359. config,
  360. message=message,
  361. autogenerate=True
  362. )
  363. return self.get_head_revision()
  364. except Exception as e:
  365. logger.error(f"Failed to generate revision: {str(e)}")
  366. return None
  367. def get_pending_migrations(self) -> List[str]:
  368. """
  369. Gets list of pending migrations that need to be applied.
  370. Returns:
  371. List[str]: List of pending migration revision IDs
  372. """
  373. config = self.get_alembic_config()
  374. script = ScriptDirectory.from_config(config)
  375. current = self.get_current_revision()
  376. head = self.get_head_revision()
  377. if current == head:
  378. return []
  379. pending = []
  380. for rev in script.iterate_revisions(current, head):
  381. pending.append(rev.revision)
  382. return pending
  383. def print_status(self) -> None:
  384. """Prints current migration status information to logger."""
  385. current = self.get_current_revision()
  386. head = self.get_head_revision()
  387. differences = self.get_schema_differences()
  388. pending = self.get_pending_migrations()
  389. logger.info("=== Database Schema Status ===")
  390. logger.info(f"Current revision: {current}")
  391. logger.info(f"Head revision: {head}")
  392. logger.info(f"Pending migrations: {len(pending)}")
  393. for rev in pending:
  394. logger.info(f" - {rev}")
  395. logger.info(f"Unmigrated changes: {len(differences)}")
  396. for diff in differences:
  397. logger.info(f" - {diff}")
  398. def ensure_schema_up_to_date(self) -> bool:
  399. """
  400. Ensures the database schema is up to date, generating and applying migrations if needed.
  401. Returns:
  402. bool: True if schema is up to date or was successfully updated
  403. """
  404. try:
  405. # Check for unmigrated changes
  406. differences = self.get_schema_differences()
  407. if differences:
  408. # Generate new migration
  409. revision = self.generate_revision("auto-generated")
  410. if not revision:
  411. return False
  412. logger.info(f"Generated new migration: {revision}")
  413. # Apply any pending migrations
  414. upgraded, status = self.check_and_upgrade()
  415. if not upgraded and "needs upgrade" in status.lower():
  416. return False
  417. return True
  418. except Exception as e:
  419. logger.error(f"Failed to ensure schema is up to date: {e}")
  420. return False