Detect DeclarativeBase model bases when none is registered

Merged
MA
1h ago
+453-43

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.py ModelRegistry.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 of DeclarativeBase.__subclasses__() keeping classes that own their metadata. 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 of db_url. The walk runs only when get_metadata() rebuilds the cached metadata, which load_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. Existing env.py files (incl. those written by 0.11.0a1) call get_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 normal drop_table, as in 0.10.
  • Legacy declarative_base() classes are not detected (that needs SQLAlchemy's private mapperlib._all_registries()); the docs say to register them. Third-party bases are included: the published reflex-workflow==0.0.1a1 and reflex-enterprise==0.9.11 define none, and a wrongly included base shows up as a reviewable create_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}.md and docs/library/tables-and-data-grids/table.md; the text explains when registering is still needed. rx.ModelRegistry.register stays public and unchanged.

Test plan

  • tests/units/utils/test_migrations.py: test_makemigrations_and_migrate_follow_model_changes gains an unregistered case; new test_forgotten_register_keeps_the_tables (makemigrations, migrate, migrate(autogenerate=True), alembic_autogenerate, startup check, bare alembic 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 after models.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.sh and v1b_noregister_mixed.sh scenarios against this tree — alembic check "No new upgrade operations detected.", makemigrations rc 0 with no revision, migrate rc 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_table in its db_url migrations until it registers the right base. Shipped as a bugfix fragment; say if you want a breaking one 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 MetaData would be merged twice (an SAWarning); rare, not handled.

News fragment: news/+detect-declarative-bases.bugfix.md.


Generated by Claude Code