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

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