Migrations¶
Migration files are Python, not YAML or JSON, and that is not a stylistic
choice: python_type is a Python type, and serialising it to JSON would need a name-to-type registry
— a second, worse type system. In .py it is an import and a reference.
Each operation knows how to apply itself and how to undo itself. What an engine cannot do is stopped in the PLAN, with a readable reason, rather than halfway through a deploy.
Where this text comes from
Everything below the headings is generated from the package's own docstrings, on every build.
The headings themselves are written by hand, so this page can fall behind the module — it had, by seven operations. The list that cannot fall behind is the module's own:
Runners¶
A migration declares what it comes AFTER, and the loader turns those declarations into one order across packages. A cycle is refused out loud, naming the migrations that close it — picking an order and hoping is the one thing it will not do.
from snakeorm.migration import Migration
class AddOrderTotals(Migration):
"""A migration from another package can be named as a dependency."""
depends_on = ["billing.0003_add_plans"]
operations = [...]
Migration
dataclass
¶
Migration(
version: str,
operations: tuple[SnakeMigrationOperation, ...],
replaces: tuple[str, ...] = (),
)
A versioned unit of operations (schema and/or data). The order comes from its number.
replaces
class-attribute
instance-attribute
¶
Versions this migration REPLACES (a squash). Empty in a normal migration.
It allows a history to be collapsed without breaking the databases where the originals were
already applied: there the squash is marked applied without being executed. See apply.
MigrationRunner
¶
MigrationRunner(driver: SnakeDriver, dialect: SnakeDialect)
Applies/reverts migrations and records the applied ones in snake_migrations.
ensure_tracking_table
¶
Creates the tracking table if it does not exist (idempotent).
The DDL itself is tracking_table_ddl, shared with the async runner. It used to live here,
and the async one carried its own copy with the MySQL bug this one had already fixed — which
is the reason it is a loose function now and not a method on each.
apply
¶
apply(migrations: list[Migration]) -> list[str]
Applies the pending migrations in order. Returns the versions applied just now.
Every migration is ATOMIC: its operations and the version record go together. With
transactional DDL a failure halfway does a rollback(); without it, how many were applied is
reported.
AsyncMigrationRunner
¶
AsyncMigrationRunner(
driver: AsyncDriver, dialect: SnakeDialect
)
Applies/reverts migrations over an AsyncDriver, with the same tracking as the sync one.
ensure_tracking_table
async
¶
Creates the tracking table if it does not exist (idempotent).
applied_versions
async
¶
Returns the set of versions already applied.
apply
async
¶
apply(migrations: list[Migration]) -> list[str]
Applies the pending migrations in order. Returns the versions applied just now.
Same semantics as the synchronous one: idempotent, atomic per migration with transactional DDL, and a hard stop if a squash replaces a history that was applied HALFWAY.
Autodetection¶
autodetect
¶
Django-style autogen: rebuilds the state by replaying migrations and diffs it.
replay applies the operations of every migration onto an empty SchemaState -> the state the
schema SHOULD have according to the history. autodetect diffs that against the current
metadata (the code-first source of truth) and returns the operations for the new migration.
Neither snapshots nor reflection: the migration history IS the record of the state.
current_schema
¶
current_schema(
reg: SnakeRegistry = registry,
*,
database: str | None = None,
include_unmanaged: bool = False,
) -> list[SnakeTableInfo]
The current metadata (code-first source of truth): the tables of the registered models.
Excludes MIRROR models (@snake_db_first): they are not a source of truth, and autogen must not
touch them. With database, only those of THAT connection (which avoids creating every table in
every DB). include_unmanaged=True returns them too: drift detection uses it (comparing the
mirror against the DB), but MIGRATIONS never touch them (the default behaviour).
current_routines
¶
current_routines(
reg: SnakeRegistry = registry,
) -> list[SnakeRoutineInfo]
The desired routines (code-first source of truth): those declared with snake_function.
current_triggers
¶
current_triggers(
reg: SnakeRegistry = registry,
) -> list[SnakeTriggerInfo]
The DESIRED triggers: those declared with snake_trigger(...).
replay
¶
replay(migrations: Iterable[Migration]) -> SchemaState
Rebuilds the schema state by applying the migrations' operations in order.
autodetect
¶
autodetect(
migrations: Iterable[Migration],
current: Iterable[SnakeTableInfo],
routines: Iterable[SnakeRoutineInfo] | None = None,
triggers: Iterable[SnakeTriggerInfo] | None = None,
) -> list[SnakeOperation]
Diffs the state replayed from the history against the current metadata -> new operations.
Resolves FK targets by model name through the global registry. Global order: tables -> FKs -> views (topological) -> functions -> triggers (each one depends on the ones before it).
replay
¶
replay(migrations: Iterable[Migration]) -> SchemaState
Rebuilds the schema state by applying the migrations' operations in order.
diff_schema
¶
diff_schema(
before: Iterable[SnakeTableInfo],
after: Iterable[SnakeTableInfo],
resolve_target: ResolveTarget | None = None,
resolve_qualified: ResolveTarget | None = None,
triggers: Iterable[SnakeTriggerInfo] = (),
) -> list[SnakeOperation]
Derives the operations to get from schema before to after.
Order: tables (create/drop/columns) -> FKs -> views (which depend on the tables). Views are not mixed in with columns or FKs; if their definition changes the whole thing is replaced (AlterView).
triggers are the ones the schema ALREADY has — the replayed state's, which is the only place
that knows they exist, since SnakeTableInfo has no field for one. They are not diffed here
(diff_triggers does that, afterwards): they are handed to any RebuildTable this call emits,
because on an engine with no ALTER TABLE ADD CONSTRAINT that rebuild DROPS the table and takes
them with it. Filling them in the same call that builds the operation is what keeps a rebuild
from coming out headless — nobody has to remember a second step.
THE VIEWS ARE NOT HANDED OVER, and the difference from the triggers is a fact against a guess.
A SnakeTriggerInfo has a .table, so "the triggers of this table" is a question the state
answers exactly. Nothing says which TABLES a view reads: depends_on is view->view only and is
refused for tables on purpose, and a view declared with sql= is raw text. So the generator
emits the rebuild bare: the engine refuses it, the migration rolls back whole, and
explain_rebuild_failure says to put a DropView before it and a CreateView after it.
render_migration
¶
render_migration(
version: str,
operations: Sequence[SnakeMigrationOperation],
replaces: Sequence[str] = (),
) -> str
Generates the text of a migration file that rebuilds operations when it is imported.
It exposes version, operations and migration: Migration. replaces are the versions that
this file SUPERSEDES (a squash); it is written only when there is one (a normal migration does not
shift a single byte) and it must be written: the runner needs it so as not to rerun a squash over
a DB that is already migrated.
load
¶
load(directory: str | Path) -> list[Migration]
Discovers, validates and loads a directory's migrations, ordered by number.
Returns [] if the directory does not exist or has no migrations. Raises SnakeMigrationError
if there are duplicate numbers or gaps in the sequence.
drop_order
¶
Orders tables so the one HOLDING a foreign key is dropped before the one it points at.
Two of the three engines refuse DROP TABLE while a key points at the table — measured:
PostgreSQL says other objects depend on it, MariaDB answers error 1451, and only SQLite accepts
it and leaves the key dangling. It is not a dialect difference to translate in the emitter: the
SQL is correct and what matters is the ORDER.
It is the exact mirror of the creation order the planner already derives from the same FKs, and
of topological_view_order, which drops views in reverse of the order it creates them in.
Only edges INSIDE this set count: a key into a table that is not being dropped constrains
nothing here. A table pointing at ITSELF (parent_id, the commonest tree there is) imposes no
order either — its own edge cannot make it wait for itself. A real cycle between two tables
cannot be ordered at all, so it stops and names THE WHOLE LOOP: naming one end sends the reader
to look at half of it, and the half they cannot see is the one holding the other key.
Schemas¶
CreateSchema
dataclass
¶
Creates a schema; its reverse drops it. It goes BEFORE any table using it.
Table operations¶
RenameTable
dataclass
¶
Renames a table KEEPING its rows; its reverse gives it the old name back.
Written BY HAND, like RenameColumn and for the same reason one level up: the diff sees a
rename as CreateTable + DropTable, which is correct SQL and destroys every row in the table.
The diff is not taught to guess it — guessing wrong keeps a table somebody asked to destroy,
with another table's data inside.
THE OLD NAME IS table.name and there is no second field holding it. Two spellings of one fact
are two things that can disagree, and this repository has already paid for a pair broken in half
inside the linker; here the table being renamed IS the table this operation carries, so the
question does not arise. RenameColumn needs an old_name because a table has many columns and
the table alone cannot say which one.
It renames WITHIN a schema. Moving a table to another schema is ALTER TABLE ... SET SCHEMA, a
different statement, and Postgres refuses to spell it as a qualified RENAME (measured).
RebuildTable
dataclass
¶
RebuildTable(
before: SnakeTableInfo,
after: SnakeTableInfo,
triggers: tuple[SnakeTriggerInfo, ...] = (),
)
Takes a table from one CONSTRAINT shape to another; its reverse takes it back.
AN OPERATION AND NOT A SIDE EFFECT. SQLite has no ALTER TABLE ADD/DROP CONSTRAINT, so
changing a CHECK or a foreign key means remaking the table. It is in the file, with a name and
both snapshots, so a reader knows which table gets remade and a revert gets the other shape back.
The file stays engine-agnostic: each dialect spells it — the minimal ALTER on Postgres and
MySQL, the whole rebuild on SQLite.
THE TWO SNAPSHOTS MAY ONLY DIFFER IN CONSTRAINTS, checked here rather than trusted. A pair
disagreeing about a column would apply on SQLite (which recreates from after) and not on
Postgres (whose minimal change emits no ALTER COLUMN), leaving two engines on different
schemas without a word. Columns have their own operations; renaming has RenameTable.
THE TRIGGERS RIDE IN A THIRD FIELD, and that asymmetry is forced: indexes come back because
they live inside the snapshot, and SnakeTableInfo has no triggers. They are filled by the
caller that holds the state (diff_schema) and recreated by _remake_table.
THE VIEWS DO NOT TRAVEL: a trigger knows its .table, a view does not. The consequence is
translated rather than hidden — SQLite's closing ALTER TABLE ... RENAME TO reparses the
schema, so a standing view that READS this table fails the migration whole
(error in view <v>: no such table), and explain_rebuild_failure turns that line into the one
that says what to write: a DropView before and a CreateView after.
triggers
class-attribute
instance-attribute
¶
triggers: tuple[SnakeTriggerInfo, ...] = ()
The triggers hanging off this table, which the rebuild has to put back after dropping it.
apply_to_state
¶
Leaves the after snapshot in the state — and REFUSES to drop a trigger on the floor.
THIS IS WHERE THE QUESTION CAN BE ASKED AT ALL. The operation cannot see the triggers by
itself: up_sql gets a dialect and nothing else, and the file that builds it is imported
with no state anywhere near it. apply_to_state is the one place a rebuild of ANY
provenance — autodetected or written by hand — meets a SchemaState, and replay walks
every operation of every migration through it on each makemigrations and each squash.
So a rebuild whose table has triggers the payload does not carry stops the replay and names
them, instead of leaving the state believing in triggers the DROP TABLE already ate. The
normal path never gets here with the question open: diff_schema receives the state's
triggers in the same call that builds the operation.
AlterTableComment
dataclass
¶
Changes an existing table's COMMENT ON TABLE; its reverse restores the previous one.
The COLUMN one is already covered by AlterColumn; this is the TABLE one. On an engine without
comments (SQLite) up/down come out empty: the operation exists in the history but emits nothing.
AddColumn
dataclass
¶
Adds a column to a table; its reverse drops it.
DropColumn
dataclass
¶
Drops a column from a table; its reverse recreates it (with its original info).
RenameColumn
dataclass
¶
Renames a column KEEPING its data; its reverse gives it the old name back.
It is written BY HAND, replacing the DropColumn + AddColumn the diff generates (correct but
catastrophic: it drops the old column along with its data).
AlterColumn
dataclass
¶
Changes an existing column (type/nullable); its reverse undoes the change.
RebuildTable takes a table from one CONSTRAINT shape to another, and that is SQLite's way out: it
has no ALTER TABLE ADD/DROP CONSTRAINT and never will, so a CHECK or a foreign key on a table that
ALREADY EXISTS can only get there by remaking the table around it — create the new one, copy the
rows, drop the old one, rename. The operation names no engine: Postgres and MySQL get the single
minimal ALTER TABLE ... ADD CONSTRAINT, SQLite gets the whole rebuild.
It takes two WHOLE SnakeTableInfo snapshots, plus the triggers hanging off the table — the rebuild
drops them along with it and owes them back. And it REFUSES a pair that disagrees about anything but
CHECKs and foreign keys, naming what disagrees: a difference in columns would apply on SQLite (which
recreates the table from after) and not on Postgres (whose minimal ALTER emits nothing for it),
leaving the two engines holding different schemas with neither saying a word. Columns keep their own
operations, and dropping one a foreign key still holds is NOT this operation: SQLite has no
DROP CONSTRAINT to take the key out of the way first either, so that one is a hand-written
RunSQL, which is the user's call — see known limits.
from dataclasses import replace
from snakeorm.metadata import (
SnakeColumnInfo,
SnakeForeignKeyInfo,
SnakePrimaryKeyInfo,
SnakeRelationshipInfo,
SnakeRelationshipKind,
SnakeTableInfo,
)
from snakeorm.migration import RebuildTable
tag_id = SnakeColumnInfo(name="id", python_type=int, attr_name="id", autoincrement=True)
parent_id = SnakeColumnInfo(
name="parent_id", python_type=int, nullable=True, attr_name="parent_id"
)
# The WHOLE table as this migration finds it: on SQLite the rebuild recreates it from `after`, so
# anything left out of the snapshot is structure lost without a word.
tags = SnakeTableInfo(
name="tags",
columns=(tag_id, parent_id),
primary_key=SnakePrimaryKeyInfo(columns=(tag_id,)),
)
parent = SnakeRelationshipInfo(
name="parent",
target="Tag",
kind=SnakeRelationshipKind.TO_ONE,
foreign_key=SnakeForeignKeyInfo(target="Tag", pairs=(("parent_id", "id"),)),
target_table="public.tags",
)
operations = [
RebuildTable(
before=tags,
after=replace(tags, relationships=(parent,)),
triggers=(),
),
]
Constraints and indexes¶
CreateIndex
dataclass
¶
Creates an index on an ALREADY existing table; its reverse drops it.
It only appears when the table is already in the state: a NEW table's indexes are emitted by
CreateTable.up_sql itself, and duplicating them here would make the migration fail on apply.
DropIndex
dataclass
¶
Drops an index; its reverse recreates it (with its original info: columns, unique, name).
AddCheck
dataclass
¶
AddCheck(table: SnakeTableInfo, check: SnakeCheckInfo)
Adds a CHECK constraint to an ALREADY existing table; its reverse drops it.
A NEW table's checks are emitted by its own CreateTable, just as its indexes are.
DropCheck
dataclass
¶
DropCheck(table: SnakeTableInfo, check: SnakeCheckInfo)
Drops a CHECK constraint; its reverse recreates it with its original condition.
AddForeignKey
dataclass
¶
AddForeignKey(
table: SnakeTableInfo,
relationship: SnakeRelationshipInfo,
target: SnakeTableInfo,
)
Adds an FK (at the end, after the tables are created); its reverse drops it.
DropForeignKey
dataclass
¶
DropForeignKey(
table: SnakeTableInfo,
relationship: SnakeRelationshipInfo,
target: SnakeTableInfo,
)
Drops an FK; its reverse recreates it (with the original target table).
Views, functions and triggers¶
CreateView
dataclass
¶
Creates a view (CREATE VIEW ... AS <def>); its reverse drops it.
DropView
dataclass
¶
Drops a view (DROP VIEW); its reverse recreates it with its original definition.
AlterView
dataclass
¶
Changes a view's definition; its reverse restores the old one.
A changed FILTER is a CREATE OR REPLACE VIEW. A changed PROJECTION is not: no engine's
replacement can rename an output column — measured on PostgreSQL, cannot change name of view
column "a" to "x" — so the view has to be dropped and made again.
That is decided by comparing the two column lists and NOT by asking the dialect what it supports,
because it is not a capability question. PostgreSQL and MySQL both declare Cap.REPLACE_VIEW and
both refuse this: Cap answers "can this engine do X", and what is being asked here is "can X
express the change". The two agreed until a view's projection moved.
CreateFunction
dataclass
¶
Creates (or replaces) a routine by emitting its body; its reverse drops it (DROP FUNCTION).
DropFunction
dataclass
¶
Drops a routine (DROP FUNCTION); its reverse recreates it with its original body.
AlterFunction
dataclass
¶
Changes a routine (CREATE OR REPLACE with the new body); its reverse restores the old one.
CreateTrigger
dataclass
¶
CreateTrigger(definition: SnakeTriggerInfo)
Creates a trigger. Its inverse is dropping it.
DropTrigger
dataclass
¶
DropTrigger(definition: SnakeTriggerInfo)
Drops a trigger. Its inverse is creating it again.
apply_to_state
¶
Removes the trigger from the replayed state.
AlterTrigger
dataclass
¶
AlterTrigger(old: SnakeTriggerInfo, new: SnakeTriggerInfo)
Replaces a trigger: it is DROPPED and created again.
With no portable CREATE OR REPLACE TRIGGER, it is done in two steps. The down recreates the
OLD one, so undoing gives back exactly the trigger that was there.
apply_to_state
¶
Leaves the NEW trigger in the replayed state.
Escape hatches¶
RunSQL
dataclass
¶
RAW data SQL: it runs up's statements, and down's as the reverse (or nothing).
An escape hatch: bare SQL, NOT portable between engines. It fits SnakeOperation (it emits SQL)
but it is a DATA migration: apply_to_state is a no-op (it mutates rows, not the abstract schema).
up_sql
¶
up_sql(dialect: SnakeDialect) -> list[str]
up's statements (raw: the dialect does not touch them).
down_sql
¶
down_sql(dialect: SnakeDialect) -> list[str]
down's statements, or [] if no reverse was declared.
apply_to_state
¶
No-op: a data migration does not change the abstract state's tables.
RunPython
dataclass
¶
RunPython(
forward: Callable[[SnakeSession], None],
backward: Callable[[SnakeSession], None] | None = None,
)
A DATA operation running Python code with the typed ORM (a SnakeSession).
forward/backward receive a SnakeSession and migrate data with the ORM. They MUST be
module-level functions (importable), not lambdas or closures: the renderer writes them by
reference. Without backward the migration is not reversible and unrun says so plainly.
run
¶
run(session: SnakeSession) -> None
Applies the data migration: it invokes forward with the session.
unrun
¶
unrun(session: SnakeSession) -> None
Undoes the data migration with backward; without it, raises SnakeMigrationError.
apply_to_state
¶
No-op: a data migration does not change the abstract state's tables.