Detect DeclarativeBase model bases when none is registered
Fixes FINDING-004 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/db_models/ and verify_dbm/).
Defect
The 0.11 database docs move every app to SQLAlchemy DeclarativeBase models, which only took part in migrations after rx.ModelRegistry.register(Base). Forgetting that one line made ModelRegistry.get_metadata() return an empty (or partial) MetaData, which the template env.py passes as target_metadata, bypassing alembic's own refusal (it only refuses None). reflex db makemigrations then wrote, with exit code 0 and no warning, a revision whose upgrade() dropped every existing table of that base, and reflex db migrate applied it — total data loss. With an extra rx.Model table (e.g. reflex-local-auth) only the Base tables were dropped; a fresh reflex db init silently created nothing. The startup hint "Detected database schema changes. Run reflex db makemigrations" steered users towards the destructive revision. Not a regression vs 0.10.1, but newly likely because of the docs.
Fix
rx.ModelRegistry.register(Base) becomes optional.
reflex/model.pyModelRegistry.get_models(): unless some registered model is a base other than SQLModel's (rx.Model is always registered and does not count), it adds the bases found by_find_bases(): a recursive walk ofDeclarativeBase.__subclasses__()keeping classes that own theirmetadata. Abstract subclasses and empty bases are ignored (include_empty=False); a base is looked up on its module by__qualname__, so one nested in a class body (Namespace.Base) is found, and one the module no longer holds (a stale class left by an in-process reload) is skipped. Registering any base turns detection off, so multi-database apps keep control by registering the bases ofdb_url. The walk runs only whenget_metadata()rebuilds the cached metadata, whichload_app()already resets after the app and pages are imported, so a base imported only by a page is seen. Detected bases are logged at debug level. Existingenv.pyfiles (incl. those written by 0.11.0a1) callget_metadata()and get this without being regenerated.reflex/.templates/alembic/env.py: an autogenerated revision that drops tables logs one warning naming them ("review it before running reflex db migrate"); deleting a model on purpose keeps alembic's normaldrop_table, as in 0.10.- Legacy
declarative_base()classes are not detected (that needs SQLAlchemy's privatemapperlib._all_registries()); the docs say to register them. Third-party bases are included: the publishedreflex-workflow==0.0.1a1andreflex-enterprise==0.9.11define none, and a wrongly included base shows up as a reviewablecreate_table, whereas a wrongly excluded one reintroduces the silent drop. - Docs: the register line is removed from the six code samples in
docs/database/{overview,tables,relationships,queries}.mdanddocs/library/tables-and-data-grids/table.md; the text explains when registering is still needed.rx.ModelRegistry.registerstays public and unchanged.
Test plan
tests/units/utils/test_migrations.py:test_makemigrations_and_migrate_follow_model_changesgains anunregisteredcase; newtest_forgotten_register_keeps_the_tables(makemigrations, migrate,migrate(autogenerate=True),alembic_autogenerate, startup check, barealembic check/revision --autogenerate: no revision, rows intact),test_unregistered_base_beside_rx_model,test_registered_base_excludes_unregistered_bases,test_unregistered_base_imported_by_a_page_is_detected,test_autogenerate_warns_when_it_drops_tables.tests/units/test_model.py: detection of module-level and class-nested bases skipping abstract / empty / function-local ones, a re-imported module replacing its base, registration disabling detection, one walk per metadata build.tests/units/utils/test_telemetry_accounting.py(model count now 1 aftermodels.clear()),docs/app/tests/test_database_examples.py(blocks located by the Base class line).- Failing before on unfixed
main: 10 failed, 32 passed (e.g.no such table: thing;assert 2 == 1— a dropping revision was written;{'alembic_version', 'legacy'} == {..., 'thing'}; the page-imported model missing;assert set() == {Base, EmptyBase}). - After: the three touched unit modules 67 passed; full unit suite 15645 passed, 65 skipped (the Playwright browser-driver module could not run locally for lack of a Chromium binary); docs database examples 12 passed.
ruff check,ruff format,pyright reflex tests(and pyright on the template env.py): clean. - End to end: the verifier's
v1_noregister.shandv1b_noregister_mixed.shscenarios against this tree —alembic check"No new upgrade operations detected.",makemigrationsrc 0 with no revision,migraterc 0, every table and row kept.
Type of change
- Bug fix (non-breaking change which fixes an issue)
- This change requires a documentation update (included)
Notes for reviewers
- Behaviour change: an app with an unregistered base meant for another database (and nothing registered) now gets that base's tables as
create_tablein itsdb_urlmigrations until it registers the right base. Shipped as abugfixfragment; say if you want abreakingone too. - A base created inside a function or bound under another name than its
__qualname__is not detected and must be registered (documented in the docstring). - Two detected bases sharing one
MetaDatawould be merged twice (an SAWarning); rare, not handled.
News fragment: news/+detect-declarative-bases.bugfix.md.
Generated by Claude Code