Skip to content

Migrations

The Alembic bridge. get_metadata() builds a SQLAlchemy MetaData describing all registered Ferro models from the compiled SchemaIR modelset, so Alembic's --autogenerate can diff your models against the live database and emit migration scripts. Assign it to target_metadata in your Alembic env.py (requires the ferro-orm[alembic] extra). See the Schema Migrations guide for the full workflow.

Internal JSON-derivation helpers (_build_sa_table, _map_to_sa_type) are deprecated and scheduled for removal in v0.14.0. Replace internal usages with get_metadata(); see Migrating to v0.12.0.

get_metadata()

Generate a SQLAlchemy MetaData object representing all registered Ferro models. This is intended to be used in alembic's env.py for autogenerate support.

Enum columns are mapped to named sqlalchemy.Enum types so PostgreSQL autogenerate and DDL compilation succeed (anonymous enums are rejected). When the field annotation is a Python enum.Enum subclass, the database type name defaults to the enum class name in lowercase; otherwise the column name is used as the type name.

For :class:~ferro.base.ForeignKey fields with unique=True (one-to-one relations), the shadow *_id column is emitted with Column(unique=True) so Alembic autogenerate includes the matching UNIQUE constraint.

Column nullability: Column.nullable follows :class:~ferro.base.FerroField / :class:~ferro.base.ForeignKey nullable when set to a boolean (force NULL / NOT NULL). The default nullable='infer' uses whether the Python annotation allows None (after unwrapping Annotated). Shadow *_id columns infer from the forward relation field's annotation, not from the synthetic *_id field. Primary key columns are always nullable=False. Pydantic "required" and JSON-schema defaults do not change inferred nullability.

Source code in src/ferro/migrations/alembic.py
def get_metadata() -> "sa.MetaData":
    """
    Generate a SQLAlchemy MetaData object representing all registered Ferro models.
    This is intended to be used in alembic's env.py for autogenerate support.

    Enum columns are mapped to named ``sqlalchemy.Enum`` types so PostgreSQL
    autogenerate and DDL compilation succeed (anonymous enums are rejected).
    When the field annotation is a Python ``enum.Enum`` subclass, the database
    type name defaults to the enum class name in lowercase; otherwise the
    column name is used as the type name.

    For :class:`~ferro.base.ForeignKey` fields with ``unique=True`` (one-to-one
    relations), the shadow ``*_id`` column is emitted with ``Column(unique=True)``
    so Alembic autogenerate includes the matching UNIQUE constraint.

    **Column nullability:** ``Column.nullable`` follows :class:`~ferro.base.FerroField`
    / :class:`~ferro.base.ForeignKey` ``nullable`` when set to a boolean (force
    NULL / NOT NULL). The default ``nullable='infer'`` uses whether the Python
    annotation allows ``None`` (after unwrapping ``Annotated``). Shadow ``*_id``
    columns infer from the **forward relation** field's annotation, not from the
    synthetic ``*_id`` field. Primary key columns are always ``nullable=False``.
    Pydantic "required" and JSON-schema defaults do not change inferred nullability.
    """
    if sa is None:
        raise ImportError(
            "SQLAlchemy is required to use the alembic bridge. "
            "Install it via 'pip install ferro-orm[alembic]'."
        )

    metadata = sa.MetaData(naming_convention=_FERRO_NAMING_CONVENTION)

    # 1. First, ensure all relationships are resolved
    from ..relations import resolve_relationships

    resolve_relationships()

    # 2. Build SQLAlchemy metadata from SchemaIR modelset only.
    schema_ir = compile_registry_schema_ir()
    payload = schema_ir.get("payload", {})
    models = payload.get("models", [])
    for model_ir in models:
        if isinstance(model_ir, dict):
            _build_sa_table_from_ir(metadata, model_ir)

    return metadata