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

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