Refuse to autogenerate a NOT NULL column filled only by a dataclass default_factory
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 byColumn.key, which can differ from the name), thenmapper.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 aCommandErrorrather 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.defaultis still evaluated once, not refused. Examples are sqlmodelField(default_factory=...)andmapped_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_rowscovers it. Only a dataclassdefault_factorythat leavesColumn.defaultempty is refused. - A non-callable
Column.defaultstill becomes the server default, and a nullable column is never refused.
Test plan
-
New test
test_default_factory_column_is_refusedintests/units/utils/test_migrations.py(real alembic and sqlite, MappedAsDataclass app). It failed before the fix:makemigrationsexited 0 and wrote..._factory.py. After the fix it checks:makemigrationsexits 1 with the message naming'thing.score', and no revision is written.- Bare
alembic checkfails 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=7getsserver_default=sa.text('7')and migrates the existing row. - A nullable
default_factorycolumn is accepted.
-
uv run pytest tests/units --cov(withouttests/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 .anduv run ruff format --check .are clean.uv run pyright reflex testsgives 0 errors. -
End to end with the verifier's
verify_dbm/v3_default_factory.sh, run on an editable install:Variant Result dfmakemigrationsrc 1 with the message, no revision;migraterc 0 and its schema check logs the refusal; row untouched; no_alembic_tmp_*tabledfbatchsame as dfdfltserver_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 initwrote 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