Enum columns (safe on both databases)¶
SQLAlchemy already maps Mapped[MyEnum] to a column. Its defaults,
however, cost safety in three ways — and the SDK changes all three.
What changes¶
from sqlalchemy.orm import Mapped
from tempest_fastapi_sdk import BaseModel, BaseStrEnum
class OrderStatus(BaseStrEnum):
OPEN = "open"
IN_PROGRESS = "in_progress"
DONE = "done"
class OrderModel(BaseModel):
status: Mapped[OrderStatus]
With no configuration at all, that annotation produces:
-- PostgreSQL
CREATE TYPE order_status_enum AS ENUM ('open', 'in_progress', 'done');
status order_status_enum NOT NULL
-- SQLite
status VARCHAR(11) NOT NULL
CONSTRAINT ck_order_order_status_enum
CHECK (status IN ('open', 'in_progress', 'done'))
The three changed defaults:
- It stores the
value, not thename. SQLAlchemy's default would writeIN_PROGRESS. Every consumer that is not this Python process — a report, a dashboard, a sibling service — would read a string the domain never defined. - A
CHECKon SQLite. The default emits a bareVARCHARwith no constraint: the production column rejects an invalid value, the test column accepts it silently. A bug the database would have caught in production would pass the test suite. - A collision-free type name. The default would name the PostgreSQL
type
orderstatus; the SDK usesorder_status_enum, because types and tables share one namespace.
Declaration order becomes the type's order
PostgreSQL sorts an ENUM column by label order, not alphabetically.
Declaring OPEN, IN_PROGRESS, DONE makes ORDER BY status follow the
workflow.
When the annotation is not enough¶
enum_column() is the same thing spelled out, for when the column needs
arguments:
from sqlalchemy.orm import Mapped
from tempest_fastapi_sdk import BaseModel, BaseStrEnum, enum_column
class OrderStatus(BaseStrEnum):
OPEN = "open"
IN_PROGRESS = "in_progress"
DONE = "done"
class OrderModel(BaseModel):
status: Mapped[OrderStatus] = enum_column(
OrderStatus, default=OrderStatus.OPEN, index=True
)
An explicit type always wins over the annotation map, so
mapped_column(sqlalchemy.Enum(...)) remains available for a column that
needs the original behavior.
Changed the enum? That is a schema change¶
And alembic revision --autogenerate does not detect it on its own,
on either backend:
- on PostgreSQL the labels live in
pg_enum, which autogenerate does not compare; - on SQLite they live inside the
CHECK, which it does not compare either — and theVARCHAR(n)only changes length when the longest value changes, so not evencompare_typenotices.
The SDK closes this with the sync_enum_types hook, already wired into
the env.py that tempest db init generates. Add a member to the enum,
run autogenerate, and the migration comes out filled in:
from alembic import op
from tempest_fastapi_sdk import EnumColumnRef
def upgrade() -> None:
"""Add ``archived`` to the order status enum."""
op.replace_enum(
"order_status_enum",
new_values=["open", "in_progress", "done", "archived"],
old_values=["open", "in_progress", "done"],
columns=[EnumColumnRef(table="order", column="status")],
)
Why not ALTER TYPE ... ADD VALUE¶
It is the command everyone reaches for first, and it:
- cannot run inside a transaction block on older servers — the classic enum-migration error;
- cannot remove a value at all;
- cannot reorder.
replace_enum renames the old type, creates the new one under the real
name, casts every dependent column across and drops the old one. All of
that is ordinary DDL, so it runs inside Alembic's transaction:
ALTER TYPE order_status_enum RENAME TO order_status_enum__old;
CREATE TYPE order_status_enum AS ENUM ('open', 'in_progress', 'done', 'archived');
ALTER TABLE "order" ALTER COLUMN status
TYPE order_status_enum USING (status::text)::order_status_enum;
DROP TYPE order_status_enum__old;
On SQLite the same operation rebuilds the table so the CHECK follows.
The column DEFAULT is preserved
A DEFAULT 'open'::order_status_enum still points at the outgoing
type, and PostgreSQL refuses the cast while that is true. The
operation reads the current default from information_schema, drops
it before the cast and restores it after — rather than assuming there
is no default.
Renaming a member¶
Unaided, removing wip to introduce in_progress fails when casting the
rows that still hold wip. State the mapping:
from alembic import op
from tempest_fastapi_sdk import EnumColumnRef
def upgrade() -> None:
"""Rename ``wip`` to ``in_progress``, carrying the rows along."""
op.replace_enum(
"task_status_enum",
new_values=["open", "in_progress"],
old_values=["open", "wip"],
columns=[EnumColumnRef(table="task", column="status")],
value_map={"wip": "in_progress"},
)
The operation is reversible: downgrade swaps the lists and inverts the
value_map by itself.
Offline (--sql) mode is unsupported on PostgreSQL
Preserving the DEFAULT requires reading it from the database, and an
offline script has no connection. Rather than silently generating a
script that drops the default, the operation raises
NotImplementedError saying so. Run the upgrade online, or hand-write
the ALTER TYPE sequence for the offline script.
Detection is deliberately conservative¶
An enum the backend cannot report on is skipped, not diffed against a
guess — emitting a wrong replace_enum would drop values from live rows.
On SQLite that means only a CHECK in the shape the SDK generates is read
back; a hand-written constraint is not interpreted.
Migrations do not import the SDK¶
Alembic would render TempestEnum as a dotted path into this package, in
a file whose only imports are alembic.op and sqlalchemy as sa — the
migration would fail on import. The render_enum_types hook renders a
plain sa.Enum with the values spelled out, which also makes the
migration a real snapshot, independent of what the Python enum becomes
later.
Recap¶
Mapped[MyEnum]is already safe: thevaluein the database, a nativeENUMon PostgreSQL, aCHECKon SQLite, a collision-free type name.enum_column()for when the column needsdefault,index, and so on.- A member change is a schema change, and
sync_enum_typesdetects it where autogenerate is blind. op.replace_enum(...)adds, removes and reorders in one operation, inside the transaction, withvalue_map=for renames and an automaticdowngrade.