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

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