The query language: worked solutions¶
Companion to query-language.md (the design),
query-language-examples.md (the surface,
construct by construct), query-language-evidence.md
(what was run) and query-language-explorations.md
(branches rejected).
This page answers one question: can the query language express what consumers were hand-writing SQL for? The 113 sites surveyed in #275 / #276 / #281 fall into about a dozen shapes. Each is worked below in the spelling that ships, followed by the shapes still out of reach and what to do instead. Results are shown against the query tutorial's catalogue (described at the top of the examples page) where they are shown at all.
The surface in one page¶
frame = (
reg.query(q.dataflow) # any sl.q accessor; a plan
.filter(q.dataflow.agency == "ECB") # several arguments are ANDed
.select(q.dataflow.id, label=q.dataflow.name["en"])
.collect() # the only executing call
)
- Entry.
reg.query(<accessor>)on aLocalRegistryor aRestRegistry;reg.obs(flow)for observations; a lazy artefact's collections (cl.codes) and verbs (cl.narrower_transitive("EU")) are the sameQuery. - Paths are column references. Every hop is a declared edge;
q.dataflow.dsd.dimensions.concept.idtype-checks hop by hop. Crossing a to-many hop makes the compiler emitEXISTSin a filter and a correlated aggregate in a projection. - Strictly lazy. Terminals:
collect,ids,urns,count,exists,first,one,one_or_none,frame,stream,preview,page. - Namespaces keep the accessor small:
.strfor text matching,["en"]for localised labels.
1. Two columns, no objects — 29 sites¶
The most common shape by a distance, and the reason #275 exists: callers want a frame, not a resolved artefact graph.
SELECT s0.resource_id AS id,
(SELECT lt.text FROM sdmx.localized_text lt
WHERE lt.owner_urn = s0.urn AND lt.field = ? AND lt.lang = ?) AS name_en
FROM sdmx.artefact s0 WHERE s0.artefact_type = ?
No resolution, no Ref walking, one statement. (A maintainable's name lives in
a side table keyed by owner and language, so the label is a scalar subquery; a
code's name is a map column read in place.) frame() gives every stored
column of the relation instead, without naming them.
2. Discovery: which flows use X — 22 sites¶
"Which dataflows use REF_AREA?" spans dataflow, dsd_component and concept.
It used to be sl.artefact.uses_concept("REF_AREA"), a bespoke predicate
written because the DSL could not express the join. It is now a path:
reg.query(q.dataflow).filter(q.dataflow.dsd.dimensions.concept.id == "REF_AREA").ids()
reg.query(q.dataflow).filter(q.dataflow.dsd.dimensions.codelist.id == "CL_UNIT").ids() # ['TRADE_Q']
reg.query(q.dataflow).filter(q.dataflow.categorisations.category.id == "ECON").ids()
uses_codelist and in_category dissolve the same way. Reaching through the
concept's own default representation is a different path, and a different
question: q.dataflow.dsd.dimensions.concept.codelist.id == "CL_UNIT".
Matched by either of two routes¶
uses_concept also matched a DataStructure and a Dataflow whose DSD carried
the concept — two roots. That is not a set operation (a union needs one
relation); it is an | of two is_in sub-queries, which is what SQLAlchemy,
ibis and polars all reach for:
dsds = reg.query(q.dsd).filter(q.dsd.dimensions.codelist.id == "CL_UNIT").select(q.dsd.urn)
flows = reg.query(q.dataflow).filter(q.dataflow.dsd.dimensions.codelist.id == "CL_UNIT").select(q.dataflow.urn)
reg.query(q.artefact).filter(q.artefact.urn.is_in(dsds) | q.artefact.urn.is_in(flows)).ids()
# ['DSD_TRADE', 'TRADE_Q']
One statement: each one-column Select splices in as a subquery.
3. Dimensions of every flow, at catalogue scale — 21 sites¶
The shape that opened #280: 200 DSDs × 12 dimensions cost 265 ms by resolution against 1 ms as one statement.
Nested — one row per dataflow, its dimensions collected:
reg.query(q.dataflow).select(
flow=q.dataflow.id,
dims=q.dataflow.dsd.dimensions.id.list(),
n=q.dataflow.dsd.dimensions.count(),
).collect()
┌─────────┬───────────────────────────────┬─────┐
│ flow ┆ dims ┆ n │
╞═════════╪═══════════════════════════════╪═════╡
│ CPI_A ┆ ["FREQ", "REF_AREA"] ┆ 2 │
│ CPI_M ┆ ["FREQ", "REF_AREA"] ┆ 2 │
│ TRADE_Q ┆ ["FREQ", "REF_AREA", "UNIT"] ┆ 3 │
└─────────┴───────────────────────────────┴─────┘
Flat — one row per component, the shape sdmxlib.polars.structure_frame
returns for one DSD, reached for every DSD by rooting at the child:
reg.query(q.component).filter(q.component.role == "Dimension").select(
dsd=q.component.dsd.id,
component=q.component.id,
position=q.component.position,
concept=q.component.concept.id,
codelist=q.component.codelist.id,
n_codes=q.component.codelist.codes.count(),
).order_by("dsd", "position").collect()
Choosing between them is a grain decision, not a style one. To get one row per
(dataflow, dimension), nest the flows and explode in polars — a DSD backs many
dataflows, so q.component.dsd.dataflows is to-many and cannot be projected
bare:
reg.query(q.component).filter(q.component.role == "Dimension").select(
component=q.component.id,
flows=q.component.dsd.dataflows.id.list(),
).collect().explode("flows")
4. Reference metadata for an artefact — 13 sites¶
Every QUALITY statement reported about one dataflow:
ra = q.reported_attribute
reg.query(ra).filter(
ra.metadata_set.targets.artefact.urn == str(flow.urn),
ra.attribute_path.str.starts_with("QUALITY"),
).select(ra.attribute_path, text=ra.value["en"], set=ra.metadata_set.id).collect()
# [('QUALITY', None, 'Q_CPI'), ('QUALITY.ACCURACY', 'High.', 'Q_CPI')]
reg.reported_attributes() is the same query with the root already chosen:
reg.reported_attributes().filter(ra.attribute_path.str.starts_with("QUALITY")).
The metadata sets themselves: reg.query(q.metadata_set).filter(
q.metadata_set.targets.artefact.id == "CPI_M").
5. Labels and annotations per code — 12 sites¶
reg.query(q.code).filter(q.code.codelist.id == "CL_FREQ").select(
q.code.id,
label=q.code.name["en"],
order=q.code.annotations.list(q.code.annotations.text["en"], where=q.annotation.type == "ORDER"),
).collect().rows()
# [('A', 'Annual', ['1']), ('M', 'Monthly', []), ('Q', 'Quarterly', ['2'])]
Annotations are a to-many side table, so the list is a correlated subquery — no
join, row count preserved. where= picks which annotations; a code with none
gets [].
6. Concept-driven discovery — 8 sites¶
reg.query(q.concept).filter(
q.concept.scheme.id == "CS",
q.concept.name["en"].str.caseless.contains("area"),
).select(q.concept.id, scheme=q.concept.scheme.id).collect()
caseless ignores case; unaccented adds diacritics and folded punctuation,
on labels. == is exact except at the caseless rung (q.code.id.str.caseless
== "fr").
7. Counts, groups and the top N¶
reg.query(q.dataflow).group_by(q.dataflow.agency).len().order_by("agency").collect().rows()
# [('ECB', 2), ('ESTAT', 1)]
reg.query(q.code).group_by(q.code.codelist.id).agg(
n=sl.len(),
nested=q.code.id.count(where=q.code.depth >= 1),
).having(sl.len() > 2).collect().rows()
# [('CL_AREA', 6, 5), ('CL_FREQ', 3, 0)]
# the largest codelist — sorted and sliced in SQL, not after fetching every group
reg.query(q.code).group_by(q.code.codelist.id).agg(n=q.code.id.count()).order_by(
q.code.id.count().desc()
).limit(1).collect().rows()
# [('CL_AREA', 6)]
A per-row count is a fan-out aggregate in select, with no grouping:
c = q.codelist.codes
reg.query(q.codelist).select(q.codelist.id, n=c.count(), roots=c.count(where=q.code.depth == 0))
A fan-out inside a grouped aggregate is refused —
group_by(q.dataflow.agency).agg(n=q.dataflow.dsd.dimensions.id.n_unique())
cannot compile, because a correlated subquery has no value to aggregate across a
group. Root at the child instead, where the path is to-one:
reg.query(q.component).filter(q.component.role == "Dimension").group_by(
q.component.dsd.agency
).agg(n=q.component.id.n_unique())
8. Trees — ancestry as a relationship¶
Every tree relation (q.code, q.concept, q.category, q.hcode,
q.metadata_attribute) carries parent, children, ancestors,
descendants, depth and position:
cl.codes.filter(q.code.ancestors.id == "W").ids() # everything under W
cl.codes.filter(q.code.ancestors.id == "W", q.code.depth <= 1).ids() # ...one level: ['EU', 'AS']
cl.codes.filter(q.code.ancestors.name["en"] == "Europe").ids() # under a label: ['FR', 'DE']
cl.codes.filter(q.code.children.count() == 0).ids() # leaves: ['FR', 'DE', 'JP']
cl.codes.select(q.code.id, chain=q.code.ancestors.id.list()) # each code's ancestors
cl.codes.filter(q.code.depth == 1).select(
q.code.id, kids=q.code.children.id.list(), n_below=q.code.descendants.count(),
).collect().rows()
# [('EU', ['FR', 'DE'], 2), ('AS', ['JP'], 1)]
The depth bound is a predicate, not a parameter — == 2, >= 3 and
is_in([1, 3]) are free. None of it is a recursive query: the writer bakes each
row's ancestors, so ancestors is a membership test over a stored list. The
lazy codelist's named verbs say the common ones in a word:
cl.narrower_transitive("EU"), cl.roots(), cl.leaves().
9. The latest version of each artefact¶
reg.query(q.codelist).qualify(
q.codelist.version.rank(descending=True).over(q.codelist.agency, q.codelist.id) == 1
).select(q.codelist.agency, q.codelist.id, q.codelist.version).collect()
qualify runs after the window whatever the call order; filter runs before
it. Version orders by version, so 1.10 outranks 1.9. "The three newest" is
.row_number(descending=True).over(...).le(3).
10. What nothing uses¶
# codelists no component references
used = reg.query(q.component).select(q.component.codelist.urn)
reg.query(q.codelist).filter(~q.codelist.urn.is_in(used)).ids() # ['CL_RETIRED']
# structures no dataflow is built on
reg.query(q.dsd).filter(q.dsd.dataflows.count() == 0).ids()
11. Data-quality universals¶
"Every dimension is coded" is a universal, and the empty case is vacuously true:
A child whose column is NULL counts as a violation, not a pass — so
q.dsd.components.every(q.component.codelist.id != "CL_UNIT") is false for
every DSD here, because each OBS_VALUE measure has no codelist. To ask about
the coded ones only, restrict the fan-out:
q.dsd.dimensions.every(...), or q.dsd.components.count(where=...) == 0 for
"none of them is like this".
12. Two predicates on the same child¶
reg.query(q.dsd).filter(
q.dsd.agency == "ECB",
q.dsd.dimensions.any(
q.component.id == "REF_AREA",
q.component.codelist.id == "CL_AREA",
),
)
Written as two dotted predicates this asks "some dimension is REF_AREA, and some
dimension uses CL_AREA", which is not the question. See
query-language-examples.md §3.
13. Shapes that look like self-joins¶
"A DSD with two dimensions sharing a concept" reads as a self-join and is a grouping at the child's grain:
reg.query(q.component).filter(q.component.role == "Dimension").group_by(
q.component.dsd.id, q.component.concept.id
).agg(n=sl.len()).having(sl.len() > 1).collect()
Two that compare a row with a neighbour are not expressible in the path
language today — a comparison's right-hand side must be a literal
(query-language.md §13):
reg.query(q.component).filter(q.component.codelist.id != q.component.concept.codelist.id) # refused
reg.query(q.code).filter(q.code.parent.name["en"] == q.code.name["en"]) # refused
Use the escape hatch, with sl.schema names so the SQL is checked against the
layout:
cmp, con = sl.schema.dsd_component, sl.schema.concept
reg.sql(f"""
SELECT {cmp.dsd_urn}, {cmp.component_id}
FROM {cmp} JOIN {con} ON {con.concept_urn} = {cmp.concept_urn}
WHERE {cmp.codelist_urn} IS DISTINCT FROM {con.codelist_urn}
""").collect()
14. Label with a fallback¶
fill_null and sl.when(...) were designed and not built. Project both and
coalesce in polars, which is one call:
reg.query(q.dataflow).select(q.dataflow.id, q.dataflow.name["fr"]).collect().with_columns(
label=pl.coalesce("name_fr", "id")
)
There is no default language anywhere, so the fallback language is always named.
15. As of a date¶
valid_from / valid_to are columns on every maintainable:
import datetime as dt
cutoff = dt.datetime(2025, 1, 1, tzinfo=dt.UTC)
reg.query(q.codelist).filter((q.codelist.valid_to > cutoff) | q.codelist.valid_to.is_null())
16. Paging a large catalogue¶
ordered = reg.query(q.dataflow).order_by(q.dataflow.agency, q.dataflow.id)
page = ordered.page(100) # Page(urns, cursor)
nxt = ordered.after(page.cursor).page(100)
Keyset, not offset: stable at any depth, and the cursor is tied to the order it
came from — a cursor from a differently shaped order is refused rather than
silently skipping rows. Reading everything once is stream(), which needs no
order.
17. Observations¶
obs = reg.obs(flow)
obs.filter(obs.col("REF_AREA").is_in(["FR", "DE"])).frame(labels="en")
# the highest value per area
obs.qualify(obs.col("OBS_VALUE").rank(descending=True).over(obs.col("REF_AREA")) == 1).frame()
# a mean per area
obs.group_by(obs.col("REF_AREA")).agg(m=obs.col("OBS_VALUE").mean()).collect()
Columns are named by component id (obs.col("REF_AREA")) because they come from
the DSD at runtime; obs.components lists them. See
data-plane-query.md.
18. The same question over SDMX REST¶
RestRegistry.query takes the same queries. It plans what the endpoint can
answer, fetches that (with references= chosen to close over every relation the
paths reach), materialises it, and runs the query locally:
with sl.RestRegistry(sl.Provider.ECB) as ecb:
exr = ecb.query(q.dataflow).filter(
q.dataflow.agency == "ECB",
q.dataflow.dsd.dimensions.id == "CURRENCY",
)
exr.ids() # ['EXR']
exr.explain() # the SQL, and a line saying the rows are fetched first
The one thing REST asks for that a local store does not: a scan that leaves
anything to filter locally must name an agency, or the terminal raises
PushdownError naming the column to add — rather than downloading every
agency's dataflows to keep three. A bare listing, or equality on id or version,
needs none. Pass store= to keep what is fetched across sessions. The rules are
in query-pushdown.md.
Observations over REST push key-dimension == / is_in into the data key and
attribute filters into SDMX 3.0's c[...]; the rest runs locally on what came
back (data-plane-pushdown.md).
What is still out of reach¶
| shape | status | do instead |
|---|---|---|
| comparing two columns of one row | refused — literal right-hand side only | reg.sql with sl.schema (§13 above) |
| any-language label search | no spelling | one name["xx"] per language, ORed |
| regex over ids or labels | no operator | reg.sql, or polars after collect() |
| joins over a caller's own tables | no accessor of theirs can dot into ours | is_in with their one-column SqlRelation, or reg.sql on a shared connection |
| range joins (overlapping validity) | every edge is an equi-join | reg.sql |
PIVOT, fill_null, when/then |
not built | polars, after collect() |
| federation across registries in one query | open (query-pushdown.md §14) |
RestRegistry(store=...) into one store |
Cross-schema joins are the consumer shape the language reaches only
partially. A caller's relation can filter ours — is_in accepts any
one-column SqlRelation, including producers from other libraries, compiled
into the same statement — but a caller cannot declare an accessor whose rows
dot through to code.name["en"]: the column and edge factories are not public
and the edge table is closed. That was prototyped and type-checked; it is open
in query-language.md §13.
PIVOT is a reshaping of a materialised frame, and polars does it in one
call. Following the rule that kept server-side explode out: do not build what
polars already gives the caller for free.