Skip to content

Migrations

migrations

Alembic integration behind a project's env.py: URL resolution, import_models, run, revision generation (process_revision_directives). Extra spltz-viur-models[migrations].

target_metadata

target_metadata() -> Any

SQLModel.metadata — the whole schema once the models are imported.

Source code in src/viur/models/migrations.py
def target_metadata() -> t.Any:
    """``SQLModel.metadata`` — the whole schema once the models are imported."""
    return SQLModel.metadata

import_models

import_models(*packages: str) -> list[str]

Import each package and its non-private submodules (one level) to fill SQLModel.metadata. Returns the imported module names.

Source code in src/viur/models/migrations.py
def import_models(*packages: str) -> list[str]:
    """Import each package and its non-private submodules (one level) to fill ``SQLModel.metadata``.
    Returns the imported module names."""
    imported = []
    for package_name in packages:
        package = importlib.import_module(package_name)
        imported.append(package_name)
        for info in pkgutil.iter_modules(getattr(package, "__path__", [])):
            if info.name.startswith("_"):
                continue
            importlib.import_module(f"{package_name}.{info.name}")
            imported.append(f"{package_name}.{info.name}")
    return imported

resolve_url

resolve_url(config: Config | None = None, *, x_args: dict | None = None, fallback_url: str | None = None) -> str

Database URL for this run, most explicit first: -x url=… (x_args), $VIUR_MODELS_DSN, the configured engine, the conf.models preset, fallback_url, sqlalchemy.url from alembic.ini. -x db=<name> selects a non-default database (engine or conf.models.databases entry only). RuntimeError when none applies.

Source code in src/viur/models/migrations.py
def resolve_url(
    config: "Config | None" = None,
    *,
    x_args: dict | None = None,
    fallback_url: str | None = None,
) -> str:
    """Database URL for this run, most explicit first: ``-x url=…`` (``x_args``),
    ``$VIUR_MODELS_DSN``, the configured engine, the ``conf.models`` preset, ``fallback_url``,
    ``sqlalchemy.url`` from ``alembic.ini``. ``-x db=<name>`` selects a non-default database
    (engine or ``conf.models.databases`` entry only). ``RuntimeError`` when none applies."""
    import os

    from . import db

    database = _database(x_args)
    if x_args and (url := (x_args.get("url") or "").strip()):
        return url
    if database == db.DEFAULT and (url := os.environ.get(DSN_ENV_VAR, "").strip()):
        return url

    try:
        engine = db.get_engine(database)
    except RuntimeError:
        pass
    else:
        return str(engine.url.render_as_string(hide_password=False))

    # a set but misconfigured preset must surface: no try/except here
    cfg = _models_conf()
    if database != db.DEFAULT:
        if cfg is None:
            raise RuntimeError(f"No engine {database!r} and conf.models is not installed")
        return db.url_from_conf(database)
    if cfg is not None and db.DEFAULT in cfg.databases:
        return db.url_from_conf()

    if fallback_url and fallback_url.strip():
        return fallback_url.strip()

    if config is not None:
        if url := (config.get_main_option("sqlalchemy.url") or "").strip():
            return url

    raise RuntimeError(
        "No database URL for the migration. Tried, in order: -x url=…, "
        f"${DSN_ENV_VAR}, the configured engine (viur.models.db.configure), "
        "conf.models, the env.py fallback, and sqlalchemy.url in alembic.ini."
    )

include_object

include_object(obj: Any, name: str | None, type_: str, reflected: bool, compare_to: Any) -> bool

Autogenerate filter: everything but alembic_version. Wrap it to exclude foreign tables.

Source code in src/viur/models/migrations.py
def include_object(
    obj: t.Any, name: str | None, type_: str, reflected: bool, compare_to: t.Any,
) -> bool:
    """Autogenerate filter: everything but ``alembic_version``. Wrap it to exclude foreign tables."""
    return not (type_ == "table" and name == "alembic_version")

include_object_for

include_object_for(database: str) -> Callable

include_object restricted to the tables of database (db.tables_for).

Source code in src/viur/models/migrations.py
def include_object_for(database: str) -> t.Callable:
    """``include_object`` restricted to the tables of ``database`` (``db.tables_for``)."""
    from . import db

    names = {table.name for table in db.tables_for(database)}

    def _include(obj: t.Any, name: str | None, type_: str, reflected: bool, compare_to: t.Any) -> bool:
        if type_ == "table" and name not in names:
            return False
        return include_object(obj, name, type_, reflected, compare_to)

    return _include

