Query Builder¶
Introduction¶
Arvent's query builder provides a fluent interface for building and running database queries. Every query starts from a model and chains synchronous builder methods; an async terminal method runs the query and returns the result.
items = await (
Item.where(is_active=True)
.where(Item.price < 100)
.order_by("-created_at")
.limit(20)
.get()
)
Item.where(...) returns a QueryBuilder. Builder methods like where, order_by, and limit return the builder so you can chain. Terminal methods — get, first, count, paginate — execute against the active database session and must be awaited.
Running Queries¶
Retrieving Rows¶
| Method | Returns |
|---|---|
get() / all() | All matching models (a ModelCollection) |
first() | First model, or None |
first_or_fail() | First model, or raises ModelNotFoundError |
first_where(**kwargs) | First model matching the kwargs |
sole() | Exactly one row (errors if zero or many) |
find(pk) / find_or_fail(pk) | By primary key (respects scopes) |
value("col") | A single column value from the first row |
pluck("col") | A list of one column; pluck("col", "key") → dict |
items = await Item.where(is_active=True).get()
first = await Item.where(is_active=True).first()
names = await Item.query().pluck("name")
Aggregates¶
total = await Item.where(is_active=True).count()
revenue = await Item.query().sum("price") # empty result → 0
top = await Item.query().max("price")
exists = await Item.where(sku="ABC").exists()
none = await Item.where(sku="ABC").doesnt_exist()
Where Clauses¶
The where Method¶
where accepts two forms: keyword equality, or a SQLAlchemy column expression. Use keyword arguments for simple equality:
await Item.where(is_active=True).get()
await Item.where(status="published", featured=True).get() # AND
For comparisons other than equality, pass a SQLAlchemy expression on the model's columns:
Warning
Arvent does not support the three-argument string form where("price", "<", 100) on where. Use a column expression (Item.price < 100) or keyword equality (where(price=100)). The three-argument operator form is supported on having and on the multi-column helpers (where_any, where_all) only.
Or Where Clauses¶
Chain or_where to add an OR branch:
Additional Where Clauses¶
The builder offers a wide range of where variants. Most have an or_* sibling:
| Method | Example |
|---|---|
where_in / where_not_in | where_in("id", [1, 2, 3]) |
where_between / where_not_between | where_between("age", 18, 65) |
where_null / where_not_null | where_null("deleted_at") |
where_like / where_not_like | where_like("name", "%ada%", case_sensitive=False) |
where_date / where_year / where_month / where_day | date-part filters |
where_column | compare two columns |
where_raw | where_raw("price > tax * 10") |
where_exists | correlated EXISTS subquery |
where_json_path / where_json_contains | PostgreSQL JSON filters |
where_full_text | full-text search |
where_any / where_all / where_none | apply an operator across several columns |
has / where_has / doesnt_have / where_relation | filter by related rows |
Logical Grouping¶
To group conditions inside parentheses, pass a closure that returns the builder:
await Item.where(is_active=True).where(
lambda q: q.where(Item.price < 10).or_where(Item.featured == True)
).get()
This produces WHERE is_active AND (price < 10 OR featured).
Ordering, Grouping, Limit & Offset¶
.order_by("created_at") # ascending
.order_by("-created_at") # descending (leading "-")
.order_by_desc("created_at")
.latest() # order_by created_at desc
.oldest()
.in_random_order()
.reorder("name") # clear existing order, then re-apply
.limit(20)
.offset(40)
.group_by("status")
.having("total", ">", 100) # the 3-arg operator form IS valid on having
.distinct()
Note
Arvent uses limit / offset. There is no take / skip on the query builder (those names exist on the Collection wrapper instead).
Selecting & Joining¶
Select specific columns, or run joins with SQLAlchemy expressions:
.select("id", "name")
.add_select("email")
.select_raw("COUNT(*) AS total")
.distinct("status")
# joins take a target model and an ON expression
.join(Post, Post.user_id == User.id)
.left_join(Post, Post.user_id == User.id)
Note
Joins are expressed with SQLAlchemy column expressions, not the string "table.col", "=", "table.col" form. For ergonomic column-to-column joins, join_on(Target, lambda on: ...) is also available.
A select() of specific columns (or select_raw) returns a Collection of dict rows rather than a ModelCollection of models.
Conditional Clauses¶
Build queries from runtime conditions without breaking the chain. when runs the callback when the condition is truthy; unless runs it when falsy:
query = Item.query()
query = query.when(search, lambda q: q.where_like("name", f"%{search}%"))
query = query.unless(include_inactive, lambda q: q.where(is_active=True))
items = await query.get()
Pagination¶
Arvent offers three paginators. The default paginate runs a COUNT so it knows the total and last page:
page = await Item.where(is_active=True).paginate(per_page=15, page=2)
page.items # the rows for this page (a list)
page.total # total matching rows
page.current_page # 2
page.last_page # computed from total / per_page
page.has_more_pages # bool
When handling an HTTP request, the page number is read from the request automatically — paginate(15) is enough.
Paginator Output¶
Serialize a paginator with to_dict() (a {data, meta, links} envelope) or to_response() (a flat Laravel-style envelope). Pass a base_url to get URL links instead of bare page numbers:
The idiomatic way to return a paginated list from an HTTP endpoint is to hand the paginator to a resource collection, which renders the standard envelope for you.
Simple & Cursor Pagination¶
For large datasets, skip the COUNT:
# next/prev only, no total — lighter
page = await Item.query().simple_paginate(per_page=15)
page.has_more
# keyset cursor pagination — best for deep, stable scrolling
page = await Item.query().cursor_paginate(per_page=15, cursor=token)
page.next_cursor
page.prev_cursor
Chunking & Streaming¶
To process large result sets without loading everything into memory:
# iterate one row at a time
async for item in Item.query().lazy():
...
# process in batches; return False from the callback to stop early
await Item.query().chunk(500, process_batch)
# stable when the callback mutates rows that would shift offsets
await Item.query().chunk_by_id(500, process_batch)
Bulk Writes¶
The builder runs set-based writes that bypass per-row model events:
await Item.where(is_active=False).update({"is_active": True})
await Item.query().insert([{...}, {...}])
new_id = await Item.query().insert_get_id({...})
await Item.query().upsert(rows, unique_by=["sku"], update=["price"])
await Item.where(...).increment("views")
await Item.where(...).decrement("stock", 2)
await Item.where(...).delete() # soft delete if SoftDeletes, else hard
Get-or-create helpers cover the common idioms: first_or_create, first_or_new, update_or_create, update_or_insert.
Warning
Bulk writes operate at the SQL level and do not fire model events or run per-row casts. When you need events, load the models and save them individually.
Scopes¶
Local Scopes¶
Local scopes are reusable query fragments. Define a scope_<name> method on the model and call it as Model.<name>():
class Post(Model, Timestamps):
__tablename__ = "posts"
id: int = id_()
def scope_published(self, query):
return query.where(status="published")
def scope_of_author(self, query, author_id: int):
return query.where(user_id=author_id)
published = await Post.published().get()
mine = await Post.published().of_author(7).get()
Global Scopes¶
Global scopes apply to every query for a model — this is exactly how soft deletes hide trashed rows. Register one and opt out per query:
Post.add_global_scope("tenant", lambda q: q.where(tenant_id=current_tenant()))
await Post.query().without_global_scope("tenant").get()
await Post.query().without_global_scopes().get()
Collections¶
Query results come back as a ModelCollection — a list subclass with fluent helpers. Because it's a list, ordinary iteration and indexing work, plus:
items = await Item.where(is_active=True).get()
items.pluck("name")
items.map(lambda i: i.price)
items.filter(lambda i: i.price > 10)
items.first_where(sku="ABC")
items.group_by(lambda i: i.status)
items.key_by("id")
items.sum("price") # sum by attribute name
items.only(1, 2, 3) # members whose primary key is in the set
sum takes an attribute name, not a lambda. On a collection, only(...)/except_(...) filter members by primary key — to pick a subset of columns from a single model, use item.only("id", "name"), which returns a dict.
ModelCollection adds model-aware helpers on top: model_keys(), PK-aware contains/find, and await items.load("relation") to eager-load onto an existing collection.
Note
The Collection filtering helper is first_where(**kwargs), not a Laravel-style where(...). There's no where() on collections.
Debugging¶
Inspect the compiled SQL and bindings without running the query, or get the database's execution plan: