statcatlib 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
statcatlib/query.py ADDED
@@ -0,0 +1,924 @@
1
+ """Composable filter objects for Catalog queries.
2
+
3
+ Usage::
4
+
5
+ import statcatlib.query as q
6
+
7
+ results = (
8
+ store.query(sc.AggregateDataset)
9
+ .filter(q.has_code("GEO", "ON"))
10
+ .filter(q.has_dimensions(["GEO", "SEX", "AGE"]))
11
+ .execute()
12
+ )
13
+
14
+ results = store.query(sc.BaseDataset).filter(q.distribution(format="CSV")).execute()
15
+
16
+ Filter objects are passed as positional arguments to ``QueryBuilder.filter()``.
17
+ Multiple positional filters are ANDed together. Within a single
18
+ ``distribution()`` call, all keyword arguments must match the *same*
19
+ distribution row (AND semantics). Two separate ``distribution()`` calls
20
+ require *two* matching distribution rows.
21
+
22
+ Predicate compilation
23
+ ---------------------
24
+
25
+ Each filter implements ``to_sql(ctx)`` returning ``(fragment, params)``.
26
+ The ``ctx`` is a `CompileContext` (extended in ``store.store`` as
27
+ ``_QueryContext`` with a WHERE accumulator on top of the shared join
28
+ state).
29
+
30
+ This used to be *sdmxlib's* ``CompileContext``, so one compiler could walk
31
+ statcatlib and sdmxlib predicates through the same context. sdmxlib#306 retired
32
+ that seam in 0.50 — a downstream library can still inject a `SqlRelation`,
33
+ but no longer a predicate — so the class is statcatlib's now.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from typing import override
39
+
40
+ import sdmxlib as sl
41
+ from attrs import define, field
42
+
43
+ #: What a caller may pass as ``hierarchy=``. Spelled out rather than left as
44
+ #: `Any`: `str | Any` collapses to `Any`, so the union it looked like it was
45
+ #: documenting was never checked. `SupportsUrn` is sdmxlib's protocol for
46
+ #: "has a maintainable URN", which is exactly what `_hierarchy_urn` requires
47
+ #: of the third arm — a Protocol rather than a concrete class, so a future
48
+ #: artefact type satisfies it without a change here.
49
+ type HierarchyLike = str | sl.Ref[sl.Hierarchy] | sl.SupportsUrn
50
+
51
+ __all__ = [
52
+ "CodeDescent",
53
+ "ExistsFilter",
54
+ "FieldFilter",
55
+ "MapCondition",
56
+ "MapFilter",
57
+ "PositionalFilter",
58
+ "SdmxCodeFilter",
59
+ "SdmxCodelistFilter",
60
+ "SdmxDimensionFilter",
61
+ "descendants_of",
62
+ "description_contains",
63
+ "distribution",
64
+ "has_code",
65
+ "has_dimensions",
66
+ "has_keyword",
67
+ "has_service",
68
+ "has_spatial",
69
+ "publishes",
70
+ "serves",
71
+ "title_contains",
72
+ "within",
73
+ ]
74
+
75
+
76
+ @define
77
+ class CompileContext:
78
+ """State threaded through one compilation pass.
79
+
80
+ Each filter's ``to_sql(ctx)`` returns its own WHERE fragment and
81
+ parameters; the context carries what has to be *shared* across the
82
+ filters in a single query — most importantly joins, so a second
83
+ filter touching the same bridge table reuses the first one's alias
84
+ instead of emitting a duplicate.
85
+
86
+ This was ``sdmxlib.CompileContext`` until sdmxlib#306 retired the
87
+ cross-library predicate seam (`Predicate`, `CompileContext`,
88
+ `RowKind`, `ColumnSql`) in 0.50. sdmxlib kept `SqlRelation` — a
89
+ downstream library can still inject a *relation* — but the shared
90
+ compile state went with the predicates that needed it, so statcatlib
91
+ carries its own. Ported unchanged rather than redesigned: the
92
+ subclass in ``store.store`` and the ten ``to_sql`` implementations
93
+ below already spoke this shape, and a rewrite would have mixed a
94
+ behaviour change into an upgrade.
95
+
96
+ An attrs class, like the rest of the module — it was a dataclass only
97
+ because sdmxlib's was.
98
+
99
+ Attributes:
100
+ row_alias: The main table's alias in the outer query — ``"d"``
101
+ for ``statcat.dataset d``, ``"c"`` for ``sdmx.code c``.
102
+ join_aliases: Dedup map for repeated joins. Keyed by an
103
+ implementation-defined join id (annotation type, bridge
104
+ table + dimension); the value is the alias assigned the
105
+ first time that join was added.
106
+ join_sqls: Accumulated JOIN fragments, spliced between ``FROM``
107
+ and ``WHERE`` in the outer query.
108
+ join_params: Parameters for those joins, in insertion order.
109
+ schemas: Logical-to-physical schema name map. Identity by
110
+ default (``"sdmx"`` -> ``"sdmx"``); override to remap.
111
+ registry: The catalog's co-located `sdmxlib.LocalRegistry`, when
112
+ one is open. Filters that delegate to sdmxlib's path language
113
+ build their relation through it — see `SdmxCodeFilter`. Left
114
+ ``None`` by callers that compile filters without a catalog
115
+ (the unit tests do), which is why it is optional rather than
116
+ required: only the descent path needs it, and it says so.
117
+ """
118
+
119
+ row_alias: str
120
+ join_aliases: dict[str, str] = field(factory=dict)
121
+ join_sqls: list[str] = field(factory=list)
122
+ join_params: list[object] = field(factory=list)
123
+ schemas: dict[str, str] = field(factory=dict)
124
+ registry: sl.LocalRegistry | None = None
125
+ _alias_counter: int = 0
126
+
127
+ def schema(self, logical: str) -> str:
128
+ """Resolve a logical schema name to its physical SQL identifier."""
129
+ return self.schemas.get(logical, logical)
130
+
131
+ def next_alias(self, prefix: str = "f") -> str:
132
+ """Return a fresh ``{prefix}{n}`` alias, unique within this pass.
133
+
134
+ Correlated subqueries would otherwise collide when two filters
135
+ target the same table. The counter is per-context, so aliases
136
+ are deterministic within a query and renumber for the next one.
137
+ Prefixes like ``"ef"`` (exists filter) keep the SQL readable.
138
+ """
139
+ self._alias_counter += 1
140
+ return f"{prefix}{self._alias_counter}"
141
+
142
+
143
+ @define(frozen=True)
144
+ class MapCondition:
145
+ """A condition on a MAP(VARCHAR, VARCHAR) column.
146
+
147
+ Used inside ``ExistsFilter.conditions`` when the bridge-table column
148
+ is a DuckDB MAP rather than a scalar. ``element_at(col, key)`` is
149
+ emitted in the SQL instead of a plain ``col = ?``.
150
+ """
151
+
152
+ key: str # language key, e.g. "en"
153
+ value: str # value to match
154
+
155
+
156
+ # Accent- AND punctuation-insensitive label matching. ``strip_accents`` folds
157
+ # diacritics; the regexp collapses runs of Unicode punctuation + separators
158
+ # (``\p{P}\p{Z}`` + whitespace) to one space, so "Trois Rivieres" matches
159
+ # "Trois-Rivières". ``\p{P}\p{Z}`` keeps letters of any script (Latin,
160
+ # syllabics, …) — unlike a naive ``[^a-zA-Z0-9]`` — and strips ILIKE wildcards
161
+ # (``%`` / ``_`` are punctuation), so the bound value needs no LIKE-escaping.
162
+ _LABEL_NORM_REGEX = r"[\p{P}\p{Z}\s]+"
163
+
164
+
165
+ def label_norm_sql(expr: str) -> str:
166
+ """Wrap a SQL ``expr`` in the accent + punctuation fold used for label search."""
167
+ return f"trim(regexp_replace(strip_accents({expr}), '{_LABEL_NORM_REGEX}', ' ', 'g'))"
168
+
169
+
170
+ @define(frozen=True)
171
+ class MapFilter:
172
+ """Filter on a MAP column of the main ``dataset`` table.
173
+
174
+ Unlike ``ExistsFilter`` (which targets bridge tables via a subquery),
175
+ ``MapFilter`` adds a direct WHERE clause against ``d.<column>``.
176
+ """
177
+
178
+ column: str # "title", "description"
179
+ key: str # language key, e.g. "en"
180
+ value: str # raw search text (ilike) or exact value (eq)
181
+ op: str = "eq" # "eq" | "ilike"
182
+
183
+ def to_sql(self, ctx: CompileContext) -> tuple[str, list[object]]:
184
+ """Return ``(sql_fragment, params)`` for a WHERE clause."""
185
+ del ctx # MapFilter targets the main row directly; no subquery alias needed
186
+ if self.op == "ilike":
187
+ # Accent- AND punctuation-insensitive contains: fold diacritics and
188
+ # collapse punctuation/whitespace on both sides, so "Trois Rivieres"
189
+ # matches "Trois-Rivières" and "Montreal" matches "Montréal".
190
+ # ``value`` is the raw search text. (eq stays exact for identity.)
191
+ col = label_norm_sql(f"d.{self.column}[?]")
192
+ val = label_norm_sql("?")
193
+ return f"{col} ILIKE '%' || {val} || '%'", [self.key, self.value]
194
+ return f"d.{self.column}[?] = ?", [self.key, self.value]
195
+
196
+
197
+ @define
198
+ class FieldFilter:
199
+ """A simple equality/containment filter on a flat column."""
200
+
201
+ field: str
202
+ op: str # "eq" | "in" | "like"
203
+ value: object
204
+
205
+
206
+ @define
207
+ class ExistsFilter:
208
+ """A correlated EXISTS subquery filter.
209
+
210
+ Instances are created by the helper functions below, not directly.
211
+
212
+ Attributes:
213
+ table: The table to run the EXISTS subquery against.
214
+ conditions: Column → value pairs that must all be satisfied (AND).
215
+ join: Optional extra JOIN clause inside the subquery (e.g. to reach data_service through
216
+ distributions).
217
+ group_count: When set, appends a ``GROUP BY / HAVING COUNT(DISTINCT ...) = n`` clause for "all-of"
218
+ semantics (used by ``has_dimensions``).
219
+ """
220
+
221
+ table: str
222
+ conditions: dict[str, object] = field(factory=dict)
223
+ join: str | None = None
224
+ group_count: tuple[str, int] | None = None # (column, expected_count)
225
+
226
+ def to_sql(self, ctx: CompileContext) -> tuple[str, list[object]]:
227
+ """Return (sql_fragment, params) for a correlated EXISTS clause.
228
+
229
+ ``alias_prefix`` is used to avoid table-alias collisions when
230
+ multiple ExistsFilters are combined in one query.
231
+ """
232
+ alias_prefix = ctx.next_alias("ef")
233
+ table_alias = f"{alias_prefix}_t"
234
+ join_sql = f" {self.join}" if self.join else ""
235
+
236
+ col_clauses: list[str] = [f"{table_alias}.dataset_id = d.identifier"]
237
+ params: list[object] = []
238
+
239
+ for col, val in self.conditions.items():
240
+ if isinstance(val, MapCondition):
241
+ col_clauses.append(f"{table_alias}.{col}[?] = ?")
242
+ params.extend([val.key, val.value])
243
+ elif val is None:
244
+ col_clauses.append(f"{table_alias}.{col} IS NULL")
245
+ elif isinstance(val, (list, tuple)):
246
+ placeholders = ", ".join("?" * len(val))
247
+ col_clauses.append(f"{table_alias}.{col} IN ({placeholders})")
248
+ params.extend(val)
249
+ else:
250
+ col_clauses.append(f"{table_alias}.{col} = ?")
251
+ params.append(val)
252
+
253
+ where_sql = " AND ".join(col_clauses)
254
+
255
+ if self.group_count is not None:
256
+ group_col, expected = self.group_count
257
+ group_sql = f" GROUP BY {table_alias}.dataset_id HAVING COUNT(DISTINCT {table_alias}.{group_col}) = ?"
258
+ params.append(expected)
259
+ else:
260
+ group_sql = ""
261
+
262
+ sql = f"EXISTS (SELECT 1 FROM {self.table} {table_alias}{join_sql} WHERE {where_sql}{group_sql})"
263
+ return sql, params
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Internal: no-op filter (matches everything — used for empty dimension lists)
268
+ # ---------------------------------------------------------------------------
269
+
270
+
271
+ @define
272
+ class _NoOpFilter(ExistsFilter):
273
+ """A filter that never restricts results."""
274
+
275
+ table: str = ""
276
+
277
+ @override
278
+ def to_sql(self, ctx: CompileContext) -> tuple[str, list[object]]:
279
+ del ctx
280
+ return "1=1", []
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Public filter constructors
285
+ # ---------------------------------------------------------------------------
286
+
287
+
288
+ @define(frozen=True)
289
+ class SdmxCodelistFilter:
290
+ """Filter datasets whose SDMX DSD uses *codelist_urn* in any dimension.
291
+
292
+ JOINs through ``sdmx.dataflow → sdmx.dsd_component``.
293
+ """
294
+
295
+ codelist_urn: str
296
+
297
+ def to_sql(self, ctx: CompileContext) -> tuple[str, list[object]]:
298
+ """Return (sql_fragment, params) for a correlated EXISTS clause."""
299
+ alias_prefix = ctx.next_alias("ef")
300
+ df = f"{alias_prefix}_df"
301
+ comp = f"{alias_prefix}_comp"
302
+ return (
303
+ f"EXISTS ("
304
+ f"SELECT 1 FROM sdmx.dataflow {df}"
305
+ f" JOIN sdmx.dsd_component {comp} ON {comp}.dsd_urn = {df}.dsd_urn"
306
+ f" AND {comp}.codelist_urn = ?"
307
+ f" WHERE {df}.urn = d.sdmx_dataflow_urn"
308
+ f")"
309
+ ), [self.codelist_urn]
310
+
311
+
312
+ @define(frozen=True)
313
+ class CodeDescent:
314
+ """Descent over a code tree, as an argument to `has_code`.
315
+
316
+ Built by `descendants_of`. Carries *what* to match, never how to
317
+ match it — the SQL is `SdmxCodeFilter`'s, and the tree-walking is
318
+ sdmxlib's.
319
+
320
+ Attributes:
321
+ root: The ancestor code id (e.g. ``"CA"``).
322
+ hierarchy_urn: A Hierarchy URN to descend. ``None`` resolves the
323
+ applicable Hierarchy per dataflow, and falls back to the
324
+ codelist's own parent chain where none applies.
325
+ include_self: Whether *root* matches itself. Strict by default,
326
+ matching sdmxlib's ``q.hcode``, whose ``ancestors`` excludes
327
+ the node itself.
328
+ """
329
+
330
+ root: str
331
+ hierarchy_urn: str | None = None
332
+ include_self: bool = False
333
+
334
+
335
+ @define(frozen=True)
336
+ class SdmxCodeFilter:
337
+ """Filter datasets whose SDMX DSD has *dimension* with *code* in its codelist.
338
+
339
+ JOINs through ``sdmx.dataflow -> sdmx.dsd_component -> sdmx.code``.
340
+ Only matches ``AggregateDataset`` records with ``sdmx_dataflow_urn`` set.
341
+
342
+ ``code`` is either a literal code id (exact match) or a `CodeDescent`
343
+ from `descendants_of`, which matches any code under a root.
344
+
345
+ Descent delegates to ``sl.q.hierarchy_applicability`` — sdmxlib's view
346
+ answering "which hierarchy applies to this component, in this
347
+ dataflow's context", with scoped associations outranking unscoped ones
348
+ at ``rank == 1``. The projected relation is a `sdmxlib.SqlRelation`,
349
+ spliced here as a ``LEFT JOIN`` and matched on ``dataflow_urn``. It
350
+ stays lazy: the descendant set is never enumerated client-side, and
351
+ neither is the candidate dataflow list.
352
+
353
+ What statcatlib still decides is the *fallback*. The applicability view has
354
+ a row only where a HierarchyAssociation exists; where none does, this
355
+ filter reads the codelist's intrinsic parent chain from
356
+ ``sdmx.code.ancestor_ids``. That is a statcatlib policy — "when nothing
357
+ says otherwise, use the codelist's own tree" — rather than an SDMX
358
+ rule, which is why it lives here and the rest does not.
359
+ """
360
+
361
+ dimension: str
362
+ code: str | CodeDescent
363
+ codelist_urn: str | None = None
364
+
365
+ def to_sql(self, ctx: CompileContext) -> tuple[str, list[object]]:
366
+ """Return (sql_fragment, params) for a correlated EXISTS clause."""
367
+ alias_prefix = ctx.next_alias("ef")
368
+ df = f"{alias_prefix}_df"
369
+ comp = f"{alias_prefix}_comp"
370
+ c = f"{alias_prefix}_c"
371
+ params: list[object] = [self.dimension]
372
+ urn_clause = ""
373
+ if self.codelist_urn is not None:
374
+ urn_clause = f" AND {comp}.codelist_urn = ?"
375
+ params.append(self.codelist_urn)
376
+
377
+ if isinstance(self.code, str):
378
+ code_clause, code_params, extra_joins = f"{c}.code_id = ?", [self.code], ""
379
+ else:
380
+ code_clause, code_params, extra_joins = self._compile_descent(
381
+ self.code, ctx, df_alias=df, c_alias=c, alias_prefix=alias_prefix
382
+ )
383
+
384
+ return (
385
+ f"EXISTS ("
386
+ f"SELECT 1 FROM sdmx.dataflow {df}"
387
+ f" JOIN sdmx.dsd_component {comp} ON {comp}.dsd_urn = {df}.dsd_urn"
388
+ f" AND {comp}.component_id = ?"
389
+ f"{urn_clause}"
390
+ f" JOIN sdmx.code {c} ON {c}.codelist_urn = {comp}.codelist_urn"
391
+ f"{extra_joins}"
392
+ f" WHERE {df}.urn = d.sdmx_dataflow_urn"
393
+ f" AND ({code_clause})"
394
+ f")"
395
+ ), [*params, *code_params]
396
+
397
+ def _compile_descent(
398
+ self,
399
+ descent: CodeDescent,
400
+ ctx: CompileContext,
401
+ *,
402
+ df_alias: str,
403
+ c_alias: str,
404
+ alias_prefix: str,
405
+ ) -> tuple[str, list[object], str]:
406
+ """Compile a `CodeDescent` to ``(code_clause, params, extra_joins)``.
407
+
408
+ ``extra_joins`` are two ``LEFT JOIN``s against sdmxlib's applicability
409
+ relation; they must land between the ``sdmx.code`` join and the WHERE,
410
+ because the clause reads their columns.
411
+
412
+ **The descendant set is joined on, never scanned.** The obvious
413
+ spelling — join the relation on ``dataflow_urn`` and test
414
+ ``list_contains(codes, code_id)`` — makes the match a linear scan of
415
+ the descendant list once per (dataflow x code) row, which is a nested
416
+ loop wearing a join's clothes. At 100 dataflows over a 3k-code
417
+ geography that measured ~900M element comparisons and 7s, against
418
+ 49ms for the shape below. ``unnest`` gives one row per pair, and the
419
+ equality on ``code_id`` lets DuckDB hash-join it.
420
+
421
+ That costs a second relation. Unnesting drops any dataflow whose
422
+ descendant list is empty, so "an association applies but nothing
423
+ matched" would become indistinguishable from "no association applies"
424
+ and fall through to the parent chain — the wrong answer, and the one
425
+ the divergent fixture in the tests exists to catch. The existence
426
+ relation carries that distinction; it repeats the applicability query
427
+ without the descent, which is the cheap half.
428
+
429
+ Parameter order follows SQL text order: each join's parameters are
430
+ emitted where the join is, ahead of the WHERE fragment's.
431
+ """
432
+ if ctx.registry is None:
433
+ msg = (
434
+ "descendants_of() needs the catalog's sdmxlib registry to resolve "
435
+ "which hierarchy applies. Compile this filter through an open "
436
+ "Catalog (store.query(...).filter(...)), not a bare CompileContext."
437
+ )
438
+ raise ValueError(msg)
439
+
440
+ ha = f"{alias_prefix}_ha"
441
+ hr = f"{alias_prefix}_hr"
442
+ a, hcode = sl.q.hierarchy_applicability, sl.q.hcode
443
+
444
+ # Strict descent by default: `ancestors` excludes the node itself,
445
+ # so self-inclusion is a second disjunct rather than a wider walk.
446
+ under = hcode.ancestors.any(hcode.id == descent.root)
447
+ if descent.include_self:
448
+ under = under | (hcode.id == descent.root)
449
+
450
+ applicable = ctx.registry.query(a).filter((a.rank == 1) & (a.component.id == self.dimension))
451
+ if descent.hierarchy_urn is not None:
452
+ applicable = applicable.filter(a.hierarchy.urn == descent.hierarchy_urn)
453
+
454
+ exists_rel = applicable.select(dataflow_urn=a.dataflow.urn).to_sql()
455
+ pairs_rel = applicable.select(
456
+ dataflow_urn=a.dataflow.urn,
457
+ codes=a.hierarchy.hcodes.code.id.list(where=under),
458
+ ).to_sql()
459
+
460
+ extra_joins = (
461
+ f" LEFT JOIN ({exists_rel.sql}) {ha} ON {ha}.dataflow_urn = {df_alias}.urn"
462
+ f" LEFT JOIN (SELECT dataflow_urn, unnest(codes) AS code_id FROM ({pairs_rel.sql})) {hr}"
463
+ f" ON {hr}.dataflow_urn = {df_alias}.urn AND {hr}.code_id = {c_alias}.code_id"
464
+ )
465
+
466
+ # No applicable Hierarchy: the codelist's own parent chain, which
467
+ # the storage writer has already flattened into `ancestor_ids`.
468
+ fallback = f"? = ANY({c_alias}.ancestor_ids)"
469
+ fallback_params: list[object] = [descent.root]
470
+ if descent.include_self:
471
+ fallback = f"({fallback} OR {c_alias}.code_id = ?)"
472
+ fallback_params.append(descent.root)
473
+
474
+ code_clause = f"CASE WHEN {ha}.dataflow_urn IS NOT NULL THEN {hr}.code_id IS NOT NULL ELSE {fallback} END"
475
+ params = [*exists_rel.params, *pairs_rel.params, *fallback_params]
476
+ return code_clause, params, extra_joins
477
+
478
+
479
+ @define(frozen=True)
480
+ class SdmxDimensionFilter:
481
+ """Filter datasets whose SDMX DSD has ALL of the listed dimensions.
482
+
483
+ JOINs through ``sdmx.dataflow → sdmx.dsd_component``.
484
+ Only matches ``AggregateDataset`` records with ``sdmx_dataflow_urn`` set.
485
+ """
486
+
487
+ dimensions: tuple[str, ...]
488
+
489
+ def to_sql(self, ctx: CompileContext) -> tuple[str, list[object]]:
490
+ """Return (sql_fragment, params) for a correlated EXISTS clause."""
491
+ alias_prefix = ctx.next_alias("ef")
492
+ df = f"{alias_prefix}_df"
493
+ comp = f"{alias_prefix}_comp"
494
+ placeholders = ", ".join("?" * len(self.dimensions))
495
+ params: list[object] = [*self.dimensions, len(self.dimensions)]
496
+ return (
497
+ f"EXISTS ("
498
+ f"SELECT 1 FROM sdmx.dataflow {df}"
499
+ f" JOIN sdmx.dsd_component {comp} ON {comp}.dsd_urn = {df}.dsd_urn"
500
+ f" AND {comp}.component_id IN ({placeholders})"
501
+ f" WHERE {df}.urn = d.sdmx_dataflow_urn"
502
+ f" GROUP BY {df}.urn"
503
+ f" HAVING COUNT(DISTINCT {comp}.component_id) = ?"
504
+ f")"
505
+ ), params
506
+
507
+
508
+ # Union of all filter types accepted as positional arguments to QueryBuilder.filter().
509
+ # All members implement to_sql(alias_prefix: str) -> tuple[str, list[object]].
510
+ PositionalFilter = ExistsFilter | SdmxCodeFilter | SdmxCodelistFilter | SdmxDimensionFilter
511
+
512
+
513
+ def descendants_of(
514
+ root: str,
515
+ *,
516
+ hierarchy: HierarchyLike | None = None,
517
+ include_self: bool = False,
518
+ ) -> CodeDescent:
519
+ """Match any code beneath *root* in a code tree.
520
+
521
+ Pass the result to `has_code`. `within` is the one-call shortcut and
522
+ is usually what you want::
523
+
524
+ q.has_code("REF_AREA", q.descendants_of("CA"))
525
+ q.within("REF_AREA", "CA") # the same filter
526
+
527
+ Args:
528
+ root: The ancestor code id (e.g. ``"CA"``).
529
+ hierarchy: A Hierarchy URN string, `sdmxlib.Hierarchy`, or ``Ref[Hierarchy]`` to descend. ``None``
530
+ resolves the applicable Hierarchy per dataflow through ``sdmx.hierarchy_applicability``, falling
531
+ back to the codelist's own parent chain where none applies.
532
+ include_self: Whether *root* matches itself. Strict by default, matching sdmxlib's
533
+ ``q.hcode.ancestors``, which excludes the node itself.
534
+
535
+ Returns:
536
+ A `CodeDescent` carrying *root*, the resolved hierarchy URN and
537
+ the self-inclusion flag. Pass it to `has_code`.
538
+ """
539
+ return CodeDescent(
540
+ root=root,
541
+ hierarchy_urn=_hierarchy_urn(hierarchy),
542
+ include_self=include_self,
543
+ )
544
+
545
+
546
+ def _hierarchy_urn(hierarchy: HierarchyLike | None) -> str | None:
547
+ """Coerce a URN string, ``Ref[Hierarchy]``, or Hierarchy to its URN."""
548
+ if hierarchy is None or isinstance(hierarchy, str):
549
+ return hierarchy
550
+ if isinstance(hierarchy, sl.Ref):
551
+ return str(hierarchy.urn)
552
+ return str(sl.artefact_urn(hierarchy))
553
+
554
+
555
+ def has_code(
556
+ dimension: str,
557
+ code: str | CodeDescent,
558
+ *,
559
+ codelist_urn: str | None = None,
560
+ ) -> SdmxCodeFilter:
561
+ """Filter datasets whose SDMX DSD has *dimension* with *code* in its codelist.
562
+
563
+ JOINs through ``sdmx.dataflow -> sdmx.dsd_component -> sdmx.code``.
564
+ Only applies to ``AggregateDataset`` records ingested via SDMX.
565
+
566
+ Args:
567
+ dimension: The dimension ID to match (e.g. ``"GEO"``).
568
+ code: Either a literal code id for exact match (e.g. ``"ON"``) **or** a `CodeDescent` from
569
+ `descendants_of`, which matches any code under a root. Descent stays server-side: the descendant
570
+ set is never enumerated first.
571
+ codelist_urn: Optional codelist URN to disambiguate dimensions that share the same ID across
572
+ different DSDs. When omitted, matches any codelist.
573
+
574
+ Examples:
575
+ Exact match — find datasets that use the ``"ON"`` code on ``GEO``::
576
+
577
+ store.query(sc.AggregateDataset).filter(q.has_code("GEO", "ON"))
578
+
579
+ Hierarchy descent against an explicit Hierarchy — find datasets that
580
+ use *any* descendant of ``"CA"`` in ``H_GEO_ADMIN``::
581
+
582
+ store.query(sc.AggregateDataset).filter(
583
+ q.has_code("REF_AREA", q.descendants_of("CA", hierarchy=h_admin))
584
+ )
585
+
586
+ Default hierarchy resolution — when ``hierarchy=`` is omitted, the
587
+ applicable Hierarchy is resolved per dataflow via
588
+ ``sdmx.hierarchy_applicability`` (scoped associations outrank
589
+ unscoped ones), falling back to the codelist's parent chain when no
590
+ association applies::
591
+
592
+ store.query(sc.AggregateDataset).filter(q.has_code("REF_AREA", q.descendants_of("CA")))
593
+
594
+ Returns:
595
+ An `SdmxCodeFilter` for `QueryBuilder.filter`.
596
+ """
597
+ return SdmxCodeFilter(dimension=dimension, code=code, codelist_urn=codelist_urn)
598
+
599
+
600
+ def within(
601
+ dimension: str,
602
+ root_code: str,
603
+ *,
604
+ hierarchy: HierarchyLike | None = None,
605
+ include_self: bool = False,
606
+ codelist_urn: str | None = None,
607
+ ) -> SdmxCodeFilter:
608
+ """Filter datasets whose DSD has *dimension* with a code beneath *root_code*.
609
+
610
+ The common geography-descent case, in one call. Equivalent to::
611
+
612
+ q.has_code(
613
+ dimension,
614
+ q.descendants_of(root_code, hierarchy=hierarchy, include_self=include_self),
615
+ codelist_urn=codelist_urn,
616
+ )
617
+
618
+ When ``hierarchy`` is ``None``, the applicable Hierarchy is resolved
619
+ per dataflow via ``sdmx.hierarchy_applicability`` (scoped associations
620
+ outrank unscoped ones), falling back to the codelist's intrinsic
621
+ parent chain when no association applies.
622
+
623
+ Args:
624
+ dimension: The dimension ID (e.g. ``"REF_AREA"``).
625
+ root_code: The ancestor code id (e.g. ``"ON"``).
626
+ hierarchy: A Hierarchy URN string, `sdmxlib.Hierarchy`, or ``Ref[Hierarchy]``. ``None`` triggers
627
+ per-dataflow resolution.
628
+ include_self: Whether *root_code* matches itself. **Strict by default** — a codelist containing
629
+ ``ON`` and nothing under it does not match ``within("REF_AREA", "ON")``. This matches sdmxlib's
630
+ ``q.hcode.ancestors``, which excludes the node itself.
631
+ codelist_urn: Optional codelist URN to disambiguate same-named dimensions across DSDs.
632
+
633
+ Example::
634
+
635
+ store.query(sc.AggregateDataset).filter(
636
+ q.within("REF_AREA", "ON", hierarchy=h_geo_statistical_urn)
637
+ )
638
+
639
+ Returns:
640
+ An `SdmxCodeFilter` for `QueryBuilder.filter` — the same object
641
+ `has_code` would return for the equivalent `descendants_of`.
642
+ """
643
+ return SdmxCodeFilter(
644
+ dimension=dimension,
645
+ code=descendants_of(root_code, hierarchy=hierarchy, include_self=include_self),
646
+ codelist_urn=codelist_urn,
647
+ )
648
+
649
+
650
+ def has_dimensions(dims: list[str]) -> SdmxDimensionFilter | _NoOpFilter:
651
+ """Filter datasets whose SDMX DSD has ALL of the listed dimensions.
652
+
653
+ JOINs through ``sdmx.dataflow → sdmx.dsd_component``.
654
+ An empty list matches all datasets.
655
+
656
+ Example::
657
+
658
+ store.query(sc.AggregateDataset).filter(q.has_dimensions(["GEO", "SEX"])).execute()
659
+ """
660
+ if not dims:
661
+ return _NoOpFilter()
662
+ return SdmxDimensionFilter(dimensions=tuple(dims))
663
+
664
+
665
+ def distribution(**kwargs: object) -> ExistsFilter:
666
+ """Filter datasets that have a distribution matching *all* kwargs.
667
+
668
+ Supported keys: ``format``, ``media_type``. For service-level
669
+ filtering pass ``service_type`` or ``endpoint_url`` — these are
670
+ resolved via a LEFT JOIN to ``data_service``.
671
+
672
+ All kwargs within one call must match the *same* distribution row.
673
+ For "at least two separate distributions" semantics, call this
674
+ function twice::
675
+
676
+ .filter(q.distribution(format="CSV"))
677
+ .filter(q.distribution(format="JSON"))
678
+
679
+ Example::
680
+
681
+ store.query(sc.BaseDataset).filter(q.distribution(media_type="text/csv")).execute()
682
+ """
683
+ service_keys = {"service_type", "endpoint_url"}
684
+ dist_conditions: dict[str, object] = {}
685
+ svc_conditions: dict[str, object] = {}
686
+ needs_service_join = False
687
+
688
+ for k, v in kwargs.items():
689
+ if k in service_keys:
690
+ svc_conditions[f"svc.{k}"] = v
691
+ needs_service_join = True
692
+ else:
693
+ dist_conditions[k] = v
694
+
695
+ conditions = dict(dist_conditions)
696
+ join: str | None = None
697
+
698
+ if needs_service_join:
699
+ join = "LEFT JOIN data_service svc ON dist_t.access_service_id = svc.id"
700
+ # Merge service conditions — they reference the svc alias so keep as-is
701
+ # We'll handle this specially in to_sql by passing raw conditions
702
+ conditions.update(svc_conditions)
703
+
704
+ return _DistributionFilter(conditions=conditions, join=join)
705
+
706
+
707
+ def has_service(**kwargs: object) -> ExistsFilter:
708
+ """Filter datasets served by a data service matching *all* kwargs.
709
+
710
+ Supported keys: ``service_type``, ``endpoint_url``.
711
+
712
+ Example::
713
+
714
+ store.query(sc.BaseDataset).filter(
715
+ q.has_service(endpoint_url="https://example.com/sdmx")
716
+ ).execute()
717
+ """
718
+ conditions = {f"svc.{k}": v for k, v in kwargs.items()}
719
+ join = "LEFT JOIN data_service svc ON dist_t.access_service_id = svc.id"
720
+ return _DistributionFilter(conditions=conditions, join=join)
721
+
722
+
723
+ # ---------------------------------------------------------------------------
724
+ # Internal: distribution filter with correct alias handling
725
+ # ---------------------------------------------------------------------------
726
+
727
+
728
+ @define
729
+ class _DistributionFilter(ExistsFilter):
730
+ """EXISTS filter against distributions, with optional service JOIN.
731
+
732
+ Overrides ``to_sql`` to use the ``dist`` alias so that svc.* conditions
733
+ reference the correct alias.
734
+ """
735
+
736
+ table: str = "distribution"
737
+
738
+ @override
739
+ def to_sql(self, ctx: CompileContext) -> tuple[str, list[object]]:
740
+ alias_prefix = ctx.next_alias("ef")
741
+ _ = alias_prefix
742
+ join_sql = f" {self.join}" if self.join else ""
743
+ col_clauses: list[str] = ["dist_t.dataset_id = d.identifier"]
744
+ params: list[object] = []
745
+
746
+ for col, val in self.conditions.items():
747
+ # col may already have a table prefix (e.g. "svc.service_type")
748
+ fq_col = col if "." in col else f"dist_t.{col}"
749
+ if val is None:
750
+ col_clauses.append(f"{fq_col} IS NULL")
751
+ elif isinstance(val, (list, tuple)):
752
+ placeholders = ", ".join("?" * len(val))
753
+ col_clauses.append(f"{fq_col} IN ({placeholders})")
754
+ params.extend(val)
755
+ else:
756
+ col_clauses.append(f"{fq_col} = ?")
757
+ params.append(val)
758
+
759
+ where_sql = " AND ".join(col_clauses)
760
+ sql = f"EXISTS (SELECT 1 FROM distribution dist_t{join_sql} WHERE {where_sql})"
761
+ return sql, params
762
+
763
+
764
+ # ---------------------------------------------------------------------------
765
+ # Cross-entity filter constructors
766
+ # ---------------------------------------------------------------------------
767
+
768
+
769
+ # Supported kwargs for dataset-level conditions inside publishes/serves
770
+ _DATASET_KWARG_COLS: dict[str, str] = {
771
+ "kind": "kind",
772
+ "status": "status",
773
+ }
774
+
775
+
776
+ @define
777
+ class _PublishesFilter(ExistsFilter):
778
+ """EXISTS filter for Agent queries — datasets the agent publishes.
779
+
780
+ Correlates ``datasets.publisher_id = id`` where ``id`` is the ``agents``
781
+ primary key from the outer query. Inner ExistsFilter objects (e.g.
782
+ ``has_code``) are applied inside the ``datasets d`` subquery where they
783
+ can reference ``d.identifier`` normally.
784
+ """
785
+
786
+ table: str = "dataset"
787
+ inner: list[PositionalFilter] = field(factory=list)
788
+ dataset_kwargs: dict[str, object] = field(factory=dict)
789
+
790
+ @override
791
+ def to_sql(self, ctx: CompileContext) -> tuple[str, list[object]]:
792
+ wheres: list[str] = ["d.publisher_id = id"]
793
+ params: list[object] = []
794
+
795
+ for key, val in self.dataset_kwargs.items():
796
+ col = _DATASET_KWARG_COLS.get(key)
797
+ if col:
798
+ wheres.append(f"d.{col} = ?")
799
+ params.append(val)
800
+
801
+ for ef in self.inner:
802
+ ef_sql, ef_params = ef.to_sql(ctx)
803
+ wheres.append(ef_sql)
804
+ params.extend(ef_params)
805
+
806
+ where_sql = " AND ".join(wheres)
807
+ sql = f"EXISTS (SELECT 1 FROM dataset d WHERE {where_sql})"
808
+ return sql, params
809
+
810
+
811
+ @define
812
+ class _ServesFilter(ExistsFilter):
813
+ """EXISTS filter for DataService queries — datasets the service serves.
814
+
815
+ Correlates via ``service_dataset`` bridge table. Inner ExistsFilter
816
+ objects are applied against the ``dataset d`` alias inside the subquery,
817
+ where they can reference ``d.identifier`` normally.
818
+ """
819
+
820
+ table: str = "dataset"
821
+ inner: list[PositionalFilter] = field(factory=list)
822
+ dataset_kwargs: dict[str, object] = field(factory=dict)
823
+
824
+ @override
825
+ def to_sql(self, ctx: CompileContext) -> tuple[str, list[object]]:
826
+ wheres: list[str] = ["d.identifier IN (SELECT dataset_id FROM service_dataset WHERE service_id = id)"]
827
+ params: list[object] = []
828
+
829
+ for key, val in self.dataset_kwargs.items():
830
+ col = _DATASET_KWARG_COLS.get(key)
831
+ if col:
832
+ wheres.append(f"d.{col} = ?")
833
+ params.append(val)
834
+
835
+ for ef in self.inner:
836
+ ef_sql, ef_params = ef.to_sql(ctx)
837
+ wheres.append(ef_sql)
838
+ params.extend(ef_params)
839
+
840
+ where_sql = " AND ".join(wheres)
841
+ sql = f"EXISTS (SELECT 1 FROM dataset d WHERE {where_sql})"
842
+ return sql, params
843
+
844
+
845
+ def publishes(*inner_filters: PositionalFilter, **kwargs: object) -> ExistsFilter:
846
+ """Filter Agents that publish datasets matching the given criteria.
847
+
848
+ Pass ``ExistsFilter`` objects (e.g. ``has_code``, ``has_dimensions``) as
849
+ positional arguments, and/or dataset-level conditions as keyword arguments
850
+ (``kind``, ``status``).
851
+
852
+ Example::
853
+
854
+ # Agents that publish aggregate datasets about Ontario
855
+ store.query(sc.Agent).filter(
856
+ q.publishes(q.has_code("GEO", "ON"), kind="aggregate")
857
+ ).all()
858
+ """
859
+ return _PublishesFilter(inner=list(inner_filters), dataset_kwargs=dict(kwargs))
860
+
861
+
862
+ def serves(*inner_filters: PositionalFilter, **kwargs: object) -> ExistsFilter:
863
+ """Filter DataServices that serve datasets matching the given criteria.
864
+
865
+ Pass ``ExistsFilter`` objects as positional arguments and/or dataset-level
866
+ conditions as keyword arguments (``kind``, ``status``).
867
+
868
+ Example::
869
+
870
+ # DataServices that serve datasets with a GEO dimension
871
+ store.query(sc.DataService).filter(q.serves(q.has_dimensions(["GEO"]))).all()
872
+ """
873
+ return _ServesFilter(inner=list(inner_filters), dataset_kwargs=dict(kwargs))
874
+
875
+
876
+ # ---------------------------------------------------------------------------
877
+ # MAP column filter helpers
878
+ # ---------------------------------------------------------------------------
879
+
880
+
881
+ def has_spatial(label: str, *, lang: str = "en") -> ExistsFilter:
882
+ """Filter datasets that have a spatial coverage matching *label*.
883
+
884
+ Example::
885
+
886
+ store.query(sc.BaseDataset).filter(q.has_spatial("Canada")).execute()
887
+ """
888
+ return ExistsFilter(
889
+ table="dataset_spatial",
890
+ conditions={"label": MapCondition(key=lang, value=label)},
891
+ )
892
+
893
+
894
+ def has_keyword(keyword: str, *, lang: str = "en") -> ExistsFilter:
895
+ """Filter datasets that have a keyword matching *keyword*.
896
+
897
+ Example::
898
+
899
+ store.query(sc.BaseDataset).filter(q.has_keyword("employment")).execute()
900
+ """
901
+ return ExistsFilter(
902
+ table="dataset_keyword",
903
+ conditions={"keyword": MapCondition(key=lang, value=keyword)},
904
+ )
905
+
906
+
907
+ def title_contains(text: str, *, lang: str = "en") -> MapFilter:
908
+ """Filter datasets whose title contains *text* (case-insensitive).
909
+
910
+ Example::
911
+
912
+ store.query(sc.BaseDataset).filter(q.title_contains("GDP")).execute()
913
+ """
914
+ return MapFilter(column="title", key=lang, value=text, op="ilike")
915
+
916
+
917
+ def description_contains(text: str, *, lang: str = "en") -> MapFilter:
918
+ """Filter datasets whose description contains *text* (case-insensitive).
919
+
920
+ Example::
921
+
922
+ store.query(sc.BaseDataset).filter(q.description_contains("exchange")).execute()
923
+ """
924
+ return MapFilter(column="description", key=lang, value=text, op="ilike")