jetbase 0.7.0__py3-none-any.whl → 0.12.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jetbase/cli/main.py +146 -4
- jetbase/commands/current.py +20 -0
- jetbase/commands/fix_checksums.py +172 -0
- jetbase/commands/fix_files.py +133 -0
- jetbase/commands/history.py +53 -0
- jetbase/commands/init.py +29 -0
- jetbase/commands/lock_status.py +25 -0
- jetbase/commands/new.py +65 -0
- jetbase/commands/rollback.py +172 -0
- jetbase/commands/status.py +212 -0
- jetbase/commands/unlock.py +29 -0
- jetbase/commands/upgrade.py +248 -0
- jetbase/commands/validators.py +37 -0
- jetbase/config.py +304 -25
- jetbase/constants.py +10 -2
- jetbase/database/connection.py +40 -0
- jetbase/database/queries/base.py +353 -0
- jetbase/database/queries/default_queries.py +215 -0
- jetbase/database/queries/postgres.py +14 -0
- jetbase/database/queries/query_loader.py +87 -0
- jetbase/database/queries/sqlite.py +197 -0
- jetbase/engine/checksum.py +25 -0
- jetbase/engine/dry_run.py +105 -0
- jetbase/engine/file_parser.py +324 -0
- jetbase/engine/formatters.py +61 -0
- jetbase/engine/lock.py +65 -0
- jetbase/engine/repeatable.py +125 -0
- jetbase/engine/validation.py +238 -0
- jetbase/engine/version.py +144 -0
- jetbase/enums.py +37 -1
- jetbase/exceptions.py +87 -0
- jetbase/models.py +45 -0
- jetbase/repositories/lock_repo.py +129 -0
- jetbase/repositories/migrations_repo.py +451 -0
- jetbase-0.12.1.dist-info/METADATA +135 -0
- jetbase-0.12.1.dist-info/RECORD +39 -0
- {jetbase-0.7.0.dist-info → jetbase-0.12.1.dist-info}/WHEEL +1 -1
- jetbase/core/dry_run.py +0 -38
- jetbase/core/file_parser.py +0 -199
- jetbase/core/initialize.py +0 -33
- jetbase/core/repository.py +0 -169
- jetbase/core/rollback.py +0 -67
- jetbase/core/upgrade.py +0 -75
- jetbase/core/version.py +0 -163
- jetbase/queries.py +0 -72
- jetbase-0.7.0.dist-info/METADATA +0 -12
- jetbase-0.7.0.dist-info/RECORD +0 -17
- {jetbase-0.7.0.dist-info → jetbase-0.12.1.dist-info}/entry_points.txt +0 -0
- {jetbase-0.7.0.dist-info → jetbase-0.12.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_display_version(
|
|
5
|
+
migration_type: str,
|
|
6
|
+
version: str | None = None,
|
|
7
|
+
) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Get the display string for a migration version.
|
|
10
|
+
|
|
11
|
+
Returns the version string for versioned migrations, or a label
|
|
12
|
+
for repeatable migrations.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
migration_type (str): The type of migration ('runs_always',
|
|
16
|
+
'runs_on_change', or 'versioned').
|
|
17
|
+
version (str | None): The version string for versioned migrations.
|
|
18
|
+
Defaults to None.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
str: The version string if provided, "RUNS_ALWAYS" for runs_always
|
|
22
|
+
migrations, or "RUNS_ON_CHANGE" for runs_on_change migrations.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ValueError: If migration_type is invalid and version is None.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
>>> get_display_version("runs_always")
|
|
29
|
+
'RUNS_ALWAYS'
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
if version:
|
|
33
|
+
return version
|
|
34
|
+
if migration_type.lower() == "runs_always":
|
|
35
|
+
return "RUNS_ALWAYS"
|
|
36
|
+
elif migration_type.lower() == "runs_on_change":
|
|
37
|
+
return "RUNS_ON_CHANGE"
|
|
38
|
+
raise ValueError("Invalid migration type for display version.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_applied_at(applied_at: dt.datetime | str | None) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Format a timestamp for display in migration history.
|
|
44
|
+
|
|
45
|
+
Handles both datetime objects (PostgreSQL) and string timestamps
|
|
46
|
+
(SQLite) by truncating to a consistent 22-character format.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
applied_at (dt.datetime | str | None): The timestamp to format.
|
|
50
|
+
Can be a datetime object, string, or None.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
str: Formatted timestamp string truncated to 22 characters,
|
|
54
|
+
or empty string if input is None.
|
|
55
|
+
"""
|
|
56
|
+
if applied_at is None:
|
|
57
|
+
return ""
|
|
58
|
+
if isinstance(applied_at, str):
|
|
59
|
+
# SQLite returns strings - just truncate to match format
|
|
60
|
+
return applied_at[:22]
|
|
61
|
+
return applied_at.strftime("%Y-%m-%d %H:%M:%S.%f")[:22]
|
jetbase/engine/lock.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from typing import Generator
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.engine import CursorResult
|
|
6
|
+
|
|
7
|
+
from jetbase.repositories.lock_repo import lock_database, release_lock
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def acquire_lock() -> str:
|
|
11
|
+
"""
|
|
12
|
+
Acquire the migration lock immediately.
|
|
13
|
+
|
|
14
|
+
Attempts to acquire the database migration lock using a unique process ID.
|
|
15
|
+
The lock prevents concurrent migrations from running.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
str: Unique UUID process identifier for this lock acquisition.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
RuntimeError: If the lock is already held by another process.
|
|
22
|
+
"""
|
|
23
|
+
process_id = str(uuid.uuid4())
|
|
24
|
+
|
|
25
|
+
result: CursorResult = lock_database(process_id=process_id)
|
|
26
|
+
|
|
27
|
+
if result.rowcount == 0: # already locked
|
|
28
|
+
raise RuntimeError(
|
|
29
|
+
"Migration lock is already held by another process.\n\n"
|
|
30
|
+
"If you are completely sure that no other migrations are running, "
|
|
31
|
+
"you can unlock using:\n"
|
|
32
|
+
" jetbase unlock\n\n"
|
|
33
|
+
"WARNING: Unlocking then running a migration while another migration process is running may "
|
|
34
|
+
"lead to database corruption."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return process_id
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@contextmanager
|
|
41
|
+
def migration_lock() -> Generator[None, None, None]:
|
|
42
|
+
"""
|
|
43
|
+
Context manager for acquiring and releasing the migration lock.
|
|
44
|
+
|
|
45
|
+
Acquires the lock on entry and ensures it is released on exit,
|
|
46
|
+
even if an exception occurs. Fails immediately if the lock is
|
|
47
|
+
already held by another process.
|
|
48
|
+
|
|
49
|
+
Yields:
|
|
50
|
+
None: Yields control to the context block.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
RuntimeError: If the lock is already held by another process.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
>>> with migration_lock():
|
|
57
|
+
... run_migration()
|
|
58
|
+
"""
|
|
59
|
+
process_id: str | None = None
|
|
60
|
+
try:
|
|
61
|
+
process_id = acquire_lock()
|
|
62
|
+
yield
|
|
63
|
+
finally:
|
|
64
|
+
if process_id:
|
|
65
|
+
release_lock(process_id=process_id)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from jetbase.constants import RUNS_ALWAYS_FILE_PREFIX, RUNS_ON_CHANGE_FILE_PREFIX
|
|
4
|
+
from jetbase.engine.checksum import calculate_checksum
|
|
5
|
+
from jetbase.engine.file_parser import (
|
|
6
|
+
parse_upgrade_statements,
|
|
7
|
+
validate_filename_format,
|
|
8
|
+
)
|
|
9
|
+
from jetbase.repositories.migrations_repo import (
|
|
10
|
+
get_existing_on_change_filenames_to_checksums,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_repeatable_always_filepaths(directory: str) -> list[str]:
|
|
15
|
+
"""
|
|
16
|
+
Get file paths for all runs-always (RA__) migrations in a directory.
|
|
17
|
+
|
|
18
|
+
Scans the directory for migration files starting with the RA__ prefix
|
|
19
|
+
and validates their filename format.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
directory (str): Path to the migrations directory to scan.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
list[str]: Sorted list of absolute file paths for RA__ migrations.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
InvalidMigrationFilenameError: If any file has an invalid format.
|
|
29
|
+
MigrationFilenameTooLongError: If any filename exceeds 512 characters.
|
|
30
|
+
"""
|
|
31
|
+
repeatable_always_filepaths: list[str] = []
|
|
32
|
+
for root, _, files in os.walk(directory):
|
|
33
|
+
for filename in files:
|
|
34
|
+
validate_filename_format(filename=filename)
|
|
35
|
+
if filename.startswith(RUNS_ALWAYS_FILE_PREFIX):
|
|
36
|
+
filepath: str = os.path.join(root, filename)
|
|
37
|
+
repeatable_always_filepaths.append(filepath)
|
|
38
|
+
|
|
39
|
+
repeatable_always_filepaths.sort()
|
|
40
|
+
return repeatable_always_filepaths
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_runs_on_change_filepaths(
|
|
44
|
+
directory: str, changed_only: bool = False
|
|
45
|
+
) -> list[str]:
|
|
46
|
+
"""
|
|
47
|
+
Get file paths for runs-on-change (ROC__) migrations in a directory.
|
|
48
|
+
|
|
49
|
+
Scans the directory for migration files starting with the ROC__ prefix.
|
|
50
|
+
Optionally filters to only include files whose checksums have changed
|
|
51
|
+
since they were last applied.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
directory (str): Path to the migrations directory to scan.
|
|
55
|
+
changed_only (bool): If True, only returns files that have been
|
|
56
|
+
modified since last migration. Defaults to False.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
list[str]: Sorted list of absolute file paths for ROC__ migrations.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
InvalidMigrationFilenameError: If any file has an invalid format.
|
|
63
|
+
MigrationFilenameTooLongError: If any filename exceeds 512 characters.
|
|
64
|
+
"""
|
|
65
|
+
runs_on_change_filepaths: list[str] = []
|
|
66
|
+
for root, _, files in os.walk(directory):
|
|
67
|
+
for filename in files:
|
|
68
|
+
validate_filename_format(filename=filename)
|
|
69
|
+
if filename.startswith(RUNS_ON_CHANGE_FILE_PREFIX):
|
|
70
|
+
filepath: str = os.path.join(root, filename)
|
|
71
|
+
runs_on_change_filepaths.append(filepath)
|
|
72
|
+
|
|
73
|
+
if runs_on_change_filepaths and changed_only:
|
|
74
|
+
existing_on_change_migrations: dict[str, str] = (
|
|
75
|
+
get_existing_on_change_filenames_to_checksums()
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for filepath in runs_on_change_filepaths.copy():
|
|
79
|
+
filename: str = os.path.basename(filepath)
|
|
80
|
+
sql_statements: list[str] = parse_upgrade_statements(file_path=filepath)
|
|
81
|
+
checksum: str = calculate_checksum(sql_statements=sql_statements)
|
|
82
|
+
|
|
83
|
+
if existing_on_change_migrations.get(filename) == checksum:
|
|
84
|
+
runs_on_change_filepaths.remove(filepath)
|
|
85
|
+
|
|
86
|
+
runs_on_change_filepaths.sort()
|
|
87
|
+
return runs_on_change_filepaths
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_ra_filenames() -> list[str]:
|
|
91
|
+
"""
|
|
92
|
+
Get all runs-always (RA__) migration filenames from the migrations directory.
|
|
93
|
+
|
|
94
|
+
Scans the 'migrations' subdirectory in the current working directory
|
|
95
|
+
for files starting with the RA__ prefix.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
list[str]: List of RA__ migration filenames (not full paths).
|
|
99
|
+
"""
|
|
100
|
+
ra_filenames: list[str] = []
|
|
101
|
+
for root, _, files in os.walk(os.path.join(os.getcwd(), "migrations")):
|
|
102
|
+
for filename in files:
|
|
103
|
+
if filename.startswith(RUNS_ALWAYS_FILE_PREFIX):
|
|
104
|
+
ra_filenames.append(filename)
|
|
105
|
+
return ra_filenames
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_repeatable_filenames() -> list[str]:
|
|
109
|
+
"""
|
|
110
|
+
Get all repeatable migration filenames from the migrations directory.
|
|
111
|
+
|
|
112
|
+
Scans the 'migrations' subdirectory in the current working directory
|
|
113
|
+
for files starting with either RA__ or ROC__ prefix.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
list[str]: List of all repeatable migration filenames (not full paths).
|
|
117
|
+
"""
|
|
118
|
+
repeatable_filenames: list[str] = []
|
|
119
|
+
for root, _, files in os.walk(os.path.join(os.getcwd(), "migrations")):
|
|
120
|
+
for filename in files:
|
|
121
|
+
if filename.startswith(RUNS_ALWAYS_FILE_PREFIX) or filename.startswith(
|
|
122
|
+
RUNS_ON_CHANGE_FILE_PREFIX
|
|
123
|
+
):
|
|
124
|
+
repeatable_filenames.append(filename)
|
|
125
|
+
return repeatable_filenames
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from packaging.version import parse as parse_version
|
|
4
|
+
|
|
5
|
+
from jetbase.config import get_config
|
|
6
|
+
from jetbase.constants import MIGRATIONS_DIR
|
|
7
|
+
from jetbase.engine.checksum import calculate_checksum
|
|
8
|
+
from jetbase.engine.file_parser import parse_upgrade_statements
|
|
9
|
+
from jetbase.engine.repeatable import get_repeatable_filenames
|
|
10
|
+
from jetbase.engine.version import (
|
|
11
|
+
get_migration_filepaths_by_version,
|
|
12
|
+
)
|
|
13
|
+
from jetbase.exceptions import (
|
|
14
|
+
ChecksumMismatchError,
|
|
15
|
+
OutOfOrderMigrationError,
|
|
16
|
+
)
|
|
17
|
+
from jetbase.repositories.migrations_repo import (
|
|
18
|
+
fetch_repeatable_migrations,
|
|
19
|
+
get_checksums_by_version,
|
|
20
|
+
get_migrated_versions,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_current_migration_files_match_checksums(
|
|
25
|
+
migrated_filepaths_by_version: dict[str, str],
|
|
26
|
+
migrated_versions_and_checksums: list[tuple[str, str]],
|
|
27
|
+
) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Validate that migration files have not been modified since being applied.
|
|
30
|
+
|
|
31
|
+
Compares the current checksum of each migration file against the
|
|
32
|
+
checksum stored in the database when the migration was applied.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
migrated_filepaths_by_version (dict[str, str]): Mapping of version
|
|
36
|
+
strings to file paths for migrations to check.
|
|
37
|
+
migrated_versions_and_checksums (list[tuple[str, str]]): List of
|
|
38
|
+
(version, checksum) tuples from the database.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
None: Returns silently if all checksums match.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ChecksumMismatchError: If any migration file's current checksum
|
|
45
|
+
differs from its stored checksum.
|
|
46
|
+
"""
|
|
47
|
+
versions_changed: list[str] = []
|
|
48
|
+
for index, (file_version, filepath) in enumerate(
|
|
49
|
+
migrated_filepaths_by_version.items()
|
|
50
|
+
):
|
|
51
|
+
sql_statements: list[str] = parse_upgrade_statements(file_path=filepath)
|
|
52
|
+
checksum: str = calculate_checksum(sql_statements=sql_statements)
|
|
53
|
+
|
|
54
|
+
for migrated_version, migrated_checksum in migrated_versions_and_checksums:
|
|
55
|
+
if file_version == migrated_version:
|
|
56
|
+
if checksum != migrated_checksum:
|
|
57
|
+
versions_changed.append(file_version)
|
|
58
|
+
|
|
59
|
+
if versions_changed:
|
|
60
|
+
raise ChecksumMismatchError(
|
|
61
|
+
f"Checksum mismatch for versions: {', '.join(versions_changed)}. Files have been changed since migration."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_migrated_versions_in_current_migration_files(
|
|
66
|
+
migrated_versions: list[str],
|
|
67
|
+
current_migration_filepaths_by_version: dict[str, str],
|
|
68
|
+
) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Ensure all migrated versions have corresponding migration files.
|
|
71
|
+
|
|
72
|
+
Verifies that every version recorded in the database still has
|
|
73
|
+
its migration file present in the migrations directory.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
migrated_versions (list[str]): List of version strings that have
|
|
77
|
+
been applied to the database.
|
|
78
|
+
current_migration_filepaths_by_version (dict[str, str]): Mapping
|
|
79
|
+
of version strings to file paths for existing files.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
None: Returns silently if all files are present.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
FileNotFoundError: If any migrated version is missing its file.
|
|
86
|
+
"""
|
|
87
|
+
for migrated_version in migrated_versions:
|
|
88
|
+
if migrated_version not in current_migration_filepaths_by_version:
|
|
89
|
+
raise FileNotFoundError(
|
|
90
|
+
f"Version {migrated_version} has been migrated but is missing from the current migration files."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_no_new_migration_files_with_lower_version_than_latest_migration(
|
|
95
|
+
current_migration_filepaths_by_version: dict[str, str],
|
|
96
|
+
migrated_versions: list[str],
|
|
97
|
+
latest_migrated_version: str,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Ensure no new migration files have versions lower than the latest applied.
|
|
101
|
+
|
|
102
|
+
Prevents out-of-order migrations by checking that all new migration
|
|
103
|
+
files have version numbers higher than the most recently applied version.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
current_migration_filepaths_by_version (dict[str, str]): Mapping
|
|
107
|
+
of version strings to file paths for all migration files.
|
|
108
|
+
migrated_versions (list[str]): List of versions already applied.
|
|
109
|
+
latest_migrated_version (str): The most recently applied version.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
None: Returns silently if validation passes.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
OutOfOrderMigrationError: If a new migration file has a version
|
|
116
|
+
lower than the latest migrated version.
|
|
117
|
+
"""
|
|
118
|
+
for file_version, filepath in current_migration_filepaths_by_version.items():
|
|
119
|
+
if (
|
|
120
|
+
parse_version(file_version) < parse_version(latest_migrated_version)
|
|
121
|
+
and file_version not in migrated_versions
|
|
122
|
+
):
|
|
123
|
+
filename: str = os.path.basename(filepath)
|
|
124
|
+
raise OutOfOrderMigrationError(
|
|
125
|
+
f"{filename} has version {file_version} which is lower than the latest migrated version {latest_migrated_version}.\n"
|
|
126
|
+
"New migration files cannot have versions lower than the latest migrated version.\n"
|
|
127
|
+
f"Please rename the file to have a version higher than {latest_migrated_version}.\n"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate_migrated_repeatable_versions_in_migration_files(
|
|
132
|
+
migrated_repeatable_filenames: list[str],
|
|
133
|
+
all_repeatable_filenames: list[str],
|
|
134
|
+
) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Ensure all migrated repeatable migrations have corresponding files.
|
|
137
|
+
|
|
138
|
+
Verifies that every repeatable migration recorded in the database
|
|
139
|
+
still has its file present in the migrations directory.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
migrated_repeatable_filenames (list[str]): List of repeatable
|
|
143
|
+
migration filenames from the database.
|
|
144
|
+
all_repeatable_filenames (list[str]): List of all repeatable
|
|
145
|
+
migration filenames currently in the migrations directory.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
None: Returns silently if all files are present.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
FileNotFoundError: If any migrated repeatable file is missing.
|
|
152
|
+
"""
|
|
153
|
+
missing_filenames: list[str] = []
|
|
154
|
+
for r_file in migrated_repeatable_filenames:
|
|
155
|
+
if r_file not in all_repeatable_filenames:
|
|
156
|
+
missing_filenames.append(r_file)
|
|
157
|
+
if missing_filenames:
|
|
158
|
+
raise FileNotFoundError(
|
|
159
|
+
f"The following migrated repeatable files are missing: {', '.join(missing_filenames)}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run_migration_validations(
|
|
164
|
+
latest_migrated_version: str,
|
|
165
|
+
skip_validation: bool = False,
|
|
166
|
+
skip_checksum_validation: bool = False,
|
|
167
|
+
skip_file_validation: bool = False,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Run all migration validations before performing an upgrade.
|
|
171
|
+
|
|
172
|
+
Executes validation checks including duplicate version detection,
|
|
173
|
+
file presence verification, out-of-order detection, and checksum
|
|
174
|
+
validation. Validations can be skipped via parameters or config.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
latest_migrated_version (str): The most recently applied version,
|
|
178
|
+
used to check for out-of-order migrations.
|
|
179
|
+
skip_validation (bool): If True, skips all validations except
|
|
180
|
+
duplicate version check. Defaults to False.
|
|
181
|
+
skip_checksum_validation (bool): If True, skips checksum validation.
|
|
182
|
+
Defaults to False.
|
|
183
|
+
skip_file_validation (bool): If True, skips file presence and
|
|
184
|
+
out-of-order validations. Defaults to False.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
None: Returns silently if all validations pass.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
DuplicateMigrationVersionError: If duplicate versions are found.
|
|
191
|
+
FileNotFoundError: If migration files are missing.
|
|
192
|
+
OutOfOrderMigrationError: If out-of-order migrations are detected.
|
|
193
|
+
ChecksumMismatchError: If file checksums don't match stored values.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
skip_validation_config: bool = get_config().skip_validation
|
|
197
|
+
skip_checksum_validation_config: bool = get_config().skip_checksum_validation
|
|
198
|
+
skip_file_validation_config: bool = get_config().skip_file_validation
|
|
199
|
+
|
|
200
|
+
migrations_directory_path: str = os.path.join(os.getcwd(), MIGRATIONS_DIR)
|
|
201
|
+
|
|
202
|
+
migration_filepaths_by_version: dict[str, str] = get_migration_filepaths_by_version(
|
|
203
|
+
directory=migrations_directory_path
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if not skip_validation and not skip_validation_config:
|
|
207
|
+
if not skip_file_validation and not skip_file_validation_config:
|
|
208
|
+
migrated_versions: list[str] = get_migrated_versions()
|
|
209
|
+
|
|
210
|
+
validate_no_new_migration_files_with_lower_version_than_latest_migration(
|
|
211
|
+
current_migration_filepaths_by_version=migration_filepaths_by_version,
|
|
212
|
+
migrated_versions=migrated_versions,
|
|
213
|
+
latest_migrated_version=latest_migrated_version,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
validate_migrated_versions_in_current_migration_files(
|
|
217
|
+
migrated_versions=migrated_versions,
|
|
218
|
+
current_migration_filepaths_by_version=migration_filepaths_by_version,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
validate_migrated_repeatable_versions_in_migration_files(
|
|
222
|
+
migrated_repeatable_filenames=[
|
|
223
|
+
r.filename for r in fetch_repeatable_migrations()
|
|
224
|
+
],
|
|
225
|
+
all_repeatable_filenames=get_repeatable_filenames(),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
migrated_filepaths_by_version: dict[str, str] = (
|
|
229
|
+
get_migration_filepaths_by_version(
|
|
230
|
+
directory=migrations_directory_path, end_version=latest_migrated_version
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if not skip_checksum_validation and not skip_checksum_validation_config:
|
|
235
|
+
validate_current_migration_files_match_checksums(
|
|
236
|
+
migrated_filepaths_by_version=migrated_filepaths_by_version,
|
|
237
|
+
migrated_versions_and_checksums=get_checksums_by_version(),
|
|
238
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from packaging.version import parse as parse_version
|
|
4
|
+
|
|
5
|
+
from jetbase.constants import (
|
|
6
|
+
VERSION_FILE_PREFIX,
|
|
7
|
+
)
|
|
8
|
+
from jetbase.engine.file_parser import (
|
|
9
|
+
is_filename_format_valid,
|
|
10
|
+
is_filename_length_valid,
|
|
11
|
+
)
|
|
12
|
+
from jetbase.exceptions import (
|
|
13
|
+
DuplicateMigrationVersionError,
|
|
14
|
+
InvalidMigrationFilenameError,
|
|
15
|
+
MigrationFilenameTooLongError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_version_key_from_filename(filename: str) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Extract and normalize the version key from a migration filename.
|
|
22
|
+
|
|
23
|
+
Parses the version portion between 'V' and '__', then normalizes
|
|
24
|
+
underscores to periods for consistent version comparison.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
filename (str): The migration filename to parse.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
str: Normalized version string with periods as separators.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If the filename doesn't follow the expected format.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> _get_version_key_from_filename("V1_2__desc.sql")
|
|
37
|
+
'1.2'
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
version = filename.split("__")[0][1:]
|
|
41
|
+
except Exception:
|
|
42
|
+
raise (
|
|
43
|
+
ValueError(
|
|
44
|
+
"Filename must be in the following format: V1__my_description.sql, V1_1__my_description.sql, V1.1__my_description.sql"
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
return version.replace("_", ".")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_migration_filepaths_by_version(
|
|
51
|
+
directory: str,
|
|
52
|
+
version_to_start_from: str | None = None,
|
|
53
|
+
end_version: str | None = None,
|
|
54
|
+
) -> dict[str, str]:
|
|
55
|
+
"""
|
|
56
|
+
Get versioned migration file paths sorted by version number.
|
|
57
|
+
|
|
58
|
+
Scans the directory for SQL migration files, validates their format,
|
|
59
|
+
and returns a dictionary mapping version strings to file paths.
|
|
60
|
+
Results can be filtered by version range.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
directory (str): Path to the migrations directory to scan.
|
|
64
|
+
version_to_start_from (str | None): Minimum version to include
|
|
65
|
+
(inclusive). If provided, only files with versions >= this
|
|
66
|
+
are returned. Defaults to None.
|
|
67
|
+
end_version (str | None): Maximum version to include (inclusive).
|
|
68
|
+
If provided, only files with versions <= this are returned.
|
|
69
|
+
Defaults to None.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
dict[str, str]: Dictionary mapping normalized version strings
|
|
73
|
+
to their absolute file paths, sorted by version number.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
InvalidMigrationFilenameError: If a file has an invalid format.
|
|
77
|
+
MigrationFilenameTooLongError: If a filename exceeds 512 characters.
|
|
78
|
+
DuplicateMigrationVersionError: If duplicate versions are detected.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
>>> get_migration_filepaths_by_version('/migrations')
|
|
82
|
+
{'1.0': '/migrations/V1__init.sql', '1.1': '/migrations/V1_1__add.sql'}
|
|
83
|
+
"""
|
|
84
|
+
version_to_filepath_dict: dict[str, str] = {}
|
|
85
|
+
seen_versions: set[str] = set()
|
|
86
|
+
|
|
87
|
+
for root, _, files in os.walk(directory):
|
|
88
|
+
for filename in files:
|
|
89
|
+
if filename.endswith(".sql") and not is_filename_format_valid(
|
|
90
|
+
filename=filename
|
|
91
|
+
):
|
|
92
|
+
raise InvalidMigrationFilenameError(
|
|
93
|
+
f"Invalid migration filename format: {filename}.\n"
|
|
94
|
+
"Filenames must start with 'V', followed by the version number, "
|
|
95
|
+
"two underscores '__', a description, and end with '.sql'.\n"
|
|
96
|
+
"V<version_number>__<my_description>.sql. "
|
|
97
|
+
"Examples: 'V1_2_0__add_new_table.sql' or 'V1.2.0__add_new_table.sql'\n"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if filename.endswith(".sql") and not is_filename_length_valid(
|
|
101
|
+
filename=filename
|
|
102
|
+
):
|
|
103
|
+
raise MigrationFilenameTooLongError(
|
|
104
|
+
f"Migration filename too long: {filename}.\n"
|
|
105
|
+
f"Filename is currently {len(filename)} characters.\n"
|
|
106
|
+
"Filenames must not exceed 512 characters."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if is_filename_format_valid(filename=filename):
|
|
110
|
+
if filename.startswith(VERSION_FILE_PREFIX):
|
|
111
|
+
file_path: str = os.path.join(root, filename)
|
|
112
|
+
file_version: str = _get_version_key_from_filename(
|
|
113
|
+
filename=filename
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if file_version in seen_versions:
|
|
117
|
+
raise DuplicateMigrationVersionError(
|
|
118
|
+
f"Duplicate migration version detected: {file_version}.\n"
|
|
119
|
+
"Each file must have a unique version.\n"
|
|
120
|
+
"Please rename the file to have a unique version."
|
|
121
|
+
)
|
|
122
|
+
seen_versions.add(file_version)
|
|
123
|
+
|
|
124
|
+
if end_version:
|
|
125
|
+
if parse_version(file_version) > parse_version(end_version):
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
if version_to_start_from:
|
|
129
|
+
if parse_version(file_version) >= parse_version(
|
|
130
|
+
version_to_start_from
|
|
131
|
+
):
|
|
132
|
+
version_to_filepath_dict[file_version] = file_path
|
|
133
|
+
|
|
134
|
+
else:
|
|
135
|
+
version_to_filepath_dict[file_version] = file_path
|
|
136
|
+
|
|
137
|
+
ordered_version_to_filepath_dict: dict[str, str] = dict(
|
|
138
|
+
sorted(
|
|
139
|
+
version_to_filepath_dict.items(),
|
|
140
|
+
key=lambda item: parse_version(item[0]),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return ordered_version_to_filepath_dict
|
jetbase/enums.py
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
class
|
|
4
|
+
class MigrationDirectionType(Enum):
|
|
5
|
+
"""
|
|
6
|
+
Enum representing the direction of a migration operation.
|
|
7
|
+
|
|
8
|
+
Attributes:
|
|
9
|
+
UPGRADE: Apply migrations forward (run upgrade SQL).
|
|
10
|
+
ROLLBACK: Revert migrations backward (run rollback SQL).
|
|
11
|
+
"""
|
|
12
|
+
|
|
5
13
|
UPGRADE = "upgrade"
|
|
6
14
|
ROLLBACK = "rollback"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MigrationType(Enum):
|
|
18
|
+
"""
|
|
19
|
+
Enum representing the type of migration.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
VERSIONED: Standard migration with a version number, runs once.
|
|
23
|
+
RUNS_ON_CHANGE: Repeatable migration that runs when its checksum changes.
|
|
24
|
+
RUNS_ALWAYS: Repeatable migration that runs on every upgrade.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
VERSIONED = "VERSIONED"
|
|
28
|
+
RUNS_ON_CHANGE = "RUNS_ON_CHANGE"
|
|
29
|
+
RUNS_ALWAYS = "RUNS_ALWAYS"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DatabaseType(Enum):
|
|
33
|
+
"""
|
|
34
|
+
Enum representing supported database types.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
POSTGRESQL: PostgreSQL database.
|
|
38
|
+
SQLITE: SQLite database.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
POSTGRESQL = "postgresql"
|
|
42
|
+
SQLITE = "sqlite"
|