render_item

render_item(type_: str, obj: Any, autogen_context: Any) -> Any

Render a TypeDecorator as its impl type (RecordJSON cannot round-trip); SQLModel's decorators and dialect-specific impls are left to Alembic.

Source code in src/viur/models/migrations.py
def render_item(type_: str, obj: t.Any, autogen_context: t.Any) -> t.Any:
    """Render a ``TypeDecorator`` as its ``impl`` type (``RecordJSON`` cannot round-trip);
    SQLModel's decorators and dialect-specific impls are left to Alembic."""
    from sqlalchemy.types import TypeDecorator, TypeEngine

    if type_ != "type" or not isinstance(obj, TypeDecorator):
        return False
    if type(obj).__module__.startswith("sqlmodel."):
        return False
    impl = obj.impl if isinstance(obj.impl, TypeEngine) else obj.impl()
    if not type(impl).__module__.startswith("sqlalchemy.sql.sqltypes"):
        return False  # dialect-specific impl (JSONB, …): no sa. name
    autogen_context.imports.add("import sqlalchemy as sa")
    return f"sa.{impl!r}"

run_offline

run_offline(url: str, database: str = 'default', **overrides: Any) -> None

--sql mode.

Source code in src/viur/models/migrations.py
def run_offline(url: str, database: str = "default", **overrides: t.Any) -> None:
    """``--sql`` mode."""
    context.configure(url=url, literal_binds=True,
                      dialect_opts={"paramstyle": "named"},
                      **_configure_kwargs(url, database, **overrides))
    with context.begin_transaction():
        context.run_migrations()

run_online

run_online(url: str, config: Config, database: str = 'default', **overrides: Any) -> None

Connect and run the migrations in one transaction.

Source code in src/viur/models/migrations.py
def run_online(url: str, config: "Config", database: str = "default", **overrides: t.Any) -> None:
    """Connect and run the migrations in one transaction."""
    section = config.get_section(config.config_ini_section) or {}
    section["sqlalchemy.url"] = url
    engine: "Engine" = engine_from_config(
        section, prefix="sqlalchemy.", poolclass=pool.NullPool,
    )
    connection: "Connection"
    with engine.connect() as connection:
        context.configure(connection=connection, **_configure_kwargs(url, database, **overrides))
        with context.begin_transaction():
            context.run_migrations()
    engine.dispose()

run

run(*, fallback_url: str | None = None, **overrides: Any) -> str

env.py entry point: resolve the URL (-x db=<name> picks the database), dispatch offline/online; overrides go to context.configure. Returns the URL.

Source code in src/viur/models/migrations.py
def run(*, fallback_url: str | None = None, **overrides: t.Any) -> str:
    """``env.py`` entry point: resolve the URL (``-x db=<name>`` picks the database), dispatch
    offline/online; ``overrides`` go to ``context.configure``. Returns the URL."""
    config = context.config
    x_args = context.get_x_argument(as_dictionary=True)
    url = resolve_url(config, x_args=x_args, fallback_url=fallback_url)
    database = _database(x_args)
    if context.is_offline_mode():
        run_offline(url, database, **overrides)
    else:
        run_online(url, config, database, **overrides)
    return url

process_revision_directives

process_revision_directives(context: Any, revision: Any, directives: list) -> None

Alembic hook: correct the DDL diff for bone-level transitions, write the structure snapshot.

Source code in src/viur/models/migrations.py
def process_revision_directives(context: t.Any, revision: t.Any, directives: list) -> None:
    """Alembic hook: correct the DDL diff for bone-level transitions, write the structure snapshot."""
    from alembic import util as alembic_util

    from . import schema

    if not directives:
        return
    script = directives[0]
    if script.upgrade_ops is None:
        return

    config = context.config if hasattr(context, "config") else None
    location = config.get_main_option("script_location") if config else None
    if not location:
        return
    directory = schema.snapshot_dir(location)

    current = schema.snapshot()
    previous = schema.load(directory, _parent_revision(revision))
    transitions = schema.diff(previous, current)

    for name in _name_constraints(script.upgrade_ops):
        alembic_util.msg(f"viur-models: named the constraint {name} (SQLite needs it)")

    if transitions:
        for note in _generate_transition_ops(script.upgrade_ops, transitions):
            alembic_util.msg(f"viur-models: {note}")

    if _is_dry_run(context):
        return  # alembic check

    schema.save(directory, script.rev_id, current)
    schema.prune(directory, _known_revisions(config) | {script.rev_id})

