Skip to content

Commit d0c147c

Browse files
authored
feat(migrations): add migration squash engine (#350)
Implement core squash functionality for consolidating multiple sequential migrations into a single release migration file (Django-style squash workflow).
1 parent a1850d3 commit d0c147c

27 files changed

Lines changed: 3479 additions & 56 deletions

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,7 @@ ignore = [
516516
"B903", # class could be a dataclass or named tuple
517517
"PLW0603", # Using the global statement to update is discouraged
518518
"PLW0108", # Replace lambda expression with a def
519+
"RUF067", # ruff - init should only have import statements
519520
]
520521
select = ["ALL"]
521522

sqlspec/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
"""SQLSpec: Type-safe SQL query mapper for Python."""
22

3+
# ruff: noqa: E402
4+
# Suppress noisy Google library deprecation warnings about Python version support.
5+
# These are informational and clutter CLI output unnecessarily.
6+
import warnings as _warnings
7+
8+
_warnings.filterwarnings(
9+
"ignore",
10+
message="You are using a Python version.*which Google will stop supporting",
11+
category=FutureWarning,
12+
module=r"google\.api_core\._python_version_support",
13+
)
14+
del _warnings
15+
316
from sqlspec import adapters, base, builder, core, driver, exceptions, extensions, loader, migrations, typing, utils
417
from sqlspec.__metadata__ import __version__
518
from sqlspec.base import SQLSpec

sqlspec/cli.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,80 @@ async def async_fix() -> None:
899899

900900
_execute_for_config(sqlspec_config, sync_fix, async_fix)
901901

902+
@database_group.command(name="squash", help="Squash multiple migrations into a single file.")
903+
@bind_key_option
904+
@click.argument("version_range", required=True)
905+
@click.option("-m", "--message", required=True, help="Description for squashed migration")
906+
@dry_run_option
907+
@click.option("--yes", is_flag=True, help="Skip confirmation prompt")
908+
@click.option("--no-database", is_flag=True, help="Skip database record updates")
909+
@click.option("--allow-gaps", is_flag=True, help="Allow gaps in version sequence")
910+
@click.option(
911+
"--output-format",
912+
type=click.Choice(["sql", "py"], case_sensitive=False),
913+
default="sql",
914+
help="Output format for squashed migration (default: sql)",
915+
)
916+
def squash_migrations(
917+
bind_key: str | None,
918+
version_range: str,
919+
message: str,
920+
dry_run: bool,
921+
yes: bool,
922+
no_database: bool,
923+
allow_gaps: bool,
924+
output_format: str,
925+
) -> None:
926+
"""Squash multiple sequential migrations into a single file.
927+
928+
VERSION_RANGE should be in START:END format, e.g., "0001:0004".
929+
930+
Examples:
931+
sqlspec db squash 0001:0004 -m "v1.0 release"
932+
sqlspec db squash 0001:0003 -m "initial schema" --dry-run
933+
"""
934+
from sqlspec.migrations.commands import create_migration_commands
935+
936+
ctx = _ensure_click_context()
937+
938+
if ":" not in version_range:
939+
console.print("[red]Error: VERSION_RANGE must be in START:END format (e.g., 0001:0004)[/]")
940+
raise SystemExit(1)
941+
942+
start_version, end_version = version_range.split(":", 1)
943+
start_version = start_version.strip().zfill(4)
944+
end_version = end_version.strip().zfill(4)
945+
946+
console.rule("[yellow]Migration Squash Command[/]", align="left")
947+
sqlspec_config = get_config_by_bind_key(ctx, bind_key)
948+
migration_commands = create_migration_commands(config=sqlspec_config)
949+
950+
def sync_squash() -> None:
951+
migration_commands.squash(
952+
start_version=start_version,
953+
end_version=end_version,
954+
description=message,
955+
dry_run=dry_run,
956+
update_database=not no_database,
957+
yes=yes,
958+
allow_gaps=allow_gaps,
959+
output_format=output_format,
960+
)
961+
962+
async def async_squash() -> None:
963+
await cast("AsyncMigrationCommands[Any]", migration_commands).squash(
964+
start_version=start_version,
965+
end_version=end_version,
966+
description=message,
967+
dry_run=dry_run,
968+
update_database=not no_database,
969+
yes=yes,
970+
allow_gaps=allow_gaps,
971+
output_format=output_format,
972+
)
973+
974+
_execute_for_config(sqlspec_config, sync_squash, async_squash)
975+
902976
@database_group.command(name="show-config", help="Show all configurations with migrations enabled.")
903977
@bind_key_option
904978
def show_config(bind_key: str | None = None) -> None: # pyright: ignore[reportUnusedFunction]

sqlspec/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"SQLSpecError",
3838
"SerializationConflictError",
3939
"SerializationError",
40+
"SquashValidationError",
4041
"StackExecutionError",
4142
"StorageCapabilityError",
4243
"StorageOperationFailedError",
@@ -360,6 +361,17 @@ class OutOfOrderMigrationError(MigrationError):
360361
"""
361362

362363

364+
class SquashValidationError(MigrationError):
365+
"""Raised when migration squash validation fails.
366+
367+
Squash validation errors occur when:
368+
- Version range is invalid (start > end)
369+
- Gap detected in version sequence
370+
- Mixed migration types that cannot be squashed
371+
- Target file already exists
372+
"""
373+
374+
363375
# SQLSTATE class code length (first 2 characters of 5-character SQLSTATE)
364376
SQLSTATE_CLASS_CODE_LEN: Final[int] = 2
365377

sqlspec/migrations/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
get_migration_loader,
1414
)
1515
from sqlspec.migrations.runner import AsyncMigrationRunner, SyncMigrationRunner, create_migration_runner
16+
from sqlspec.migrations.squash import MigrationSquasher, SquashPlan
1617
from sqlspec.migrations.tracker import AsyncMigrationTracker, SyncMigrationTracker
1718
from sqlspec.migrations.utils import create_migration_file, drop_all, get_author
1819

@@ -22,8 +23,10 @@
2223
"AsyncMigrationTracker",
2324
"BaseMigrationLoader",
2425
"MigrationLoadError",
26+
"MigrationSquasher",
2527
"PythonFileLoader",
2628
"SQLFileLoader",
29+
"SquashPlan",
2730
"SyncMigrationCommands",
2831
"SyncMigrationRunner",
2932
"SyncMigrationTracker",

sqlspec/migrations/base.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def _get_create_table_sql(self) -> CreateTable:
8484
.column("execution_time_ms", "INTEGER")
8585
.column("checksum", "VARCHAR(64)")
8686
.column("applied_by", "VARCHAR(255)")
87+
.column("replaces", "TEXT")
8788
)
8889

8990
def _get_current_version_sql(self) -> Select:
@@ -189,6 +190,70 @@ def _get_update_version_sql(self, old_version: str, new_version: str, new_versio
189190
.where(sql.version_num == old_version)
190191
)
191192

193+
def _get_delete_versions_sql(self, versions: "list[str]") -> Delete:
194+
"""Get SQL builder for deleting multiple version records.
195+
196+
Used by squash operations to remove replaced migration records.
197+
198+
Args:
199+
versions: List of version strings to delete.
200+
201+
Returns:
202+
SQL builder object for delete.
203+
"""
204+
return sql.delete().from_(self.version_table).where(sql.version_num.in_(versions))
205+
206+
def _get_record_squashed_migration_sql(
207+
self,
208+
version: str,
209+
version_type: str,
210+
execution_sequence: int,
211+
description: str,
212+
execution_time_ms: int,
213+
checksum: str,
214+
applied_by: str,
215+
replaces: str,
216+
) -> Insert:
217+
"""Get SQL builder for recording a squashed migration.
218+
219+
Args:
220+
version: Version number of the squashed migration.
221+
version_type: Version format type ('sequential' or 'timestamp').
222+
execution_sequence: Auto-incrementing application order.
223+
description: Description of the migration.
224+
execution_time_ms: Execution time in milliseconds.
225+
checksum: MD5 checksum of the migration content.
226+
applied_by: User who applied the migration.
227+
replaces: Comma-separated list of replaced versions.
228+
229+
Returns:
230+
SQL builder object for insert.
231+
"""
232+
return (
233+
sql
234+
.insert(self.version_table)
235+
.columns(
236+
"version_num",
237+
"version_type",
238+
"execution_sequence",
239+
"description",
240+
"execution_time_ms",
241+
"checksum",
242+
"applied_by",
243+
"replaces",
244+
)
245+
.values(
246+
version,
247+
version_type,
248+
execution_sequence,
249+
description,
250+
execution_time_ms,
251+
checksum,
252+
applied_by,
253+
replaces,
254+
)
255+
)
256+
192257
def _get_check_column_exists_sql(self) -> Select:
193258
"""Get SQL to check what columns exist in the tracking table.
194259
@@ -324,7 +389,7 @@ def _extract_version(self, filename: str) -> str | None:
324389
parts = stem.split("_", 1)
325390
return parts[0].zfill(4) if parts and parts[0].isdigit() else None
326391

327-
def _calculate_checksum(self, content: str) -> str:
392+
def calculate_checksum(self, content: str) -> str:
328393
"""Calculate MD5 checksum of migration content.
329394
330395
Args:
@@ -403,7 +468,7 @@ def _load_migration_metadata(self, file_path: Path, version: "str | None" = None
403468
loader = get_migration_loader(file_path, self.migrations_path, self.project_root, context_to_use)
404469
loader.validate_migration_file(file_path)
405470
content = file_path.read_text(encoding="utf-8")
406-
checksum = self._calculate_checksum(content)
471+
checksum = self.calculate_checksum(content)
407472
description = self._extract_description(content, file_path)
408473
if not description:
409474
description = file_path.stem.split("_", 1)[1] if "_" in file_path.stem else ""

0 commit comments

Comments
 (0)