Data-plane helpers¶
params ¶
Query-parameter parsing for the SDMX-REST data plane.
Everything here is driven by the request's query string plus a
sdmxlib.DataflowBinding — no catalog-specific knowledge. A consumer
wires these into its own /data handler.
sql_ident ¶
Quote a SQL identifier. Source is producer-controlled binding metadata.
parse_bracketed ¶
Extract bracketed query params (c[<id>]=… / within[<id>]=…).
Repeated keys (foo[X]=A&foo[X]=B) and +-joined values
(foo[X]=A+B) are both accepted and merged into one value list.
Non-matching items are ignored.
parse_c_params ¶
parse_c_params(
items: Iterable[tuple[str, str]],
) -> tuple[
dict[str, list[str]], dict[str, list[tuple[str, str]]]
]
Parse c[<id>]=<expr> filters per SDMX-REST 2.2.2.
Each <expr> is a +-joined list of terms; each term may carry an
optional <op>: prefix where <op> is one of eq/ne/lt/
le/gt/ge/co/nc/sw/ew. Default (no prefix) is
eq. The + separator is interpreted as a per-component AND across
operator terms — matches the spec example
c[TIME_PERIOD]=ge:2020-01+le:2020-12.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
items
|
Iterable[tuple[str, str]]
|
Output of |
required |
Returns:
| Type | Description |
|---|---|
dict[str, list[str]]
|
|
dict[str, list[tuple[str, str]]]
|
values matched as equals (multi-value collapses to |
tuple[dict[str, list[str]], dict[str, list[tuple[str, str]]]]
|
sdmxlib's |
tuple[dict[str, list[str]], dict[str, list[tuple[str, str]]]]
|
|
tuple[dict[str, list[str]], dict[str, list[tuple[str, str]]]]
|
renders them as SQL fragments. |
like_pattern ¶
Wrap a user value in LIKE wildcards per operator semantics, escaping LIKE metachars.
build_non_eq_where ¶
build_non_eq_where(
non_eq_terms: dict[str, list[tuple[str, str]]],
binding: DataflowBinding,
) -> tuple[list[str], list[object]]
Render {dim_id: [(op, value), …]} as (fragments, params) SQL clauses.
Resolves each component id against the binding's dimension + attribute
column maps; unknown ids raise HTTPException(400) so callers see
exactly which one was wrong. Each (op, value) becomes one
WHERE fragment using the template in NON_EQ_OPERATOR_SQL.
For LIKE-based operators (co/nc/sw/ew) the user-supplied
value is escaped (%, _, \\) and wrapped in % wildcards per
operator semantics — so co:Census matches …Census…, sw:Census
matches Census…, ew:Province matches …Province, nc:foo
is the NOT counterpart of co:foo.
validate_dimension_at_observation ¶
Validate the SDMX-REST 2.2.2 dimensionAtObservation param.
Accepts None (param omitted — defaults to AllDimensions), the
literal AllDimensions, or any key-dimension ID declared in the
binding. Anything else is 400.
parse_sort ¶
Parse SDMX-REST 2.2.2 sort= into [(physical_column, direction), …].
Syntax (per spec + upstream PaginationHelper.cs):
comp_id[:asc|:desc]— single componentcomp_id[:asc|:desc]+comp_id[:asc|:desc]…— multi-component,+-joined*[:asc|:desc]— every key dimension in DSD orderseries_key[:asc|:desc]— every non-TIME key dimension in DSD order, for clients wanting a stable series listing independent of TIME_PERIOD
series_key combined with dimensionAtObservation=AllDimensions is
upstream-invalid (AllDimensions already flattens series); that check belongs
in the caller, which sees both params.
Each segment names a key dimension or attribute. Unknown ids raise
HTTPException(400) so callers see exactly which one was wrong.
reject_unsupported_standard_params ¶
reject_unsupported_standard_params(
*,
includeHistory: bool | None,
updatedAfter: str | None,
reportingYearStartDay: str | None,
) -> None
Return 501 if the caller asked for a SDMX-REST 2.2.2 param this server won't implement.
Consumers backed by a single static edition have no version history and no fiscal-period semantics, so these three can never be implemented for them.
projection ¶
Column projection for the data plane — SDMX-REST attributes= / measures=.
Projection is applied by trimming the binding's column maps before they reach
the serialiser: the underlying query still SELECT *s, and the writer emits
only the columns the trimmed binding lists. The arrow/parquet path needs the
projection applied to the batches themselves, which project_arrow_batches
does.
resolve_projection_set ¶
resolve_projection_set(
available: dict[str, str],
raw: str | None,
*,
keep_all_keywords: frozenset[str],
drop_all_keywords: frozenset[str],
kind: str,
) -> set[str] | None
Parse a SDMX-REST 2.2.2 projection param.
Returns None (no filter — keep every column) when the param is missing
or matches a "keep all" keyword, the empty set on none or any
drop_all_keywords token, or the set of IDs to retain on a
comma-separated list. Raises HTTPException(400) if any listed ID
isn't in available.
project_binding ¶
project_binding(
binding: DataflowBinding,
*,
attributes_param: str | None,
measures_param: str | None,
) -> sl.DataflowBinding
Return a copy of binding with attribute / measure column maps projected.
Parses the SDMX-REST 2.2.2 attributes= / measures= query strings — a
comma-separated list of IDs, the keyword all / dsd (keep everything,
the default), or none (drop everything). Unknown IDs raise so callers
surface them as 400. Dimension columns are never projected (they always
appear in the response).
project_arrow_batches ¶
project_arrow_batches(
batches: Iterable[RecordBatch], binding: DataflowBinding
) -> Iterator[pa.RecordBatch]
Yield each arrow batch projected to the columns the binding declares.
The CSV writer reads from the trimmed binding to pick columns, but a
parquet writer fed the raw SELECT * batches would emit everything —
so the arrow path has to project explicitly to honour attributes= /
measures=.
materialise_dsd_for_flow ¶
materialise_dsd_for_flow(
registry: EditionRegistry, flow_urn: SdmxUrn[Dataflow]
) -> sl.DataStructure | None
Resolve flow_urn to its bound DSD, materialised.
sdmxlib's SDMX-JSON writers need the DSD object (not just a URN) to pull localised dimension / attribute / code labels.
key_dimension_ids ¶
key_dimension_ids(
registry: EditionRegistry,
flow_urn: SdmxUrn[Dataflow],
binding: DataflowBinding,
) -> list[str]
Resolve the flow's key-dimension ids in DSD order.
Dotted-key parsing is positional, so the order is the contract — segment
i of A5.2021.._T means whatever dimension sits at index i here.
The DSD is authoritative; the binding's column map is the fallback for
flows whose DSD is missing or not materialisable, which is lossy only if
the producer wrote that map in a different order.
Memoised per registry and flow URN: the answer is fixed for an edition,
and the lookup is on the hot path of every /data and /availability
request. A reopened edition is a new registry, so it starts empty.
Deliberately does not materialise a lazy DSD — an already-materialised one is used, anything else takes the binding fallback. Forcing the materialise here would change the dimension order this returns for lazy flows, which would silently re-interpret every dotted key against them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry
|
EditionRegistry
|
Registry used to resolve the flow and its DSD. |
required |
flow_urn
|
SdmxUrn[Dataflow]
|
URN of the dataflow. |
required |
binding
|
DataflowBinding
|
Supplies the fallback dimension order. |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
The key-dimension ids, in the order dotted keys are parsed against. |
label_maps_for_dims ¶
label_maps_for_dims(
registry: EditionRegistry,
dim_codelist_urns: dict[str, SdmxUrn[Codelist]],
binding: DataflowBinding,
lang: str,
) -> dict[str, dict[str, str]]
Map each key dim bound to a codelist to its code labels.
Takes the dim_id -> codelist URN mapping as an argument so consumers
can supply a cached one. Uses LazyCodelist.code_map, which is
itself cached per (codelist_urn, lang). Dims without an enumeration, or
whose codelist the registry cannot resolve, yield no entry — the CSV emit
path passes their ids through unchanged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry
|
EditionRegistry
|
Registry used to resolve each codelist URN. |
required |
dim_codelist_urns
|
dict[str, SdmxUrn[Codelist]]
|
|
required |
binding
|
DataflowBinding
|
Supplies the |
required |
lang
|
str
|
Language tag passed to |
required |
Returns:
| Type | Description |
|---|---|
dict[str, dict[str, str]]
|
|
sql ¶
SQL fragment builders driven by the dataflow binding.
These shape the generated query. Each one is binding-driven and degrades to a no-op when the columns it needs are absent, so a catalog that doesn't model them pays nothing.
decimals_format_replace_clause ¶
decimals_format_replace_clause(
binding: DataflowBinding,
*,
targets: Iterable[str],
decimals_id: str = "DECIMALS",
) -> str
Build REPLACE arguments formatting numeric columns to per-row precision.
Wraps each target in printf('%.*f', DECIMALS, value) so the row's own
DECIMALS attribute drives the decimal places.
The problem this solves is generic to SDMX: a single OBS_VALUE column
typed DOUBLE has to carry counts, percentages, rates and medians at
once, so the wire format needs per-row precision. DECIMALS is the
standard attribute that carries it. NULL inputs (missing DECIMALS,
suppressed value) propagate to NULL — printf short-circuits.
Each target is looked up in the measure map and the attribute map: a producer may model confidence bounds either way, and the underlying column is numeric regardless.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
binding
|
DataflowBinding
|
Supplies both column maps. |
required |
targets
|
Iterable[str]
|
Component IDs to format, e.g. |
required |
decimals_id
|
str
|
Attribute ID carrying the per-row precision. |
'DECIMALS'
|
Returns:
| Type | Description |
|---|---|
str
|
The |
str
|
declares no |
str
|
In either case a plain |
qualify_is_active ¶
Decide whether firstNObservations / lastNObservations needs a QUALIFY.
The QUALIFY row_number() OVER (PARTITION BY <series> ORDER BY <time>) <= N
pattern is expensive: the Window operator blocks partition pruning from
being pushed through to the parquet read (measured ~85x slower than the
bare scan on a filtered census query). When the time dimension's
cardinality is already <= N the cap would keep every row anyway, so
the QUALIFY can be skipped entirely for the same result.
A cardinality of 0 means "unknown" and takes the safe path — apply the QUALIFY.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cardinality
|
int
|
Distinct values of the time dimension for this flow. Callers typically supply a cached probe. |
required |
n_cap
|
int
|
The requested first/last N. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
False when the cap would keep every row, so the QUALIFY can be skipped. |
paging ¶
Range request / Content-Range response handling for observation slices.
SDMX-REST has no pagination of its own for the data plane; clients that need it (the .Stat Data Explorer in particular) reuse HTTP Range with a row-count unit in place of bytes. This module parses the request side and builds the matching response headers.
parse_items_range ¶
Parse a Range: <unit>=<start>-<end> header into (start, end, unit).
The unit is echoed back on the response so Range: values=0-9 yields
Content-Range: values 0-9/<total>.
Returns None for an absent header or any other unit (e.g. bytes) —
callers fall through to the unranged stream. Validation of the resulting
bounds belongs to the caller, which can raise 400 with route context.
build_range_headers ¶
Build the Content-Range + Accept-Ranges headers for a 206 response.
with_range ¶
with_range(
base_headers: dict[str, str],
range_info: tuple[int, int, int, str] | None,
*,
sort_skipped: bool = False,
) -> dict[str, str]
Layer Content-Range / Accept-Ranges onto a headers dict when paginating.
sort_skipped=True adds X-Sort-Skipped: true — for callers that
enforce a ceiling on sorted pagination and served an unordered first page
instead of an expensive global sort.
range_status ¶
206 Partial Content when the response is a Range slice, 200 OK otherwise.
encoding ¶
Wire-format encoders for observation streams.
Every encoder here takes an iterable of Arrow record batches (or of CSV byte chunks) and yields bytes, so memory stays bounded by batch size. Bulk SDMX downloads are routinely larger than a server process's memory budget.
emit_csv_with_labels ¶
emit_csv_with_labels(
batches: Iterable[RecordBatch],
binding: DataflowBinding,
*,
structure_id: str,
label_maps: dict[str, dict[str, str]],
structure_kind: str = "dataflow",
action: str = "I",
) -> Iterator[bytes]
SDMX-CSV writer emitting an extra <dim>_label column after each coded dim.
Mirrors the shape sdmxlib's write_sdmx_csv produces (STRUCTURE,
STRUCTURE_ID, ACTION, dims, measures, attrs) but interleaves a
<dim>_label column after each dim column present in label_maps.
Attributes and measures pass through unchanged — labels only make sense
for enumerated key dims.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
batches
|
Iterable[RecordBatch]
|
The result rows, in the binding's physical column names. |
required |
binding
|
DataflowBinding
|
Maps each component to its physical column. |
required |
structure_id
|
str
|
The |
required |
label_maps
|
dict[str, dict[str, str]]
|
|
required |
structure_kind
|
str
|
The |
'dataflow'
|
action
|
str
|
The |
'I'
|
Yields:
| Type | Description |
|---|---|
bytes
|
One encoded chunk per batch, the first carrying the header line; the |
bytes
|
header alone when |
Raises:
| Type | Description |
|---|---|
KeyError
|
If a batch is missing a column the binding declares. |
prepend_utf8_bom ¶
Emit a leading UTF-8 BOM then forward every chunk from csv_chunks.
For the SDMX-REST 2.2.2 format=csvfile / csvfilewithlabels download
flow: Excel opens a UTF-8 CSV cleanly with the BOM present and mangles
diacritics without it. Upstream gates this on file=true.
stream_csv_zip ¶
Wrap a CSV byte iterator in a single-entry streaming zip.
Uses zipstream-ng so the central directory is appended at the end of
the body — no rewinding, no buffering of the whole archive. Compression
level 1 matches the gzip path's CPU budget; the same DEFLATE runs
underneath, so the speed/ratio trade-off is identical.
encode_parquet ¶
Stream Arrow batches as a parquet file.
The parquet wire format is sequential — magic header, row groups, then a
footer ending in the file metadata. Readers consume in order and parse the
footer last, so HTTP streaming is fine as long as no Content-Length is
advertised. Each yielded chunk is whatever the ParquetWriter flushed
since the previous yield; the final yield carries the closing footer.
Memory is bounded by row-group size (one RecordBatch), and TTFB is
the first batch's scan + encode.
write_statistics and use_dictionary are off deliberately:
SDMX-CSV-shaped data is string-heavy with high repetition, zstd compresses
the raw bytes better than dictionary encoding represents them, and the
page min/max scan costs CPU that bulk-download readers get nothing for
(they don't do row-group predicate pushdown).