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.
Files changed (49) hide show
  1. jetbase/cli/main.py +146 -4
  2. jetbase/commands/current.py +20 -0
  3. jetbase/commands/fix_checksums.py +172 -0
  4. jetbase/commands/fix_files.py +133 -0
  5. jetbase/commands/history.py +53 -0
  6. jetbase/commands/init.py +29 -0
  7. jetbase/commands/lock_status.py +25 -0
  8. jetbase/commands/new.py +65 -0
  9. jetbase/commands/rollback.py +172 -0
  10. jetbase/commands/status.py +212 -0
  11. jetbase/commands/unlock.py +29 -0
  12. jetbase/commands/upgrade.py +248 -0
  13. jetbase/commands/validators.py +37 -0
  14. jetbase/config.py +304 -25
  15. jetbase/constants.py +10 -2
  16. jetbase/database/connection.py +40 -0
  17. jetbase/database/queries/base.py +353 -0
  18. jetbase/database/queries/default_queries.py +215 -0
  19. jetbase/database/queries/postgres.py +14 -0
  20. jetbase/database/queries/query_loader.py +87 -0
  21. jetbase/database/queries/sqlite.py +197 -0
  22. jetbase/engine/checksum.py +25 -0
  23. jetbase/engine/dry_run.py +105 -0
  24. jetbase/engine/file_parser.py +324 -0
  25. jetbase/engine/formatters.py +61 -0
  26. jetbase/engine/lock.py +65 -0
  27. jetbase/engine/repeatable.py +125 -0
  28. jetbase/engine/validation.py +238 -0
  29. jetbase/engine/version.py +144 -0
  30. jetbase/enums.py +37 -1
  31. jetbase/exceptions.py +87 -0
  32. jetbase/models.py +45 -0
  33. jetbase/repositories/lock_repo.py +129 -0
  34. jetbase/repositories/migrations_repo.py +451 -0
  35. jetbase-0.12.1.dist-info/METADATA +135 -0
  36. jetbase-0.12.1.dist-info/RECORD +39 -0
  37. {jetbase-0.7.0.dist-info → jetbase-0.12.1.dist-info}/WHEEL +1 -1
  38. jetbase/core/dry_run.py +0 -38
  39. jetbase/core/file_parser.py +0 -199
  40. jetbase/core/initialize.py +0 -33
  41. jetbase/core/repository.py +0 -169
  42. jetbase/core/rollback.py +0 -67
  43. jetbase/core/upgrade.py +0 -75
  44. jetbase/core/version.py +0 -163
  45. jetbase/queries.py +0 -72
  46. jetbase-0.7.0.dist-info/METADATA +0 -12
  47. jetbase-0.7.0.dist-info/RECORD +0 -17
  48. {jetbase-0.7.0.dist-info → jetbase-0.12.1.dist-info}/entry_points.txt +0 -0
  49. {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
+ )