Query composition: the refinement principle¶
Companion:
query-language.mdcovers how relations are named and joined. This file covers how clauses compose within one query.
There is one query class, Query[R] (src/sdmxlib/query/query.py), and every
relation in sl.q — codes, hierarchy placements, the artefact spine,
reported metadata values, observations — is a root of it. Before #306 there
were four DSLs, each with a composition law per clause and no stated
principle, so each new clause was a fresh argument and the answers drifted.
This file states the rule the individual decisions are instances of. Having
one class makes it hold in one place: a clause added for one root is added
for every root.
It was written down because the rule was already implemented and never written, which is the worst of both: consistent behaviour nobody could check. #264 shipped a first implementation that broke it and was caught only by a hand-written test.
The principle¶
Every row-set composition refines the query. Nothing a later caller adds can undo what an earlier call established.
The reason is not aesthetic. Queries here are built by more than one party — a
helper scopes a query to an agency, a federation layer caps it, a caller pages
it — and refinement is what makes that safe. Under last-wins semantics a
caller's .limit(10_000) silently undoes a helper's .limit(100), which is
the wrong direction for the mistake to run.
| clause | law | why |
|---|---|---|
filter(p) |
AND | narrows rows |
qualify(w) |
AND | narrows rows, by a window predicate |
intersect(other) / difference(other) |
semi-join / anti-join | narrows rows |
after(cursor) |
raises the floor | narrows rows (keyset paging) |
limit(n) |
min |
lowers the ceiling |
offset(n) |
raises start, shrinks the length |
raises the floor |
select(...) |
not a row refinement — returns a Select, whose filter raises |
|
group_by(...) |
not a refinement at all, changes shape — raises after order_by/limit |
A tree walk is not a clause of its own. What descendants_of(id) used to be
is a filter over an edge — cl.codes.filter(sl.q.code.ancestors.id == "EU")
is every code under EU — so it is governed by the filter row and needs no
law of its own (query-tree-paths.md).
The last two rows are the test of whether the principle is real: both errors
predate it and both fall out of it, rather than being bolted on afterwards.
select is refused with "Filter first, then select()"; group_by with
"a limit before grouping aggregates a truncated set".
The window arithmetic is narrow_limit and advance_window in
src/sdmxlib/query/_common.py. Each clause evolves a new frozen Plan
(_plan.py), so a query is a value and composing one never mutates another.
The three laws¶
Generated in tests/unit/test_properties_query_laws.py over three roots of
Query — q.code (through LazyCodelist.codes), q.hcode and
q.artefact — chosen because they have generative strategies. Reaching more
roots would test new strategies rather than the laws, which are one
implementation.
Monotonicity. rows(c(q)) ⊆ rows(q). The one that matters, and the one
264's first (start, length) window broke: base.offset(8) returned rows¶
base had excluded.
Idempotence. c(c(q)) == c(q) for the same argument. Stating a constraint
twice states it once.
Every ordering either commutes or is refused. The strongest usable form, and stronger than "these pairs commute": it leaves no third category where order changes the answer without saying so.
test_every_composition_method_is_classified guards the class rather than
the instances: every public Query method returning Self must be assigned a
law (row-set or preserving), so the next limit-shaped method cannot arrive
untested.
Two things the principle does not cover¶
Ordering is not a row-set property. order_by permutes; it does not change
membership. Applying refinement to it by symmetry was a category error, and
294 was the resulting defect: appending keys made a caller's order_by a¶
silent no-op whenever the earlier key was unique.
It now replaces, takes several keys in one call, and clears when called
with no arguments — matching Django's order_by. Direction is per key
(sl.q.code.depth.desc()) rather than a descending=[...] list parallel to
the keys: SQLAlchemy, ibis and Django all mark the key, and a parallel array
puts the direction at a distance from what it describes.
with_annotations was the second preserving clause and had the same shape.
306 removed it — collect() hydrates whole artefacts in one batch, so what¶
is loaded is no longer a query clause — leaving order_by as the only one, and
it is asserted to be row-set preserving, which is stronger than monotone.
A default order is neither. codelist.codes comes back in declaration
order because a codelist has one, and that is a fact about the relation
rather than a decision by a caller: it does not block group_by, and the
first order_by replaces it outright. Conflating the two would have made
codelist.codes.group_by(...) impossible, which is how the distinction was
found.
And declining an order is a third thing again — order_by(None) (#389).
Clearing the keys leaves the root-key tiebreak, so an unsorted result is
stable between calls rather than merely unspecified; paging depends on that,
and #264 is what happens without it. That clause is free on a codelist and was
5.4x the scan it decorated on a 4.26 M-row observation partition — 337 ms
against 62 ms, for an order most callers of frame() never look at.
So there are three states, not two:
q.order_by(sl.q.code.id) # sort by id, then the tiebreak
q.order_by() # clear the keys, keep the tiebreak
q.order_by(None) # no ORDER BY at all
order_by(None) is SQLAlchemy's spelling — "All existing ORDER BY criteria
may be cancelled by passing None" — and the survey behind this table is why
it is not a flag on frame(): no Python query or dataframe library puts an
ordering knob on a materialisation call. Not SQLAlchemy's .all(), Django's
list(), ibis's .to_pandas(), polars's .collect(), DuckDB's .fetchall(),
PySpark's .collect() or Dask's .compute() — those carry execution concerns
only. The reset is universally the same builder method with an empty or None
argument, which is also why it is not a new verb like unordered().
It is a Plan field (unordered), so it composes, keys under
canonical_key, and shows in repr — and it closes the query to limit,
offset, after and page, because a window over an unspecified scan
order silently skips and repeats rows between calls, which is precisely what
the tiebreak exists to prevent. The two are made exclusive rather than
documented apart. A later order_by(...) reopens it.
limit and offset do not commute. Narrowing, idempotence and
commutativity are not simultaneously available for that pair. SQL buys
commutativity by making the limit a length measured from the offset — which is
exactly what lets a later offset slide the window past rows an earlier
limit excluded. We chose narrowing:
q.offset(5).limit(10) # ten rows starting at five
q.limit(10).offset(5) # five rows — of the first ten, skip five
Both read left to right and both are useful. It is the only order-sensitive pair in the language, and it is asserted non-commutative rather than merely excluded from the commutativity property, because a carve-out nobody checks is a place to hide a bug.
The guard that makes the principle true¶
The principle was false before #296. Every clause compiles into one
SELECT, so a filter added after a limit lands in the WHERE and runs
before it:
Not a surprise — a violation. So filter, qualify and order_by raise
SliceError on an already-sliced query. Both readings of the composition are
defensible ("filter the whole set, then cap" and "of the first n, the ones
matching"), and the language refuses rather than picking one silently.
Django does the same, for the same reason — Cannot filter a query once a slice
has been taken. Unlike Django we can name the other reading, because is_in
already expresses it — a sliced query is a relation, and
base.filter(sl.q.code.id.is_in(sliced.select(sl.q.code.id))) is "of the
first n, the ones matching" — so the error points at a spelling that works
rather than only saying no.
limit and offset after a slice stay legal: they are refinements, and
paging is made of them.
Two families, two names¶
order_by is the SQL query language. ItemList and ArtefactList keep
sort_by, and that is deliberate rather than an unfinished rename: they
sort in Python, take a key function, and mirror sorted(key=, reverse=) —
including a descending= flag, which the SQL side replaced with a per-key
.desc().
dsd.dimensions.sort_by(lambda d: d.id, descending=True) # in memory
cl.codes.order_by(sl.q.code.id.desc()) # compiled to SQL
Different mechanisms, different vocabularies, and the name is the signal. The
one place they can be confused is order_by("id") on a Query — a habit
from the string-keyed family, and from pandas — which raises a TypeError
naming what a column reference looks like. (A Select's order_by does
take a string: there it names an output column of the projection, which has
no accessor.)
Is there an intermediate representation? (#273)¶
Yes, and it is the Plan. src/sdmxlib/query/_plan.py holds what a query
says — root, predicates, qualifiers, order keys, default order, the
unordered flag, cursor, limit, offset — and Query holds a Plan plus how
to run it. query-pushdown.md §2 argues why that is enough: the one
transformation a backend is owed (evaluating a path prefix separately and
applying it as set membership, §9.1 there) consumes Plans and produces
Plans, so no separate node set was needed.
This section once answered no, and that answer was right for its premise. It is kept in outline because the reasoning still explains what an IR is not for here.
The original argument was about expressiveness. #273 proposed the node set
Scan | Filter | Project | Join | Aggregate | Sort | Limit | Distinct on the
grounds that canonicalization, normalization and rewrites had nowhere to live.
Measured against the query shapes collected from six consuming applications,
13 of 18 were already expressible, and the five that remained were feature
decisions an IR would not deliver:
| gap | what it needed | now |
|---|---|---|
COALESCE(name[?], id) AS label |
expression-valued projection | not built; label in polars after collect() |
list(annotation.title) per code |
array aggregation over a side table | c.list(...) — a correlated list subquery, no join, no grouping |
cross-schema join (statcat.dataset ⋈ sdmx.code) |
joins over caller tables | not in the language; reg.sql(), or a SqlRelation into is_in |
PIVOT |
a reshaping node | not built; pivot in polars |
ROW_NUMBER() OVER |
window functions | qualify(col.rank(...).over(...) == 1) |
270's canonical form was then built without an IR (canonical_key in¶
_common.py) and fitted, which settled the expressiveness question.
Two things changed the premise, neither of them expressiveness.
Cross-relation projection within the sdmx schema was a sixth gap the table
above missed — its "cross-schema join" row is about caller tables. A
projection now spans relations through paths:
reg.query(sl.q.dataflow).select(sl.q.dataflow.id, dsd=sl.q.dataflow.dsd.id)
reads two relations through a to-one hop, and a to-many hop projects only
through an aggregate, so it never multiplies rows.
The library has two query backends. With DuckDB and SDMX REST as
simultaneous targets the question became where a query runs — pushdown,
capability declaration, and a local residual — which is orthogonal to
expressiveness. That is what extracting Plan from Query was for, and
query-pushdown.md is the design as built.
The data plane¶
273 correctly identified the data plane as where SQL text was a hazard:¶
local/data_store._build_query interpolated caller-supplied where,
columns and order_by as SQL text, a statement-injection surface #257
had decided to document rather than validate. That method is gone. A
dataflow's observations are now a root of the same Query
(reg.obs(flow) → ObsQuery, src/sdmxlib/query/observations.py),
so the data plane accepts field references and literals and nothing else,
like every other root (data-plane-query.md). ComponentFilter reaches it
structurally through obs.filter_components(...);
ComponentFilter.to_where_clauses survives only inside
builders/contentconstraint.py, rendering bound parameters.
The structural language's safety property is that it never accepts SQL text
in the language — a caller supplies field references and literals, and #272
type-checks even those. Raw SQL is not banned; it is a separate, visibly
different terminal, reg.sql(...) (query/raw.py), which does not splice
back into sl.q. Splicing text in would give the safe surface a
text-accepting path, which is what the ban protects against, whether the
text arrives as a string or wrapped in an object.
Where normalization sits, and its laws¶
Nowhere yet, and it needs no home until a second rewrite exists. One of the
brief's six is done (In(d, []) → False, compiled as 1 = 0), one is
outstanding (In(d, [c]) → Eq(d, c); we emit = ANY(?) with a one-element
list), and four have nothing to act on. When there are enough to be worth a
pass, it belongs between construction and emission, with
normalize(normalize(q)) == normalize(q) as the law and
tests/support/sql_corpus.py as the equivalence check.
Why not the alternatives¶
Prior art was checked rather than recalled, and it does not settle the question — among declarative peers exactly one law is unanimous:
| clause | SQLAlchemy 2.0 | Django 6.1 | ibis 12 | here |
|---|---|---|---|---|
filter / where |
AND | AND | AND | AND |
order_by |
append | replace | prepend | replace (#294) |
| cancel the order | order_by(None) |
order_by() |
— | both, and they differ (#389) |
limit |
last-wins | clamp | nest subquery | min |
offset |
last-wins | accumulate (slice) | nest | raise the floor |
So there is nothing to copy, and an internal principle is required rather than a reference. Two observations that did carry weight:
- Django clamps its limit (
qs[:5][:10]is five rows), so refinement is not our invention. SQLAlchemy's last-wins is the outlier, and it is a consequence of being a statement builder where each clause is a slot — the tell is thatwhere(None)compiles toAND NULL. - polars is not the reference for composition. Its vocabulary is borrowed
here, but it is a pipeline:
lf.head(3).filter(p)andlf.filter(p).head(3)differ, where the equivalents here do not (the second is refused). Borrowing the names without the laws is the standing hazard — see #222, #255, #261 and #292, four defects found in exactly that seam.