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/__init__.py +187 -0
- statcatlib/bundle.py +565 -0
- statcatlib/cli/__init__.py +546 -0
- statcatlib/expr.py +167 -0
- statcatlib/model/__init__.py +56 -0
- statcatlib/model/agents.py +32 -0
- statcatlib/model/catalog.py +71 -0
- statcatlib/model/classification.py +19 -0
- statcatlib/model/dataset.py +198 -0
- statcatlib/model/distribution.py +50 -0
- statcatlib/model/licence.py +18 -0
- statcatlib/model/quality.py +31 -0
- statcatlib/model/skos.py +141 -0
- statcatlib/model/spatial.py +19 -0
- statcatlib/model/temporal.py +33 -0
- statcatlib/model/types.py +5 -0
- statcatlib/py.typed +0 -0
- statcatlib/query.py +924 -0
- statcatlib/sources/__init__.py +13 -0
- statcatlib/sources/ckan.py +490 -0
- statcatlib/sources/sdmx.py +321 -0
- statcatlib/store/__init__.py +38 -0
- statcatlib/store/lance.py +1494 -0
- statcatlib/store/row.py +30 -0
- statcatlib/store/schema.py +403 -0
- statcatlib/store/scopes.py +1419 -0
- statcatlib/store/sentinel.py +99 -0
- statcatlib/store/store.py +6836 -0
- statcatlib/store/xhtml.py +206 -0
- statcatlib/urn.py +108 -0
- statcatlib-0.8.0.dist-info/METADATA +429 -0
- statcatlib-0.8.0.dist-info/RECORD +35 -0
- statcatlib-0.8.0.dist-info/WHEEL +4 -0
- statcatlib-0.8.0.dist-info/entry_points.txt +3 -0
- statcatlib-0.8.0.dist-info/licenses/LICENSE +180 -0
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")
|