Colunas de enum (seguras nos dois bancos)¶
O SQLAlchemy já mapeia Mapped[MeuEnum] para uma coluna. Os defaults
dele, porém, custam segurança de três formas — e o SDK troca as três.
O que muda¶
from sqlalchemy.orm import Mapped
from tempest_fastapi_sdk import BaseModel, BaseStrEnum
class OrderStatusEnum(BaseStrEnum):
OPEN = "open"
IN_PROGRESS = "in_progress"
DONE = "done"
class OrderModel(BaseModel):
status: Mapped[OrderStatusEnum]
Sem nenhuma configuração, essa anotação produz:
-- 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'))
Os três defaults trocados:
- Guarda o
value, não oname. O default do SQLAlchemy gravariaIN_PROGRESS. Todo consumidor que não é este processo Python — um relatório, um dashboard, um serviço vizinho — leria uma string que o domínio nunca definiu. CHECKno SQLite. O default emite umVARCHARcru, sem constraint: a coluna de produção rejeita valor inválido, a de teste aceita em silêncio. Um bug que o banco pegaria em produção passaria na suíte.- Nome de tipo sem colisão. O default nomearia o tipo do PostgreSQL
como
orderstatusenum; o SDK usaorder_status_enum, porque tipo e tabela dividem o mesmo namespace. UmEnumno fim do nome da classe sai antes do sufixo:OrderStatusEnumtambém viraorder_status_enum, e nãoorder_status_enum_enum. Com ou sem o sufixo na classe, o tipo é o mesmo.
A convenção é o sufixo Enum, e o tempest check a aplica
Todo BaseStrEnum do projeto termina em Enum. O passo lint-layers
do tempest check (e o tempest lint-layers sozinho) acusa a classe
sem o sufixo como ENUM001, dizendo o nome esperado:
src/core/enums.py:12: ENUM001 `Party` inherits BaseStrEnum; rename it to `PartyEnum`. The database type name does not change (enum_type_name drops one trailing `Enum`), so no migration is needed.
Renomear não pede migration: Party e PartyEnum dão o mesmo tipo,
party_enum. Detalhes na receita da CLI.
A ordem de declaração vira a ordem do tipo
O PostgreSQL ordena uma coluna ENUM pela ordem dos labels, não pelo
alfabeto. Declarar OPEN, IN_PROGRESS, DONE faz ORDER BY status
seguir o fluxo de trabalho.
Quando a anotação não basta¶
enum_column() é a mesma coisa escrita por extenso, para quando a coluna
precisa de argumentos:
from sqlalchemy.orm import Mapped
from tempest_fastapi_sdk import BaseModel, BaseStrEnum, enum_column
class OrderStatusEnum(BaseStrEnum):
OPEN = "open"
IN_PROGRESS = "in_progress"
DONE = "done"
class OrderModel(BaseModel):
status: Mapped[OrderStatusEnum] = enum_column(
OrderStatusEnum, default=OrderStatusEnum.OPEN, index=True
)
Um tipo explícito sempre vence o mapa de anotações, então
mapped_column(sqlalchemy.Enum(...)) continua disponível para uma coluna
que precise do comportamento original.
Mudou o enum? Isso é mudança de schema¶
E o alembic revision --autogenerate não detecta sozinho, em nenhum
dos dois bancos:
- no PostgreSQL os labels moram no
pg_enum, que o autogenerate não compara; - no SQLite moram dentro do
CHECK, que ele também não compara — e oVARCHAR(n)só muda de tamanho quando o valor mais longo muda, então nem ocompare_typepercebe.
O SDK fecha isso com o hook sync_enum_types, já ligado no env.py
gerado pelo tempest db init. Acrescente um membro ao enum, rode o
autogenerate e a migration sai preenchida:
from alembic import op
from tempest_fastapi_sdk import EnumColumnRef
def upgrade() -> None:
"""Acrescenta ``archived`` ao enum de status do pedido."""
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")],
)
Por que não ALTER TYPE ... ADD VALUE¶
É o comando que todo mundo tenta primeiro, e ele:
- não roda dentro de um bloco de transação em servidores mais antigos — o erro clássico de migration de enum;
- não remove valor nenhum;
- não reordena.
replace_enum renomeia o tipo antigo, cria o novo com o nome real,
converte cada coluna dependente e derruba o antigo. Tudo isso é DDL
comum, então roda dentro da transação do Alembic:
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;
No SQLite a mesma operação reconstrói a tabela para que o CHECK
acompanhe.
DEFAULT da coluna é preservado
Um DEFAULT 'open'::order_status_enum ainda aponta para o tipo que
está saindo, e o PostgreSQL recusa a conversão enquanto isso for
verdade. A operação lê o default atual do information_schema, remove
antes da conversão e restaura depois — em vez de assumir que não há
default.
Renomear um membro¶
Sem ajuda, remover wip para introduzir in_progress falha na conversão
das linhas que ainda têm wip. Diga o mapeamento:
from alembic import op
from tempest_fastapi_sdk import EnumColumnRef
def upgrade() -> None:
"""Renomeia ``wip`` para ``in_progress``, levando as linhas junto."""
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"},
)
A operação é reversível: o downgrade troca as listas e inverte o
value_map sozinho.
Modo offline (--sql) não é suportado no PostgreSQL
Preservar o DEFAULT exige lê-lo do banco, e um script offline não
tem conexão. Em vez de gerar em silêncio um script que derruba o
default, a operação levanta NotImplementedError explicando isso.
Rode o upgrade online, ou escreva a sequência ALTER TYPE à mão para
o script offline.
Detecção é deliberadamente conservadora¶
Um enum que o backend não consegue reportar é ignorado, não comparado
com um palpite — emitir um replace_enum errado derrubaria valores de
linhas vivas. No SQLite isso significa que só um CHECK no formato que o
SDK gera é lido de volta; uma constraint escrita à mão não é interpretada.
Migrations não importam o SDK¶
O Alembic renderizaria TempestEnum como um caminho pontilhado para
dentro deste pacote, num arquivo cujos únicos imports são alembic.op e
sqlalchemy as sa — a migration quebraria no import. O hook
render_enum_types renderiza um sa.Enum com os valores por extenso, o
que também transforma a migration num retrato de verdade, independente do
que o enum em Python virar depois.
Um CHECK só por coluna¶
Sob SQLAlchemy 2.1, o Alembic deixa de reconhecer o CHECK que o tipo enum
anexa à coluna (ele procura constraint._create_rule.target, que o 2.1 não
define mais). Medido com SQLAlchemy 2.1.4 e Alembic 1.20.0, o create_table
gerado saía assim (as colunas do BaseModel resumidas a id):
import sqlalchemy as sa
from alembic import op
def upgrade() -> None:
"""O que o autogenerate escrevia sob SQLAlchemy 2.1, sem o SDK."""
op.create_table(
"things",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"role",
sa.Enum(
"GUARDIAN",
"TEEN",
name="user_role_enum",
native_enum=True,
create_constraint=True,
),
nullable=False,
),
sa.CheckConstraint("role IN ('GUARDIAN', 'TEEN')", name="user_role_enum"),
sa.CheckConstraint(
"role IN ('GUARDIAN', 'TEEN')", name=op.f("ck_things_user_role_enum")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_things")),
)
A naming convention transforma os dois nomes em ck_things_user_role_enum, e
o PostgreSQL 16 recusa o segundo:
check constraint "ck_things_user_role_enum" already exists.
O render_enum_types omite esse CHECK ligado ao tipo. O
sa.Enum(..., create_constraint=True) da coluna já o recria onde ele faz
sentido: no SQLite a tabela fica com exatamente um CHECK, chamado
ck_things_user_role_enum (medido sob SQLAlchemy 2.0.52 e 2.1.4), e no
PostgreSQL não há CHECK nenhum, só o tipo nativo. Um CheckConstraint que
você declara em __table_args__ não é ligado a tipo e continua sendo
renderizado.
Coluna enum numa tabela que já existe¶
No SQLite o env.py do SDK gera toda alteração de tabela dentro de
batch_alter_table, que reconstrói a tabela copiando os CHECK que reflete.
Duas coisas davam errado ali, medidas com SQLAlchemy 2.0.52 e 2.1.4:
- Duas colunas do mesmo enum perdiam um
CHECK. O nome doCHECKsai do tipo, então as duas ganhavamck_things_age_range_enum; o batch guarda as constraints por nome e ficava com uma. A outra coluna aceitava qualquer valor, sem aviso. - O downgrade de toda coluna enum adicionada quebrava. O
drop_columnreconstruía a tabela com oCHECKque cita a coluna removida, e o SQLite recusava comno such column: age_range.
O hook sqlite_enum_checks, ligado no env.py gerado, reescreve a migration:
import sqlalchemy as sa
from alembic import op
def upgrade() -> None:
"""Duas colunas do mesmo enum, cada uma com o seu CHECK."""
with op.batch_alter_table("things", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"age_range",
sa.Enum(
"KID",
"TEEN",
name="age_range_enum",
native_enum=True,
create_constraint=False,
),
nullable=True,
)
)
batch_op.create_enum_check(
"age_range", ["KID", "TEEN"], name="ck_things_age_range_enum"
)
batch_op.add_column(
sa.Column(
"store_age_range",
sa.Enum(
"KID",
"TEEN",
name="age_range_enum",
native_enum=True,
create_constraint=False,
),
nullable=True,
)
)
batch_op.create_enum_check(
"store_age_range",
["KID", "TEEN"],
name="ck_things_age_range_enum_store_age_range",
)
def downgrade() -> None:
"""Derruba o CHECK de cada coluna antes da coluna."""
with op.batch_alter_table("things", schema=None) as batch_op:
batch_op.drop_enum_check("store_age_range")
batch_op.drop_column("store_age_range")
batch_op.drop_enum_check("age_range")
batch_op.drop_column("age_range")
- A primeira coluna de um enum numa tabela mantém
ck_<tabela>_<enum>; a segunda em diante ganhack_<tabela>_<enum>_<coluna>. OTempestEnumdá o mesmo nome no model, então tabela com uma coluna por enum não muda de nome. create_enum_checkedrop_enum_checknão fazem nada fora do SQLite: no PostgreSQL a mesma migration roda semCHECKnenhum, só com o tipo nativo.drop_enum_checkacha oCHECKpela forma (<coluna> IN (...)), não pelo nome, então também limpa o de uma tabela criada antes desta versão.
Migration escrita antes desta versão
No downgrade(), acrescente batch_op.drop_enum_check("<coluna>") antes
de cada batch_op.drop_column("<coluna>") de coluna enum.
Downgrade derruba o tipo¶
O downgrade que o Alembic escreve para uma tabela nova é só op.drop_table,
e no PostgreSQL isso deixa o ENUM para trás. Medido no PostgreSQL 16: depois
de upgrade → downgrade, o user_role_enum continuava em pg_type, e o
upgrade seguinte falhava com type "user_role_enum" already exists.
O hook drop_enum_types_on_downgrade, ligado no env.py gerado, acrescenta o
drop do tipo no fim do downgrade:
from alembic import op
def downgrade() -> None:
"""Downgrade gerado para duas tabelas que dividem um enum."""
op.drop_table("things")
op.drop_table("others")
op.drop_enum_type("user_role_enum")
op.drop_enum_typeemiteDROP TYPE IF EXISTSno PostgreSQL e não faz nada nos outros bancos — no SQLite o enum mora noCHECK, que sai junto com a tabela.- Um tipo usado por duas tabelas só é derrubado pela revisão que remove a última: se outra tabela do model ainda usa o tipo, o hook não acrescenta o drop.
- A decisão sai só da metadata, sem conexão, então a mesma migration roda nos
dois bancos. Com o hook,
upgrade→downgrade→upgradepassa no PostgreSQL 16.
Migration escrita antes desta versão
O hook só age em revisões novas. Numa migration já gerada, acrescente
op.drop_enum_type("<nome>_enum") no fim do downgrade(), depois dos
op.drop_table que usavam o tipo.
Coluna enum com tipo novo¶
O create_table cria o ENUM das colunas dele; o add_column não. Medido no
PostgreSQL 16: acrescentar age_range: Mapped[AgeRange | None] a uma tabela
que já existia gerava só o ALTER TABLE things ADD COLUMN age_range
age_range_enum, e o upgrade falhava com type "age_range_enum" does not
exist.
O hook create_enum_types_on_add_column, ligado no env.py gerado, põe o tipo
antes da coluna, e o drop_enum_types_on_downgrade o derruba depois do
drop_column:
import sqlalchemy as sa
from alembic import op
def upgrade() -> None:
"""Coluna enum nova numa tabela que já existe."""
op.create_enum_type("age_range_enum", ["KID", "TEEN"])
with op.batch_alter_table("things", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"age_range",
sa.Enum(
"KID",
"TEEN",
name="age_range_enum",
native_enum=True,
create_constraint=False,
),
nullable=True,
)
)
batch_op.create_enum_check(
"age_range", ["KID", "TEEN"], name="ck_things_age_range_enum"
)
def downgrade() -> None:
"""Remove a coluna e, por último, o tipo."""
with op.batch_alter_table("things", schema=None) as batch_op:
batch_op.drop_enum_check("age_range")
batch_op.drop_column("age_range")
op.drop_enum_type("age_range_enum")
op.create_enum_typeconsulta opg_typee só cria o tipo que falta; no modo offline (--sql) escreve oCREATE TYPEdireto. Fora do PostgreSQL não faz nada.- Tipo que outra tabela já usa não entra na migration: nem
create_enum_typeno upgrade, nemdrop_enum_typeno downgrade. - Com os dois hooks,
upgrade→downgrade→upgradepassa no PostgreSQL 16 nos três casos (um enum, dois enums, o mesmo enum em duas colunas), sem nenhumCHECKno PostgreSQL.
Renomear o tipo¶
O autogenerate não percebe um tipo renomeado: no PostgreSQL as colunas
continuam apontando para o tipo antigo, e o model escreve o nome novo em todo
statement que converte para ele. Renomear a classe do enum muda o nome do tipo,
e o mesmo aconteceu com toda classe terminada em Enum quando o SDK parou de
dobrar o sufixo.
O sync_enum_types detecta o caso e escreve op.rename_enum_type:
from alembic import op
from tempest_fastapi_sdk.db.enum_migrations import EnumColumnRef
def upgrade() -> None:
"""Renomeia o tipo sem recriar o banco."""
op.rename_enum_type(
"candidate_slug_enum_enum",
"candidate_slug_enum",
columns=[EnumColumnRef(table="candidates", column="slug")],
)
def downgrade() -> None:
"""Volta ao nome antigo."""
op.rename_enum_type(
"candidate_slug_enum",
"candidate_slug_enum_enum",
columns=[EnumColumnRef(table="candidates", column="slug")],
)
- No PostgreSQL vira
ALTER TYPE candidate_slug_enum_enum RENAME TO candidate_slug_enum. As linhas ficam onde estão; ocolumns=não é lido. - No SQLite não existe tipo: o nome mora no
CHECKde cada coluna (ck_candidates_candidate_slug_enum_enum). A tabela de cada coluna listada é reconstruída com oCHECKrenomeado, para oalembic checknão acusar a diferença. - A detecção só age quando todas as colunas do tipo concordam sobre o nome antigo, e quando o nome antigo não é mais declarado por nenhum model. Fora disso, nada é gerado.
Recapitulando¶
Mapped[MeuEnum]já sai seguro:valueno banco,ENUMnativo no PostgreSQL,CHECKno SQLite, nome de tipo sem colisão.enum_column()para quando a coluna precisa dedefault,index, etc.OrderStatuseOrderStatusEnumdão o mesmo tipo,order_status_enum; um tipo renomeado viraop.rename_enum_typeno autogenerate.- Mudança de membro é mudança de schema, e o
sync_enum_typesa detecta onde o autogenerate é cego. op.replace_enum(...)adiciona, remove e reordena numa operação só, dentro da transação, comvalue_map=para renomes edowngradeautomático.- Coluna enum com tipo novo numa tabela existente ganha
op.create_enum_typeantes doadd_column, e o downgrade derruba o tipo depois dodrop_column. - No SQLite cada coluna enum tem o seu
CHECK, inclusive duas do mesmo enum, e o downgrade deadd_columnderruba oCHECKantes da coluna. - O
CHECKligado ao tipo não sai duplicado na migration (orender_enum_typeso omite), e o downgrade de tabela nova derruba oENUMdo PostgreSQL comop.drop_enum_type.