Repository navigation
Rewrite the database docs around SQLAlchemy models #7679
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
FarhanAliRaza
wants to merge
6
commits into
main
Choose a base branch
from
ccr-921eda66-kjt8mt-model-deprecation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+953
−370
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
33a0557
Note the rx.Model deprecation on every page that still uses it
FarhanAliRaza ec95873
Limit the deprecation notice to rx.Model
FarhanAliRaza fd8bc39
Merge remote-tracking branch 'origin/main' into ccr-921eda66-kjt8mt-m…
FarhanAliRaza 70977dc
Rewrite the database docs around SQLAlchemy models
FarhanAliRaza 17b7b32
Register the example bases so reflex db sees their tables
FarhanAliRaza 2551222
Address review on the database docs rewrite
FarhanAliRaza File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,352 @@ | ||
| """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 import serializers | ||
| 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. | ||
|
|
||
| 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'}" | ||
| ) | ||
| 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): | ||
| """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(), | ||
| } | ||
| assert serialize(module.Post()) == {"id": None, "update_ts": None} | ||
|
|
||
|
|
||
| @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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.