scaffold

Alembic scaffold generator (alembic.ini + migrations/); never overwrites.

generate

generate(root: str | PathLike, *, app_dir: str | PathLike | None = None, models_package: str = 'models', settings_module: str = 'models_db') -> list[str]

Write the missing scaffold files into root.

Parameters:

Name Type Description Default
app_dir str | PathLike | None

Distribution folder for prepend_sys_path (default: cwd).

None
models_package str

Package env.py imports to fill SQLModel.metadata.

'models'
settings_module str

Module providing the url() fallback.

'models_db'

Returns:

Type Description
list[str]

Created paths relative to root.

Source code in src/viur/models/scaffold.py
def generate(
    root: str | os.PathLike,
    *,
    app_dir: str | os.PathLike | None = None,
    models_package: str = "models",
    settings_module: str = "models_db",
) -> list[str]:
    """Write the missing scaffold files into ``root``.

    :param app_dir: Distribution folder for ``prepend_sys_path`` (default: cwd).
    :param models_package: Package ``env.py`` imports to fill ``SQLModel.metadata``.
    :param settings_module: Module providing the ``url()`` fallback.
    :returns: Created paths relative to ``root``.
    """
    root = pathlib.Path(root).resolve()
    app_dir = pathlib.Path(app_dir).resolve() if app_dir else pathlib.Path.cwd()
    fields = {
        "migrations_dirname": MIGRATIONS_DIRNAME,
        "models_package": models_package,
        "settings_module": settings_module,
        "sys_path": _sys_path_entry(root, app_dir),
    }
    contents = {
        "alembic.ini": ALEMBIC_INI.format(**fields),
        f"{MIGRATIONS_DIRNAME}/env.py": ENV_PY.format(**fields),
        f"{MIGRATIONS_DIRNAME}/script.py.mako": _script_mako(),
        f"{MIGRATIONS_DIRNAME}/README": README,
        f"{MIGRATIONS_DIRNAME}/{VERSIONS_DIRNAME}/.gitkeep": "",
    }

    created = []
    for name in FILES:
        path = root / name
        if path.exists():
            continue
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(contents[name], encoding="utf-8")
        created.append(name)
    return created

is_complete

is_complete(root: str | PathLike) -> bool

Whether root holds every scaffold file.

Source code in src/viur/models/scaffold.py
def is_complete(root: str | os.PathLike) -> bool:
    """Whether ``root`` holds every scaffold file."""
    root = pathlib.Path(root)
    return all((root / name).exists() for name in FILES)

schema

Structure snapshots and bone-level diffing; snapshots live in <script_location>/structures/<revision>.json.

Transition dataclass

One bone-level change.

Parameters:

Name Type Description Default
kind str

See diff.

required
old dict | None

Previous bone entry (None for additions).

None
new dict | None

Current bone entry (None for removals).

None
detail dict

Facts the operation needs (link table, columns, languages, …).

dict()
Source code in src/viur/models/schema.py
@dataclasses.dataclass(frozen=True)
class Transition:
    """One bone-level change.

    :param kind: See ``diff``.
    :param old: Previous bone entry (``None`` for additions).
    :param new: Current bone entry (``None`` for removals).
    :param detail: Facts the operation needs (link table, columns, languages, …).
    """

    kind: str
    table: str
    field: str
    old: dict | None = None
    new: dict | None = None
    detail: dict = dataclasses.field(default_factory=dict)

describe

describe(model: type) -> dict

Snapshot entry of one model; resolve_refs=False (no skeleton registry in a shell), bypasses the structure cache.

Source code in src/viur/models/schema.py
def describe(model: type) -> dict:
    """Snapshot entry of one model; ``resolve_refs=False`` (no skeleton registry in a shell),
    bypasses the structure cache."""
    from .structure import structure_for_model

    relations = {}
    for rel_name, info in model.viur_relations().items():
        relations[rel_name] = _relation_shape(model, rel_name, info)
    return {
        "table": model._viur_kind(),
        "structure": structure_for_model(model, resolve_refs=False),
        "relations": relations,
    }

