Migrations without files¶
An application that changes a table's schema while it runs — a table whose columns its users configure —
builds its migrations in memory, keeps them where it likes (as text in its own database, say) and applies
them itself. Everything below is imported from hare.migrations: Migration, State,
MigrationRunner, MigrationRecorder, MigrationWriter, OperationEffect, OperationPlan.
class Migration:
def __init__(
self,
name: str,
app_label: str,
*,
operations: Iterable[Operation] | None = None,
dependencies: Iterable[tuple[str, str]] | None = None,
) -> None: ...
@classmethod
def from_source(cls, source: str, *, name: str, app_label: str) -> Migration: ...
class State:
@classmethod
def from_models(
cls,
models: Iterable[type[Model]],
*,
app_label: str | None = None,
default_connections: dict[str, str] | None = None,
) -> State: ...
def get_operations(
self, new_state: State, app_label: str, rename_questions: RenameQuestions | None = None
) -> OperationPlan: ...
class Operation(ABC):
def get_effect(self, app_label: str, state: State, dialect: Dialect) -> OperationEffect: ...
class MigrationRunner:
def __init__(
self,
connection: DatabaseClient,
*,
recorder: MigrationRecorder | None = None,
lock_timeout: float | None = None,
) -> None: ...
async def ensure_journal(self) -> None: ...
async def apply(self, migration: Migration, state: State, *, dry_run: bool = False) -> State: ...
async def unapply(self, migration: Migration, state: State, *, dry_run: bool = False) -> State: ...
async def record(
self,
migration: Migration,
*,
applied: bool,
connection: DatabaseClient | None = None,
) -> None: ...
async def collect_sql(
self,
migration: Migration,
state: State,
*,
backward: bool = False,
) -> list[str]: ...
def get_effects(self, migration: Migration, state: State) -> list[OperationEffect]: ...
class MigrationRecorder:
def __init__(self, connection: DatabaseClient, *, table_name: str = "hare_migrations") -> None: ...
Migration(...)builds a migration in code;operationsanddependenciestake the place of the class attributes a migration file declares.Migration.from_source()reads a migration from the text of a migration file — whatMigrationWriter(name, app_label, operations).as_string()renders. The text runs as Python, so it must come from a place you trust. Text that doesn't run, or declares noMigrationclass, raiseshare.migrations.exceptions.MigrationLoadError.State.from_models()is the schema state of model classes as they are declared. A model's app is itsMeta.app, elseapp_label; a model with neither raisesConfigurationError.old_state.get_operations(new_state, app_label)returns anOperationPlan(operations, warnings, data_loss_warnings)— the operationsmakemigrationswould write for the app, with its warnings. A rename is made only asrename_questionsanswers it (RenameQuestions({"content.Book.title": "name"})— see Renames); without them a renamed field or model is removed and added.Operation.get_effect()returns anOperationEffect(operation, reversible, rewrites_table, loses_data, reason): whether the operation can be unapplied, whether the database rewrites the whole table (the time grows with its rows), whether values or rows can be lost, and why.RemoveFieldandDeleteModellose data.AlterFieldloses data when it changes a column's type, and rewrites the table when the dialect does that for this change — seealter_field_rewrites_table.MigrationRunnerapplies one migration onconnectionexactly ashare migratedoes: an atomic migration in one transaction together with its journal record, constraint checks before the commit.lock_timeoutis the lock timeout its statements run under.ensure_journal()creates the recorder's table when it doesn't exist.apply()returns the state after the migration;unapply()takes the state before it was applied and returns it. Withdry_run=Trueeither runs the operations without touching the database or the journal.record()marks a migration applied or unapplied in the journal. It does nothing without arecorder, and so do the journal steps ofapply()/unapply().collect_sql()returns the migration's SQL (or its rollback's, withbackward=True) without running it, insideBEGIN;/COMMIT;for an atomic migration on a database that rolls back DDL (not ClickHouse).get_effects()returns theOperationEffectof each operation, in order.
MigrationRecorderis the journal of applied migrations, in the tabletable_name. Give runtime migrations a table of their own, sohare migratenever sees them.
A model registered while the application runs (Hare.register_live_models()), with its first migration
built from the difference between two states:
from hare import Connections, Hare
from hare.migrations import Migration, MigrationRecorder, MigrationRunner, MigrationWriter, State
connection = Connections.get("default")
runner = MigrationRunner(connection, recorder=MigrationRecorder(connection, table_name="content_migrations"))
await runner.ensure_journal()
old_state = State.from_models([Book])
plan = old_state.get_operations(State.from_models([Book, Notice]), "content")
migration = Migration("0001_notice", "content", operations=plan.operations)
print("\n".join(await runner.collect_sql(migration, old_state))) # the SQL, nothing run yet
source = MigrationWriter(migration.name, "content", migration.operations).as_string() # keep it
new_state = await runner.apply(migration, old_state)
Hare.register_live_models([Notice], app_label="content", managed=False)
managed=False keeps makemigrations, migrate and drift checks away from the table: its migrations
live only where the application keeps them, and hare migrate doesn't know them. The current state of
such a table comes from its own migrations applied in order, not from the registered class. The journal
of these migrations is a table no model backs: a drift check is told of it, or reports it as untracked —
await detect_drift_for_alias(Hare.apps, apps_config, "default", journal_tables=["content_migrations"]),
hare drift --journal-table content_migrations.