Constraints¶
Recipes for working with DataConstraint — artefacts that define the
valid key combinations and time coverage for a dataflow.
A DataConstraint answers the question: for this dataflow, which
combinations of dimension values actually have data? They are used to
validate observation keys before loading data and to discover time coverage.
Fetching constraints for a dataflow¶
Constraints are published alongside dataflows in the SDMX registry, but they
point at the dataflow, not the other way round. reg.get(flow_urn,
resolve=True) follows the dataflow's own references (references=descendants
— its DSD, codelists, concept schemes), so it never brings a constraint back.
Ask for the constraints themselves, and name the dataflow they attach to:
import sdmxlib as sl
with sl.RestRegistry(sl.Provider.SDMX_GLOBAL) as reg:
constraints = (
reg.query(sl.q.data_constraint)
.filter(
sl.q.data_constraint.agency == "ECB",
sl.q.data_constraint.dataflows.any(sl.q.dataflow.id == "ECB_EXR1"),
)
.collect()
)
for cc in constraints:
print(f"{cc.id}: {len(cc.regions)} region(s), type={cc.constraint_type}")
Validating observation keys¶
Use constraint.allows(key) to check whether a dimension combination is
valid before sending a data request or inserting a row.
with sl.RestRegistry(sl.Provider.SDMX_GLOBAL) as reg:
# Fetch a constraint directly from the registry
cc = reg.get(sl.DataConstraint.urn_for(agency="ECB", id="ECB_EXR1_CONSTRAINTS"))
# Check individual observation keys
valid_key = {"FREQ": "D", "CURRENCY": "USD", "CURRENCY_DENOM": "EUR"}
invalid_key = {"FREQ": "D", "CURRENCY": "XYZ", "CURRENCY_DENOM": "EUR"}
print(cc.allows(valid_key)) # True
print(cc.allows(invalid_key)) # False — XYZ not in allowed currency codes
# Filter a list of candidate keys
candidate_keys = [
{"FREQ": "D", "CURRENCY": "USD", "CURRENCY_DENOM": "EUR"},
{"FREQ": "D", "CURRENCY": "GBP", "CURRENCY_DENOM": "EUR"},
{"FREQ": "D", "CURRENCY": "XYZ", "CURRENCY_DENOM": "EUR"},
]
valid_keys = [k for k in candidate_keys if cc.allows(k)]
print(f"{len(valid_keys)} of {len(candidate_keys)} keys are valid")
Inspecting allowed values per dimension¶
Drill into CubeRegion.keys to see exactly which codes are listed for
each dimension. Two flags decide what a listing means: the region's own
include, and each value set's KeyValue.include — an included region may
still exclude some of one dimension's values, so read both:
with sl.RestRegistry(sl.Provider.SDMX_GLOBAL) as reg:
cc = reg.get(sl.DataConstraint.urn_for(agency="ECB", id="ECB_EXR1_CONSTRAINTS"))
for region in cc.regions:
kind = "included" if region.include else "excluded"
print(f"Region ({kind}):")
for kv in region.keys:
codes = sorted(kv.codes())[:5]
truncated = "..." if len(kv.values) > 5 else ""
listed = "in" if kv.include else "not in"
print(f" {kv.dimension_id} {listed}: {codes}{truncated}")
Region (included):
FREQ in: ['D', 'M']
CURRENCY in: ['AUD', 'BGN', 'BRL', 'CAD', 'CHF']...
CURRENCY_DENOM in: ['EUR']
allows() reads both flags for you; walk them by hand only when you need the
listing itself.
Reading time coverage¶
DataConstraint.reference_period describes the time span of available
data. Use it to avoid requesting data outside the covered range:
with sl.RestRegistry(sl.Provider.SDMX_GLOBAL) as reg:
cc = reg.get(sl.DataConstraint.urn_for(agency="ECB", id="ECB_EXR1_CONSTRAINTS"))
if cc.reference_period:
rp = cc.reference_period
print(f"Data available from {rp.start_period.date()} to {rp.end_period.date()}")
else:
print("No reference period declared")
Availability: what data is actually there¶
Everything above reads a constraint someone published. The other question is what the data itself says, and for a local lake you can synthesise the answer rather than wait for a provider to declare it.
build_dataflow_constraint scans the bound table and returns a real
DataConstraint of type ACTUAL — one inclusive CubeRegion whose
KeyValues are the distinct values present per dimension:
It reads the lake, so it takes a LocalRegistry — the one holding both the
binding and the rows:
from sdmxlib.builders.contentconstraint import build_dataflow_constraint
with sl.LocalRegistry("lake.duckdb") as local:
cc = build_dataflow_constraint(local, flow_urn, name={"en": "Availability"})
cc.constraint_type # ConstraintType.ACTUAL
{kv.dimension_id: sorted(kv.codes()) for kv in cc.regions[0].keys}
# {"FREQ": ["A", "M", "Q"], "REF_AREA": ["CA", "US"]}
The dimension set and the physical column names come from the dataflow's
DataflowBinding, which is the authoritative record of what columns the
materialised table actually has — the right source for a statement about
actual availability.
It is a DataConstraint like any other, so everything on this page applies to
it. allows() works, and it answers a different question from a published
constraint: what is there rather than what may be.
cc.allows({"FREQ": "A", "REF_AREA": "CA"}) # True — rows exist
cc.allows({"FREQ": "A", "REF_AREA": "ZZ"}) # False — no rows, though the code is valid
A dimension value that is NULL in the lake is excluded rather than
collected as an empty string: a missing value is not a value the cube contains.
One component at a time¶
build_component_availability is the framework-free core of the SDMX REST
request GET /availableconstraint/<flow>/<key>/<component>?mode=<mode> — the
call a faceted UI makes on every click to decide which options to grey out.
from sdmxlib.builders.contentconstraint import build_component_availability
with sl.LocalRegistry("lake.duckdb") as local:
cc = build_component_availability(
local, flow_urn, name={"en": "Availability"},
key="A.CA", component_id="REF_AREA", mode="exact", api="2.1",
)
key is the dotted, positional SDMX key over the DSD's key slots, and "all"
means unfiltered in either position. name is required, as on every artefact:
SDMX requires one, and which languages it is in is yours to say.
api is required, and the two versions disagree
How many slots the key has depends on the SDMX-REST generation you are
serving: 2.1 leaves the time dimension out of the dotted key and
filters it with startPeriod / endPeriod, while 3.0 gives it a
segment. dsd.key_slot_ids(api) is the authority.
There is no default, deliberately. A key of the other shape is usually caught by its length — but a structure whose measure dimension makes the counts coincide would be read with every value one slot out, which returns a different slice with no error at all (#337).
The two modes differ in one thing, and the difference is the whole point:
| mode | filter while enumerating component C |
|---|---|
exact |
the full key, C's own constraint included |
available |
the key with C's own constraint dropped |
So with key="A.CA" and rows A/CA, A/US, Q/CA, M/US:
with sl.LocalRegistry("lake.duckdb") as local:
exact = build_component_availability(
local, flow_urn, name={"en": "Availability"},
key="A.CA", component_id="REF_AREA", mode="exact", api="2.1",
)
# REF_AREA -> {"CA"} — what the current selection contains
available = build_component_availability(
local, flow_urn, name={"en": "Availability"},
key="A.CA", component_id="REF_AREA", mode="available", api="2.1",
)
# REF_AREA -> {"CA", "US"} — what you could switch to without emptying the cube
available is what greys out a dropdown correctly. exact would grey out
every option except the one already selected, which is a UI that cannot be
navigated.
Other components keep their constraints in both modes — only the one being enumerated is relaxed.
On batching the scans¶
build_component_availability runs one SELECT DISTINCT per component,
and that is deliberate rather than unconsidered. Batching them into a single
array_agg(DISTINCT c) FILTER (…) per column — the shape a consumer
hand-wrote on the assumption it would be cheaper — measured 1.2× to 2.5×
slower across every width, row count and cardinality tried, on parquet and
in memory alike.
Parquet is columnar, so N queries projecting one column each read the same
bytes as one query projecting N: there is no I/O to save, and a single-column
parallel hash-distinct beats N distinct sets built inside one operator.
tests/bench/test_availability_scan_shape.py keeps that answer attached to
its evidence and fails if a DuckDB release ever reverses it.
Reading from a local file¶
Constraints are part of StructureMessage and read from, and write to, every
SDMX-ML version. Not every version carries every field, though: SDMX-ML 2.1's
value sets have no validity period or removePrefix, 3.0 dropped
ReferencePeriod, and 3.1 dropped ReleaseCalendar. sl.unrepresentable(cc,
"2.1") names what a given constraint would lose before you write it — empty
means it writes losslessly.
msg = sl.read("ecb_exr1_with_constraints.xml")
for cc in msg.constraints:
print(f"{cc.id}: {cc.constraint_type}, {len(cc.regions)} region(s)")
if cc.reference_period:
rp = cc.reference_period
print(f" Coverage: {rp.start_period.date()} – {rp.end_period.date()}")
for gap in sl.unrepresentable(cc, "2.1"):
print(f" lost to 2.1: {gap}")