snapshot

snapshot() -> dict

Describe every imported Model table (migrations.import_models).

Source code in src/viur/models/schema.py
def snapshot() -> dict:
    """Describe every imported ``Model`` table (``migrations.import_models``)."""
    return {
        model._viur_kind(): describe(model)
        for model in sorted(_table_models(), key=lambda cls: cls._viur_kind())
    }

snapshot_dir

snapshot_dir(script_location: str | Path) -> Path

Snapshot directory of an Alembic script directory.

Source code in src/viur/models/schema.py
def snapshot_dir(script_location: str | pathlib.Path) -> pathlib.Path:
    """Snapshot directory of an Alembic script directory."""
    return pathlib.Path(script_location) / "structures"

prune

prune(directory: str | Path, keep: Iterable[str]) -> list[str]

Delete snapshots of revisions that no longer exist. Returns the removed ids.

Source code in src/viur/models/schema.py
def prune(directory: str | pathlib.Path, keep: t.Iterable[str]) -> list[str]:
    """Delete snapshots of revisions that no longer exist. Returns the removed ids."""
    path = pathlib.Path(directory)
    if not path.is_dir():
        return []
    keep = set(keep)
    removed = []
    for snapshot_file in sorted(path.glob("*.json")):
        if snapshot_file.stem not in keep:
            snapshot_file.unlink()
            removed.append(snapshot_file.stem)
    return removed

load

load(directory: str | Path, revision: str | None) -> dict

One snapshot; {} for an unknown/missing revision (diff then reports only additions).

Source code in src/viur/models/schema.py
def load(directory: str | pathlib.Path, revision: str | None) -> dict:
    """One snapshot; ``{}`` for an unknown/missing revision (``diff`` then reports only additions)."""
    if not revision:
        return {}
    path = pathlib.Path(directory) / f"{revision}.json"
    if not path.is_file():
        return {}
    try:
        return json.loads(path.read_text())
    except ValueError:
        return {}

diff

diff(old: dict, new: dict) -> list[Transition]

Bone-level transitions between two snapshots, by table then field.

Kinds: field_added/field_removed, multiple_collapsed/multiple_expanded (link table ↔ FK column), languages_reduced/languages_expanded, type_changed (detail["from"]/["to"]), select_values_changed, precision_changed, using_field_added/using_field_removed (link model payload).

