diff --git a/README.md b/README.md index 4192a5a..26026a9 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,16 @@ Here are some commands: ``` Run all needed migrations on the db. Fails if a migration fails, or if there is no managed db at the path. This is equivalent to calling `fastmigrate.run_migrations()` +5. **Enroll an existing db**: + ``` + fastmigrate_enroll_db --db path/to/data.db + ``` + This will create a new versioned database at the path, and copy the contents of the existing database into it. This is equivalent to calling `fastmigrate.enroll_db()`. + + This command will offer to create an initial migration script for you, based on the current schema of the database. + + + ### How to enroll an existing, unversioned database into fastmigrate diff --git a/enrolling.md b/enrolling.md index 998e220..f991c3d 100644 --- a/enrolling.md +++ b/enrolling.md @@ -23,8 +23,8 @@ But if you are enrolling an existing db into fastmigrate, then you need to do th - Second, manually modify your current data to add fastmigrate version tag and set its version to 1. You can do this by using fastmigrate's internal API. Doing this constitutes asserting that the db is in fact in the state which would be produced by the migration script 0001. After doing this, fastmigrate will recognize your db as managed. Here is how to do it: ```python -from fastmigrate.core import _ensure_meta_table, _set_db_version -_ensure_meta_table("path/to/data.db") +from fastmigrate.core import enroll_db, _set_db_version +enroll_db("path/to/data.db") _set_db_version("path/to/data.db",1) ``` diff --git a/fastmigrate/cli.py b/fastmigrate/cli.py index 0d17ab4..615c818 100644 --- a/fastmigrate/cli.py +++ b/fastmigrate/cli.py @@ -9,6 +9,10 @@ from importlib.metadata import version from fastmigrate import core +from rich.console import Console + +# Initialize Rich console +console = Console() # Define constants - single source of truth for default values DEFAULT_DB = Path("data/database.db") @@ -128,6 +132,57 @@ def create_db( print(f"Unexpected error: {e}") sys.exit(1) + +@call_parse +def enroll_db( + db:Path=DEFAULT_DB, # Path to the SQLite database file + migrations:Path=DEFAULT_MIGRATIONS, # migrations dir, which may not exist + config_path:Path=DEFAULT_CONFIG, # Path to config file + force:bool=False # Force enrollment even if no migrations are available +) -> None: + """Convert an unversioned SQLite database to a versioned one. + + Note: command line arguments take precedence over values from a + config file, unless they are equal to default values. + """ + db_path, migrations_path = _get_config(config_path, db, migrations) + if not db_path.exists(): + raise FileNotFoundError(f"Database file does not exist: {db_path}") + + initial_migration_path = migrations_path / "0001_initial.sql" + if not force and not migrations_path.exists(): + console.print(f"[red bold]No migrations directory or initial migration exists.[/red bold]") + while True: + answer = input(f"Want fastmigrate to generate the initial migration? [Y/n] ").strip().lower() + if answer.lower() in ('y', 'yes', ''): + migrations_path.mkdir(parents=True, exist_ok=True) + console.print(f"Created directory: {migrations_path}") + schema = core.get_db_schema(db_path) + initial_migration_path.write_text(schema) + console.print(f"Created initial migration at '{initial_migration_path}'") + break + elif answer.lower() in ('n', 'no'): + answer2 = input(f"We [bold]strongly[/bold] recommend creating a migrations directory.\nWant to continue anyway? [N/y] ").strip().lower() + if answer2.lower() in ('n', 'no', ''): + sys.exit(1) + elif answer2.lower() in ('y', 'yes'): + console.print("[red]Continuing without a migrations directory or initial migration.[/red]") + break + else: + console.print("Invalid input. Please enter 'y' or 'n'.") + + try: + success = core.enroll_db(db_path) + except core.MetaTableExists: + console.print(f"[red]Database at {db_path} is already versioned.[/red]") + sys.exit(1) + if not success: + console.print(f"[red]Failed to enroll the database at {db_path}.[/red]") + sys.exit(1) + console.print(f"[bold]Database at {db_path} is now versioned.[/bold]") + + + @call_parse def run_migrations( db: Path = DEFAULT_DB, # Path to the SQLite database file @@ -142,5 +197,22 @@ def run_migrations( db_path, migrations_path = _get_config(config_path, db, migrations) success = core.run_migrations(db_path, migrations_path, verbose=True) if not success: + console.print(f"Ran migrations from {migrations_path} to {db_path}") sys.exit(1) + +@call_parse +def get_db_schema( + db: Path = DEFAULT_DB, # Path to the SQLite database file + config_path: Path = DEFAULT_CONFIG # Path to config file +) -> None: + """Get the schema of the SQLite database. + + Note: command line arguments take precedence over values from a + config file, unless they are equal to default values. + """ + db_path, migrations_path = _get_config(config_path, db) + schema = core.get_db_schema(db_path) + print(schema) + # if not success: + # sys.exit(1) \ No newline at end of file diff --git a/fastmigrate/core.py b/fastmigrate/core.py index 8f90a12..3d14ab1 100644 --- a/fastmigrate/core.py +++ b/fastmigrate/core.py @@ -9,14 +9,15 @@ import warnings from datetime import datetime from pathlib import Path -from typing import Dict, Optional +from typing import Dict, List, Optional, Tuple from rich.console import Console # Initialize Rich console console = Console() -__all__ = ["run_migrations", "create_db", "get_db_version", "create_db_backup", +__all__ = ["run_migrations", "create_db", "get_db_version", + "create_db_backup", "get_db_schema", "enroll_db", "MetaTableExists" # deprecated "ensure_versioned_db", "create_database_backup"] @@ -36,7 +37,7 @@ def create_db(db_path:Path) -> int: db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(db_path) conn.close() - _ensure_meta_table(db_path) + enroll_db(db_path, err_if_versioned=False) return 0 else: return get_db_version(db_path) @@ -49,24 +50,41 @@ def ensure_versioned_db(db_path:Path) -> int: stacklevel=2) return create_db(db_path) -def _ensure_meta_table(db_path: Path) -> None: +def _ensure_meta_table(db_path:Path) -> None: + "See enroll_db" + warnings.warn("_ensure_meta_table is deprecated, as it has been renamed to enroll_db, which is identical except adding a boolean return value", + DeprecationWarning, + stacklevel=2) + return enroll_db(db_path, err_if_versioned=False) + +class MetaTableExists(Exception): pass + +def enroll_db(db_path: Path, err_if_versioned:bool=True) -> bool: """Create the _meta table if it doesn't exist, with a single row constraint. Uses a single-row pattern with a PRIMARY KEY on a constant value (1). This ensures we can only have one row in the table. + Depending on how this function is called, it may raise an error if the + table already exists or return 0. This allows different levels of verbosity + to be used in different contexts. + WARNING: users should call this directly only if preparing a versioned db manually, for instance, for testing or for enrolling a non-version db after verifying its values and schema are what - would be produced by migration scripts. + would be produced by migration scripts. Args: db_path: Path to the SQLite database + err_if_versioned: If True, raises an error if the table already exists. + If False, returns False if the table exists. + + Returns: + bool: True if the table was created successfully, False otherwise Raises: FileNotFoundError: If database file doesn't exist sqlite3.Error: If unable to read or write to the database - """ db_path = Path(db_path) # First check if the file exists @@ -100,12 +118,15 @@ def _ensure_meta_table(db_path: Path) -> None: conn.execute("INSERT INTO _meta (id, version) VALUES (1, 0)") except sqlite3.Error as e: raise sqlite3.Error(f"Failed to create _meta table: {e}") + return True + else: + if err_if_versioned: raise MetaTableExists("_meta table already exists") + else: return False except sqlite3.Error as e: raise sqlite3.Error(f"Failed to access database: {e}") finally: if conn: - conn.close() - + conn.close() def get_db_version(db_path: Path) -> int: """Get the current database version. @@ -212,7 +233,6 @@ def get_migration_scripts(migrations_dir: Path) -> Dict[int, Path]: f"{migration_scripts[version]} and {file_path}" ) migration_scripts[version] = file_path - return migration_scripts @@ -360,6 +380,52 @@ def create_database_backup(db_path:Path) -> Path | None: return create_db_backup(db_path) +def get_db_schema(db_path: Path) -> str: + """Get the SQL schema of a SQLite database file. + + This function retrieves the CREATE statements for all tables, + indices, triggers, and views in the database. + + Args: + db_path: Path to the SQLite database file + + Returns: + str: The SQL schema as a string + + Raises: + FileNotFoundError: If database file doesn't exist + sqlite3.Error: If unable to access the database + """ + db_path = Path(db_path) + # First check if the file exists + if not db_path.exists(): + raise FileNotFoundError(f"Database file does not exist: {db_path}") + + conn = None + try: + conn = sqlite3.connect(db_path) + sqls = [] + # Get schema information for all objects + for row in conn.execute( + "select sql from sqlite_master where sql is not null" + ).fetchall(): + sql = row[0] + if 'sqlite_stat1' in sql: continue + if 'sqlite_stat4' in sql: continue + if 'CREATE TABLE' in sql: sql = sql.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS") + if 'CREATE INDEX' in sql: sql = sql.replace("CREATE INDEX", "CREATE INDEX IF NOT EXISTS") + if not sql.strip().endswith(";"): + sql += ";" + sqls.append(sql) + return "\n".join(sqls) + + except sqlite3.Error as e: + raise sqlite3.Error(f"Failed to retrieve database schema: {e}") + finally: + if conn: + conn.close() + + def execute_migration_script(db_path: Path, script_path: Path) -> bool: """Execute a migration script based on its file extension.""" db_path = Path(db_path) diff --git a/pyproject.toml b/pyproject.toml index 1e17d8a..b644143 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ dev = [ fastmigrate_backup_db = "fastmigrate.cli:backup_db" fastmigrate_check_version = "fastmigrate.cli:check_version" fastmigrate_create_db = "fastmigrate.cli:create_db" +fastmigrate_enroll_db = "fastmigrate.cli:enroll_db" +fastmigrate_get_db_schema = "fastmigrate.cli:get_db_schema" fastmigrate_run_migrations = "fastmigrate.cli:run_migrations" [tool.pytest] diff --git a/tests/test_cli.py b/tests/test_cli.py index 0cdd004..2d5702d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,21 +9,30 @@ from unittest.mock import patch import subprocess -from fastmigrate.cli import backup_db, check_version, create_db, run_migrations -from fastmigrate.core import _ensure_meta_table, _set_db_version +from fastmigrate.cli import backup_db, check_version, create_db, enroll_db, run_migrations +from fastmigrate.core import enroll_db, _set_db_version # Path to the test migrations directory CLI_MIGRATIONS_DIR = Path(__file__).parent / "test_cli" def test_cli_help_backup_db(): - """Test the CLI help output for .""" + """Test the CLI help output for backup_db.""" # Capture standard output result = subprocess.run(['fastmigrate_backup_db', '--help'], capture_output=True, text=True) assert result.returncode == 0 assert "usage: fastmigrate_backup_db [-h] [--db DB]" in result.stdout + +def test_cli_help_enroll_db(): + """Test the CLI help output for enroll_db.""" + # Capture standard output + result = subprocess.run(['fastmigrate_enroll_db', '--help'], + capture_output=True, text=True) + assert result.returncode == 0 + assert "usage: fastmigrate_enroll_db [-h] [--db DB]" in result.stdout + def test_cli_explicit_paths(): """Test CLI with explicit path arguments.""" with tempfile.TemporaryDirectory() as temp_dir: @@ -41,7 +50,7 @@ def test_cli_explicit_paths(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Create a migration with open(migrations_dir / "0001-test.sql", "w") as f: @@ -84,7 +93,7 @@ def test_cli_backup_option(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Create a test migration with open(migrations_path / "0001-test.sql", "w") as f: @@ -154,7 +163,7 @@ def test_cli_config_file(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Create a migration (migrations_dir / "0001-test.sql").write_text("CREATE TABLE custom_config (id INTEGER PRIMARY KEY);") @@ -206,7 +215,7 @@ def test_cli_precedence(): conn = sqlite3.connect(db) conn.close() # Initialize the database with _meta table - _ensure_meta_table(db) + enroll_db(db) # Create different migrations in each directory with open(migrations_config / "0001-config.sql", "w") as f: @@ -249,122 +258,252 @@ def test_cli_precedence(): assert cursor.fetchone() is None, "CLI DB should not have config_table" conn_cli.close() - +def test_cli_enroll_db_success(tmp_path): + """Test the CLI enroll_db command successfully enrolls an unversioned database.""" + db_path = tmp_path / "unversioned.db" + + # Create an unversioned database with a sample table + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)") + conn.execute("INSERT INTO users (name) VALUES ('test_user')") + conn.commit() + conn.close() + + # Verify _meta table doesn't exist yet + conn = sqlite3.connect(db_path) + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_meta'") + assert cursor.fetchone() is None + conn.close() + + # Run the enroll_db command + result = subprocess.run([ + "fastmigrate_enroll_db", + "--db", db_path, '--force' + ], capture_output=True, text=True) + + assert result.returncode == 0 + + # Verify the database has been enrolled (_meta table created) + conn = sqlite3.connect(db_path) + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_meta'") + assert cursor.fetchone() is not None + + # Original data should still be intact + cursor = conn.execute("SELECT name FROM users WHERE name='test_user'") + assert cursor.fetchone() is not None + + # Version should be 0 + cursor = conn.execute("SELECT version FROM _meta WHERE id = 1") + assert cursor.fetchone()[0] == 0 + + conn.close() -def test_cli_createdb_flag(): - """Test the --create_db flag properly initializes a database with _meta table.""" - with tempfile.TemporaryDirectory() as temp_dir: - temp_dir_path = Path(temp_dir) - db_path = temp_dir_path / "new_db.db" - - # Verify the database doesn't exist yet - assert not db_path.exists() - - # Run the CLI with just the --create_db flag - result = subprocess.run([ - "fastmigrate_create_db", - "--db", db_path, - ]) - - assert result.returncode == 0 - - # Verify database was created - assert db_path.exists() - - # Verify the _meta table exists with version 0 - conn = sqlite3.connect(db_path) - cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_meta'") - assert cursor.fetchone() is not None - - cursor = conn.execute("SELECT version FROM _meta WHERE id = 1") - assert cursor.fetchone()[0] == 0 + +def test_cli_enroll_db_already_versioned(tmp_path): + """Test the CLI enroll_db command fails when the database is already versioned.""" + db_path = tmp_path / "versioned.db" + + # Create a versioned database + conn = sqlite3.connect(db_path) + conn.execute(""" + CREATE TABLE _meta ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL DEFAULT 0 + ) + """) + conn.execute("INSERT INTO _meta (id, version) VALUES (1, 42)") + conn.commit() + conn.close() + + # Run the enroll_db command on an already versioned database + result = subprocess.run([ + "fastmigrate_enroll_db", + "--db", db_path, '--force' + ], capture_output=True, text=True) + + # Should exit with non-zero status because the database is already versioned + assert result.returncode == 1 + + # Verify the database version wasn't changed + conn = sqlite3.connect(db_path) + cursor = conn.execute("SELECT version FROM _meta WHERE id = 1") + assert cursor.fetchone()[0] == 42 + conn.close() + + +def test_cli_enroll_db_nonexistent_db(tmp_path): + """Test the CLI enroll_db command fails when the database doesn't exist.""" + db_path = tmp_path / "nonexistent.db" + + # Verify file doesn't exist + assert not db_path.exists() + + # Run the enroll_db command on a non-existent database + result = subprocess.run([ + "fastmigrate_enroll_db", + "--db", db_path, '--force' + ], capture_output=True, text=True) + + # Should exit with non-zero status + assert result.returncode == 1 + assert "does not exist" in result.stdout or "does not exist" in result.stderr + + +def test_cli_enroll_db_invalid_db(tmp_path): + """Test the CLI enroll_db command fails when the database is invalid.""" + db_path = tmp_path / "invalid.db" + + # Create an invalid database file + with open(db_path, 'wb') as f: + f.write(b'This is not a valid SQLite database') + + # Run the enroll_db command on an invalid database + result = subprocess.run([ + "fastmigrate_enroll_db", + "--db", db_path + ], capture_output=True, text=True) + + # Should exit with non-zero status + assert result.returncode == 1 + + +def test_cli_enroll_db_with_config_file(tmp_path): + """Test the CLI enroll_db command with configuration from a file.""" + db_path = tmp_path / "db_from_config.db" + config_path = tmp_path / "test_config.ini" + + # Create an unversioned database + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)") + conn.commit() + conn.close() + + # Create a config file + config_path.write_text(f"[paths]\ndb = {db_path}") + + # Run the enroll_db command with config + result = subprocess.run([ + "fastmigrate_enroll_db", + "--config", config_path, '--force' + ], capture_output=True, text=True) + + assert result.returncode == 0 + + # Verify the database has been enrolled + conn = sqlite3.connect(db_path) + cursor = conn.execute("SELECT version FROM _meta WHERE id = 1") + assert cursor.fetchone()[0] == 0 + conn.close() - conn.close() + +def test_cli_createdb_flag(tmp_path): + """Test the --create_db flag properly initializes a database with _meta table.""" + db_path = tmp_path / "new_db.db" + + # Verify the database doesn't exist yet + assert not db_path.exists() + + # Run the CLI with just the --create_db flag + result = subprocess.run([ + "fastmigrate_create_db", + "--db", db_path, + ]) + + assert result.returncode == 0 + + # Verify database was created + assert db_path.exists() + + # Verify the _meta table exists with version 0 + conn = sqlite3.connect(db_path) + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_meta'") + assert cursor.fetchone() is not None + + cursor = conn.execute("SELECT version FROM _meta WHERE id = 1") + assert cursor.fetchone()[0] == 0 + + conn.close() -def test_check_db_version_option(): +def test_check_db_version_option(tmp_path): """Test the --check_db_version option correctly reports the database version.""" - with tempfile.TemporaryDirectory() as temp_dir: - temp_dir_path = Path(temp_dir) - db_path = temp_dir_path / "test.db" - - # Create database file with version 42 - conn = sqlite3.connect(db_path) - conn.close() - _ensure_meta_table(db_path) - _set_db_version(db_path, 42) - - # Test with versioned database - result = subprocess.run([ - "fastmigrate_check_version", - "--db", db_path - ], capture_output=True, text=True) - - assert result.returncode == 0 - assert "Database version: 42" in result.stdout - - # Create unversioned database - unversioned_db = temp_dir_path / "unversioned.db" - conn = sqlite3.connect(unversioned_db) - conn.close() - - # Test with unversioned database - result = subprocess.run([ - "fastmigrate_check_version", - "--db", unversioned_db, - ], capture_output=True, text=True) - - assert result.returncode == 0 - assert "unversioned" in result.stdout.lower() - - # Test with non-existent database - nonexistent_db = temp_dir_path / "nonexistent.db" - result = subprocess.run([ - "fastmigrate_check_version", - "--db", nonexistent_db, - ], capture_output=True, text=True) - - assert result.returncode == 1 - assert "does not exist" in result.stdout + db_path = tmp_path / "test.db" + + # Create database file with version 42 + conn = sqlite3.connect(db_path) + conn.close() + enroll_db(db_path) + _set_db_version(db_path, 42) + + # Test with versioned database + result = subprocess.run([ + "fastmigrate_check_version", + "--db", db_path + ], capture_output=True, text=True) + + assert result.returncode == 0 + assert "Database version: 42" in result.stdout + + # Create unversioned database + unversioned_db = tmp_path / "unversioned.db" + conn = sqlite3.connect(unversioned_db) + conn.close() + + # Test with unversioned database + result = subprocess.run([ + "fastmigrate_check_version", + "--db", unversioned_db, + ], capture_output=True, text=True) + + assert result.returncode == 0 + assert "unversioned" in result.stdout.lower() + + # Test with non-existent database + nonexistent_db = tmp_path / "nonexistent.db" + result = subprocess.run([ + "fastmigrate_check_version", + "--db", nonexistent_db, + ], capture_output=True, text=True) + + assert result.returncode == 1 + assert "does not exist" in result.stdout -def test_cli_with_testsuite_a(): +def test_cli_with_testsuite_a(tmp_path): """Test CLI using testsuite_a.""" - with tempfile.TemporaryDirectory() as temp_dir: - temp_dir_path = Path(temp_dir) - db_path = temp_dir_path / "test.db" - - # Create empty database file - conn = sqlite3.connect(db_path) - conn.close() - - # Initialize the database with _meta table - _ensure_meta_table(db_path) - - # Run the CLI with explicit paths to the test suite - result = subprocess.run([ - "fastmigrate_run_migrations", - "--db", db_path, - "--migrations", CLI_MIGRATIONS_DIR / "migrations" - ], capture_output=True, text=True) - - assert result.returncode == 0 - - # Verify migrations applied - conn = sqlite3.connect(db_path) - - # Version should be 4 (all migrations applied) - cursor = conn.execute("SELECT version FROM _meta") - assert cursor.fetchone()[0] == 4 - - # Verify tables exist - tables = ["users", "posts", "tags", "post_tags"] - for table in tables: - cursor = conn.execute( - f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table}'" - ) - assert cursor.fetchone() is not None - - conn.close() + db_path = tmp_path / "test.db" + + # Create empty database file + conn = sqlite3.connect(db_path) + conn.close() + + # Initialize the database with _meta table + enroll_db(db_path) + + # Run the CLI with explicit paths to the test suite + result = subprocess.run([ + "fastmigrate_run_migrations", + "--db", db_path, + "--migrations", CLI_MIGRATIONS_DIR / "migrations" + ], capture_output=True, text=True) + + assert result.returncode == 0 + + # Verify migrations applied + conn = sqlite3.connect(db_path) + + # Version should be 4 (all migrations applied) + cursor = conn.execute("SELECT version FROM _meta") + assert cursor.fetchone()[0] == 4 + + # Verify tables exist + tables = ["users", "posts", "tags", "post_tags"] + for table in tables: + cursor = conn.execute( + f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table}'" + ) + assert cursor.fetchone() is not None + + conn.close() diff --git a/tests/test_comprehensive/test_comprehensive.py b/tests/test_comprehensive/test_comprehensive.py index 03742b6..e27b5cf 100644 --- a/tests/test_comprehensive/test_comprehensive.py +++ b/tests/test_comprehensive/test_comprehensive.py @@ -26,7 +26,7 @@ from pathlib import Path from fastmigrate.core import ( - _ensure_meta_table, get_db_version, _set_db_version, + enroll_db, get_db_version, _set_db_version, get_migration_scripts, run_migrations ) @@ -51,7 +51,7 @@ def setUp(self): self.conn.close() # Initialize the _meta table (should be version 0) - _ensure_meta_table(self.db_path) + enroll_db(self.db_path) # Verify we're starting with version 0 self.assertEqual(get_db_version(self.db_path), 0, diff --git a/tests/test_core.py b/tests/test_core.py index 0589b44..6067ecb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,7 +9,7 @@ import pytest from fastmigrate.core import ( - _ensure_meta_table, + enroll_db, create_db, get_db_version, _set_db_version, @@ -20,14 +20,14 @@ ) -def test_ensure_meta_table(): +def testenroll_db(): """Test ensuring the _meta table exists.""" # Create a temp file database for testing with tempfile.NamedTemporaryFile(suffix='.db') as temp_file: db_path = Path(temp_file.name) - # Call _ensure_meta_table on the path - _ensure_meta_table(db_path) + # Call enroll_db on the path + enroll_db(db_path) # Connect and check results conn = sqlite3.connect(db_path) @@ -62,7 +62,7 @@ def test_ensure_meta_table(): # Test with invalid path to verify exception is raised with pytest.raises(FileNotFoundError): - _ensure_meta_table(Path("/nonexistent/path/to/db.db")) + enroll_db(Path("/nonexistent/path/to/db.db")) def test_get_set_db_version(): # Tests the internal _set_db_version function @@ -72,7 +72,7 @@ def test_get_set_db_version(): # Tests the internal _set_db_version function db_path = Path(temp_file.name) # Initialize the database first - _ensure_meta_table(db_path) + enroll_db(db_path) # Initial version should be 0 assert get_db_version(db_path) == 0 diff --git a/tests/test_enroll_db.py b/tests/test_enroll_db.py new file mode 100644 index 0000000..9949703 --- /dev/null +++ b/tests/test_enroll_db.py @@ -0,0 +1,128 @@ +"""Tests for the enroll_db functionality of fastmigrate.""" + +import os +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +from fastmigrate.core import enroll_db, get_db_version + + +def test_enroll_db_on_new_db(tmp_path): + """Test enrolling a newly created database.""" + # Create a temp file database for testing + db_path = tmp_path / "test_enroll.db" + + # Create empty database file + conn = sqlite3.connect(db_path) + conn.close() + + # Enroll the database + result = enroll_db(db_path) + + # Connect and check results + conn = sqlite3.connect(db_path) + + # Check the table exists + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_meta'") + assert cursor.fetchone() is not None + + # Check there's one row + cursor = conn.execute("SELECT COUNT(*) FROM _meta") + assert cursor.fetchone()[0] == 1 + + # Check the version is 0 + cursor = conn.execute("SELECT version FROM _meta WHERE id = 1") + assert cursor.fetchone()[0] == 0 + + # Check result is True (table was created) + assert result is True + + # Also verify using get_db_version + assert get_db_version(db_path) == 0 + + conn.close() + + +def test_enroll_db_on_versioned_db(tmp_path): + """Test enrolling a database that is already versioned.""" + # Create a temp file database for testing + db_path = tmp_path / "test_enroll.db" + + # Create a versioned database + conn = sqlite3.connect(db_path) + conn.execute(""" + CREATE TABLE _meta ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL DEFAULT 0 + ) + """) + conn.execute("INSERT INTO _meta (id, version) VALUES (1, 42)") + conn.commit() + conn.close() + + # Try to enroll the already versioned database + result = enroll_db(db_path, err_if_versioned=False) + + # Check result is False (table already existed) + assert result is False + + # Verify version wasn't changed + assert get_db_version(db_path) == 42 + + +def test_enroll_db_on_unversioned_db_with_tables(tmp_path): + """Test enrolling an existing database with tables but no version tracking.""" + # Create a temp file database for testing + db_path = tmp_path / "test_enroll.db" + + # Create an unversioned database with a sample table + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)") + conn.execute("INSERT INTO users (name) VALUES ('test_user')") + conn.commit() + conn.close() + + # Enroll the database + result = enroll_db(db_path) + + # Check result is True (table was created) + assert result is True + + # Verify the version is 0 + assert get_db_version(db_path) == 0 + + # Verify the sample table still exists + conn = sqlite3.connect(db_path) + cursor = conn.execute("SELECT name FROM users") + assert cursor.fetchone()[0] == 'test_user' + conn.close() + + +def test_enroll_db_nonexistent_file(tmp_path): + """Test enrolling a database that doesn't exist.""" + # Create a path to a non-existent database + db_path = tmp_path / "nonexistent.db" + + # Verify file doesn't exist + assert not db_path.exists() + + # Try to enroll the non-existent database (should raise FileNotFoundError) + with pytest.raises(FileNotFoundError): + enroll_db(db_path) + + +def test_enroll_db_invalid_db(tmp_path): + """Test enrolling an invalid database file.""" + # Create a temp file with invalid content + db_path = tmp_path / "bad.db" + + # Write some invalid binary data + with open(db_path, 'wb') as f: + f.write(b'This is not a valid SQLite database') + + # Try to enroll the invalid database (should raise sqlite3.Error) + with pytest.raises(sqlite3.Error): + enroll_db(db_path) \ No newline at end of file diff --git a/tests/test_failures.py b/tests/test_failures.py index 76b9a01..a139bff 100644 --- a/tests/test_failures.py +++ b/tests/test_failures.py @@ -7,7 +7,7 @@ import pytest -from fastmigrate.core import run_migrations, _ensure_meta_table +from fastmigrate.core import run_migrations, enroll_db # Path to the migrations directory @@ -25,7 +25,7 @@ def test_sql_failure(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Run migrations - should fail on the second migration result = run_migrations(db_path, migrations_dir) @@ -63,7 +63,7 @@ def test_cli_sql_failure(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Run the CLI with path to the failure test suite result = subprocess.run([ @@ -95,7 +95,7 @@ def test_python_failure(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Create a test database with initial successful migration initial_migration = migrations_dir / "0001-init.sql" @@ -139,7 +139,7 @@ def test_shell_failure(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Create a test database with initial successful migration initial_migration = migrations_dir / "0001-init.sql" @@ -188,7 +188,7 @@ def test_testsuite_failure_cli(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Create a temporary migrations directory with just the successful migration # and the specific failure migration we want to test diff --git a/tests/test_migrations.py b/tests/test_migrations.py index bf7ee36..9031e8b 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -7,7 +7,7 @@ import pytest -from fastmigrate.core import run_migrations, _ensure_meta_table +from fastmigrate.core import run_migrations, enroll_db def test_run_migrations_sql(): @@ -24,7 +24,7 @@ def test_run_migrations_sql(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Create SQL migration files with open(os.path.join(migrations_dir, "0001-create-table.sql"), "w") as f: @@ -93,7 +93,7 @@ def test_run_migrations_python(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Create a base SQL migration with open(os.path.join(migrations_dir, "0001-create-table.sql"), "w") as f: @@ -152,7 +152,7 @@ def test_run_migrations_failed(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(db_path) + enroll_db(db_path) # Create a valid migration with open(os.path.join(migrations_dir, "0001-create-table.sql"), "w") as f: @@ -193,7 +193,7 @@ def test_testsuite_a(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(str(db_path)) + enroll_db(str(db_path)) # Run migrations assert run_migrations(str(db_path), str(migrations_dir)) is True diff --git a/tests/test_selective_migrations.py b/tests/test_selective_migrations.py index d68c25a..3465b40 100644 --- a/tests/test_selective_migrations.py +++ b/tests/test_selective_migrations.py @@ -8,7 +8,7 @@ import pytest -from fastmigrate.core import run_migrations, _ensure_meta_table +from fastmigrate.core import run_migrations, enroll_db # Path to the selective migrations directory @@ -29,7 +29,7 @@ def test_selective_migrations_core(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(str(db_path)) + enroll_db(str(db_path)) # First run: should apply all migrations (0001 through 0010) assert run_migrations(str(db_path), str(migrations_dir)) is True @@ -99,7 +99,7 @@ def test_selective_migrations_resume_after_failure(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(str(db_path)) + enroll_db(str(db_path)) # Create initial migration with open(migrations_dir / "0001-initial.sql", "w") as f: @@ -181,7 +181,7 @@ def test_selective_migrations_with_gaps(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(str(db_path)) + enroll_db(str(db_path)) # Run migrations assert run_migrations(str(db_path), str(migrations_dir)) is True @@ -210,7 +210,7 @@ def test_cli_selective_migrations(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(str(db_path)) + enroll_db(str(db_path)) # Create a temporary migrations directory with just one initial migration migrations_dir = Path(temp_dir) / "migrations" diff --git a/tests/test_transactions.py b/tests/test_transactions.py index 9d75f33..f737f7b 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -7,7 +7,7 @@ import pytest -from fastmigrate.core import run_migrations, _ensure_meta_table +from fastmigrate.core import run_migrations, enroll_db def test_migration_success(): @@ -22,7 +22,7 @@ def test_migration_success(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(str(db_path)) + enroll_db(str(db_path)) # Create first migration with open(migrations_dir / "0001-initial.sql", "w") as f: @@ -86,7 +86,7 @@ def test_migration_failure(): conn.close() # Initialize the database with _meta table - _ensure_meta_table(str(db_path)) + enroll_db(str(db_path)) # Create first migration with open(migrations_dir / "0001-initial.sql", "w") as f: