Refuse to autogenerate a NOT NULL column filled only by a dataclass default_factory

MA
1m ago
+142-6

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • This change requires a documentation update

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

Stacks on #7797 (fix/autogenerate-never-drops-tables, rebased onto its final head 93beac9a3). The template reflex/.templates/alembic/env.py and the alembic tests change in both PRs. Until #7797 merges, GitHub also shows its commits here. Only the top two commits belong to this PR: "Refuse a NOT NULL column filled only by a dataclass default_factory" and "Look up the added column by key and its property directly". It does not depend on #7809, which handles FINDING-005/006/017. The new test sits next to the callable-default test, so the two PRs do not conflict.

Fixes FINDING-012 from the reflex 0.11.0a1 pre-release testing (artifacts on branch claude/vibrant-pasteur-3921g3, prerelease-testing/runs/2026-11-11-v0.11.0a1/).

Defect

Take a non-empty table and add score: Mapped[int] = mapped_column(default_factory=lambda: 7) on a MappedAsDataclass model. Autogenerate wrote add_column(... nullable=False) with no server_default, so reflex db migrate failed with NOT NULL constraint failed. When the revision rebuilt the table in sqlite batch mode, alembic also left an _alembic_tmp_* table behind that broke the next migrate.

The cause is the env.py template's add-column hook, which only reads Column.default. A dataclass default_factory is stored on the dataclass field only, so Column.default is None.

Fix

The hook now handles a NOT NULL added column that has no server_default and no Column.default like this:

  • It looks the column up in the mapped dataclasses of the registered or detected model bases. It uses table.c[op.column.key] (keyed by Column.key, which can differ from the name), then mapper.get_property_by_column(column).key, then __dataclass_fields__. A mapper that doesn't map the column, as with a single-table-inheritance parent, is skipped.

  • If the column's attribute has a default_factory, the hook raises a CommandError rather than guess a value:

    Cannot add the NOT NULL column 'item.score': its default_factory gives the rows the table already holds no value. Give the column a server_default=..., or a literal default= or insert_default=, or make it nullable.

I put the refusal at autogenerate time because it fails before a broken revision is written, so no _alembic_tmp_* leftover can come from this path. It also fires in alembic check, so the startup schema check reports it.

Docs: docs/database/tables.md gets a sentence about default_factory and migrations.

Known behaviour

This was confirmed by the release owner.

  • A callable Column.default is still evaluated once, not refused. Examples are sqlmodel Field(default_factory=...) and mapped_column(insert_default=callable). The value is taken when the revision is generated and becomes the server default for existing rows, as in 0.10.1. test_callable_default_is_evaluated_for_existing_rows covers it. Only a dataclass default_factory that leaves Column.default empty is refused.
  • A non-callable Column.default still becomes the server default, and a nullable column is never refused.

Test plan

  • New test test_default_factory_column_is_refused in tests/units/utils/test_migrations.py (real alembic and sqlite, MappedAsDataclass app). It failed before the fix: makemigrations exited 0 and wrote ..._factory.py. After the fix it checks:

    • makemigrations exits 1 with the message naming 'thing.score', and no revision is written.
    • Bare alembic check fails with the same message.
    • A column with a custom key, mapped_column('points', key='points_key', default_factory=...), is refused too, naming 'thing.points'. This case was added for the review thread and failed before the key fix.
    • A literal default=7 gets server_default=sa.text('7') and migrates the existing row.
    • A nullable default_factory column is accepted.
  • uv run pytest tests/units --cov (without tests/units/reflex_bench/drivers/test_browser.py, since there is no Chromium in this sandbox): 15645 passed, 66 skipped, coverage 81.44%. After the rebase onto 93beac9a3, the migration and model tests pass again: 43/43 locally, and test_migrations 30/30 on SQLite 3.45.

  • uv run ruff check . and uv run ruff format --check . are clean. uv run pyright reflex tests gives 0 errors.

  • End to end with the verifier's verify_dbm/v3_default_factory.sh, run on an editable install:

    VariantResult
    dfmakemigrations rc 1 with the message, no revision; migrate rc 0 and its schema check logs the refusal; row untouched; no _alembic_tmp_* table
    dfbatchsame as df
    dfltserver_default=sa.text('7'), rows [(1, 'a', 7)]
    insserver_default=sa.text('7'), rows [(1, 'a', 7)]
    rxdfserver_default=sa.text('7'), rows [(1, 'a', 7)]

Notes

  • The fix lives in the env.py template. Apps upgrading from 0.10.x get it when their stock env.py is replaced. An env.py that 0.11.0a1's reflex db init wrote keeps the old hook until it is moved aside and re-initialized.
  • I did not add cleanup of an _alembic_tmp_* table left by an already-written broken revision.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dxjadq3qkhKnqZR2b9WBFi