Source code in src/viur/models/schema.py
def diff(old: dict, new: dict) -> list[Transition]:
    """Bone-level transitions between two snapshots, by table then field.

    Kinds: ``field_added``/``field_removed``, ``multiple_collapsed``/``multiple_expanded``
    (link table ↔ FK column), ``languages_reduced``/``languages_expanded``, ``type_changed``
    (``detail["from"]``/``["to"]``), ``select_values_changed``, ``precision_changed``,
    ``using_field_added``/``using_field_removed`` (link model payload).
    """
    transitions: list[Transition] = []

    for table in sorted(new):
        current, previous = new[table], old.get(table)
        if previous is None:
            continue  # new table: plain CREATE TABLE
        old_structure = previous.get("structure", {})
        new_structure = current.get("structure", {})
        old_relations = previous.get("relations", {})
        new_relations = current.get("relations", {})

        for field in sorted(set(old_structure) | set(new_structure)):
            old_bone = old_structure.get(field)
            new_bone = new_structure.get(field)

            if old_bone is None:
                transitions.append(Transition(
                    "field_added", table, field, None, new_bone,
                    {"relation": field in new_relations},
                ))
                continue
            if new_bone is None:
                transitions.append(Transition("field_removed", table, field, old_bone, None))
                continue
            if not _bone_changed(old_bone, new_bone):
                # unchanged bone; its using payload may differ
                transitions.extend(_using_transitions(
                    table, field, old_relations.get(field), new_relations.get(field),
                    new_bone=new_bone,
                ))
                continue

            old_multiple = bool(old_bone.get("multiple"))
            new_multiple = bool(new_bone.get("multiple"))
            is_relation = field in old_relations or field in new_relations

            if is_relation and old_multiple != new_multiple:
                old_shape = old_relations.get(field, {})
                new_shape = new_relations.get(field, {})
                if old_multiple:
                    transitions.append(Transition(
                        "multiple_collapsed", table, field, old_bone, new_bone,
                        {
                            "link_table": old_shape.get("link_table"),
                            "link_parent_fk": old_shape.get("link_parent_fk"),
                            "link_dest_fk": old_shape.get("link_dest_fk"),
                            "target_column": new_shape.get("fk"),
                            "target_table": new_shape.get("target_table")
                            or old_shape.get("target_table"),
                        },
                    ))
                else:
                    transitions.append(Transition(
                        "multiple_expanded", table, field, old_bone, new_bone,
                        {
                            "link_table": new_shape.get("link_table"),
                            "link_parent_fk": new_shape.get("link_parent_fk"),
                            "link_dest_fk": new_shape.get("link_dest_fk"),
                            "source_column": old_shape.get("fk"),
                        },
                    ))
                continue

            old_langs, new_langs = old_bone.get("languages"), new_bone.get("languages")
            if bool(old_langs) != bool(new_langs):
                if old_langs:
                    transitions.append(Transition(
                        "languages_reduced", table, field, old_bone, new_bone,
                        {"languages": list(old_langs)},
                    ))
                else:
                    transitions.append(Transition(
                        "languages_expanded", table, field, old_bone, new_bone,
                        {"languages": list(new_langs)},
                    ))
                continue

            if old_bone.get("type") != new_bone.get("type"):
                transitions.append(Transition(
                    "type_changed", table, field, old_bone, new_bone,
                    {"from": old_bone.get("type"), "to": new_bone.get("type")},
                ))
                continue

            if new_bone.get("type", "").startswith("select"):
                old_values = set((old_bone.get("values") or {}))
                new_values = set((new_bone.get("values") or {}))
                if old_values != new_values:
                    transitions.append(Transition(
                        "select_values_changed", table, field, old_bone, new_bone,
                        {
                            "added": sorted(new_values - old_values),
                            "removed": sorted(old_values - new_values),
                        },
                    ))
                    continue

            if new_bone.get("type", "").startswith("numeric") \
                    and _numeric_precision(old_bone) != _numeric_precision(new_bone):
                transitions.append(Transition(
                    "precision_changed", table, field, old_bone, new_bone,
                    {"precision": new_bone.get("precision", 0)},
                ))
                continue

            transitions.extend(_using_transitions(
                table, field, old_relations.get(field), new_relations.get(field),
                new_bone=new_bone,
            ))

        transitions = _pair_renamed_relations(
            transitions, table, old_relations, new_relations,
        )

    return transitions

migrate

Alembic operations for bone-level data migrations; row-wise coercions per viur-core's rules.

ReduceLanguagesOp

Bases: _ViURMigrateOp

Language[X] → plain field (pick_language).

Source code in src/viur/models/migrate.py
@Operations.register_operation("reduce_languages")
class ReduceLanguagesOp(_ViURMigrateOp):
    """``Language[X]`` → plain field (``pick_language``)."""

    op_name = "reduce_languages"

    @classmethod
    def reduce_languages(
        cls, operations: t.Any, table: str, column: str, *,
        new_type: t.Any, languages: t.Sequence[str] = (), keep: str | None = None,
        pk: str = "id", nullable: bool = True,
    ) -> t.Any:
        return operations.invoke(cls(
            table, column=column, new_type=new_type, languages=list(languages),
            keep=keep, pk=pk, nullable=nullable,
        ))

ExpandLanguagesOp

Bases: _ViURMigrateOp

Plain field → Language[X] (wrap_language).

Source code in src/viur/models/migrate.py
@Operations.register_operation("expand_languages")
class ExpandLanguagesOp(_ViURMigrateOp):
    """Plain field → ``Language[X]`` (``wrap_language``)."""

    op_name = "expand_languages"

    @classmethod
    def expand_languages(
        cls, operations: t.Any, table: str, column: str, *,
        languages: t.Sequence[str], new_type: t.Any = None, pk: str = "id",
    ) -> t.Any:
        return operations.invoke(cls(
            table, column=column, languages=list(languages),
            new_type=new_type, pk=pk,
        ))

CoerceNumericOp

Bases: _ViURMigrateOp

Numeric precision change (coerce_number).

