From 33a05576b98b550cb89b021ade9250dac32b329c Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 27 Oct 2026 20:15:04 +0000 Subject: [PATCH 1/5] Note the rx.Model deprecation on every page that still uses it The database overview already warns that rx.Model is deprecated since 0.9.2, but the tables, queries and relationships pages and the rx.table database example presented it without any note. Claude-Session: https://claude.ai/code/session_01LwJUCUDZhZY71q8Azr37BQ --- docs/app/news/+model-deprecation-notes.docs.md | 1 + docs/database/queries.md | 6 ++++++ docs/database/relationships.md | 6 ++++++ docs/database/tables.md | 6 ++++++ docs/library/tables-and-data-grids/table.md | 6 ++++++ 5 files changed, 25 insertions(+) create mode 100644 docs/app/news/+model-deprecation-notes.docs.md diff --git a/docs/app/news/+model-deprecation-notes.docs.md b/docs/app/news/+model-deprecation-notes.docs.md new file mode 100644 index 00000000000..31f0fb1b781 --- /dev/null +++ b/docs/app/news/+model-deprecation-notes.docs.md @@ -0,0 +1 @@ +Add the `rx.Model` deprecation notice to the database tables, queries and relationships pages and to the table component's database example. diff --git a/docs/database/queries.md b/docs/database/queries.md index fb20ab10d52..245c18df3a3 100644 --- a/docs/database/queries.md +++ b/docs/database/queries.md @@ -6,6 +6,12 @@ A query is a request for information from a database table or combination of tables. A query can be used to retrieve data from a single table or multiple tables. A query can also be used to insert, update, or delete data from a table. +```md alert warning +# `rx.Model` is deprecated + +`rx.Model` and `rx.session` are deprecated as of Reflex 0.9.2 and are scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). +``` + ## Session To execute a query you must first create a `rx.session`. You can use the session diff --git a/docs/database/relationships.md b/docs/database/relationships.md index 55260602b7d..d4d79d9e880 100644 --- a/docs/database/relationships.md +++ b/docs/database/relationships.md @@ -9,6 +9,12 @@ To establish bidirectional relationships a model must correctly set the `back_populates` keyword argument on the `Relationship` to the relationship attribute in the _other_ model. +```md alert warning +# `rx.Model` is deprecated + +`rx.Model` and `rx.session` are deprecated as of Reflex 0.9.2 and are scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). +``` + ## Foreign Key Relationships To create a relationship, first add a field to the model that references the diff --git a/docs/database/tables.md b/docs/database/tables.md index 304663b0e4b..0d3872e3045 100644 --- a/docs/database/tables.md +++ b/docs/database/tables.md @@ -6,6 +6,12 @@ In tables, data is logically organized in a row-and-column format similar to a spreadsheet. Each row represents a unique record, and each column represents a field in the record. +```md alert warning +# `rx.Model` is deprecated + +`rx.Model` and `rx.session` are deprecated as of Reflex 0.9.2 and are scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). +``` + ## Creating a Table To create a table, make a class that inherits from `rx.Model`. diff --git a/docs/library/tables-and-data-grids/table.md b/docs/library/tables-and-data-grids/table.md index 82df0adb071..9f6e08454f6 100644 --- a/docs/library/tables-and-data-grids/table.md +++ b/docs/library/tables-and-data-grids/table.md @@ -472,6 +472,12 @@ The more common use case for building an `rx.table` is to use data from a databa The code below shows how to load data from a database and place it in an `rx.table`. +```md alert warning +# `rx.Model` is deprecated + +`rx.Model` and `rx.session` are deprecated as of Reflex 0.9.2 and are scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). +``` + ## Loading data into table A `Customer` [model](/docs/database/tables/) is defined that inherits from `rx.Model`. From ec95873f0a199d7888b00506df8e9cd9359a7792 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 27 Oct 2026 20:22:00 +0000 Subject: [PATCH 2/5] Limit the deprecation notice to rx.Model Only rx.Model emits the deprecation warning; rx.session has no removal date of its own. Claude-Session: https://claude.ai/code/session_01LwJUCUDZhZY71q8Azr37BQ --- docs/database/queries.md | 2 +- docs/database/relationships.md | 2 +- docs/database/tables.md | 2 +- docs/library/tables-and-data-grids/table.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/database/queries.md b/docs/database/queries.md index 245c18df3a3..33aa6a22a90 100644 --- a/docs/database/queries.md +++ b/docs/database/queries.md @@ -9,7 +9,7 @@ tables. A query can also be used to insert, update, or delete data from a table. ```md alert warning # `rx.Model` is deprecated -`rx.Model` and `rx.session` are deprecated as of Reflex 0.9.2 and are scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). +`rx.Model` is deprecated as of Reflex 0.9.2 and is scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). ``` ## Session diff --git a/docs/database/relationships.md b/docs/database/relationships.md index d4d79d9e880..89758883213 100644 --- a/docs/database/relationships.md +++ b/docs/database/relationships.md @@ -12,7 +12,7 @@ attribute in the _other_ model. ```md alert warning # `rx.Model` is deprecated -`rx.Model` and `rx.session` are deprecated as of Reflex 0.9.2 and are scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). +`rx.Model` is deprecated as of Reflex 0.9.2 and is scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). ``` ## Foreign Key Relationships diff --git a/docs/database/tables.md b/docs/database/tables.md index 0d3872e3045..3626cbf89fc 100644 --- a/docs/database/tables.md +++ b/docs/database/tables.md @@ -9,7 +9,7 @@ field in the record. ```md alert warning # `rx.Model` is deprecated -`rx.Model` and `rx.session` are deprecated as of Reflex 0.9.2 and are scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). +`rx.Model` is deprecated as of Reflex 0.9.2 and is scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). ``` ## Creating a Table diff --git a/docs/library/tables-and-data-grids/table.md b/docs/library/tables-and-data-grids/table.md index 9f6e08454f6..cf84803d8f1 100644 --- a/docs/library/tables-and-data-grids/table.md +++ b/docs/library/tables-and-data-grids/table.md @@ -475,7 +475,7 @@ The code below shows how to load data from a database and place it in an `rx.tab ```md alert warning # `rx.Model` is deprecated -`rx.Model` and `rx.session` are deprecated as of Reflex 0.9.2 and are scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). +`rx.Model` is deprecated as of Reflex 0.9.2 and is scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). ``` ## Loading data into table From 70977dc8bc6e1bea7543a86e191da6e9c4454781 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 27 Oct 2026 22:33:11 +0000 Subject: [PATCH 3/5] Rewrite the database docs around SQLAlchemy models The database overview, tables, queries and relationships pages and the table component's database examples documented the deprecated rx.Model and rx.session. Instead of flagging that with a deprecation callout, show the supported pattern: a shared MappedAsDataclass + DeclarativeBase base (so Reflex serializes rows into state without extra code), a sessionmaker factory with expire_on_commit=False, 2.0-style select() queries, and rx.ModelRegistry.register(Base) so the reflex db migration commands keep working. The overview gains a migration table from rx.Model, and the relationships page explains eager loading and a loaded-attributes serializer for models with relationships. The table page's CSV and JSON download examples relied on the Pydantic __fields__ and .dict() APIs; they now use dataclasses. A new docs test executes the documented models, handlers and serializers against a SQLite file, and reflex db init/makemigrations/migrate were verified against a registered DeclarativeBase. Claude-Session: https://claude.ai/code/session_01LwJUCUDZhZY71q8Azr37BQ --- .../app/news/+model-deprecation-notes.docs.md | 1 - .../news/+sqlalchemy-database-docs.docs.md | 1 + docs/app/tests/test_database_examples.py | 336 ++++++++++++++++++ docs/database/overview.md | 180 ++++++---- docs/database/queries.md | 331 +++++++++-------- docs/database/relationships.md | 195 ++++++---- docs/database/tables.md | 159 ++++++--- docs/library/tables-and-data-grids/table.md | 109 +++--- 8 files changed, 920 insertions(+), 392 deletions(-) delete mode 100644 docs/app/news/+model-deprecation-notes.docs.md create mode 100644 docs/app/news/+sqlalchemy-database-docs.docs.md create mode 100644 docs/app/tests/test_database_examples.py diff --git a/docs/app/news/+model-deprecation-notes.docs.md b/docs/app/news/+model-deprecation-notes.docs.md deleted file mode 100644 index 31f0fb1b781..00000000000 --- a/docs/app/news/+model-deprecation-notes.docs.md +++ /dev/null @@ -1 +0,0 @@ -Add the `rx.Model` deprecation notice to the database tables, queries and relationships pages and to the table component's database example. diff --git a/docs/app/news/+sqlalchemy-database-docs.docs.md b/docs/app/news/+sqlalchemy-database-docs.docs.md new file mode 100644 index 00000000000..f77625c488e --- /dev/null +++ b/docs/app/news/+sqlalchemy-database-docs.docs.md @@ -0,0 +1 @@ +Rewrite the database overview, tables, queries and relationships pages and the table component's database examples to use SQLAlchemy `DeclarativeBase` models with `MappedAsDataclass`, a `sessionmaker` session factory and 2.0-style `select` queries instead of the deprecated `rx.Model` and `rx.session`. The overview gains a migration table from `rx.Model`. diff --git a/docs/app/tests/test_database_examples.py b/docs/app/tests/test_database_examples.py new file mode 100644 index 00000000000..6fb865306a4 --- /dev/null +++ b/docs/app/tests/test_database_examples.py @@ -0,0 +1,336 @@ +"""Execute the database docs examples against a SQLite file.""" + +import csv +import inspect +import json +import re +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from uuid import uuid4 + +import pytest +import reflex as rx +from reflex_base.registry import RegistrationContext +from reflex_base.utils.serializers import serialize + +pytest.importorskip("sqlalchemy") + +DOCS = Path(__file__).resolve().parents[2] +PAGES = { + "overview": DOCS / "database" / "overview.md", + "tables": DOCS / "database" / "tables.md", + "queries": DOCS / "database" / "queries.md", + "relationships": DOCS / "database" / "relationships.md", + "table": DOCS / "library" / "tables-and-data-grids" / "table.md", +} + + +@pytest.fixture(autouse=True) +def isolated_registration(tmp_path, monkeypatch): + """Point the docs' config-driven engine at a throwaway database.""" + monkeypatch.setattr(rx.ModelRegistry, "models", set()) + monkeypatch.setattr(rx.ModelRegistry, "_metadata", None) + config = rx.Config( + app_name="database_docs_test", db_url=f"sqlite:///{tmp_path / 'docs.db'}" + ) + with RegistrationContext(_config=config): + yield + + +def block(page, marker): + """Return the one python code block of a page that contains the marker.""" + source = PAGES[page].read_text() + blocks = re.findall( + r"^```python(?: exec)?\n(.*?)^```", source, re.MULTILINE | re.DOTALL + ) + matches = [code for code in blocks if marker in code] + assert len(matches) == 1, f"{page}: {len(matches)} blocks contain {marker!r}" + return matches[0] + + +def load(monkeypatch, *sources): + """Exec the snippets in order inside one fresh module, like one app file.""" + module = ModuleType(f"database_docs_{uuid4().hex}") + monkeypatch.setitem(sys.modules, module.__name__, module) + exec("import reflex as rx\n" + "\n".join(sources), module.__dict__) + return module + + +def to_json(value): + """Serialize a value the way the frontend receives it, nested objects included.""" + return json.loads(json.dumps(value, default=serialize)) + + +def run(cls, event, *args, **state_vars): + """Run a state's event handler against a plain object standing in for it.""" + helpers = { + key: value for key, value in vars(cls).items() if inspect.isfunction(value) + } + state = type(cls.__name__, (SimpleNamespace,), helpers)(**state_vars) + result = getattr(cls, event).fn(state, *args) + if result is not None: + list(result) + return state + + +def test_overview_examples(monkeypatch): + """The overview's connection, model and query snippets work together.""" + module = load( + monkeypatch, + block("overview", "Session = sessionmaker"), + block("overview", "rx.ModelRegistry.register(Base)"), + block("overview", "class UserState(rx.State)").replace( + "from .db import Session\nfrom .models import User\n", "" + ), + ) + assert module.Base in rx.ModelRegistry.get_models() + module.Base.metadata.create_all(module.engine) + run(module.UserState, "add_user") + state = run(module.UserState, "load_users", users=[]) + assert [user.username for user in state.users] == ["test"] + assert serialize(state.users[0]) == { + "id": 1, + "username": "test", + "email": "admin@reflex.dev", + } + + +def test_tables_register_with_the_migration_registry(monkeypatch): + """The tables page's base class is what `reflex db` manages.""" + module = load(monkeypatch, block("tables", "rx.ModelRegistry.register(Base)")) + assert set(rx.ModelRegistry.get_metadata().tables) == {"user"} + user = module.User(username="ada", email="ada@example.com") + assert user.id is None + assert serialize(user) == { + "id": None, + "username": "ada", + "email": "ada@example.com", + } + + +def test_tables_custom_serializer(monkeypatch): + """The explicit column type example serializes with its registered function.""" + module = load( + monkeypatch, + block("tables", "rx.ModelRegistry.register(Base)"), + block("tables", "server_default=sqlalchemy.func.now()").replace( + " ...\n", "" + ), + block("tables", "def serialize_post"), + "engine = sqlalchemy.create_engine(rx.config.get_config().db_url)\n" + "Base.metadata.create_all(engine)\n" + "from sqlalchemy.orm import Session as _Session\n" + "with _Session(engine, expire_on_commit=False) as session:\n" + " session.add(Post())\n" + " session.commit()\n" + " post = session.get(Post, 1)\n" + " post.update_ts # noqa: B018\n", + ) + assert serialize(module.post) == { + "id": 1, + "update_ts": module.post.update_ts.replace(microsecond=0).isoformat(), + } + + +@pytest.fixture +def queries(monkeypatch): + """Load the queries page's models, session factory and CRUD states.""" + module = load( + monkeypatch, + block("queries", "Session = sessionmaker"), + block("queries", "class User(Base)"), + block("queries", "class QueryUser"), + block("queries", "class AddUser(rx.State)"), + block("queries", "class ChangeEmail(rx.State)"), + block("queries", "class RemoveUser(rx.State)"), + block("queries", "class AddUserForm(rx.State):\n user: User | None"), + block("queries", "def update_user").replace( + "class AddUserForm(rx.State):\n ...\n", + "class AddUserForm(AddUserForm):\n", + ), + block("queries", "def raw_user_tuples"), + block("queries", "class Order(Base)"), + block("queries", "class OrderStats"), + block("queries", "class OrderKPIs"), + ) + module.Base.metadata.create_all(module.engine) + return module + + +def test_queries_crud(queries): + """Insert, select, update and delete rows through the documented handlers.""" + run(queries.AddUser, "add_user", username="ada", email="ada@example.com") + run(queries.AddUser, "add_user", username="bob", email="bob@example.com") + state = run(queries.QueryUser, "get_users", name="ad", users=[]) + assert [user.username for user in state.users] == ["ada"] + assert serialize(state.users[0])["email"] == "ada@example.com" + + run(queries.ChangeEmail, "modify_user", username="ada", email="ada@reflex.dev") + run(queries.ChangeEmail, "modify_user", username="nobody", email="x@reflex.dev") + state = run(queries.QueryUser, "get_users", name="ada", users=[]) + assert state.users[0].email == "ada@reflex.dev" + + run(queries.RemoveUser, "delete_user", username="bob") + run(queries.RemoveUser, "delete_user", username="bob") + state = run(queries.QueryUser, "get_users", name="", users=[]) + assert [user.username for user in state.users] == ["ada"] + + +def test_queries_object_lifecycle(queries): + """Committed objects keep their generated id and can be updated later.""" + form = {"username": "ada", "email": "ada@example.com"} + state = run(queries.AddUserForm, "add_user", form, user=None) + assert state.user.id == 1 + state = run( + queries.AddUserForm, "update_user", {"email": "ada@reflex.dev"}, user=state.user + ) + assert state.user.email == "ada@reflex.dev" + assert serialize(state.user) == { + "id": 1, + "username": "ada", + "email": "ada@reflex.dev", + } + + +def test_queries_raw_sql(queries): + """Raw SQL inserts bind parameters and the computed var reads the rows back.""" + state = run(queries.State, "insert_user_raw", "ada", "ada@example.com") + assert queries.State.raw_user_tuples.fget(state) == [[1, "ada", "ada@example.com"]] + + +def test_queries_aggregates(queries): + """Aggregation examples compute counts and sums inside the database.""" + with queries.Session() as session: + session.add_all([ + queries.Order(user_id=1, status="completed", amount=10.0), + queries.Order(user_id=1, status="completed", amount=None), + queries.Order(user_id=2, status="pending", amount=2.5), + ]) + session.commit() + stats = run(queries.OrderStats, "load_stats", order_count=0, orders_by_status=[]) + assert stats.order_count == 3 + assert sorted(stats.orders_by_status) == [("completed", 2), ("pending", 1)] + kpis = run( + queries.OrderKPIs, + "load_kpis", + total_orders=0, + completed_orders=0, + total_amount=0.0, + ) + assert (kpis.total_orders, kpis.completed_orders, kpis.total_amount) == (3, 2, 12.5) + + +def test_relationships(monkeypatch): + """Linked objects insert, eager-load and serialize without lazy loads.""" + module = load( + monkeypatch, + block("queries", "Session = sessionmaker"), + block("relationships", "class Flag(Base)"), + block("relationships", "def serialize_model"), + block("relationships", "class FlagPostForm"), + block("relationships", "class PostState"), + block("relationships", "class PostFlagsState"), + ) + module.Base.metadata.create_all(module.engine) + with module.Session() as session: + user = module.User(username="ada", email="ada@example.com") + session.add(user) + session.commit() + session.add(module.Post(title="Hello", body="World", user_id=user.id)) + session.commit() + run( + module.FlagPostForm, "flag_post", {"message": "spam", "post_id": "1"}, user=user + ) + + state = run(module.PostState, "load_posts", posts=[]) + post = to_json(state.posts[0]) + assert post["user"]["username"] == "ada" + assert post["flags"][0]["message"] == "spam" + assert post["flags"][0]["user"]["email"] == "ada@example.com" + assert "posts" not in post["user"] + + state = run(module.PostFlagsState, "load_posts", posts=[]) + assert set(serialize(state.posts[0])) == {"id", "title", "body", "user_id"} + state = run(module.PostFlagsState, "select_post", 1, selected_flags=[]) + assert [flag.message for flag in state.selected_flags] == ["spam"] + + +@pytest.fixture +def table(monkeypatch): + """Load the table component's database examples with seeded customers.""" + module = load( + monkeypatch, + block("queries", "Session = sessionmaker"), + block("table", "class Customer(Base):\n __tablename__"), + block("table", "class DatabaseTableState(rx.State)"), + block( + "table", "class DatabaseTableState2(rx.State):\n users: list[Customer]" + ), + block( + "table", "class DatabaseTableState3(rx.State):\n users: list[Customer]" + ), + block( + "table", "class TableDownloadState(rx.State):\n users: list[Customer]" + ), + ) + module.Base.metadata.create_all(module.engine) + with module.Session() as session: + for index in range(5): + session.add( + module.Customer( + name=f"Customer {index}", + email=f"customer{index}@example.com", + phone=f"555-000{index}", + address=f"{index} Main St", + ) + ) + session.commit() + return module + + +def test_table_loading_and_download(table): + """The load and CSV download examples read every customer.""" + state = run(table.DatabaseTableState, "load_entries", users=[]) + assert len(state.users) == 5 + assert serialize(state.users[0])["name"] == "Customer 0" + state = run(table.TableDownloadState, "load_entries", users=[]) + rows = list( + csv.reader(table.TableDownloadState._convert_to_csv(state).splitlines()) + ) + assert rows[0] == ["id", "name", "email", "phone", "address"] + assert len(rows) == 6 + + +def test_table_filter_and_sort(table): + """Searching and sorting run inside the query.""" + state = run( + table.DatabaseTableState2, + "load_entries", + users=[], + sort_value="", + search_value="%customer3%", + ) + assert [user.name for user in state.users] == ["Customer 3"] + state = run( + table.DatabaseTableState2, + "load_entries", + users=[], + sort_value="phone", + search_value="", + ) + assert [user.phone for user in state.users] == [f"555-000{i}" for i in range(5)] + + +def test_table_pagination(table): + """Pagination returns one page and the total count.""" + state = run( + table.DatabaseTableState3, + "load_entries", + users=[], + total_items=0, + offset=3, + limit=3, + ) + assert [user.name for user in state.users] == ["Customer 3", "Customer 4"] + assert state.total_items == 5 diff --git a/docs/database/overview.md b/docs/database/overview.md index c3869347b16..744611e6691 100644 --- a/docs/database/overview.md +++ b/docs/database/overview.md @@ -1,17 +1,17 @@ --- -meta_description: Build Reflex dashboards and internal tools with MySQL, PostgreSQL, SQL Server, or SQLite using Python models, database sessions, and migrations. +meta_description: Build Reflex dashboards and internal tools with MySQL, PostgreSQL, SQL Server, or SQLite using SQLAlchemy models, database sessions, and migrations. --- # Database Overview -Connect a Reflex Python app to **MySQL, PostgreSQL, SQL Server, or SQLite** to build database-driven dashboards, admin panels, and internal tools. Define models as Python classes and query them in the app's Python backend without creating a separate API service just for the UI. +Connect a Reflex Python app to **MySQL, PostgreSQL, SQL Server, or SQLite** to build database-driven dashboards, admin panels, and internal tools. Define tables as Python classes with [SQLAlchemy](https://docs.sqlalchemy.org/en/20/), query them in the app's Python backend, and load the results into state to render them, without creating a separate API service just for the UI. ## Key takeaways -- Install the optional database dependencies with the `db` extra. -- For new apps, define tables and query data with SQLModel or SQLAlchemy directly. The legacy `rx.Model` and `rx.session()` examples below cover the existing Reflex ORM interface and migrations. +- Define tables as SQLAlchemy models on a shared dataclass-mapped base. Reflex serializes those models, so they can be stored in state and rendered directly. +- Create one engine and one session factory for the app, and open a short-lived session inside each event handler that reads or writes the database. - Configure the database URL and install the driver for your chosen database. Check dialect-specific types, queries, and migrations when switching databases. -- Load query results into state to display them in the UI. External database changes require another query, polling, or an event to refresh the app. +- Register the model base with Reflex to manage schema changes with the `reflex db` migration commands. - Use ordinary Python clients or REST APIs for external data sources such as Airtable, Databricks, and Snowflake. ## Supported databases @@ -23,50 +23,6 @@ Connect a Reflex Python app to **MySQL, PostgreSQL, SQL Server, or SQLite** to b | SQL Server | SQLAlchemy MSSQL dialect and a compatible driver | Apps connected to Microsoft SQL Server | | SQLite | Local database file | Local development and small applications | -Reflex uses [sqlmodel](https://sqlmodel.tiangolo.com) to provide a built-in ORM wrapping SQLAlchemy. - -```md alert warning -# ORM compatibility - -`rx.Model` is deprecated as of Reflex 0.9.2 and is scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; the same database connection and Python state patterns still apply. -``` - -For new applications, follow the [SQLModel tutorial](https://sqlmodel.tiangolo.com/tutorial/select/) using `SQLModel`, `create_engine()`, and `Session(engine)` directly. Keep database queries in your Python backend and load their results into Reflex state. - -Here is a minimal SQLModel example for a new app. Install `sqlmodel` (or the `reflex[db]` extra shown below), then run this code in a Python script to create a local SQLite table, save a customer, and query their email: - -```python -from sqlmodel import Field, Session, SQLModel, create_engine, select - - -class Customer(SQLModel, table=True): - id: int | None = Field(default=None, primary_key=True) - name: str - email: str - - -engine = create_engine("sqlite:///customers.db") -SQLModel.metadata.create_all(engine) - -with Session(engine) as session: - customer = Customer(name="Alex", email="alex@example.com") - session.add(customer) - session.commit() - - customers = session.exec( - select(Customer).where(Customer.email == "alex@example.com") - ).all() - for customer in customers: - print(customer.email) -``` - -In a Reflex app, create the engine once in your backend module and open a short-lived `Session(engine)` in each event handler that queries or updates the database. Copy the values you need into state for display. `create_all()` creates missing tables for this local example; use migrations to change existing schemas. Running the script again inserts another customer. - -The examples below cover the legacy integrated database interface for existing apps. Only when maintaining that interface should you adapt SQLModel examples by replacing `SQLModel` with `rx.Model` and `Session(engine)` with `rx.session()`. - -For advanced use cases, please see the -[SQLAlchemy docs](https://docs.sqlalchemy.org/en/14/orm/quickstart.html) (v1.4). - ```md alert info # Using NoSQL Databases @@ -75,19 +31,23 @@ If you are using a NoSQL database (e.g. MongoDB), you can work with it in Reflex ## Installation -The ORM dependencies (SQLModel and Alembic) are an optional extra. -Install them with the `db` extra: +Install SQLAlchemy together with the DBAPI driver for your database. The `db` +extra installs SQLAlchemy and [Alembic](https://alembic.sqlalchemy.org/en/latest/), +which the `reflex db` migration commands use: ```bash pip install "reflex[db]" ``` -## Connecting +SQLite needs no extra driver. For other databases, install the driver named in +the [SQLAlchemy dialect docs](https://docs.sqlalchemy.org/en/20/dialects/), for +example `psycopg[binary]` for PostgreSQL. -Reflex provides a built-in SQLite database for storing and retrieving data. +## Connecting -You can connect to your own SQL compatible database by modifying the -`rxconfig.py` file with your database url. +Set the database URL in `rxconfig.py`. The `reflex db` migration commands read +it from there, and the app can read it back through `rx.config.get_config()` +so the URL lives in one place. ```python import reflex as rx @@ -99,24 +59,54 @@ config = rx.Config( ``` For more examples of database URLs that can be used, see the [SQLAlchemy -docs](https://docs.sqlalchemy.org/en/14/core/engines.html#backend-specific-urls). -Be sure to install the appropriate DBAPI driver for the database you intend to -use. +docs](https://docs.sqlalchemy.org/en/20/core/engines.html#backend-specific-urls). + +Create the engine and a session factory once, in a module the rest of the app +imports. `expire_on_commit=False` keeps the attributes of committed objects +readable after the session closes, which is what lets them be stored in state. + +```python +# my_app/db.py +import reflex as rx +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +engine = create_engine(rx.config.get_config().db_url) +Session = sessionmaker(engine, expire_on_commit=False) +``` ## Tables -To create a table make a class that inherits from `rx.Model` and specify -that it is a table. +Define a base class with `MappedAsDataclass` and `DeclarativeBase`, and make +each table a subclass of it. The dataclass mapping gives every model a +keyword-argument constructor and lets Reflex serialize instances into state +without extra code. ```python +# my_app/models.py import reflex as rx +from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column + + +class Base(MappedAsDataclass, DeclarativeBase): + """Base class for the app's tables.""" + +rx.ModelRegistry.register(Base) -class User(rx.Model, table=True): - username: str - email: str + +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + username: Mapped[str] + email: Mapped[str] ``` +`rx.ModelRegistry.register(Base)` tells the `reflex db` commands which tables +to manage. See [Tables](/docs/database/tables/) for column types and +[Relationships](/docs/database/relationships/) for foreign keys. + ## Migrations Reflex leverages [alembic](https://alembic.sqlalchemy.org/en/latest/) @@ -137,24 +127,70 @@ The `reflex db migrate` command is used to apply migration scripts to bring the database up to date. During app startup, if Reflex detects that the current database schema is not up to date, a warning will be displayed on the console. -## Queries +For a local experiment, `Base.metadata.create_all(engine)` creates any missing +tables without migrations. -To query the database you can create a `rx.session()` -which handles opening and closing the database connection. +## Queries -You can use normal SQLAlchemy queries to query the database. +Open a session from the factory inside an event handler. The `with` block +closes the session when it ends, and changes that were not committed are rolled +back. ```python -with rx.session() as session: - session.add(User(username="test", email="admin@reflex.dev")) - session.commit() +import reflex as rx +from sqlalchemy import select + +from .db import Session +from .models import User + + +class UserState(rx.State): + users: list[User] = [] + + @rx.event + def add_user(self): + with Session() as session: + session.add(User(username="test", email="admin@reflex.dev")) + session.commit() + + @rx.event + def load_users(self): + with Session() as session: + self.users = session.scalars(select(User)).all() ``` +Because `User` is a dataclass-mapped model, `self.users` serializes to the +frontend and each row renders with `rx.foreach(UserState.users, ...)`. See +[Queries](/docs/database/queries/) for updates, deletes, raw SQL, and async +sessions. + ```md video https://youtube.com/embed/ITOZkzjtjUA?start=6835&end=8225 # Video: Tutorial of Database Model with Forms, Model Field Changes and Migrations, and adding a DateTime Field ``` -## Beyond the built-in ORM +## Migrating existing apps + +`rx.Model` and `rx.session()` are deprecated as of Reflex 0.9.2 and will be +removed in 1.0. Existing apps move to the pattern above with these +replacements: + +| Deprecated | Replacement | +| :--- | :--- | +| `class User(rx.Model, table=True)` | `class User(Base)` with `__tablename__` and `Mapped[...]` columns | +| implicit `id` primary key | `id: Mapped[int] = mapped_column(init=False, primary_key=True)` | +| `with rx.session() as session` | `with Session() as session` from your `sessionmaker` | +| `session.exec(select(User)).all()` | `session.scalars(select(User)).all()` | +| `session.exec(select(User)).first()` | `session.scalar(select(User))` | +| `async with rx.asession() as session` | `async with AsyncSession() as session` from your `async_sessionmaker` | +| `sqlmodel.Field(foreign_key="user.id")` | `mapped_column(ForeignKey("user.id"))` | +| `sqlmodel.Relationship(back_populates=...)` | `relationship(back_populates=..., repr=False)` | + +Columns, data, and migration history are unchanged, since both map to the same +tables. Existing apps that store naive datetimes should read +[Datetimes and SQLModel upgrades](/docs/database/tables/#datetimes-and-sqlmodel-upgrades) +before upgrading SQLModel. + +## Beyond the ORM For services such as Airtable, Databricks, or Snowflake, use their Python libraries or REST APIs in your backend. These connections are separate from the relational ORM described above. See the [AI and API integrations overview](/docs/ai/integrations/overview/) for available integrations and custom connection options. @@ -167,7 +203,7 @@ To replace the in-memory data in the [dashboard tutorial](/docs/getting-started/ ### Which databases does Reflex support? -The optional Reflex ORM uses SQLModel and SQLAlchemy to connect to MySQL, PostgreSQL, SQL Server, and SQLite. Define tables as Python classes, configure the database URL, and install the appropriate DBAPI driver. Queries run in the Python backend through database sessions. +Reflex apps use SQLAlchemy to connect to MySQL, PostgreSQL, SQL Server, and SQLite. Define tables as Python classes, configure the database URL, and install the appropriate DBAPI driver. Queries run in the Python backend through database sessions. ### Can a Reflex app connect to MySQL, SQL Server, or SQLite instead of PostgreSQL? diff --git a/docs/database/queries.md b/docs/database/queries.md index 33aa6a22a90..def528614bb 100644 --- a/docs/database/queries.md +++ b/docs/database/queries.md @@ -6,64 +6,81 @@ A query is a request for information from a database table or combination of tables. A query can be used to retrieve data from a single table or multiple tables. A query can also be used to insert, update, or delete data from a table. -```md alert warning -# `rx.Model` is deprecated +## Session -`rx.Model` is deprecated as of Reflex 0.9.2 and is scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). -``` +To execute a query you must first open a session. The examples on this page use +the engine and session factory from the [overview](/docs/database/overview/#connecting): -## Session +```python +import reflex as rx +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker -To execute a query you must first create a `rx.session`. You can use the session -to query the database using SQLModel or SQLAlchemy syntax. +engine = create_engine(rx.config.get_config().db_url) +Session = sessionmaker(engine, expire_on_commit=False) +``` -The `rx.session` statement will automatically close the session when the code -block is finished. **If `session.commit()` is not called, the changes will be -rolled back and not persisted to the database.** The code can also explicitly -rollback without closing the session via `session.rollback()`. +`with Session() as session:` closes the session when the code block is finished. +**If `session.commit()` is not called, the changes will be rolled back and not +persisted to the database.** The code can also explicitly roll back without +closing the session via `session.rollback()`. The following example shows how to create a session and query the database. First we create a table called `User`. ```python -class User(rx.Model, table=True): - username: str - email: str +from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column + + +class Base(MappedAsDataclass, DeclarativeBase): + """Base class for the app's tables.""" + + +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + username: Mapped[str] + email: Mapped[str] ``` ### Select -Then we create a session and query the User table. +Then we create a session and query the User table. `session.scalars()` runs a +`select()` statement and returns the model instances. ```python +from sqlalchemy import select + + class QueryUser(rx.State): - name: str - users: list[User] + name: str = "" + users: list[User] = [] @rx.event def get_users(self): - with rx.session() as session: - self.users = session.exec( + with Session() as session: + self.users = session.scalars( select(User).where(User.username.contains(self.name)) ).all() ``` The `get_users` method will query the database for all users that contain the -value of the state var `name`. +value of the state var `name`. Because `User` is a dataclass-mapped model, the +rows are serialized to the frontend and render with `rx.foreach`. ### Insert -Similarly, the `session.add()` method to add a new record to the -database or persist an existing object. +Similarly, the `session.add()` method adds a new record to the database. ```python class AddUser(rx.State): - username: str - email: str + username: str = "" + email: str = "" @rx.event def add_user(self): - with rx.session() as session: + with Session() as session: session.add(User(username=self.username, email=self.email)) session.commit() ``` @@ -71,24 +88,27 @@ class AddUser(rx.State): ### Update To update the user, first query the database for the object, make the desired -modifications, `.add` the object to the session and finally call `.commit()`. +modifications, and call `.commit()`. The session tracks changes to objects it +loaded, so there is no need to add the object again. ```python class ChangeEmail(rx.State): - username: str - email: str + username: str = "" + email: str = "" @rx.event def modify_user(self): - with rx.session() as session: - user = session.exec( - select(User).where((User.username == self.username)) - ).first() + with Session() as session: + user = session.scalar(select(User).where(User.username == self.username)) + if user is None: + return user.email = self.email - session.add(user) session.commit() ``` +`session.scalar()` returns the first row of the statement, or `None` when +nothing matches. + ### Delete To delete a user, first query the database for the object, then call @@ -96,48 +116,53 @@ To delete a user, first query the database for the object, then call ```python class RemoveUser(rx.State): - username: str + username: str = "" @rx.event def delete_user(self): - with rx.session() as session: - user = session.exec( - select(User).where(User.username == self.username) - ).first() + with Session() as session: + user = session.scalar(select(User).where(User.username == self.username)) + if user is None: + return session.delete(user) session.commit() ``` ## ORM Object Lifecycle -The objects returned by queries are bound to the session that created them, and cannot generally -be used outside that session. After adding or updating an object, not all fields are automatically -updated, so accessing certain attributes may trigger additional queries to refresh the object. - -To avoid this, the `session.refresh()` method can be used to update the object explicitly and -ensure all fields are up to date before exiting the session. +The objects returned by queries belong to the session that created them. With +`expire_on_commit=False` on the session factory, their loaded columns stay +readable after the session closes, so they can be stored in state and rendered. +Columns the database fills in, such as the autogenerated primary key, are loaded +by `commit()`: ```python +from typing import Any + + class AddUserForm(rx.State): user: User | None = None @rx.event def add_user(self, form_data: dict[str, Any]): - with rx.session() as session: - self.user = User(**form_data) - session.add(self.user) + with Session() as session: + user = User(**form_data) + session.add(user) session.commit() - session.refresh(self.user) + self.user = user ``` Now the `self.user` object will have a correct reference to the autogenerated primary key, `id`, even though this was not provided when the object was created from the form data. -If `self.user` needs to be modified or used in another query in a new session, -it must be added to the session. Adding an object to a session does not -necessarily create the object, but rather associates it with a session where it -may either be created or updated accordingly. +Only loaded attributes are available outside the session. Accessing a +[relationship](/docs/database/relationships/) that was not loaded raises +`DetachedInstanceError`, so eager-load the relationships the UI reads. + +To modify an object stored in state in a new session, look it up again by primary +key with `session.get()` and change the fresh copy, then store that copy so the +state reflects what was written. ```python class AddUserForm(rx.State): @@ -147,26 +172,25 @@ class AddUserForm(rx.State): def update_user(self, form_data: dict[str, Any]): if self.user is None: return - with rx.session() as session: - self.user.set(**form_data) - session.add(self.user) + with Session() as session: + user = session.get(User, self.user.id) + if user is None: + return + for field, value in form_data.items(): + setattr(user, field, value) session.commit() - session.refresh(self.user) + self.user = user ``` -If an ORM object will be referenced and accessed outside of a session, you -should call `.refresh()` on it to avoid stale object exceptions. - ## Using SQL Directly Avoiding SQL is one of the main benefits of using an ORM, but sometimes it is necessary for particularly complex queries, or when using database-specific features. -SQLModel exposes the `session.execute()` method that can be used to execute raw -SQL strings. If parameter binding is needed, the query may be wrapped in -[`sqlalchemy.text`](https://docs.sqlalchemy.org/en/14/core/sqlelement.html#sqlalchemy.sql.expression.text), -which allows colon-prefix names to be used as placeholders. +`session.execute()` runs a statement built with +[`sqlalchemy.text`](https://docs.sqlalchemy.org/en/20/core/sqlelement.html#sqlalchemy.sql.expression.text), +which allows colon-prefix names to be used as placeholders for bound parameters. ```md alert info # Never use string formatting to construct SQL queries, as this may lead to SQL injection vulnerabilities in the app. @@ -180,8 +204,8 @@ import reflex as rx class State(rx.State): @rx.event - def insert_user_raw(self, username, email): - with rx.session() as session: + def insert_user_raw(self, username: str, email: str): + with Session() as session: session.execute( sqlalchemy.text( "INSERT INTO user (username, email) VALUES (:username, :email)" @@ -192,8 +216,11 @@ class State(rx.State): @rx.var def raw_user_tuples(self) -> list[list]: - with rx.session() as session: - return [list(row) for row in session.execute("SELECT * FROM user").all()] + with Session() as session: + return [ + list(row) + for row in session.execute(sqlalchemy.text("SELECT * FROM user")).all() + ] ``` ## Writing Efficient Queries @@ -206,10 +233,13 @@ query itself and return only display-ready results. The examples below use a simple `Order` model. ```python -class Order(rx.Model, table=True): - user_id: int - status: str - amount: float | None = None +class Order(Base): + __tablename__ = "order" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + user_id: Mapped[int] + status: Mapped[str] + amount: Mapped[float | None] = mapped_column(default=None) ``` ### Aggregate in SQL, not Python @@ -219,7 +249,7 @@ database's aggregate functions (`COUNT`, `SUM`, `AVG`, `MIN`, `MAX`) with `GROUP BY` so only the summary rows come back. ```python -from sqlmodel import select, func +from sqlalchemy import func, select class OrderStats(rx.State): @@ -228,14 +258,14 @@ class OrderStats(rx.State): @rx.event def load_stats(self): - with rx.session() as session: + with Session() as session: # Count rows in the database instead of len() on a full fetch. - self.order_count = session.exec(select(func.count(Order.id))).one() + self.order_count = session.scalar(select(func.count(Order.id))) # One row per group, ready for a chart or summary table. self.orders_by_status = [ tuple(row) - for row in session.exec( + for row in session.execute( select(Order.status, func.count(Order.id)).group_by(Order.status) ).all() ] @@ -246,7 +276,7 @@ aggregation, instead of issuing one query per metric: ```python import sqlalchemy -from sqlmodel import select, func +from sqlalchemy import func, select class OrderKPIs(rx.State): @@ -256,8 +286,8 @@ class OrderKPIs(rx.State): @rx.event def load_kpis(self): - with rx.session() as session: - total, completed, amount = session.exec( + with Session() as session: + total, completed, amount = session.execute( select( func.count(Order.id), func.sum( @@ -279,15 +309,15 @@ Fetch everything in one statement with a `JOIN`, an `IN (...)` list, or ```python # Efficient: one query for all users at once. -with rx.session() as session: - orders = session.exec( +with Session() as session: + orders = session.scalars( select(Order).where(Order.user_id.in_([user.id for user in users])) ).all() # Inefficient: one query per user (N+1). -with rx.session() as session: +with Session() as session: for user in users: - orders = session.exec(select(Order).where(Order.user_id == user.id)).all() + orders = session.scalars(select(Order).where(Order.user_id == user.id)).all() ``` The `.in_()` method binds each value as a separate query parameter, so it is @@ -303,9 +333,9 @@ The example below binds the list with an expanding parameter: ```python import sqlalchemy -with rx.session() as session: +with Session() as session: rows = session.execute( - sqlalchemy.text("SELECT * FROM users WHERE id IN :user_ids").bindparams( + sqlalchemy.text("SELECT * FROM user WHERE id IN :user_ids").bindparams( sqlalchemy.bindparam("user_ids", expanding=True) ), {"user_ids": [1, 2, 3]}, @@ -351,10 +381,10 @@ guarantees a usable value. This is especially important for nullable numeric columns and for aggregates that may run over empty groups. ```python -from sqlmodel import select, func +from sqlalchemy import func, select -with rx.session() as session: - total = session.exec(select(func.coalesce(func.sum(Order.amount), 0.0))).one() +with Session() as session: + total = session.scalar(select(func.coalesce(func.sum(Order.amount), 0.0))) ``` The same works in raw SQL: `SELECT COALESCE(SUM(amount), 0) FROM ...`. @@ -376,13 +406,14 @@ orders = [ ## Async Database Operations -Reflex provides an async version of the session function called `rx.asession` for asynchronous database operations. This is useful when you need to perform database operations in an async context, such as within async event handlers. +SQLAlchemy's [asyncio extension](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) +provides an async engine and session for use in async event handlers, such as +[background tasks](/docs/events/background-events/). ### Configuring the Async Database URL -`rx.asession` needs its own `async_db_url` in `rxconfig.py`. It must point at -the same database as `db_url` but use an async driver. Calling `rx.asession()` -when it is unset raises an error. +The async engine needs a URL with an async driver. Keep it next to `db_url` in +`rxconfig.py` as `async_db_url`, pointing at the same database. ```python config = rx.Config( @@ -392,8 +423,8 @@ config = rx.Config( ) ``` -The matching async DBAPI driver is not part of the `reflex[db]` extra, so -install it separately. For the SQLite URL above, install `aiosqlite`: +The async DBAPI driver is not part of the `reflex[db]` extra, so install it +separately. For the SQLite URL above, install `aiosqlite`: ```bash pip install aiosqlite @@ -407,13 +438,21 @@ async driver, so `db_url` and `async_db_url` can share one pip install "psycopg[binary]" ``` -The `rx.asession` function returns an async SQLAlchemy session that must be used with an async context manager. Most operations against the `asession` must be awaited. +Create the async engine and session factory next to the sync ones: ```python -import sqlalchemy.ext.asyncio -import sqlalchemy - import reflex as rx +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +async_engine = create_async_engine(rx.config.get_config().async_db_url) +AsyncSession = async_sessionmaker(async_engine, expire_on_commit=False) +``` + +The async session is used with `async with`, and most operations against it +must be awaited. + +```python +from sqlalchemy import select class AsyncUserState(rx.State): @@ -421,10 +460,10 @@ class AsyncUserState(rx.State): @rx.event(background=True) async def get_users_async(self): - async with rx.asession() as asession: - result = await asession.execute(select(User)) - async with self: - self.users = result.all() + async with AsyncSession() as session: + users = (await session.scalars(select(User))).all() + async with self: + self.users = users ``` ### Async Select @@ -433,16 +472,16 @@ The following example shows how to query the database asynchronously: ```python class AsyncQueryUser(rx.State): - name: str + name: str = "" users: list[User] = [] @rx.event(background=True) async def get_users(self): - async with rx.asession() as asession: + async with AsyncSession() as session: stmt = select(User).where(User.username.contains(self.name)) - result = await asession.execute(stmt) - async with self: - self.users = result.all() + users = (await session.scalars(stmt)).all() + async with self: + self.users = users ``` ### Async Insert @@ -451,14 +490,14 @@ To add a new record to the database asynchronously: ```python class AsyncAddUser(rx.State): - username: str - email: str + username: str = "" + email: str = "" @rx.event(background=True) async def add_user(self): - async with rx.asession() as asession: - asession.add(User(username=self.username, email=self.email)) - await asession.commit() + async with AsyncSession() as session: + session.add(User(username=self.username, email=self.email)) + await session.commit() ``` ### Async Update @@ -467,19 +506,17 @@ To update a user asynchronously: ```python class AsyncChangeEmail(rx.State): - username: str - email: str + username: str = "" + email: str = "" @rx.event(background=True) async def modify_user(self): - async with rx.asession() as asession: + async with AsyncSession() as session: stmt = select(User).where(User.username == self.username) - result = await asession.execute(stmt) - user = result.first() - if user: + user = await session.scalar(stmt) + if user is not None: user.email = self.email - asession.add(user) - await asession.commit() + await session.commit() ``` ### Async Delete @@ -488,22 +525,22 @@ To delete a user asynchronously: ```python class AsyncRemoveUser(rx.State): - username: str + username: str = "" @rx.event(background=True) async def delete_user(self): - async with rx.asession() as asession: + async with AsyncSession() as session: stmt = select(User).where(User.username == self.username) - result = await asession.execute(stmt) - user = result.first() - if user: - await asession.delete(user) - await asession.commit() + user = await session.scalar(stmt) + if user is not None: + await session.delete(user) + await session.commit() ``` -### Async Refresh +### Async Insert and Store -Similar to the regular session, you can refresh an object to ensure all fields are up to date: +As with the sync session, the committed object keeps its loaded columns, +including the generated `id`, so it can be stored in state: ```python class AsyncAddUserForm(rx.State): @@ -511,12 +548,12 @@ class AsyncAddUserForm(rx.State): @rx.event(background=True) async def add_user(self, form_data: dict[str, str]): - async with rx.asession() as asession: - async with self: - self.user = User(**form_data) - asession.add(self.user) - await asession.commit() - await asession.refresh(self.user) + async with AsyncSession() as session: + user = User(**form_data) + session.add(user) + await session.commit() + async with self: + self.user = user ``` ### Async SQL Execution @@ -524,34 +561,38 @@ class AsyncAddUserForm(rx.State): You can also execute raw SQL asynchronously: ```python +import sqlalchemy + + class AsyncRawSQL(rx.State): users: list[list] = [] @rx.event(background=True) - async def insert_user_raw(self, username, email): - async with rx.asession() as asession: - await asession.execute( + async def insert_user_raw(self, username: str, email: str): + async with AsyncSession() as session: + await session.execute( sqlalchemy.text( "INSERT INTO user (username, email) VALUES (:username, :email)" ), - dict(username=username, email=email), + {"username": username, "email": email}, ) - await asession.commit() + await session.commit() @rx.event(background=True) async def get_raw_users(self): - async with rx.asession() as asession: - result = await asession.execute("SELECT * FROM user") - async with self: - self.users = [list(row) for row in result.all()] + async with AsyncSession() as session: + result = await session.execute(sqlalchemy.text("SELECT * FROM user")) + rows = [list(row) for row in result.all()] + async with self: + self.users = rows ``` ```md alert info # Important Notes for Async Database Operations - Always use the `@rx.event(background=True)` decorator for async event handlers -- Most operations against the `asession` must be awaited, including `commit()`, `execute()`, `refresh()`, and `delete()` +- Most operations against the async session must be awaited, including `commit()`, `execute()`, `scalars()`, `scalar()`, and `delete()` - The `add()` method does not need to be awaited - Result objects from queries have methods like `all()` and `first()` that are synchronous and return data directly -- Use `async with self:` when updating state variables in background tasks +- Use `async with self:` when updating state variables in background tasks, and keep the database session outside that block so the state lock is held only while assigning ``` diff --git a/docs/database/relationships.md b/docs/database/relationships.md index 89758883213..08b54ef00e6 100644 --- a/docs/database/relationships.md +++ b/docs/database/relationships.md @@ -6,62 +6,107 @@ referencing a `User` model. This would allow us to automatically query the `Post associated with a user, or find the `User` object associated with a `Post`. To establish bidirectional relationships a model must correctly set the -`back_populates` keyword argument on the `Relationship` to the relationship +`back_populates` keyword argument on the `relationship()` to the relationship attribute in the _other_ model. -```md alert warning -# `rx.Model` is deprecated +## Foreign Key Relationships -`rx.Model` is deprecated as of Reflex 0.9.2 and is scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). -``` +To create a relationship, first add a column that references the primary key of +the related table with `ForeignKey`, then add a `relationship()` attribute which +can be used to access the related objects. -## Foreign Key Relationships +```python +import reflex as rx +from sqlalchemy import ForeignKey +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + MappedAsDataclass, + mapped_column, + relationship, +) -To create a relationship, first add a field to the model that references the -primary key of the related table, then add a `sqlmodel.Relationship` attribute -which can be used to access the related objects. -Defining relationships like this requires the use of `sqlmodel` objects as -seen in the example. +class Base(MappedAsDataclass, DeclarativeBase): + """Base class for the app's tables.""" -```python -from typing import List, Optional -import sqlmodel +class Post(Base): + __tablename__ = "post" -import reflex as rx + id: Mapped[int] = mapped_column(init=False, primary_key=True) + title: Mapped[str] + body: Mapped[str] + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + user: Mapped["User"] = relationship(back_populates="posts", init=False, repr=False) + flags: Mapped[list["Flag"]] = relationship( + back_populates="post", default_factory=list, repr=False + ) -class Post(rx.Model, table=True): - title: str - body: str - user_id: int = sqlmodel.Field(foreign_key="user.id") - user: Optional["User"] = sqlmodel.Relationship(back_populates="posts") - flags: Optional[List["Flag"]] = sqlmodel.Relationship(back_populates="post") +class User(Base): + __tablename__ = "user" + id: Mapped[int] = mapped_column(init=False, primary_key=True) + username: Mapped[str] + email: Mapped[str] -class User(rx.Model, table=True): - username: str - email: str + posts: Mapped[list[Post]] = relationship( + back_populates="user", default_factory=list, repr=False + ) + flags: Mapped[list["Flag"]] = relationship( + back_populates="user", default_factory=list, repr=False + ) - posts: List[Post] = sqlmodel.Relationship(back_populates="user") - flags: List["Flag"] = sqlmodel.Relationship(back_populates="user") +class Flag(Base): + __tablename__ = "flag" -class Flag(rx.Model, table=True): - post_id: int = sqlmodel.Field(foreign_key="post.id", index=True) - user_id: int = sqlmodel.Field(foreign_key="user.id") - message: str + id: Mapped[int] = mapped_column(init=False, primary_key=True) + post_id: Mapped[int] = mapped_column(ForeignKey("post.id"), index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + message: Mapped[str] - post: Optional[Post] = sqlmodel.Relationship(back_populates="flags") - user: Optional[User] = sqlmodel.Relationship(back_populates="flags") + post: Mapped[Post] = relationship(back_populates="flags", init=False, repr=False) + user: Mapped[User] = relationship(back_populates="flags", init=False, repr=False) ``` -See the [SQLModel Relationship Docs](https://sqlmodel.tiangolo.com/tutorial/relationship-attributes/define-relationships-attributes/) for more details. +Relationships are declared with `repr=False` because the dataclass `repr` +would otherwise follow every link, and with `init=False` or a +`default_factory` so the constructor takes the foreign key column instead. + +See the [SQLAlchemy Relationship Docs](https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html) +for more details. + +## Serializing Linked Objects + +Reflex serializes a dataclass-mapped model field by field, and reading a +relationship that was not loaded on an object outside its session raises +`DetachedInstanceError`. Register a serializer for the base class that sends +only the attributes a query loaded, so linked objects render when they were +eager-loaded and are simply absent when they were not: + +```python +from typing import Any + +import sqlalchemy + + +@rx.serializer +def serialize_model(model: Base) -> dict[str, Any]: + """Send a model's loaded columns and relationships to the frontend.""" + state = sqlalchemy.inspect(model) + return { + attr.key: attr.value for attr in state.attrs if attr.key not in state.unloaded + } +``` ## Querying Relationships +The examples below open sessions with the `Session` factory from the +[database overview](/docs/database/overview/#connecting). + ### Inserting Linked Objects The following example assumes that the flagging user is stored in the state as a @@ -74,9 +119,12 @@ class FlagPostForm(rx.State): @rx.event def flag_post(self, form_data: dict[str, Any]): - with rx.session() as session: - post = session.get(Post, int(form_data.pop("post_id"))) - flag = Flag(message=form_data.pop("message"), post=post, user=self.user) + with Session() as session: + flag = Flag( + message=form_data.pop("message"), + post_id=int(form_data.pop("post_id")), + user_id=self.user.id, + ) session.add(flag) session.commit() ``` @@ -91,7 +139,7 @@ inefficient when accessing many linked objects for serialization purposes. There are several alternative loading mechanisms available that can be set on the relationship object or when performing the query. -- "joined" or `joinload` - generates a single query to load all related objects +- "joined" or `joinedload` - generates a single query to load all related objects at once. - "subquery" or `subqueryload` - generates a single query to load all related objects at once, but uses a subquery to do the join, instead of a join in the @@ -106,7 +154,7 @@ specifically avoid loading a relationship. Each loading method comes with tradeoffs and some are better suited for different data access patterns. -See [SQLAlchemy: Relationship Loading Techniques](https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html) +See [SQLAlchemy: Relationship Loading Techniques](https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html) for more detail. ### Querying Linked Objects @@ -117,26 +165,27 @@ relationships. Using this method, the linked objects will be available for rendering in frontend code without additional steps. ```python -import sqlalchemy +from sqlalchemy import select +from sqlalchemy.orm import selectinload class PostState(rx.State): - posts: List[Post] + posts: list[Post] = [] @rx.event def load_posts(self): - with rx.session() as session: - self.posts = session.exec( - Post.select.options( - sqlalchemy.orm.selectinload(Post.user), - sqlalchemy.orm.selectinload(Post.flags).options( - sqlalchemy.orm.selectinload(Flag.user), - ), - ).limit(15) + with Session() as session: + self.posts = session.scalars( + select(Post) + .options( + selectinload(Post.user), + selectinload(Post.flags).selectinload(Flag.user), + ) + .limit(15) ).all() ``` -The loading methods create new query objects and thus may be linked if the +The loading methods create new query objects and thus may be chained if the relationship itself has other relationships that need to be loaded. In this example, since `Flag` references `User`, the `Flag.user` relationship must be chain loaded from the `Post.flags` relationship. @@ -144,26 +193,24 @@ chain loaded from the `Post.flags` relationship. ### Specifying the Loading Mechanism on the Relationship Alternatively, the loading mechanism can be specified on the relationship by -passing `sa_relationship_kwargs={"lazy": method}` to `sqlmodel.Relationship`, -which will use the given loading mechanism in all queries by default. +passing `lazy=method` to `relationship()`, which will use the given loading +mechanism in all queries by default. ```python -from typing import List, Optional - -import sqlmodel - -import reflex as rx - - -class Post(rx.Model, table=True): +class Post(Base): + __tablename__ = "post" ... - user: Optional["User"] = sqlmodel.Relationship( + user: Mapped["User"] = relationship( back_populates="posts", - sa_relationship_kwargs={"lazy": "selectin"}, + lazy="selectin", + init=False, + repr=False, ) - flags: Optional[List["Flag"]] = sqlmodel.Relationship( + flags: Mapped[list["Flag"]] = relationship( back_populates="post", - sa_relationship_kwargs={"lazy": "selectin"}, + lazy="selectin", + default_factory=list, + repr=False, ) ``` @@ -175,27 +222,25 @@ relationship up front. Store the selected id in state and query only the related rows for that selection, filtering on the foreign key. ```python -from typing import List, Optional - import reflex as rx -from sqlmodel import select +from sqlalchemy import select class PostFlagsState(rx.State): - posts: List[Post] = [] - selected_post_id: Optional[int] = None - selected_flags: List[Flag] = [] + posts: list[Post] = [] + selected_post_id: int | None = None + selected_flags: list[Flag] = [] @rx.event def load_posts(self): - with rx.session() as session: - self.posts = session.exec(select(Post).limit(15)).all() + with Session() as session: + self.posts = session.scalars(select(Post).limit(15)).all() @rx.event def select_post(self, post_id: int): self.selected_post_id = post_id - with rx.session() as session: - self.selected_flags = session.exec( + with Session() as session: + self.selected_flags = session.scalars( select(Flag).where(Flag.post_id == post_id) ).all() ``` @@ -210,6 +255,6 @@ eager loading options above. ```python post_ids = [post.id for post in self.posts] -with rx.session() as session: - flags = session.exec(select(Flag).where(Flag.post_id.in_(post_ids))).all() +with Session() as session: + flags = session.scalars(select(Flag).where(Flag.post_id.in_(post_ids))).all() ``` diff --git a/docs/database/tables.md b/docs/database/tables.md index 3626cbf89fc..edc0034da98 100644 --- a/docs/database/tables.md +++ b/docs/database/tables.md @@ -6,38 +6,123 @@ In tables, data is logically organized in a row-and-column format similar to a spreadsheet. Each row represents a unique record, and each column represents a field in the record. -```md alert warning -# `rx.Model` is deprecated +## Creating a Table -`rx.Model` is deprecated as of Reflex 0.9.2 and is scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). -``` +Tables are [SQLAlchemy](https://docs.sqlalchemy.org/en/20/orm/quickstart.html) +models. Define one base class for the app and make each table a subclass of it. -## Creating a Table +```python +import reflex as rx +from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column -To create a table, make a class that inherits from `rx.Model`. -The following example shows how to create a table called `User`. +class Base(MappedAsDataclass, DeclarativeBase): + """Base class for the app's tables.""" -```python -class User(rx.Model, table=True): - username: str - email: str + +rx.ModelRegistry.register(Base) + + +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + username: Mapped[str] + email: Mapped[str] ``` -The `table=True` argument tells Reflex to create a table in the database for -this class. +Each `Mapped[...]` annotation becomes a column whose SQL type is derived from the +Python type. `MappedAsDataclass` makes the model a dataclass, so `User(username="test", email="test@example.com")` +constructs a row and Reflex can store `User` instances in state and render their +fields in components. `rx.ModelRegistry.register(Base)` lets the `reflex db` +[migration commands](/docs/database/overview/#migrations) find the tables. + +Dataclass fields without a default must come before fields with one, so declare +required columns first and optional columns, with `default=None` or +`default_factory`, after them. ### Primary Key -By default, Reflex will create a primary key column called `id` for each table. +Every table needs a primary key. Declare it with `primary_key=True`; `init=False` +excludes it from the constructor so the database assigns it on insert. -However, if an `rx.Model` defines a different field with `primary_key=True`, then the -default `id` field will not be created. A table may also redefine `id` as needed. +```python +class User(Base): + __tablename__ = "user" -It is not currently possible to create a table without a primary key. + id: Mapped[int] = mapped_column(init=False, primary_key=True) + ... +``` ## Advanced Column Types +### Nullable and Optional Columns + +A column annotated with `| None` is nullable. Give it `default=None` so it can +be left out when constructing the row. + +```python +from datetime import datetime + + +class Post(Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + title: Mapped[str] + body: Mapped[str] + published_at: Mapped[datetime | None] = mapped_column(default=None) +``` + +### Explicit column types + +SQLAlchemy maps basic Python types to column types, but for more advanced use +cases, pass the column type to `mapped_column` directly. For example, we can add +a last updated timestamp to the post example as a `DateTime` with timezone that +the database fills in. + +```python +import datetime + +import sqlalchemy + + +class Post(Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + ... + update_ts: Mapped[datetime.datetime] = mapped_column( + sqlalchemy.DateTime(timezone=True), + init=False, + server_default=sqlalchemy.func.now(), + ) +``` + +Reflex serializes `datetime` values to ISO strings for the frontend. To change +how a model is sent to the frontend, register a serializer for it. This one +strips the microseconds from `update_ts`: + +```python +import dataclasses + +import reflex as rx + + +@rx.serializer +def serialize_post(post: Post) -> dict: + data = dataclasses.asdict(post) + data["update_ts"] = post.update_ts.replace(microsecond=0).isoformat() + return data +``` + +## Existing `rx.Model` apps + +`rx.Model` is deprecated as of Reflex 0.9.2 and is scheduled for removal in +1.0.0. See [Migrating existing apps](/docs/database/overview/#migrating-existing-apps) +for the equivalent SQLAlchemy declarations. The note below applies to apps that +still use `rx.Model` or SQLModel directly. + ### Datetimes and SQLModel upgrades From SQLModel 0.0.45, a plain `datetime` field uses `UTCDateTime`: @@ -82,43 +167,3 @@ convert existing database values: determine the timezone of stored data and apply a database-specific migration before adopting UTC storage. Follow [SQLModel's datetime upgrade guide](https://sqlmodel.tiangolo.com/advanced/datetime/#upgrade-existing-applications) for the PostgreSQL, SQLite, and MySQL migration details. - -### Explicit column types - -SQLModel automatically maps basic python types to SQLAlchemy column types, but -for more advanced use cases, it is possible to define the column type using -`sqlalchemy` directly. For example, we can add a last updated timestamp to the -post example as a proper `DateTime` field with timezone. - -```python -import datetime - -import sqlmodel -import sqlalchemy - - -class Post(rx.Model, table=True): - ... - update_ts: datetime.datetime = sqlmodel.Field( - default=None, - sa_column=sqlalchemy.Column( - "update_ts", - sqlalchemy.DateTime(timezone=True), - server_default=sqlalchemy.func.now(), - ), - ) -``` - -To make the `Post` model more usable on the frontend, a `dict` method may be provided -that converts any fields to a JSON serializable value. In this case, the dict method is -overriding the default `datetime` serializer to strip off the microsecond part. - -```python -class Post(rx.Model, table=True): - ... - - def dict(self, *args, **kwargs) -> dict: - d = super().dict(*args, **kwargs) - d["update_ts"] = self.update_ts.replace(microsecond=0).isoformat() - return d -``` diff --git a/docs/library/tables-and-data-grids/table.md b/docs/library/tables-and-data-grids/table.md index cf84803d8f1..27939e9e1b6 100644 --- a/docs/library/tables-and-data-grids/table.md +++ b/docs/library/tables-and-data-grids/table.md @@ -139,13 +139,21 @@ TableRowHeaderCell: | ```python exec import reflex as rx +from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column -class Customer(rx.Model, table=True): - name: str - email: str - phone: str - address: str +class Base(MappedAsDataclass, DeclarativeBase): + """Base class for the app's tables.""" + + +class Customer(Base): + __tablename__ = "customer" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + name: Mapped[str] + email: Mapped[str] + phone: Mapped[str] + address: Mapped[str] ``` # Table @@ -472,28 +480,32 @@ The more common use case for building an `rx.table` is to use data from a databa The code below shows how to load data from a database and place it in an `rx.table`. -```md alert warning -# `rx.Model` is deprecated - -`rx.Model` is deprecated as of Reflex 0.9.2 and is scheduled for removal in 1.0.0. The examples below document the existing Reflex ORM interface. For new applications, use SQLModel or SQLAlchemy directly with your own engine and sessions; see the [database overview](/docs/database/overview/). -``` - ## Loading data into table -A `Customer` [model](/docs/database/tables/) is defined that inherits from `rx.Model`. +A `Customer` [model](/docs/database/tables/) is defined as a SQLAlchemy table, and the examples open sessions with the `Session` factory from the [database overview](/docs/database/overview/#connecting). The `load_entries` event handler executes a [query](/docs/database/queries/) that is used to request information from a database table. This `load_entries` event handler is called on the `on_mount` event trigger of the `rx.table.root`. If you want to load the data when the page in the app loads you can set `on_load` in `app.add_page()` to equal this event handler, like `app.add_page(page_name, on_load=State.load_entries)`. ```python -class Customer(rx.Model, table=True): +from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column + + +class Base(MappedAsDataclass, DeclarativeBase): + """Base class for the app's tables.""" + + +class Customer(Base): """The customer model.""" - name: str - email: str - phone: str - address: str + __tablename__ = "customer" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + name: Mapped[str] + email: Mapped[str] + phone: Mapped[str] + address: Mapped[str] ``` ```python exec @@ -563,7 +575,7 @@ loading_data_table_example() ``` ```python -from sqlmodel import select +from sqlalchemy import select class DatabaseTableState(rx.State): @@ -572,8 +584,8 @@ class DatabaseTableState(rx.State): @rx.event def load_entries(self): """Get all users from the database.""" - with rx.session() as session: - self.users = session.exec(select(Customer)).all() + with Session() as session: + self.users = session.scalars(select(Customer)).all() def show_customer(user: Customer): @@ -620,13 +632,23 @@ The `%` character before and after `search_value` makes it a wildcard pattern th ``` ```python -class Customer(rx.Model, table=True): +from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column + + +class Base(MappedAsDataclass, DeclarativeBase): + """Base class for the app's tables.""" + + +class Customer(Base): """The customer model.""" - name: str - email: str - phone: str - address: str + __tablename__ = "customer" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + name: Mapped[str] + email: Mapped[str] + phone: Mapped[str] + address: Mapped[str] ``` ```python exec @@ -749,7 +771,7 @@ loading_data_table_example_2() ``` ```python -from sqlmodel import select, asc, or_ +from sqlalchemy import asc, or_, select class DatabaseTableState2(rx.State): @@ -761,7 +783,7 @@ class DatabaseTableState2(rx.State): @rx.event def load_entries(self): """Get all users from the database.""" - with rx.session() as session: + with Session() as session: query = select(Customer) if self.search_value != "": @@ -780,7 +802,7 @@ class DatabaseTableState2(rx.State): order = asc(sort_column) query = query.order_by(order) - self.users = session.exec(query).all() + self.users = session.scalars(query).all() @rx.event def sort_values(self, sort_value): @@ -911,7 +933,7 @@ class DatabaseTableState3(rx.State): self.load_entries() def _get_total_items(self, session): - self.total_items = session.exec(select(func.count(Customer.id))).one() + self.total_items = session.scalar(select(func.count(Customer.id))) @rx.event def load_entries(self): @@ -966,7 +988,7 @@ loading_data_table_example3() ``` ```python -from sqlmodel import select, func +from sqlalchemy import func, select class DatabaseTableState3(rx.State): @@ -999,18 +1021,18 @@ class DatabaseTableState3(rx.State): def _get_total_items(self, session): """Return the total number of items in the Customer table.""" - self.total_items = session.exec(select(func.count(Customer.id))).one() + self.total_items = session.scalar(select(func.count(Customer.id))) @rx.event def load_entries(self): """Get all users from the database.""" - with rx.session() as session: + with Session() as session: query = select(Customer) # Apply pagination query = query.offset(self.offset).limit(self.limit) - self.users = session.exec(query).all() + self.users = session.scalars(query).all() self._get_total_items(session) @@ -1078,8 +1100,9 @@ For the `json` download the `rx.download` is in the frontend code attached to th For the `csv` download the `rx.download` is in the backend code as an event_handler `download_csv_data`. There is also a helper function `_convert_to_csv` that converts the data in `self.users` to `csv` format. ```python exec -import io import csv +import dataclasses +import io import json @@ -1119,14 +1142,14 @@ class TableDownloadState(rx.State): writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() for user in self.users: - writer.writerow(user.dict()) + writer.writerow(dataclasses.asdict(user)) csv_data = output.getvalue() output.close() return csv_data def _convert_to_json(self) -> str: - return json.dumps([u.dict() for u in self._mock_data], indent=2) + return json.dumps([dataclasses.asdict(u) for u in self._mock_data], indent=2) @rx.event def download_csv_data(self): @@ -1191,9 +1214,11 @@ download_data_table_example() ``` ```python -import io import csv -from sqlmodel import select +import dataclasses +import io + +from sqlalchemy import select class TableDownloadState(rx.State): @@ -1202,8 +1227,8 @@ class TableDownloadState(rx.State): @rx.event def load_entries(self): """Get all users from the database.""" - with rx.session() as session: - self.users = session.exec(select(Customer)).all() + with Session() as session: + self.users = session.scalars(select(Customer)).all() def _convert_to_csv(self) -> str: """Convert the users data to CSV format.""" @@ -1213,14 +1238,14 @@ class TableDownloadState(rx.State): self.load_entries() # Define the CSV file header based on the Customer model's attributes - fieldnames = list(Customer.__fields__) + fieldnames = [field.name for field in dataclasses.fields(Customer)] # Create a string buffer to hold the CSV data output = io.StringIO() writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() for user in self.users: - writer.writerow(user.dict()) + writer.writerow(dataclasses.asdict(user)) # Get the CSV data as a string csv_data = output.getvalue() From 17b7b32d7bf35c42c08073dcdab011b22e29a526 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 27 Oct 2026 22:37:18 +0000 Subject: [PATCH 4/5] Register the example bases so reflex db sees their tables The queries, relationships and table pages define their own Base for self-contained examples; register each one the same way the overview and tables pages do, so copying any page gives migrations that create its tables. Claude-Session: https://claude.ai/code/session_01LwJUCUDZhZY71q8Azr37BQ --- docs/database/queries.md | 3 +++ docs/database/relationships.md | 3 +++ docs/library/tables-and-data-grids/table.md | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/docs/database/queries.md b/docs/database/queries.md index def528614bb..8caf2a03514 100644 --- a/docs/database/queries.md +++ b/docs/database/queries.md @@ -36,6 +36,9 @@ class Base(MappedAsDataclass, DeclarativeBase): """Base class for the app's tables.""" +rx.ModelRegistry.register(Base) + + class User(Base): __tablename__ = "user" diff --git a/docs/database/relationships.md b/docs/database/relationships.md index 08b54ef00e6..a51c558cc0d 100644 --- a/docs/database/relationships.md +++ b/docs/database/relationships.md @@ -31,6 +31,9 @@ class Base(MappedAsDataclass, DeclarativeBase): """Base class for the app's tables.""" +rx.ModelRegistry.register(Base) + + class Post(Base): __tablename__ = "post" diff --git a/docs/library/tables-and-data-grids/table.md b/docs/library/tables-and-data-grids/table.md index 27939e9e1b6..91a193524f9 100644 --- a/docs/library/tables-and-data-grids/table.md +++ b/docs/library/tables-and-data-grids/table.md @@ -496,6 +496,9 @@ class Base(MappedAsDataclass, DeclarativeBase): """Base class for the app's tables.""" +rx.ModelRegistry.register(Base) + + class Customer(Base): """The customer model.""" @@ -639,6 +642,9 @@ class Base(MappedAsDataclass, DeclarativeBase): """Base class for the app's tables.""" +rx.ModelRegistry.register(Base) + + class Customer(Base): """The customer model.""" From 2551222ca272b62917ed21b0703d82eef1fa7df1 Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 27 Oct 2026 22:41:31 +0000 Subject: [PATCH 5/5] Address review on the database docs rewrite Describe session.scalar() as returning the first column of the first row, guard the serializer example against a row that has not been inserted yet, and keep the docs example test from leaking serializers into the process-wide registry. Claude-Session: https://claude.ai/code/session_01LwJUCUDZhZY71q8Azr37BQ --- docs/app/tests/test_database_examples.py | 22 +++++++++++++++++++--- docs/database/queries.md | 4 ++-- docs/database/tables.md | 6 ++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/app/tests/test_database_examples.py b/docs/app/tests/test_database_examples.py index 6fb865306a4..84ee684293f 100644 --- a/docs/app/tests/test_database_examples.py +++ b/docs/app/tests/test_database_examples.py @@ -12,6 +12,7 @@ import pytest import reflex as rx from reflex_base.registry import RegistrationContext +from reflex_base.utils import serializers from reflex_base.utils.serializers import serialize pytest.importorskip("sqlalchemy") @@ -28,14 +29,28 @@ @pytest.fixture(autouse=True) def isolated_registration(tmp_path, monkeypatch): - """Point the docs' config-driven engine at a throwaway database.""" + """Point the docs' config-driven engine at a throwaway database. + + The snippets register models and serializers in process-wide registries, + so each test gets a copy of those and the serializer caches are cleared + on both sides. + """ monkeypatch.setattr(rx.ModelRegistry, "models", set()) monkeypatch.setattr(rx.ModelRegistry, "_metadata", None) + monkeypatch.setattr(serializers, "SERIALIZERS", dict(serializers.SERIALIZERS)) + monkeypatch.setattr( + serializers, "SERIALIZER_TYPES", dict(serializers.SERIALIZER_TYPES) + ) config = rx.Config( app_name="database_docs_test", db_url=f"sqlite:///{tmp_path / 'docs.db'}" ) - with RegistrationContext(_config=config): - yield + try: + with RegistrationContext(_config=config): + yield + finally: + serializers.get_serializer.cache_clear() + serializers.get_serializer_type.cache_clear() + serializers._dataclass_serializer.cache_clear() def block(page, marker): @@ -131,6 +146,7 @@ def test_tables_custom_serializer(monkeypatch): "id": 1, "update_ts": module.post.update_ts.replace(microsecond=0).isoformat(), } + assert serialize(module.Post()) == {"id": None, "update_ts": None} @pytest.fixture diff --git a/docs/database/queries.md b/docs/database/queries.md index 8caf2a03514..f2690330f51 100644 --- a/docs/database/queries.md +++ b/docs/database/queries.md @@ -109,8 +109,8 @@ class ChangeEmail(rx.State): session.commit() ``` -`session.scalar()` returns the first row of the statement, or `None` when -nothing matches. +`session.scalar()` returns the first column of the first row of the statement, +here the `User` itself, or `None` when nothing matches. ### Delete diff --git a/docs/database/tables.md b/docs/database/tables.md index edc0034da98..25e5e108295 100644 --- a/docs/database/tables.md +++ b/docs/database/tables.md @@ -101,7 +101,8 @@ class Post(Base): Reflex serializes `datetime` values to ISO strings for the frontend. To change how a model is sent to the frontend, register a serializer for it. This one -strips the microseconds from `update_ts`: +strips the microseconds from `update_ts`, which is `None` until the row has been +inserted: ```python import dataclasses @@ -112,7 +113,8 @@ import reflex as rx @rx.serializer def serialize_post(post: Post) -> dict: data = dataclasses.asdict(post) - data["update_ts"] = post.update_ts.replace(microsecond=0).isoformat() + if post.update_ts is not None: + data["update_ts"] = post.update_ts.replace(microsecond=0).isoformat() return data ```