Query Builder¶
SpecStar includes a high-level query builder for searching indexed resource fields and resource metadata.
Use it when you want expressive filtering logic in Python or when you want your HTTP qb expressions to mirror the same mental model.
If you need a complete method-by-method lookup, see the Query Builder reference.
When to use it¶
The query builder is useful for:
- filtering lists by field values
- combining multiple conditions with AND, OR, and NOT
- building reusable search logic in services or tests
- keeping search code readable instead of hand-writing JSON filter payloads
Basic Python usage¶
from specstar import QB
query = (
QB["status"].eq("active")
.filter(QB["priority"] >= 3)
.exclude(QB["archived"].eq(True))
.sort("-created_time")
.page(1, 20)
)
results = manager.search_resources(query)
QB[...] returns a field-aware builder object, so comparison operators and helper methods can be chained naturally.
HTTP usage¶
The same ideas can be passed to the API through the qb query parameter:
GET /tasks?qb=(QB["status"] == "active") & (QB["priority"] >= 3)
GET /tasks?qb=QB["owner"].one_of(["alice", "bob"])
GET /tasks?qb=QB.created_time().last_n_days(7)
List endpoints are paginated by default. The startup default comes from the SPECSTAR_DEFAULT_QUERY_LIMIT environment variable, and you can still pass a different limit per request.
The server parses the expression with a safe AST parser.
Heads up — bare query params on list endpoints are silently ignored¶
GET /task?name=Alicereturns all tasks, not the ones whosenameisAlice. SpecStar's list endpoints do not auto-bind unknown query parameters to data fields — filtering has to go through one of:
?qb=…(recommended) — the safe expression DSL on this page,?data_conditions=…— JSON array of structured conditions,?conditions=…— meta-level conditions,- the dedicated metadata params (
is_deleted,created_time_start, …).The looseness is a deliberate 0.x trade-off: in a versioned system, resources can sit at different schema versions, so strict validation of arbitrary
?field=valuefilters against "the current schema" would either reject legitimate old data or require a full "which fields are queryable at which versions" matrix. That work is out of scope for 0.x.If you want a louder failure for typos, opt in per-request with
?strict_filters=true(coming after 0.11; see issue tracker) — until then, always go throughqb/conditionsfor filtering.
Common patterns¶
Simple comparisons¶
String matching¶
QB["name"].contains("ali")
QB["email"].ends_with("@example.com")
QB["title"].icontains("urgent")
QB["code"].regex(r"^[A-Z]{3}")
List membership¶
QB["status"].in_(["draft", "review"])
QB["role"].not_in(["guest"])
QB["owner"].one_of(["alice", "bob"])
Matching inside a list field: .any() / .all()¶
On a list-typed indexed field, .contains(v) means exact element
membership (["m4"] never matches "m40"). To match within an element —
substring, prefix, regex — quantify over the elements with .any() (some
element satisfies it) or .all() (every element does; an empty list matches
.all() vacuously). Inside the quantifier each element is a scalar, so every
string operator has its ordinary scalar meaning:
QB["tags"].any().contains("ol") # some element contains the substring "ol"
QB["tags"].any().icontains("OL") # ... case-insensitively
QB["tags"].any().regex("^m") # ... ^ / $ anchored to a single element
QB["tags"].any().eq("mol") # some element equals "mol" (same as .contains on a list)
QB["tags"].all().starts_with("m") # every element starts with "m"
This also works over HTTP: ?qb=QB["tags"].any().contains("ol").
Calling a bare scalar string operator (regex / starts_with / ends_with,
or the icontains family) directly on a list field is rejected — without a
quantifier it would run against the serialised array (a cross-element,
index-blind footgun). Use .any() / .all() instead.
Not to be confused with the
QB.any(cond1, cond2)/QB.all(...)static combinators below, which OR / AND whole conditions. Here the quantifier ranges over one field's elements.
Fuzzy search: .fuzzy() and .similarity()¶
When the user only knows a fragment that must be there — not the exact word —
use .fuzzy(). It matches by trigram similarity (pg_trgm word_similarity), so
a short, even slightly-misspelled query matches a longer word:
QB["title"].fuzzy("molec") # matches "molecular", "small molecule", …
QB["title"].fuzzy("capp") # a fragment of "capping"
QB["title"].fuzzy("capor", threshold=0.3) # typo-tolerant once you loosen the cutoff
QB["tags"].fuzzy("mol") # on a list field: ANY element may match
To order the matches best-first, sort by .similarity():
(
QB["title"].fuzzy("mol") # narrow to the candidates
.sort(QB["title"].similarity("mol").desc()) # then rank by closeness
)
.fuzzy() filters and .similarity() ranks — pair them: the filter is
index-accelerated, and the ranking then only has to score the survivors. Both
work over HTTP: ?qb=QB["title"].fuzzy("mol").
To make .fuzzy() (and the default-threshold path in particular)
index-accelerated instead of a full scan, annotate the field with
TrigramIndex — an opt-in pg_trgm GIN, index-only like SortIndex (no column,
no backfill, drop it any time):
from specstar import TrigramIndex
class Card(Struct):
title: Annotated[str, TrigramIndex()]
norm_keys: Annotated[list[str], TrigramIndex()] # list fields too
The same index also accelerates an exact substring .contains() /
.any().contains() on that field.
Works on every backend; indexed only on Postgres.
.fuzzy()/.similarity()are pg_trgm'sword_similarityon Postgres, and a faithful Python port of the same algorithm (specstar.util.trigram) on the memory / disk / sqlite backends — so a query returns the same rows everywhere, not merely "something fuzzy". Only the acceleration is Postgres-specific: theTrigramIndexGIN serves the default-threshold<%form (a customthresholdruns as a scan); the other backends have no such index and compute by scan, which is fine at their scale. The default cutoff is pg_trgm'sword_similarity_threshold(0.6).On Postgres without the
pg_trgmextension installed,.fuzzy()/.similarity()don't error — they degrade to the same Python computation (with a one-timeSpecStarWarning), pushing every other condition down to SQL first so only the survivors are scored in Python. Correct, just unaccelerated. Install it withCREATE EXTENSION pg_trgm;(annotating any field withTrigramIndexalso installs it) to get the GIN-served path.
Null and value checks¶
QB["deleted_at"].is_null()
QB["email"].is_not_null()
QB["nickname"].is_blank()
QB["profile"].has_value()
Date helpers¶
Sorting and pagination¶
QB["status"].eq("active").sort("-created_time", "+name")
QB["status"].eq("active").limit(10).offset(20)
QB["status"].eq("active").page(2, 10)
QB["status"].eq("active").first()
Metadata accessors¶
Use metadata helper methods when the filter targets resource metadata instead of indexed data.
QB.resource_id().starts_with("task-")
QB.current_revision_id().eq("rev-123")
QB.created_by().eq("admin")
QB.is_deleted().is_false()
QB.total_revision_count() > 3
All built-in metadata accessors are filterable and sortable, including resource_id, current_revision_id, created_time, updated_time, created_by, updated_by, is_deleted, schema_version, and total_revision_count.
Revision mirror fields¶
SpecStar stores a denormalized snapshot of the current revision's key attributes directly in ResourceMeta. These can be filtered and sorted without any extra revision reads:
QB.rev_status().eq("draft") # only resources with a draft current revision
QB.rev_status().eq("stable") # only stable
QB.rev_created_by().one_of(["alice", "bob"]) # current revision created by alice or bob
QB.rev_updated_by().ne("guest") # current revision not last touched by guest
QB.rev_created_time().last_n_days(7) # current revision created in the past week
QB.rev_updated_time().this_month() # current revision updated this month
These fields are kept in sync by SpecStar on every create(), update(), modify(), and switch() call.
Low-level alternative¶
If you need fully explicit structured queries, you can still build ResourceMetaSearchQuery objects manually:
from specstar.types import (
DataSearchCondition,
DataSearchOperator,
ResourceMetaSearchQuery,
)
query = ResourceMetaSearchQuery(
conditions=[
DataSearchCondition(
field_path="status",
operator=DataSearchOperator.equals,
value="open",
),
DataSearchCondition(
field_path="priority",
operator=DataSearchOperator.greater_than_or_equal,
value=3,
),
],
limit=20,
)
results = manager.search_resources(query)
This is useful for generated clients or integrations that prefer explicit JSON-like structures.
Listing everything (no query)¶
search_resources, count_resources, list_resources, and iter_all all
accept no argument, which matches every resource — equivalent to passing
QB.all() (or an empty ResourceMetaSearchQuery()):
manager.search_resources() # every resource's meta
manager.count_resources() # total count
manager.list_resources() # every resource, with data
for meta in manager.iter_all(): # paged full scan
...
Prefer iter_all() when you genuinely want all rows — it pages internally, so
a forgotten limit can't silently truncate the result.
Important limitations¶
- queries only work reliably on metadata fields and indexed fields
- if
qbis used in HTTP requests, do not combine it withdata_conditions,conditions,sorts, time-range / user filter params (created_time_start,created_time_end,updated_time_start,updated_time_end,created_bys,updated_bys), or revision filter params (rev_statuses,rev_created_bys,rev_updated_bys,rev_created_time_start,rev_created_time_end,rev_updated_time_start,rev_updated_time_end); conflicting requests return HTTP 422 is_deletedis the one exception: it may be combined withqb. The server ANDs it into the QB conditions automatically. Swagger always sendsis_deleted=falseby default, so QB expressions work in Swagger out of the box.- invalid or unsupported QB expressions return HTTP 400
- a bare scalar string operator (
regex/starts_with/ends_with/icontains) on a list field is rejected — quantify it with.any()/.all()instead .fuzzy()/.similarity()(trigram search) work on every backend — pg_trgm on Postgres, a faithful Python port (specstar.util.trigram) elsewhere, so the rows match; only the acceleration is Postgres-only (TrigramIndexbuilds a pg_trgm GIN; other backends ignore the annotation and compute by scan)- URL
limitandoffsetoverride pagination values defined inside the QB expression - for metadata filtering in QB mode (time ranges, creator filters, revision filters, etc.), include them directly in the expression — for example
QB.created_time().last_n_days(7),QB.created_by().eq("alice"), orQB.rev_status().eq("draft")
QB error responses at a glance¶
| Situation | HTTP | What to do |
|---|---|---|
| malformed or unsupported QB expression | 400 | fix the expression itself |
qb combined with JSON conditions, sorts, or time-range/user filter params |
422 | choose either QB mode or individual query parameters |
For the shared route-level error mapping, see the HTTP error reference page.
Good practices¶
- index fields that you plan to search frequently
- start with small filters and expand only when needed
- use
QB.all()andQB.any()for nested grouped logic - prefer QB for readability and JSON conditions for machine-generated requests