Source code in src/viur/models/migrate.py
@Operations.register_operation("coerce_numeric")
class CoerceNumericOp(_ViURMigrateOp):
    """Numeric precision change (``coerce_number``)."""

    op_name = "coerce_numeric"

    @classmethod
    def coerce_numeric(
        cls, operations: t.Any, table: str, column: str, *,
        new_type: t.Any, precision: int, pk: str = "id", nullable: bool = True,
    ) -> t.Any:
        return operations.invoke(cls(
            table, column=column, new_type=new_type, precision=precision,
            pk=pk, nullable=nullable,
        ))

CoerceTextOp

Bases: _ViURMigrateOp

strText and other string retypes (coerce_text); Alembic does not detect a dropped max_length.

Source code in src/viur/models/migrate.py
@Operations.register_operation("coerce_text")
class CoerceTextOp(_ViURMigrateOp):
    """``str`` ↔ ``Text`` and other string retypes (``coerce_text``); Alembic does not detect a
    dropped ``max_length``."""

    op_name = "coerce_text"

    @classmethod
    def coerce_text(
        cls, operations: t.Any, table: str, column: str, *,
        new_type: t.Any, pk: str = "id", nullable: bool = True,
    ) -> t.Any:
        return operations.invoke(cls(
            table, column=column, new_type=new_type, pk=pk, nullable=nullable,
        ))

CoerceBoolOp

Bases: _ViURMigrateOp

Anything → bool (coerce_bool).

Source code in src/viur/models/migrate.py
@Operations.register_operation("coerce_bool")
class CoerceBoolOp(_ViURMigrateOp):
    """Anything → ``bool`` (``coerce_bool``)."""

    op_name = "coerce_bool"

    @classmethod
    def coerce_bool(
        cls, operations: t.Any, table: str, column: str, *,
        truthy: t.Sequence[str] = BOOLEAN_TRUTHY, pk: str = "id",
        nullable: bool = True,
    ) -> t.Any:
        return operations.invoke(cls(
            table, column=column, truthy=list(truthy), pk=pk, nullable=nullable,
        ))

RemapValuesOp

Bases: _ViURMigrateOp

Explicit value mapping (boolselect); a mapping still holding Ellipsis refuses to run.

Source code in src/viur/models/migrate.py
@Operations.register_operation("remap_values")
class RemapValuesOp(_ViURMigrateOp):
    """Explicit value mapping (``bool`` → ``select``); a mapping still holding ``Ellipsis`` refuses to run."""

    op_name = "remap_values"

    @classmethod
    def remap_values(
        cls, operations: t.Any, table: str, column: str, mapping: dict, *,
        new_type: t.Any, pk: str = "id", nullable: bool = True,
    ) -> t.Any:
        return operations.invoke(cls(
            table, column=column, mapping=mapping, new_type=new_type,
            pk=pk, nullable=nullable,
        ))

FillColumnOp

Bases: _ViURMigrateOp

Fill NULLs with value, then optionally NOT NULL — the new-required-field case.

Source code in src/viur/models/migrate.py
@Operations.register_operation("fill_column")
class FillColumnOp(_ViURMigrateOp):
    """Fill ``NULL``s with ``value``, then optionally ``NOT NULL`` — the new-required-field case."""

    op_name = "fill_column"

    @classmethod
    def fill_column(
        cls, operations: t.Any, table: str, column: str, value: t.Any, *,
        nullable: bool = True,
    ) -> t.Any:
        return operations.invoke(cls(
            table, column=column, value=value, nullable=nullable,
        ))

AddEnumValuesOp

Bases: _ViURMigrateOp

ALTER TYPE … ADD VALUE IF NOT EXISTS for every label (Postgres only, no-op elsewhere). Removing a label is not covered.

Source code in src/viur/models/migrate.py
@Operations.register_operation("add_enum_values")
class AddEnumValuesOp(_ViURMigrateOp):
    """``ALTER TYPE … ADD VALUE IF NOT EXISTS`` for every label (Postgres only, no-op elsewhere).
    Removing a label is not covered."""

    op_name = "add_enum_values"

    @classmethod
    def add_enum_values(
        cls, operations: t.Any, type_name: str, labels: t.Sequence[str],
    ) -> t.Any:
        return operations.invoke(cls(type_name, labels=list(labels)))

CollapseMultipleOp

Bases: _ViURMigrateOp

Multiple relation → single FK column (pick_from_multiple); the link table is dropped after the copy. target_type: the FK column's type (default Integer; BigQueryModel needs a string type).

