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,197 @@
|
|
|
1
|
+
from sqlalchemy import TextClause, text
|
|
2
|
+
|
|
3
|
+
from jetbase.database.queries.base import BaseQueries
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SQLiteQueries(BaseQueries):
|
|
7
|
+
"""
|
|
8
|
+
SQLite-specific SQL queries.
|
|
9
|
+
|
|
10
|
+
Provides SQLite-compatible implementations for queries that differ
|
|
11
|
+
from the default PostgreSQL syntax.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def create_migrations_table_stmt() -> TextClause:
|
|
16
|
+
"""
|
|
17
|
+
Get SQLite statement to create the jetbase_migrations table.
|
|
18
|
+
|
|
19
|
+
Uses INTEGER PRIMARY KEY AUTOINCREMENT and TEXT types for
|
|
20
|
+
SQLite compatibility.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
TextClause: SQLAlchemy text clause for the CREATE TABLE statement.
|
|
24
|
+
"""
|
|
25
|
+
return text(
|
|
26
|
+
"""
|
|
27
|
+
CREATE TABLE IF NOT EXISTS jetbase_migrations (
|
|
28
|
+
order_executed INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
version TEXT,
|
|
30
|
+
description TEXT,
|
|
31
|
+
filename TEXT NOT NULL,
|
|
32
|
+
migration_type TEXT NOT NULL,
|
|
33
|
+
applied_at TEXT DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
|
|
34
|
+
checksum TEXT
|
|
35
|
+
);
|
|
36
|
+
"""
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def check_if_migrations_table_exists_query() -> TextClause:
|
|
41
|
+
"""
|
|
42
|
+
Get SQLite query to check if the jetbase_migrations table exists.
|
|
43
|
+
|
|
44
|
+
Uses sqlite_master to check for table existence.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
TextClause: SQLAlchemy text clause that returns a boolean.
|
|
48
|
+
"""
|
|
49
|
+
return text(
|
|
50
|
+
"""
|
|
51
|
+
SELECT COUNT(*) > 0
|
|
52
|
+
FROM sqlite_master
|
|
53
|
+
WHERE type = 'table'
|
|
54
|
+
AND name = 'jetbase_migrations'
|
|
55
|
+
"""
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def check_if_lock_table_exists_query() -> TextClause:
|
|
60
|
+
"""
|
|
61
|
+
Get SQLite query to check if the jetbase_lock table exists.
|
|
62
|
+
|
|
63
|
+
Uses sqlite_master to check for table existence.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
TextClause: SQLAlchemy text clause that returns the table name
|
|
67
|
+
if it exists.
|
|
68
|
+
"""
|
|
69
|
+
return text(
|
|
70
|
+
"""
|
|
71
|
+
SELECT name FROM sqlite_master
|
|
72
|
+
WHERE type='table' AND name='jetbase_lock'
|
|
73
|
+
"""
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def create_lock_table_stmt() -> TextClause:
|
|
78
|
+
"""
|
|
79
|
+
Get SQLite statement to create the jetbase_lock table.
|
|
80
|
+
|
|
81
|
+
Uses CHECK constraint to ensure only one row exists, and
|
|
82
|
+
TEXT type for timestamp storage.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
TextClause: SQLAlchemy text clause for the CREATE TABLE statement.
|
|
86
|
+
"""
|
|
87
|
+
return text(
|
|
88
|
+
"""
|
|
89
|
+
CREATE TABLE IF NOT EXISTS jetbase_lock (
|
|
90
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
91
|
+
is_locked BOOLEAN NOT NULL DEFAULT 0,
|
|
92
|
+
locked_at TEXT DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
|
|
93
|
+
process_id TEXT
|
|
94
|
+
);
|
|
95
|
+
"""
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def force_unlock_stmt() -> TextClause:
|
|
100
|
+
"""
|
|
101
|
+
Get SQLite statement to force release the migration lock.
|
|
102
|
+
|
|
103
|
+
Sets is_locked to 0 and clears the locked_at and process_id fields.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
TextClause: SQLAlchemy text clause for the UPDATE statement.
|
|
107
|
+
"""
|
|
108
|
+
return text(
|
|
109
|
+
"""
|
|
110
|
+
UPDATE jetbase_lock
|
|
111
|
+
SET is_locked = 0,
|
|
112
|
+
locked_at = NULL,
|
|
113
|
+
process_id = NULL
|
|
114
|
+
WHERE id = 1;
|
|
115
|
+
"""
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def initialize_lock_record_stmt() -> TextClause:
|
|
120
|
+
"""
|
|
121
|
+
Get SQLite statement to initialize the lock record.
|
|
122
|
+
|
|
123
|
+
Uses INSERT OR IGNORE to create the initial unlocked record
|
|
124
|
+
without failing if it already exists.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
TextClause: SQLAlchemy text clause for the INSERT statement.
|
|
128
|
+
"""
|
|
129
|
+
return text(
|
|
130
|
+
"""
|
|
131
|
+
INSERT OR IGNORE INTO jetbase_lock (id, is_locked)
|
|
132
|
+
VALUES (1, 0)
|
|
133
|
+
"""
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def acquire_lock_stmt() -> TextClause:
|
|
138
|
+
"""
|
|
139
|
+
Get SQLite statement to atomically acquire the migration lock.
|
|
140
|
+
|
|
141
|
+
Only updates if the lock is not currently held. Uses STRFTIME
|
|
142
|
+
for timestamp generation.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
TextClause: SQLAlchemy text clause with :process_id parameter.
|
|
146
|
+
"""
|
|
147
|
+
return text(
|
|
148
|
+
"""
|
|
149
|
+
UPDATE jetbase_lock
|
|
150
|
+
SET is_locked = 1,
|
|
151
|
+
locked_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'),
|
|
152
|
+
process_id = :process_id
|
|
153
|
+
WHERE id = 1 AND is_locked = 0
|
|
154
|
+
"""
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def release_lock_stmt() -> TextClause:
|
|
159
|
+
"""
|
|
160
|
+
Get SQLite statement to release the migration lock.
|
|
161
|
+
|
|
162
|
+
Only releases if the lock is held by the specified process ID.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
TextClause: SQLAlchemy text clause with :process_id parameter.
|
|
166
|
+
"""
|
|
167
|
+
return text(
|
|
168
|
+
"""
|
|
169
|
+
UPDATE jetbase_lock
|
|
170
|
+
SET is_locked = 0,
|
|
171
|
+
locked_at = NULL,
|
|
172
|
+
process_id = NULL
|
|
173
|
+
WHERE id = 1 AND process_id = :process_id
|
|
174
|
+
"""
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def update_repeatable_migration_stmt() -> TextClause:
|
|
179
|
+
"""
|
|
180
|
+
Get SQLite statement to update a repeatable migration record.
|
|
181
|
+
|
|
182
|
+
Updates the checksum and applied_at timestamp using SQLite's
|
|
183
|
+
STRFTIME function for the current time.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
TextClause: SQLAlchemy text clause with :checksum, :filename,
|
|
187
|
+
and :migration_type parameters.
|
|
188
|
+
"""
|
|
189
|
+
return text(
|
|
190
|
+
"""
|
|
191
|
+
UPDATE jetbase_migrations
|
|
192
|
+
SET checksum = :checksum,
|
|
193
|
+
applied_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
|
|
194
|
+
WHERE filename = :filename
|
|
195
|
+
AND migration_type = :migration_type
|
|
196
|
+
"""
|
|
197
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def calculate_checksum(sql_statements: list[str]) -> str:
|
|
5
|
+
"""
|
|
6
|
+
Calculate SHA256 checksum for a list of SQL statements.
|
|
7
|
+
|
|
8
|
+
Joins all statements with newlines and computes a SHA256 hash to create
|
|
9
|
+
a unique fingerprint of the migration content.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
sql_statements (list[str]): List of SQL statements to hash.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
str: 64-character hexadecimal SHA256 checksum string.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
>>> calculate_checksum(["SELECT 1", "SELECT 2"])
|
|
19
|
+
'a1b2c3d4e5f6...'
|
|
20
|
+
"""
|
|
21
|
+
formatted_sql_statements: str = "\n".join(sql_statements)
|
|
22
|
+
|
|
23
|
+
checksum: str = hashlib.sha256(formatted_sql_statements.encode("utf-8")).hexdigest()
|
|
24
|
+
|
|
25
|
+
return checksum
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from jetbase.engine.file_parser import (
|
|
4
|
+
parse_rollback_statements,
|
|
5
|
+
parse_upgrade_statements,
|
|
6
|
+
)
|
|
7
|
+
from jetbase.enums import MigrationDirectionType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def process_dry_run(
|
|
11
|
+
version_to_filepath: dict[str, str],
|
|
12
|
+
migration_operation: MigrationDirectionType,
|
|
13
|
+
repeatable_always_filepaths: list[str] | None = None,
|
|
14
|
+
runs_on_change_filepaths: list[str] | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Preview migrations without executing them.
|
|
18
|
+
|
|
19
|
+
Parses and displays the SQL statements that would be executed for
|
|
20
|
+
each migration, without actually running them against the database.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
version_to_filepath (dict[str, str]): Mapping of version strings
|
|
24
|
+
to migration file paths for versioned migrations.
|
|
25
|
+
migration_operation (MigrationDirectionType): Whether this is an
|
|
26
|
+
UPGRADE or ROLLBACK operation.
|
|
27
|
+
repeatable_always_filepaths (list[str] | None): File paths for
|
|
28
|
+
runs-always migrations. Defaults to None.
|
|
29
|
+
runs_on_change_filepaths (list[str] | None): File paths for
|
|
30
|
+
runs-on-change migrations. Defaults to None.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
None: Prints SQL preview to stdout.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
NotImplementedError: If migration_operation is not UPGRADE or ROLLBACK.
|
|
37
|
+
"""
|
|
38
|
+
print("\nJETBASE - Dry Run Mode")
|
|
39
|
+
print("No SQL will be executed. This is a preview of what would happen.")
|
|
40
|
+
print("----------------------------------------\n\n")
|
|
41
|
+
|
|
42
|
+
for version, file_path in version_to_filepath.items():
|
|
43
|
+
if migration_operation == MigrationDirectionType.UPGRADE:
|
|
44
|
+
sql_statements: list[str] = parse_upgrade_statements(
|
|
45
|
+
file_path=file_path, dry_run=True
|
|
46
|
+
)
|
|
47
|
+
elif migration_operation == MigrationDirectionType.ROLLBACK:
|
|
48
|
+
sql_statements: list[str] = parse_rollback_statements(
|
|
49
|
+
file_path=file_path, dry_run=True
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
raise NotImplementedError(
|
|
53
|
+
f"Dry run not implemented for migration operation: {migration_operation}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
filename: str = os.path.basename(file_path)
|
|
57
|
+
|
|
58
|
+
print_migration_preview(
|
|
59
|
+
filename=filename,
|
|
60
|
+
sql_statements=sql_statements,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if migration_operation == MigrationDirectionType.UPGRADE:
|
|
64
|
+
if repeatable_always_filepaths:
|
|
65
|
+
for filepath in repeatable_always_filepaths:
|
|
66
|
+
sql_statements: list[str] = parse_upgrade_statements(
|
|
67
|
+
file_path=filepath, dry_run=True
|
|
68
|
+
)
|
|
69
|
+
filename: str = os.path.basename(filepath)
|
|
70
|
+
|
|
71
|
+
print_migration_preview(
|
|
72
|
+
filename=filename, sql_statements=sql_statements
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if runs_on_change_filepaths:
|
|
76
|
+
for filepath in runs_on_change_filepaths:
|
|
77
|
+
sql_statements: list[str] = parse_upgrade_statements(
|
|
78
|
+
file_path=filepath, dry_run=True
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
print_migration_preview(
|
|
82
|
+
filename=os.path.basename(filepath), sql_statements=sql_statements
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def print_migration_preview(filename: str, sql_statements: list[str]) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Print SQL statements for a migration file preview.
|
|
89
|
+
|
|
90
|
+
Displays the filename, statement count, and full SQL content for
|
|
91
|
+
each statement in a formatted output.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
filename (str): The name of the migration file being previewed.
|
|
95
|
+
sql_statements (list[str]): List of SQL statements to display.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
None: Prints formatted preview to stdout.
|
|
99
|
+
"""
|
|
100
|
+
print(
|
|
101
|
+
f"SQL Preview for {filename} ({len(sql_statements)} {'statements' if len(sql_statements) != 1 else 'statement'})\n"
|
|
102
|
+
)
|
|
103
|
+
for statement in sql_statements:
|
|
104
|
+
print(f"{statement}\n")
|
|
105
|
+
print("----------------------------------------\n")
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from jetbase.constants import (
|
|
4
|
+
RUNS_ALWAYS_FILE_PREFIX,
|
|
5
|
+
RUNS_ON_CHANGE_FILE_PREFIX,
|
|
6
|
+
VERSION_FILE_PREFIX,
|
|
7
|
+
)
|
|
8
|
+
from jetbase.enums import MigrationDirectionType
|
|
9
|
+
from jetbase.exceptions import (
|
|
10
|
+
InvalidMigrationFilenameError,
|
|
11
|
+
MigrationFilenameTooLongError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_upgrade_statements(file_path: str, dry_run: bool = False) -> list[str]:
|
|
16
|
+
"""
|
|
17
|
+
Parse SQL statements from the upgrade section of a migration file.
|
|
18
|
+
|
|
19
|
+
Reads the migration file and extracts all SQL statements that appear
|
|
20
|
+
before the '-- rollback' marker. Statements are split on semicolons.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
file_path (str): Path to the migration SQL file.
|
|
24
|
+
dry_run (bool): If True, preserves formatting for display.
|
|
25
|
+
If False, joins lines for execution. Defaults to False.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
list[str]: List of SQL statements.
|
|
29
|
+
"""
|
|
30
|
+
statements = []
|
|
31
|
+
current_statement = []
|
|
32
|
+
|
|
33
|
+
with open(file_path, "r") as file:
|
|
34
|
+
for line in file:
|
|
35
|
+
if not dry_run:
|
|
36
|
+
line = line.strip()
|
|
37
|
+
else:
|
|
38
|
+
line = line.rstrip()
|
|
39
|
+
|
|
40
|
+
if (
|
|
41
|
+
line.strip().startswith("--")
|
|
42
|
+
and line[2:].strip().lower() == MigrationDirectionType.ROLLBACK.value
|
|
43
|
+
):
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
if not line or line.strip().startswith("--"):
|
|
47
|
+
continue
|
|
48
|
+
current_statement.append(line)
|
|
49
|
+
|
|
50
|
+
if line.strip().endswith(";"):
|
|
51
|
+
if not dry_run:
|
|
52
|
+
statement = " ".join(current_statement)
|
|
53
|
+
else:
|
|
54
|
+
statement = "\n".join(current_statement)
|
|
55
|
+
statement = statement.rstrip(";").strip()
|
|
56
|
+
if statement:
|
|
57
|
+
statements.append(statement)
|
|
58
|
+
current_statement = []
|
|
59
|
+
|
|
60
|
+
return statements
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_rollback_statements(file_path: str, dry_run: bool = False) -> list[str]:
|
|
64
|
+
"""
|
|
65
|
+
Parse SQL statements from the rollback section of a migration file.
|
|
66
|
+
|
|
67
|
+
Reads the migration file and extracts all SQL statements that appear
|
|
68
|
+
after the '-- rollback' marker. Statements are split on semicolons.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
file_path (str): Path to the migration SQL file.
|
|
72
|
+
dry_run (bool): If True, preserves formatting for display.
|
|
73
|
+
If False, joins lines for execution. Defaults to False.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
list[str]: List of SQL statements (without trailing semicolons).
|
|
77
|
+
"""
|
|
78
|
+
statements = []
|
|
79
|
+
current_statement = []
|
|
80
|
+
in_rollback_section = False
|
|
81
|
+
|
|
82
|
+
with open(file_path, "r") as file:
|
|
83
|
+
for line in file:
|
|
84
|
+
if not dry_run:
|
|
85
|
+
line = line.strip()
|
|
86
|
+
else:
|
|
87
|
+
line = line.rstrip()
|
|
88
|
+
|
|
89
|
+
if not in_rollback_section:
|
|
90
|
+
if (
|
|
91
|
+
line.strip().startswith("--")
|
|
92
|
+
and line[2:].strip().lower()
|
|
93
|
+
== MigrationDirectionType.ROLLBACK.value
|
|
94
|
+
):
|
|
95
|
+
in_rollback_section = True
|
|
96
|
+
else:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if in_rollback_section:
|
|
100
|
+
if not line or line.strip().startswith("--"):
|
|
101
|
+
continue
|
|
102
|
+
current_statement.append(line)
|
|
103
|
+
|
|
104
|
+
if line.strip().endswith(";"):
|
|
105
|
+
if not dry_run:
|
|
106
|
+
statement = " ".join(current_statement)
|
|
107
|
+
else:
|
|
108
|
+
statement = "\n".join(current_statement)
|
|
109
|
+
statement = statement.rstrip(";").strip()
|
|
110
|
+
if statement:
|
|
111
|
+
statements.append(statement)
|
|
112
|
+
current_statement = []
|
|
113
|
+
|
|
114
|
+
return statements
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_filename_format_valid(filename: str) -> bool:
|
|
118
|
+
"""
|
|
119
|
+
Check if filename follows the migration naming convention.
|
|
120
|
+
|
|
121
|
+
Valid filenames must start with 'V', 'RA__', or 'ROC__', contain '__',
|
|
122
|
+
end with '.sql', and have a non-empty description.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
filename (str): The filename to validate.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
bool: True if the filename matches the convention, False otherwise.
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
>>> is_filename_format_valid("V1__init.sql")
|
|
132
|
+
True
|
|
133
|
+
>>> is_filename_format_valid("invalid.sql")
|
|
134
|
+
False
|
|
135
|
+
"""
|
|
136
|
+
if not filename.endswith(".sql"):
|
|
137
|
+
return False
|
|
138
|
+
if not filename.startswith(
|
|
139
|
+
(VERSION_FILE_PREFIX, RUNS_ON_CHANGE_FILE_PREFIX, RUNS_ALWAYS_FILE_PREFIX)
|
|
140
|
+
):
|
|
141
|
+
return False
|
|
142
|
+
if "__" not in filename:
|
|
143
|
+
return False
|
|
144
|
+
description: str = _get_raw_description_from_filename(filename=filename)
|
|
145
|
+
if len(description.strip()) == 0:
|
|
146
|
+
return False
|
|
147
|
+
if filename.startswith((RUNS_ON_CHANGE_FILE_PREFIX, RUNS_ALWAYS_FILE_PREFIX)):
|
|
148
|
+
return True
|
|
149
|
+
raw_version: str = _get_version_from_filename(filename=filename)
|
|
150
|
+
if not _is_valid_version(version=raw_version):
|
|
151
|
+
return False
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_description_from_filename(filename: str) -> str:
|
|
156
|
+
"""
|
|
157
|
+
Extract and format the description from a migration filename.
|
|
158
|
+
|
|
159
|
+
Extracts the portion after '__' and before '.sql', then replaces
|
|
160
|
+
underscores with spaces for human-readable display.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
filename (str): The migration filename to parse.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
str: Human-readable description with spaces.
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> get_description_from_filename("V1__add_users.sql")
|
|
170
|
+
'add users'
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
raw_description: str = _get_raw_description_from_filename(filename=filename)
|
|
174
|
+
formatted_description: str = raw_description.replace("_", " ")
|
|
175
|
+
return formatted_description
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def is_filename_length_valid(filename: str, max_length: int = 512) -> bool:
|
|
179
|
+
"""
|
|
180
|
+
Check if the filename length is within the allowed maximum.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
filename (str): The filename to check.
|
|
184
|
+
max_length (int): Maximum allowed character length. Defaults to 512.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
bool: True if the filename length is <= max_length.
|
|
188
|
+
"""
|
|
189
|
+
return len(filename) <= max_length
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _get_version_from_filename(filename: str) -> str:
|
|
193
|
+
"""
|
|
194
|
+
Extract the version string from a migration filename.
|
|
195
|
+
|
|
196
|
+
Parses the portion between 'V' and '__' to get the raw version.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
filename (str): The migration filename to parse.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
str: Raw version string (e.g., "1_2_0" from "V1_2_0__desc.sql").
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
version: str = filename[1 : filename.index("__")]
|
|
206
|
+
return version
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _get_raw_description_from_filename(filename: str) -> str:
|
|
210
|
+
"""
|
|
211
|
+
Extract the raw description from a migration filename.
|
|
212
|
+
|
|
213
|
+
Returns the portion between '__' and '.sql' without any formatting.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
filename (str): The migration filename to parse.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
str: Raw description with underscores preserved.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
description: str = filename[
|
|
223
|
+
filename.index("__") + 2 : filename.index(".sql")
|
|
224
|
+
].strip()
|
|
225
|
+
return description
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _is_valid_version(version: str) -> bool:
|
|
229
|
+
"""
|
|
230
|
+
Validate that a version string follows the correct format.
|
|
231
|
+
|
|
232
|
+
Valid versions contain digits separated by periods or underscores.
|
|
233
|
+
Must start and end with a digit.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
version (str): The version string to validate.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
bool: True if the version format is valid.
|
|
240
|
+
|
|
241
|
+
Example:
|
|
242
|
+
>>> _is_valid_version("1.2.3")
|
|
243
|
+
True
|
|
244
|
+
>>> _is_valid_version("1__2")
|
|
245
|
+
False
|
|
246
|
+
"""
|
|
247
|
+
if not version:
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
# Pattern: starts with digit, ends with digit, can have periods/underscores between digits
|
|
251
|
+
pattern = r"^\d+([._]\d+)*$"
|
|
252
|
+
return bool(re.match(pattern, version))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def validate_filename_format(filename: str) -> None:
|
|
256
|
+
"""
|
|
257
|
+
Validate filename format, raising an exception if invalid.
|
|
258
|
+
|
|
259
|
+
Checks that the filename follows the migration naming convention
|
|
260
|
+
and does not exceed the maximum length.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
filename (str): The filename to validate.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
None: Returns silently if validation passes.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
InvalidMigrationFilenameError: If the filename doesn't match
|
|
270
|
+
the required naming convention.
|
|
271
|
+
MigrationFilenameTooLongError: If the filename exceeds 512 characters.
|
|
272
|
+
"""
|
|
273
|
+
is_valid_filename: bool = True
|
|
274
|
+
if not filename.endswith(".sql"):
|
|
275
|
+
is_valid_filename = False
|
|
276
|
+
if not filename.startswith(
|
|
277
|
+
(VERSION_FILE_PREFIX, RUNS_ON_CHANGE_FILE_PREFIX, RUNS_ALWAYS_FILE_PREFIX)
|
|
278
|
+
):
|
|
279
|
+
is_valid_filename = False
|
|
280
|
+
if "__" not in filename:
|
|
281
|
+
is_valid_filename = False
|
|
282
|
+
description: str = _get_raw_description_from_filename(filename=filename)
|
|
283
|
+
if len(description.strip()) == 0:
|
|
284
|
+
is_valid_filename = False
|
|
285
|
+
if filename.startswith(VERSION_FILE_PREFIX):
|
|
286
|
+
raw_version: str = _get_version_from_filename(filename=filename)
|
|
287
|
+
if not _is_valid_version(version=raw_version):
|
|
288
|
+
is_valid_filename = False
|
|
289
|
+
|
|
290
|
+
if not is_valid_filename:
|
|
291
|
+
raise InvalidMigrationFilenameError(
|
|
292
|
+
f"Invalid migration filename format: {filename}.\n"
|
|
293
|
+
"Filenames must start with 'V', followed by the version number, "
|
|
294
|
+
"two underscores '__', a description, and end with '.sql'.\n"
|
|
295
|
+
"V<version_number>__<my_description>.sql. "
|
|
296
|
+
"Examples: 'V1_2_0__add_new_table.sql' or 'V1.2.0__add_new_table.sql'\n\n"
|
|
297
|
+
"For repeatable migrations, filenames must start with 'RC' or 'RA', "
|
|
298
|
+
"followed by two underscores '__', a description, and end with '.sql'.\n"
|
|
299
|
+
"RC__<my_description>.sql or RA__<my_description>.sql."
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
_validate_filename_length(filename=filename)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _validate_filename_length(filename: str, max_length: int = 512) -> None:
|
|
306
|
+
"""
|
|
307
|
+
Validate that the filename does not exceed the maximum length.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
filename (str): The filename to validate.
|
|
311
|
+
max_length (int): Maximum allowed character length. Defaults to 512.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
None: Returns silently if validation passes.
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
MigrationFilenameTooLongError: If the filename exceeds max_length.
|
|
318
|
+
"""
|
|
319
|
+
if len(filename) > max_length:
|
|
320
|
+
raise MigrationFilenameTooLongError(
|
|
321
|
+
f"Migration filename too long: {filename}.\n"
|
|
322
|
+
f"Filename is currently {len(filename)} characters.\n"
|
|
323
|
+
"Filenames must not exceed 512 characters."
|
|
324
|
+
)
|