diff --git a/docs/database/queries.md b/docs/database/queries.md index 31a96e4eb91..e371ecac86c 100644 --- a/docs/database/queries.md +++ b/docs/database/queries.md @@ -190,6 +190,178 @@ class State(rx.State): return [list(row) for row in session.execute("SELECT * FROM user").all()] ``` +## Writing Efficient Queries + +The database is much better at filtering, joining, and aggregating than Python. +Every row returned by a query is sent over the network and held in memory as +part of the state — for every user with an open session. Do the work in the +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 +``` + +### Aggregate in SQL, not Python + +Avoid fetching a whole table just to compute a summary in Python. Use the +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 + + +class OrderStats(rx.State): + order_count: int = 0 + orders_by_status: list[tuple[str, int]] = [] + + @rx.event + def load_stats(self): + with rx.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() + + # One row per group, ready for a chart or summary table. + self.orders_by_status = [ + tuple(row) + for row in session.exec( + select(Order.status, func.count(Order.id)).group_by(Order.status) + ).all() + ] +``` + +Related metrics can be computed in a single query with conditional +aggregation, instead of issuing one query per metric: + +```python +import sqlalchemy +from sqlmodel import select, func + + +class OrderKPIs(rx.State): + total_orders: int = 0 + completed_orders: int = 0 + total_amount: float = 0.0 + + @rx.event + def load_kpis(self): + with rx.session() as session: + total, completed, amount = session.exec( + select( + func.count(Order.id), + func.sum( + sqlalchemy.case((Order.status == "completed", 1), else_=0) + ), + func.coalesce(func.sum(Order.amount), 0.0), + ) + ).one() + self.total_orders = total + self.completed_orders = completed or 0 + self.total_amount = float(amount) +``` + +### Avoid Queries Inside Loops + +Issuing a query per item — the "N+1" pattern — multiplies network round trips. +Fetch everything in one statement with a `JOIN`, an `IN (...)` list, or +`GROUP BY`. + +```python +# Inefficient: one query per user (N+1). +with rx.session() as session: + for user in users: + orders = session.exec(Order.select().where(Order.user_id == user.id)).all() + +# Efficient: one query for all users at once. +with rx.session() as session: + orders = session.exec( + Order.select().where(Order.user_id.in_([user.id for user in users])) + ).all() +``` + +The `.in_()` method binds each value as a separate query parameter, so it is +safe to use with user-provided values. In raw SQL, use an *expanding* bind +parameter for the same effect — never join the values into the SQL string: + +```python +import sqlalchemy + +with rx.session() as session: + rows = session.execute( + sqlalchemy.text("SELECT * FROM users WHERE id IN :user_ids").bindparams( + sqlalchemy.bindparam("user_ids", expanding=True) + ), + {"user_ids": [1, 2, 3]}, + ).all() +``` + +If the related objects are linked with foreign keys, the +[relationship loading techniques](/docs/database/relationships) can also fetch +linked objects without extra queries. + +### Return Only What the UI Needs + +- Select only the columns the UI displays when tables are wide, instead of + whole rows. +- Give every query that returns detail rows a `.limit()`, and use + offset-based [pagination](/docs/library/tables-and-data-grids/table) when + the user needs more rows. +- Load static data (dropdown options, date bounds) once in an `on_load` event + handler. When a filter changes, re-run only the filtered queries — not the + option lookups. +- Avoid caching large raw query results in state vars to work around a slow + query: per-user state duplicates that data in server memory for every + session. Fix the query instead — aggregate in SQL, add a limit, combine + round trips. Keeping small, display-ready results in state (chart rows, KPI + values, one page of a table) is the intended pattern. + +## Handling NULL Values + +Database columns may contain `NULL`, which arrives in Python as `None`. +Aggregates like `SUM` and `AVG` also return `NULL` when they run over zero +rows. Casting such results directly will crash: + +```python +float(row[0]) # TypeError: float() argument must be ... not 'NoneType' +``` + +There are two ways to handle this. + +### Coalesce in the Query (Preferred) + +`COALESCE` returns its first non-`NULL` argument, so the query itself +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 + +with rx.session() as session: + total = session.exec(select(func.coalesce(func.sum(Order.amount), 0.0))).one() +``` + +The same works in raw SQL: `SELECT COALESCE(SUM(amount), 0) FROM ...`. + +### Guard in Python + +When consuming rows that may contain `NULL`s, fall back explicitly while +building the values: + +```python +orders = [ + { + "status": str(row[0] or ""), + "amount": float(row[1]) if row[1] is not None else 0.0, + } + for row in rows +] +``` + ## 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. diff --git a/docs/database/relationships.md b/docs/database/relationships.md index baf690de3e1..1061e203463 100644 --- a/docs/database/relationships.md +++ b/docs/database/relationships.md @@ -44,7 +44,7 @@ class User(rx.Model, table=True): class Flag(rx.Model, table=True): - post_id: int = sqlmodel.Field(foreign_key="post.id") + post_id: int = sqlmodel.Field(foreign_key="post.id", index=True) user_id: int = sqlmodel.Field(foreign_key="user.id") message: str @@ -160,3 +160,49 @@ class Post(rx.Model, table=True): sa_relationship_kwargs={"lazy": "selectin"}, ) ``` + +### Querying Related Rows on Demand + +For master-detail interfaces — a table of parent rows where selecting a row +reveals its related rows — it is not necessary to eager load every +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 + + +class PostFlagsState(rx.State): + posts: List[Post] = [] + selected_post_id: Optional[int] = None + selected_flags: List[Flag] = [] + + @rx.event + def load_posts(self): + with rx.session() as session: + self.posts = session.exec(Post.select().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( + Flag.select().where(Flag.post_id == post_id) + ).all() +``` + +This runs a single indexed query per selection and avoids holding every child +row in state — the `index=True` on `Flag.post_id` keeps the foreign-key lookup +off a full table scan (adding the index to an existing table requires a +migration). Avoid the inverse pattern of querying children in a Python loop +over parents (the "N+1" pattern) — when the children of many parents are +needed at once, use one query with `.in_()` on the foreign key, or one of the +eager loading options above. + +```python +post_ids = [post.id for post in self.posts] +with rx.session() as session: + flags = session.exec(Flag.select().where(Flag.post_id.in_(post_ids))).all() +``` diff --git a/docs/library/tables-and-data-grids/table.md b/docs/library/tables-and-data-grids/table.md index 45d3a40dd7b..14b13c7b8bb 100644 --- a/docs/library/tables-and-data-grids/table.md +++ b/docs/library/tables-and-data-grids/table.md @@ -188,6 +188,7 @@ rx.table.root( ```md alert info # Set the table `width` to fit within its container and prevent it from overflowing. +If the table has too many columns to fit, wrap the `rx.table.root` in a container with `overflow_x="auto"` (e.g. `rx.box(rx.table.root(...), overflow_x="auto", width="100%")`) so the table scrolls horizontally inside the container instead of stretching the page. ``` ## Showing State data (using foreach) @@ -342,7 +343,7 @@ def database_table_example(): ), rx.input( placeholder="Search here...", - on_change=lambda value: DatabaseTableState.filter_values(value), + on_change=DatabaseTableState.filter_values.debounce(500), ), rx.table.root( rx.table.header( @@ -607,6 +608,11 @@ For filtering the `rx.input` component is used. The data is filtered based on th The `%` character before and after `search_value` makes it a wildcard pattern that matches any sequence of characters before or after the `search_value`. `query.where(...)` modifies the existing query to include a filtering condition. The `or_` operator is a logical OR operator that combines multiple conditions. The query will return results that match any of these conditions. `Customer.name.ilike(search_value)` checks if the `name` column of the `Customer` table matches the `search_value` pattern in a case-insensitive manner (`ilike` stands for "case-insensitive like"). +```md alert info +# Debounce the search input +`on_change` fires on every keystroke, and each event here runs a database query. Chaining `.debounce(500)` on the event handler waits until the user pauses typing for 500ms, so one query runs per search instead of one per character. See [event actions](/docs/events/event-actions) for details. +``` + ```python class Customer(rx.Model, table=True): """The customer model.""" @@ -712,7 +718,7 @@ def loading_data_table_example_2(): ), rx.input( placeholder="Search here...", - on_change=lambda value: DatabaseTableState2.filter_values(value), + on_change=DatabaseTableState2.filter_values.debounce(500), ), rx.table.root( rx.table.header( @@ -802,7 +808,7 @@ def loading_data_table_example2(): ), rx.input( placeholder="Search here...", - on_change=lambda value: DatabaseTableState2.filter_values(value), + on_change=DatabaseTableState2.filter_values.debounce(500), ), rx.table.root( rx.table.header( @@ -1044,6 +1050,11 @@ def loading_data_table_example3(): ) ``` +```md alert info +# Combining pagination with search and sorting +Give each paginated table its own state vars (`offset`, `limit`, `total_items`) so multiple tables on a page paginate independently, and reset `offset` to `0` whenever the search or sort value changes — otherwise the user may be left on a page number beyond the end of the newly filtered results. +``` + ## More advanced examples The real power of the `rx.table` comes where you are able to visualise, add and edit data live in your app. Check out these apps and code to see how this is done: app: https://customer-data-app.reflex.run code: https://github.com/reflex-dev/templates/tree/main/customer_data_app and code: https://github.com/reflex-dev/templates/tree/main/sales.