Source code in src/viur/models/migrate.py
@Operations.register_operation("collapse_multiple")
class CollapseMultipleOp(_ViURMigrateOp):
    """Multiple relation → single FK column (``pick_from_multiple``); the link table is dropped
    after the copy. ``target_type``: the FK column's type (default ``Integer``; ``BigQueryModel``
    needs a string type)."""

    op_name = "collapse_multiple"

    @classmethod
    def collapse_multiple(
        cls, operations: t.Any, table: str, *, link_table: str,
        target_column: str, link_parent_fk: str, link_dest_fk: str,
        foreign_table: str, foreign_column: str = "id",
        target_type: t.Any = None,
        keep: str = "first", pk: str = "id", drop_link_table: bool = True,
    ) -> t.Any:
        return operations.invoke(cls(
            table, link_table=link_table, target_column=target_column,
            link_parent_fk=link_parent_fk, link_dest_fk=link_dest_fk,
            foreign_table=foreign_table, foreign_column=foreign_column,
            target_type=target_type,
            keep=keep, pk=pk, drop_link_table=drop_link_table,
        ))

ExpandMultipleOp

Bases: _ViURMigrateOp

Single FK column → link table, one row per value. payload_defaults fills NOT NULL payload columns; an Ellipsis stub refuses to run (as RemapValuesOp), nullable payload stays NULL.

Source code in src/viur/models/migrate.py
@Operations.register_operation("expand_multiple")
class ExpandMultipleOp(_ViURMigrateOp):
    """Single FK column → link table, one row per value. ``payload_defaults`` fills NOT NULL
    payload columns; an ``Ellipsis`` stub refuses to run (as ``RemapValuesOp``), nullable
    payload stays ``NULL``."""

    op_name = "expand_multiple"

    @classmethod
    def expand_multiple(
        cls, operations: t.Any, table: str, *, link_table: str,
        source_column: str, link_parent_fk: str, link_dest_fk: str,
        payload_defaults: dict | None = None,
        pk: str = "id", drop_source_column: bool = True,
    ) -> t.Any:
        return operations.invoke(cls(
            table, link_table=link_table, source_column=source_column,
            link_parent_fk=link_parent_fk, link_dest_fk=link_dest_fk,
            payload_defaults=payload_defaults,
            pk=pk, drop_source_column=drop_source_column,
        ))

pick_from_multiple

pick_from_multiple(values: list, keep: str = 'first') -> Any

BaseBone.unserialize rule loadVal[0]; keep="last" takes the last.

Source code in src/viur/models/migrate.py
def pick_from_multiple(values: list, keep: str = "first") -> t.Any:
    """``BaseBone.unserialize`` rule ``loadVal[0]``; ``keep="last"`` takes the last."""
    if not values:
        return None
    return values[0] if keep == "first" else values[-1]

pick_language

pick_language(value: Any, languages: Sequence[str], keep: str | None = None) -> Any

Multilingual dict → one value (BaseBone.unserialize): keep if present (even None), else the first non-None value; a list picks its first item.

Source code in src/viur/models/migrate.py
def pick_language(value: t.Any, languages: t.Sequence[str], keep: str | None = None) -> t.Any:
    """Multilingual dict → one value (``BaseBone.unserialize``): ``keep`` if present (even
    ``None``), else the first non-``None`` value; a list picks its first item."""
    if isinstance(value, str):
        try:
            value = json.loads(value)
        except (ValueError, TypeError):
            return value
    if not isinstance(value, dict):
        return value
    candidates = {
        lang: item for lang, item in value.items()
        if lang != "_viurLanguageWrapper_" and item is not True
    }
    if keep and keep in candidates:
        picked = candidates[keep]
    else:
        picked = next((item for item in candidates.values() if item is not None), None)
    if isinstance(picked, list) and picked:  # multiple+languages → first
        picked = picked[0]
    return picked

wrap_language

wrap_language(value: Any, languages: Sequence[str]) -> dict

Scalar → {lang: value} under languages[0] (BaseBone.unserialize), the rest None.

Source code in src/viur/models/migrate.py
def wrap_language(value: t.Any, languages: t.Sequence[str]) -> dict:
    """Scalar → ``{lang: value}`` under ``languages[0]`` (``BaseBone.unserialize``), the rest ``None``."""
    result: dict[str, t.Any] = {lang: None for lang in languages}
    if value is not None and value != "" and languages:
        result[languages[0]] = value
    return result

