How to build queries¶
Your first query runs one query from model to rows. The builder in the middle
of it is the subject here. Chain .metrics(), .dimensions(), .where(),
.order_by() and .limit() to shape a query, then call .execute() to get results.
The API is fluent and immutable, so every call hands back a new query rather than mutating
the one you had.
This page walks the chain in the order you write it. Filtering has more surface than one
step of a walkthrough can hold, so .where() gets its place in the chain here and its
operator set in How to filter queries.
This guide uses the Sales model from Your first query:
from semolina import SemanticView, Metric, Dimension
class Sales(SemanticView, view="sales"):
revenue = Metric()
cost = Metric()
country = Dimension()
region = Dimension()
Select metrics¶
Use .metrics() to choose which aggregated measures to include:
query = Sales.query().metrics(Sales.revenue)
query = Sales.query().metrics(Sales.revenue, Sales.cost)
SELECT AGG("REVENUE") AS "revenue", AGG("COST") AS "cost"
FROM "SALES"
SELECT MEASURE(`revenue`) AS `revenue`, MEASURE(`cost`) AS `cost`
FROM `sales`
SELECT "revenue" AS "revenue", "cost" AS "cost"
FROM semantic_view('sales', metrics := ['revenue', 'cost'])
Passing a non-Metric field raises TypeError:
Sales.query().metrics(
Sales.country
) # TypeError: metrics() requires Metric fields
The builder’s methods are typed, so a type checker such as pyright or mypy reports that
line before it runs: .metrics() takes Metric fields, .dimensions() takes
Dimension or Fact fields, and .limit() takes an int.
At least one field is required – calling .metrics() with no arguments raises ValueError.
Select dimensions¶
Use .dimensions() to group results by Dimension or
Fact fields:
query = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
)
query = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country, Sales.region)
)
SELECT AGG("REVENUE") AS "revenue", "COUNTRY" AS "country", "REGION" AS "region"
FROM "SALES"
GROUP BY ALL
SELECT MEASURE(`revenue`) AS `revenue`, `country` AS `country`, `region` AS `region`
FROM `sales`
GROUP BY ALL
SELECT "revenue" AS "revenue", "country" AS "country", "region" AS "region"
FROM semantic_view('sales', dimensions := ['country', 'region'], metrics := ['revenue'])
Passing a Metric field raises TypeError. At least one field is required.
Each field can appear once per query, because its values come back under a single column
named after it. Selecting one twice, in one call or across two, raises ValueError while
you build the query:
Sales.query().metrics(Sales.revenue).metrics(Sales.revenue)
# ValueError: 'revenue' is selected twice. ...
Use query shorthand¶
Pass metrics and dimensions directly to query() as keyword arguments:
cursor = Sales.query(
metrics=[Sales.revenue, Sales.cost],
dimensions=[Sales.country],
).execute()
This is equivalent to the fluent chain:
cursor = (
Sales.query()
.metrics(Sales.revenue, Sales.cost)
.dimensions(Sales.country)
.execute()
)
Shorthand and builder methods are additive. Calling .metrics() after query(metrics=...)
adds to the selection:
cursor = (
Sales.query(metrics=[Sales.revenue])
.metrics(
Sales.cost
) # now selects both revenue and cost
.dimensions(Sales.country)
.execute()
)
Filter with .where()¶
.where() takes conditions built from field operators. Conditions AND together, whether
you pass several to one call or make several calls, and a None condition is a no-op.
That last part is what turns an optional filter into a line rather than a branch:
def revenue_by_country(country: str | None):
return (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.where(Sales.revenue > 1000)
.where(
Sales.country == country if country else None
)
)
Called with "US" that renders WHERE "COUNTRY" = 'US' and HAVING AGG("REVENUE") >
1000: the revenue bound is on a metric, so it filters the totals. Called with None the
country condition drops out and only the revenue bound survives.
How to filter queries covers the rest: the comparison operators, the named methods that
have no operator spelling (.between(), .in_(), .like() and the others),
composition with &, | and ~, custom lookups, and whether a filter value binds
or inlines on each backend.
Order results¶
Without .order_by() the warehouse returns groups in whatever order suits it. Pass a
bare field for ascending, or call .asc() or .desc() on the field to say so
explicitly:
# Ascending -- these two build the same query
query = (
Sales.query()
.metrics(Sales.revenue)
.order_by(Sales.revenue)
)
query = (
Sales.query()
.metrics(Sales.revenue)
.order_by(Sales.revenue.asc())
)
# Descending
query = (
Sales.query()
.metrics(Sales.revenue)
.order_by(Sales.revenue.desc())
)
Metrics and dimensions are both valid sort keys. On Snowflake a metric sort key is wrapped
in AGG(). Databricks and DuckDB sort a selected field by the name it is projected
under. Anything that is neither a field nor an order term raises TypeError.
A metric does not have to be selected to sort on it. A dimension or fact does. Sorting by
one you left out of .dimensions() would group the result by it as well, which changes
what one row means, so the query raises ValueError when you run or preview it, on every
backend:
Sales.query().metrics(Sales.revenue).order_by(
Sales.country
).to_sql()
# ValueError: order_by() names Sales.country, a dimension the query does not select. ...
# Fine: cost is a metric, so it can sort without being selected
Sales.query().metrics(Sales.revenue).dimensions(
Sales.country
).order_by(Sales.cost.desc())
Break ties with a second sort key¶
Rows sharing a revenue figure come back in an arbitrary order until you name a tiebreaker. Extra fields apply left to right, so the second key only decides where the first one ties:
query = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.order_by(Sales.revenue.desc(), Sales.country.asc())
)
SELECT AGG("REVENUE") AS "revenue", "COUNTRY" AS "country"
FROM "SALES"
GROUP BY ALL
ORDER BY AGG("REVENUE") DESC, "COUNTRY" ASC
SELECT MEASURE(`revenue`) AS `revenue`, `country` AS `country`
FROM `sales`
GROUP BY ALL
ORDER BY `revenue` DESC, `country` ASC
SELECT "revenue" AS "revenue", "country" AS "country"
FROM semantic_view('sales', dimensions := ['country'], metrics := ['revenue'])
ORDER BY "revenue" DESC, "country" ASC
Separate calls append in the same order, so .order_by(a).order_by(b) and
.order_by(a, b) produce identical SQL.
Put the NULLs where you want them¶
Sort on a nullable column and Semolina emits no NULLS clause, which leaves the position
of the NULL group to the backend – and the backends do not all default the same way. Pass
a NullsOrdering to .asc() or .desc() when the position
matters:
from semolina import NullsOrdering
# Rows with no region sort to the bottom, whichever warehouse runs this
query = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.region)
.order_by(Sales.region.asc(NullsOrdering.LAST))
)
Value |
SQL generated |
Meaning |
|---|---|---|
|
|
NULLs sort before non-NULL values |
|
|
NULLs sort after non-NULL values |
|
(no NULLS clause) |
Backend decides (default) |
Reuse an order term¶
.asc() and .desc() return OrderTerm values that hold
no reference to a query. Build the sorts your API is willing to serve once, then look them
up by name:
from semolina import NullsOrdering
SORTS = {
"revenue": Sales.revenue.desc(NullsOrdering.LAST),
"country": Sales.country.asc(),
}
def top_countries(sort: str = "revenue"):
return (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.order_by(SORTS[sort])
)
Looking the term up rather than building it from the request parameter also keeps an
unexpected sort value out of the query: an unknown key raises KeyError before any
SQL exists.
Limit result count¶
.limit(n) caps the number of rows the warehouse returns:
query = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.limit(10)
)
SELECT AGG("REVENUE") AS "revenue", "COUNTRY" AS "country"
FROM "SALES"
GROUP BY ALL
LIMIT 10
SELECT MEASURE(`revenue`) AS `revenue`, `country` AS `country`
FROM `sales`
GROUP BY ALL
LIMIT 10
SELECT "revenue" AS "revenue", "country" AS "country"
FROM semantic_view('sales', dimensions := ['country'], metrics := ['revenue'])
LIMIT 10
n must be a positive integer. Zero or a negative number raises ValueError and a
non-integer raises TypeError, both as you build the query rather than at
.execute().
Take the top N¶
On its own .limit() returns an arbitrary handful of rows. After an .order_by() it
returns the top N:
# The ten highest-revenue countries
query = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.order_by(Sales.revenue.desc())
.limit(10)
)
Note
There is no .offset()
.limit(n) is the only row-count control on the query builder. Semolina has no
offset(), so classic LIMIT/OFFSET pagination cannot be expressed through
the fluent API in this release.
For a list sorted by a dimension, filter on that dimension instead of skipping rows:
order by it, take .limit(page_size), and make the next request ask for rows past
the last key you received (.where(Sales.country > last_seen)). That is keyset
pagination, and on an aggregate query it is usually cheaper than OFFSET, because
the warehouse does not have to compute and discard the skipped groups.
A list sorted by a metric can’t be paged that way. Metric values tie, so the next-page
condition needs a dimension tiebreaker, (Sales.revenue < last) | ((Sales.revenue ==
last) & (Sales.country > last_country)), and a metric and a dimension joined under
| raise ValueError (see Mix dimension and metric conditions). For a ranked list,
fetch .limit(page_size * pages) once and slice the pages out of it.
Choose the engine¶
Use .using() to select a different registered engine by name. Engine
resolution is lazy – it happens at .execute() time, not during query
construction:
# Uses the engine registered as "warehouse" instead of "default"
query = (
Sales.query().metrics(Sales.revenue).using("warehouse")
)
If no .using() call is made, Semolina uses the engine registered as
"default". See How to manage engines and connection pools for how to build and register
engines.
Execute and read results¶
Call .execute() to run the query and get back a SemolinaCursor:
cursor = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.execute()
)
for row in cursor.fetchall_rows():
print(row.country, row.revenue) # attribute access
print(row["country"]) # dict-style access
.execute() validates the query, resolves the engine, runs the SQL and returns the
cursor. A query selecting neither a metric nor a dimension raises ValueError at this
point rather than during construction, which is what lets you build a query in pieces.
A row’s keys are the Python field names, revenue and country here, on every
backend. Semolina aliases each selected column to its field name in the SQL it sends.
Fetch methods¶
SemolinaCursor provides both Row-based and raw DBAPI fetch methods:
# Row objects (primary pattern)
rows = cursor.fetchall_rows() # list[Row]
row = cursor.fetchone_row() # Row | None
batch = cursor.fetchmany_rows(10) # list[Row]
# Raw DBAPI tuples
raw = cursor.fetchall() # list[tuple]
raw_one = cursor.fetchone() # tuple | None
raw_batch = cursor.fetchmany(10) # list[tuple]
# Context manager (closes cursor + connection on exit)
with Sales.query(
metrics=[Sales.revenue]
).execute() as cursor:
rows = cursor.fetchall_rows()
The cursor hands back other result shapes too:
How to fetch results in bulk – whole-result Arrow tables and dataframes, batched and lazy reads for large results, and the async cursor
How to map results into Pydantic models with .into() – Pydantic instances via
.into()the untyped route – dictionaries and JSON
Inspect generated SQL¶
Use .to_sql() to see the SQL structure without executing the query:
sql = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.to_sql()
)
print(sql)
SELECT AGG("REVENUE") AS "revenue", "COUNTRY" AS "country"
FROM "SALES"
GROUP BY ALL
The Snowflake dialect folds identifiers to upper case to reach the view’s columns, and the
quoted AS aliases give the result columns back their field names – see
How result columns are named.
Tip
.to_sql() renders for the engine the query names with .using(), when an engine is
registered under that name. Otherwise it renders the Snowflake dialect (AGG(),
double-quoted identifiers), including for a query with no .using(). Pass a
dialect argument to preview another backend’s SQL, for example
.to_sql(dialect="databricks") or .to_sql(dialect="duckdb").
Fork and reuse queries¶
Every builder method returns a new query and leaves the original alone, so a base query is safe to keep around and safe to hand out:
base = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
)
# Fork into specialized variants -- base is unchanged
us_only = base.where(Sales.country == "US")
us_top_10 = base.where(Sales.country == "US").limit(10)
print(base.to_sql()) # no WHERE, no LIMIT
print(us_top_10.to_sql()) # has WHERE and LIMIT
The same property makes it safe to assemble a query across function boundaries. A helper
that adds a clause cannot disturb the query its caller is still holding. Annotate it with
Query, the type every builder method returns:
from semolina import Query
def add_revenue_floor(
query: Query, threshold: int
) -> Query:
return query.where(Sales.revenue > threshold)
cursor = add_revenue_floor(base, 1000).execute()
Read a query back¶
A query reports what it holds through read-only properties, so code that receives one, such as a cache, an audit log or a middleware, can inspect it without running it:
query = (
Sales.query()
.metrics(Sales.revenue)
.dimensions(Sales.country)
.where(Sales.country == "US")
.limit(10)
)
[
field.name for field in query.selected_metrics
] # ['revenue']
[
field.name for field in query.selected_dimensions
] # ['country']
query.filters # Exact(field_name='country', value='US')
query.limit_value # 10
query.engine_name # None: the "default" engine
query.model # the Sales class
ordering holds the .order_by() terms. The underscore attributes behind these are
private and can change between releases.
See also¶
Your first query – one query from model to rows, start to finish
Shaping a report – filtering, ordering and limiting run step by step
How to filter queries – field operators, named methods and boolean composition
How to define models – define
SemanticViewsubclasses with field typesHow to map results into Pydantic models with .into() – map rows into Pydantic models, and the untyped dictionary route
How to manage engines and connection pools – build and register the engines
.using()resolvesHow to configure your warehouse backend – the connection settings each warehouse takes, and the SQL its dialect renders