coerce_number

coerce_number(value: Any, precision: int) -> Any

NumericBone._convert_to_numeric: precision > 0 rounds, 0 truncates toward zero.

Source code in src/viur/models/migrate.py
def coerce_number(value: t.Any, precision: int) -> t.Any:
    """``NumericBone._convert_to_numeric``: ``precision > 0`` rounds, ``0`` truncates toward zero."""
    if value is None:
        return None
    if isinstance(value, str):
        value = value.replace(",", ".", 1)
    try:
        if precision:
            return round(float(value), precision)
        return int(float(value))
    except (ValueError, TypeError):
        return None

coerce_text

coerce_text(value: Any) -> Any

StringBone.type_coerce_single_value: stringify, dates ISO, never truncate.

Source code in src/viur/models/migrate.py
def coerce_text(value: t.Any) -> t.Any:
    """``StringBone.type_coerce_single_value``: stringify, dates ISO, never truncate."""
    import datetime

    if value is None:
        return None
    if isinstance(value, str):
        return value
    if isinstance(value, bool):
        return str(value)
    if isinstance(value, (int, float)):
        return str(value)
    if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
        return value.isoformat()
    if not value:
        return ""
    return str(value)

coerce_bool

coerce_bool(value: Any, truthy: Sequence[str] = BOOLEAN_TRUTHY) -> bool

utils.parse.bool with conf.bone_boolean_str2true.

Source code in src/viur/models/migrate.py
def coerce_bool(value: t.Any, truthy: t.Sequence[str] = BOOLEAN_TRUTHY) -> bool:
    """``utils.parse.bool`` with ``conf.bone_boolean_str2true``."""
    return str(value).strip().lower() in tuple(truthy)

ensure_schema_type

ensure_schema_type(connection: Any, new_type: Any) -> bool

Create a dialect-level type (Postgres Enum) before its column; no-op elsewhere. Returns whether one was created.

Source code in src/viur/models/migrate.py
def ensure_schema_type(connection: t.Any, new_type: t.Any) -> bool:
    """Create a dialect-level type (Postgres ``Enum``) before its column; no-op elsewhere.
    Returns whether one was created."""
    create = getattr(new_type, "create", None)
    if not callable(create):
        return False
    create(connection, checkfirst=True)
    return True

transform_column

transform_column(operations: Any, table: str, column: str, *, new_type: Any, transform: Callable[[Any], Any], pk: str = 'id', nullable: bool = True) -> int

Retype column through a temporary column: add, copy through transform, drop, rename, constrain — alike on SQLite (batch) and Postgres. Returns rows written.

Source code in src/viur/models/migrate.py
def transform_column(
    operations: t.Any,
    table: str,
    column: str,
    *,
    new_type: t.Any,
    transform: t.Callable[[t.Any], t.Any],
    pk: str = "id",
    nullable: bool = True,
) -> int:
    """Retype ``column`` through a temporary column: add, copy through ``transform``, drop,
    rename, constrain — alike on SQLite (batch) and Postgres. Returns rows written."""
    connection = operations.get_bind()
    temp = f"{TEMP_PREFIX}{column}"

    ensure_schema_type(connection, new_type)

    with operations.batch_alter_table(table) as batch_op:
        batch_op.add_column(sa.Column(temp, new_type, nullable=True))

    written = 0
    for keys in _chunks(connection, table, pk):
        placeholders = ", ".join(f":k{index}" for index in range(len(keys)))
        params = {f"k{index}": key for index, key in enumerate(keys)}
        rows = connection.execute(
            sa.text(  # noqa: S608
                f'SELECT "{pk}", "{column}" FROM "{table}" '
                f'WHERE "{pk}" IN ({placeholders})'
            ),
            params,
        ).all()
        for key, value in rows:
            connection.execute(
                sa.text(  # noqa: S608
                    f'UPDATE "{table}" SET "{temp}" = :value WHERE "{pk}" = :key'
                ),
                {"value": _to_sql(transform(value)), "key": key},
            )
            written += 1

    # one batch block: on SQLite each block rebuilds the table
    with operations.batch_alter_table(table) as batch_op:
        batch_op.drop_column(column)
        # no existing_type: AutoString has no type.name
        batch_op.alter_column(temp, new_column_name=column, nullable=nullable)
    return written