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
|
@@ -0,0 +1,1419 @@
|
|
|
1
|
+
"""Lazy scope objects for navigating catalog structure.
|
|
2
|
+
|
|
3
|
+
Scopes represent positions in the catalog graph. Navigation builds new
|
|
4
|
+
scopes without executing SQL; only terminal methods (``.collect()``,
|
|
5
|
+
``.count()``) hit the database.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
store.themes["https://example.com/themes"].collect() # ItemList[ConceptRef]
|
|
10
|
+
store.themes["https://example.com/themes"]["13"].collect() # Concept
|
|
11
|
+
store.themes["https://example.com/themes"]["13"].datasets.collect() # list[BaseDataset]
|
|
12
|
+
|
|
13
|
+
store.themes["https://example.com/themes"].enrich(
|
|
14
|
+
n=sc.each("datasets").count(),
|
|
15
|
+
).rows()
|
|
16
|
+
|
|
17
|
+
store.codelists.enrich(
|
|
18
|
+
n=sc.each("datasets").count(),
|
|
19
|
+
).rows()
|
|
20
|
+
|
|
21
|
+
# DatasetScope — Gremlin-inspired graph navigation
|
|
22
|
+
store.dataset("housing-stats").codelists # CodelistsScope
|
|
23
|
+
store.dataset("housing-stats").themes # ThemesScope
|
|
24
|
+
store.dataset("housing-stats").related("codelist") # QueryBuilder[BaseDataset]
|
|
25
|
+
store.dataset("housing-stats").upstream(depth=5) # QueryBuilder[BaseDataset]
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import TYPE_CHECKING, Literal, TypedDict
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
34
|
+
|
|
35
|
+
from sdmxlib.model.codelist import Code
|
|
36
|
+
|
|
37
|
+
from statcatlib.expr import AliasedExpr, Expr
|
|
38
|
+
from statcatlib.model.dataset import BaseDataset
|
|
39
|
+
from statcatlib.store.store import Catalog, QueryBuilder
|
|
40
|
+
|
|
41
|
+
import attrs
|
|
42
|
+
import duckdb
|
|
43
|
+
from attrs import define, field
|
|
44
|
+
from sdmxlib import InternationalString
|
|
45
|
+
from sdmxlib.model.collections import ItemList
|
|
46
|
+
|
|
47
|
+
from statcatlib.model.skos import Concept, ConceptRef, ConceptScheme
|
|
48
|
+
from statcatlib.query import ExistsFilter, label_norm_sql
|
|
49
|
+
from statcatlib.store.row import Row
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _map_to_istring(m: dict[str, str] | None) -> InternationalString | None:
|
|
53
|
+
"""Convert a DuckDB MAP dict to InternationalString."""
|
|
54
|
+
if m is None:
|
|
55
|
+
return None
|
|
56
|
+
return InternationalString.of(m)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Edge metadata — maps (scope_type, edge_label) to SQL JOIN information
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _sdmx_codelist_count_sql(_scope_filter: dict[str, object]) -> tuple[str, list[object]]:
|
|
65
|
+
"""Custom COUNT subquery: datasets per codelist via SDMX JOIN."""
|
|
66
|
+
return (
|
|
67
|
+
"SELECT dc.codelist_urn, COUNT(DISTINCT d.identifier) AS cnt"
|
|
68
|
+
" FROM statcat.dataset d"
|
|
69
|
+
" JOIN sdmx.dataflow df ON df.urn = d.sdmx_dataflow_urn"
|
|
70
|
+
" JOIN sdmx.dsd_component dc ON dc.dsd_urn = df.dsd_urn"
|
|
71
|
+
" WHERE dc.codelist_urn IS NOT NULL"
|
|
72
|
+
" GROUP BY dc.codelist_urn"
|
|
73
|
+
), []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _sdmx_codelist_exists_sql(_scope_filter: dict[str, object]) -> tuple[str, list[object]]:
|
|
77
|
+
"""Custom EXISTS subquery: codelists with at least one dataset via SDMX JOIN."""
|
|
78
|
+
return (
|
|
79
|
+
"SELECT DISTINCT dc.codelist_urn"
|
|
80
|
+
" FROM statcat.dataset d"
|
|
81
|
+
" JOIN sdmx.dataflow df ON df.urn = d.sdmx_dataflow_urn"
|
|
82
|
+
" JOIN sdmx.dsd_component dc ON dc.dsd_urn = df.dsd_urn"
|
|
83
|
+
" WHERE dc.codelist_urn IS NOT NULL"
|
|
84
|
+
), []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _sdmx_code_count_sql(scope_filter: dict[str, object]) -> tuple[str, list[object]]:
|
|
88
|
+
"""Custom COUNT subquery: datasets per code (within a codelist) via SDMX JOIN."""
|
|
89
|
+
cl_urn = scope_filter.get("codelist_urn")
|
|
90
|
+
return (
|
|
91
|
+
"SELECT c.code_id, COUNT(DISTINCT d.identifier) AS cnt"
|
|
92
|
+
" FROM statcat.dataset d"
|
|
93
|
+
" JOIN sdmx.dataflow df ON df.urn = d.sdmx_dataflow_urn"
|
|
94
|
+
" JOIN sdmx.dsd_component dc ON dc.dsd_urn = df.dsd_urn"
|
|
95
|
+
" JOIN sdmx.code c ON c.codelist_urn = dc.codelist_urn"
|
|
96
|
+
" WHERE c.codelist_urn = ?"
|
|
97
|
+
" GROUP BY c.code_id"
|
|
98
|
+
), [cl_urn]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _sdmx_code_exists_sql(scope_filter: dict[str, object]) -> tuple[str, list[object]]:
|
|
102
|
+
"""Custom EXISTS subquery: codes that have at least one dataset via SDMX JOIN."""
|
|
103
|
+
cl_urn = scope_filter.get("codelist_urn")
|
|
104
|
+
return (
|
|
105
|
+
"SELECT DISTINCT c.code_id"
|
|
106
|
+
" FROM statcat.dataset d"
|
|
107
|
+
" JOIN sdmx.dataflow df ON df.urn = d.sdmx_dataflow_urn"
|
|
108
|
+
" JOIN sdmx.dsd_component dc ON dc.dsd_urn = df.dsd_urn"
|
|
109
|
+
" JOIN sdmx.code c ON c.codelist_urn = dc.codelist_urn"
|
|
110
|
+
" WHERE c.codelist_urn = ?"
|
|
111
|
+
), [cl_urn]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
#: A scope-scoped SQL builder: takes the scope's filter, returns
|
|
115
|
+
#: ``(sql, params)``. Typed rather than left as `Any` so a builder with the
|
|
116
|
+
#: wrong shape is a finding here, at the table, instead of a `TypeError` when
|
|
117
|
+
#: a particular edge is first traversed.
|
|
118
|
+
type _ScopeSql = Callable[[dict[str, object]], tuple[str, list[object]]]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _BridgeEdge(TypedDict):
|
|
122
|
+
"""A scope that reaches datasets through a statcatlib bridge table."""
|
|
123
|
+
|
|
124
|
+
kind: Literal["bridge"]
|
|
125
|
+
near_key: str
|
|
126
|
+
entity_key: str
|
|
127
|
+
bridge_table: str
|
|
128
|
+
far_key: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _SdmxEdge(TypedDict):
|
|
132
|
+
"""A scope whose join runs through sdmxlib's tables, so it brings its own SQL."""
|
|
133
|
+
|
|
134
|
+
kind: Literal["sdmx"]
|
|
135
|
+
near_key: str
|
|
136
|
+
entity_key: str
|
|
137
|
+
custom_count_sql: _ScopeSql
|
|
138
|
+
custom_exists_sql: _ScopeSql
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
#: The two are alternatives, not one shape with optional halves — a bridge
|
|
142
|
+
#: edge never has a custom builder and vice versa.
|
|
143
|
+
#:
|
|
144
|
+
#: Discriminated on an explicit ``kind`` rather than on key presence. Testing
|
|
145
|
+
#: ``"custom_count_sql" in edge`` to conclude ``custom_exists_sql`` also exists
|
|
146
|
+
#: is sound given the TypedDicts, but only basedpyright narrows it; pyrefly
|
|
147
|
+
#: reported reading the second key as an error. Two checkers disagreeing about
|
|
148
|
+
#: whether a branch is safe is reason enough to say which branch it is.
|
|
149
|
+
type _Edge = _BridgeEdge | _SdmxEdge
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_EDGE_MAP: dict[str, dict[str, _Edge]] = {
|
|
153
|
+
"concept": {
|
|
154
|
+
"datasets": {
|
|
155
|
+
"kind": "bridge",
|
|
156
|
+
"bridge_table": "statcat.dataset_theme",
|
|
157
|
+
"near_key": "concept_uri", # column in the bridge table
|
|
158
|
+
"entity_key": "uri", # column in the entity table (t.uri)
|
|
159
|
+
"far_key": "dataset_id",
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
"codelist": {
|
|
163
|
+
"datasets": {
|
|
164
|
+
"kind": "sdmx",
|
|
165
|
+
"custom_count_sql": _sdmx_codelist_count_sql,
|
|
166
|
+
"custom_exists_sql": _sdmx_codelist_exists_sql,
|
|
167
|
+
"near_key": "codelist_urn",
|
|
168
|
+
"entity_key": "urn",
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
"code": {
|
|
172
|
+
"datasets": {
|
|
173
|
+
"kind": "sdmx",
|
|
174
|
+
"custom_count_sql": _sdmx_code_count_sql,
|
|
175
|
+
"custom_exists_sql": _sdmx_code_exists_sql,
|
|
176
|
+
"near_key": "code_id",
|
|
177
|
+
"entity_key": "code_id",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# SQL compilation helpers for expressions
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _unwrap_expr(expr: Expr) -> tuple[Expr | None, dict[str, object], str]:
|
|
189
|
+
"""Unwrap an expression into (reduction, filter_conditions, edge_label).
|
|
190
|
+
|
|
191
|
+
Raises TypeError/NotImplementedError for unsupported expression shapes.
|
|
192
|
+
"""
|
|
193
|
+
from statcatlib.expr import CountExpr, ExistsExpr, FilteredExpr, TraversalExpr # noqa: PLC0415
|
|
194
|
+
|
|
195
|
+
current = expr
|
|
196
|
+
filter_conditions: dict[str, object] = {}
|
|
197
|
+
|
|
198
|
+
if isinstance(current, (CountExpr, ExistsExpr)):
|
|
199
|
+
source = current.source
|
|
200
|
+
reduction: Expr | None = current
|
|
201
|
+
else:
|
|
202
|
+
source = current
|
|
203
|
+
reduction = None
|
|
204
|
+
|
|
205
|
+
if isinstance(source, FilteredExpr):
|
|
206
|
+
filter_conditions = source.conditions
|
|
207
|
+
source = source.source
|
|
208
|
+
|
|
209
|
+
if not isinstance(source, TraversalExpr):
|
|
210
|
+
msg = f"Cannot compile expression: expected TraversalExpr root, got {type(source).__name__}"
|
|
211
|
+
raise TypeError(msg)
|
|
212
|
+
|
|
213
|
+
if source.edges != ("datasets",):
|
|
214
|
+
msg = f"Only 'datasets' edge is currently supported, got {source.edges}"
|
|
215
|
+
raise NotImplementedError(msg)
|
|
216
|
+
|
|
217
|
+
return reduction, filter_conditions, "datasets"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _resolve_edge(scope_type: str, edge_label: str) -> _Edge:
|
|
221
|
+
"""Look up the edge configuration for a scope type and edge label."""
|
|
222
|
+
edges = _EDGE_MAP.get(scope_type)
|
|
223
|
+
if edges is None:
|
|
224
|
+
msg = f"Unknown scope type: {scope_type!r}"
|
|
225
|
+
raise ValueError(msg)
|
|
226
|
+
edge = edges.get(edge_label)
|
|
227
|
+
if edge is None:
|
|
228
|
+
msg = f"No {edge_label!r} edge for scope type: {scope_type!r}"
|
|
229
|
+
raise ValueError(msg)
|
|
230
|
+
return edge
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _compile_enrich_sql(
|
|
234
|
+
expr: Expr,
|
|
235
|
+
alias_name: str,
|
|
236
|
+
scope_type: str,
|
|
237
|
+
*,
|
|
238
|
+
scope_filter: dict[str, object] | None = None,
|
|
239
|
+
) -> tuple[str, list[object], str]:
|
|
240
|
+
"""Compile a single expression into a LEFT JOIN + select column fragment.
|
|
241
|
+
|
|
242
|
+
Returns ``(select_fragment, params, join_fragment)``.
|
|
243
|
+
|
|
244
|
+
The ``scope_type`` selects the edge configuration from ``_EDGE_MAP``.
|
|
245
|
+
``scope_filter`` provides additional WHERE conditions on the bridge
|
|
246
|
+
table (e.g. ``{"theme_scheme": "subjects"}`` or
|
|
247
|
+
``{"codelist_urn": "urn:..."}``).
|
|
248
|
+
"""
|
|
249
|
+
from statcatlib.expr import CountExpr, ExistsExpr # noqa: PLC0415
|
|
250
|
+
|
|
251
|
+
scope_filter = scope_filter or {}
|
|
252
|
+
reduction, filter_conditions, edge_label = _unwrap_expr(expr)
|
|
253
|
+
edge = _resolve_edge(scope_type, edge_label)
|
|
254
|
+
|
|
255
|
+
near_key = edge["near_key"]
|
|
256
|
+
entity_key = edge.get("entity_key", near_key)
|
|
257
|
+
sub_alias = f"_enrich_{alias_name}"
|
|
258
|
+
|
|
259
|
+
# SDMX-joined edges have no bridge table and bring their own SQL. Branch on
|
|
260
|
+
# the *edge* first, then the reduction: keyed the other way round, an SDMX
|
|
261
|
+
# edge with an unsupported reduction fell past both arms into the bridge
|
|
262
|
+
# path and died on `edge["bridge_table"]` with a bare KeyError, where the
|
|
263
|
+
# bridge path raises a NotImplementedError naming the reduction. Same
|
|
264
|
+
# unsupported input, two different failures. This ordering is also what
|
|
265
|
+
# lets both checkers narrow `edge` to `_BridgeEdge` below.
|
|
266
|
+
if edge["kind"] == "sdmx":
|
|
267
|
+
if isinstance(reduction, CountExpr):
|
|
268
|
+
subq_sql, subq_params = edge["custom_count_sql"](scope_filter)
|
|
269
|
+
subquery = f"LEFT JOIN ({subq_sql}) {sub_alias} ON {sub_alias}.{near_key} = t.{entity_key}"
|
|
270
|
+
select_col = f"COALESCE({sub_alias}.cnt, 0) AS {alias_name}"
|
|
271
|
+
return select_col, subq_params, subquery
|
|
272
|
+
if isinstance(reduction, ExistsExpr):
|
|
273
|
+
subq_sql, subq_params = edge["custom_exists_sql"](scope_filter)
|
|
274
|
+
subquery = f"LEFT JOIN ({subq_sql}) {sub_alias} ON {sub_alias}.{near_key} = t.{entity_key}"
|
|
275
|
+
select_col = f"({sub_alias}.{near_key} IS NOT NULL) AS {alias_name}"
|
|
276
|
+
return select_col, subq_params, subquery
|
|
277
|
+
msg = f"Unsupported reduction: {type(reduction).__name__ if reduction else 'none'}"
|
|
278
|
+
raise NotImplementedError(msg)
|
|
279
|
+
|
|
280
|
+
# Bridge table path (for theme/concept edges)
|
|
281
|
+
bridge = edge["bridge_table"]
|
|
282
|
+
where_clauses: list[str] = []
|
|
283
|
+
params: list[object] = []
|
|
284
|
+
|
|
285
|
+
for col, val in scope_filter.items():
|
|
286
|
+
where_clauses.append(f"bt.{col} = ?")
|
|
287
|
+
params.append(val)
|
|
288
|
+
|
|
289
|
+
far_join = ""
|
|
290
|
+
for col, val in filter_conditions.items():
|
|
291
|
+
far_join = f" JOIN statcat.dataset ds ON ds.identifier = bt.{edge['far_key']}"
|
|
292
|
+
where_clauses.append(f"ds.{col} = ?")
|
|
293
|
+
params.append(val)
|
|
294
|
+
|
|
295
|
+
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
|
296
|
+
|
|
297
|
+
if isinstance(reduction, CountExpr):
|
|
298
|
+
subquery = (
|
|
299
|
+
f"LEFT JOIN ("
|
|
300
|
+
f"SELECT bt.{near_key}, COUNT(*) AS cnt"
|
|
301
|
+
f" FROM {bridge} bt{far_join}"
|
|
302
|
+
f" WHERE {where_sql}"
|
|
303
|
+
f" GROUP BY bt.{near_key}"
|
|
304
|
+
f") {sub_alias} ON {sub_alias}.{near_key} = t.{entity_key}"
|
|
305
|
+
)
|
|
306
|
+
select_col = f"COALESCE({sub_alias}.cnt, 0) AS {alias_name}"
|
|
307
|
+
return select_col, params, subquery
|
|
308
|
+
if isinstance(reduction, ExistsExpr):
|
|
309
|
+
subquery = (
|
|
310
|
+
f"LEFT JOIN ("
|
|
311
|
+
f"SELECT DISTINCT bt.{near_key}"
|
|
312
|
+
f" FROM {bridge} bt{far_join}"
|
|
313
|
+
f" WHERE {where_sql}"
|
|
314
|
+
f") {sub_alias} ON {sub_alias}.{near_key} = t.{entity_key}"
|
|
315
|
+
)
|
|
316
|
+
select_col = f"({sub_alias}.{near_key} IS NOT NULL) AS {alias_name}"
|
|
317
|
+
return select_col, params, subquery
|
|
318
|
+
msg = f"Unsupported reduction: {type(reduction).__name__ if reduction else 'none'}"
|
|
319
|
+
raise NotImplementedError(msg)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _compile_enriched_query(
|
|
323
|
+
scope_type: str,
|
|
324
|
+
entity_sql: str,
|
|
325
|
+
entity_id_col: str,
|
|
326
|
+
entity_select_cols: list[str],
|
|
327
|
+
scope_filter: dict[str, object],
|
|
328
|
+
exprs: tuple[AliasedExpr, ...],
|
|
329
|
+
conn: duckdb.DuckDBPyConnection,
|
|
330
|
+
*,
|
|
331
|
+
entity_params: Sequence[object] | None = None,
|
|
332
|
+
where_sql: str = "",
|
|
333
|
+
where_params: Sequence[object] | None = None,
|
|
334
|
+
) -> list[Row]:
|
|
335
|
+
"""Compile and execute an enriched query for any scope type.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
scope_type: Key into ``_EDGE_MAP`` (``"theme"``, ``"codelist"``, ``"code"``).
|
|
339
|
+
entity_sql: FROM clause for the entity table — ``"FROM statcat.theme t"``,
|
|
340
|
+
``"FROM sdmx.code t"``, and so on.
|
|
341
|
+
entity_id_col: Column alias in ``t`` that becomes ``near_key`` for the JOIN, e.g. ``"t.id"`` for
|
|
342
|
+
themes, ``"t.urn"`` for codelists.
|
|
343
|
+
entity_select_cols: Columns to SELECT, e.g. ``["t.id", "t.label"]``.
|
|
344
|
+
scope_filter: Extra WHERE conditions on the bridge table.
|
|
345
|
+
exprs: The aliased expressions to compile.
|
|
346
|
+
conn: DuckDB connection to execute on.
|
|
347
|
+
entity_params: Parameters for the entity_sql (e.g. lang param for JOIN).
|
|
348
|
+
where_sql: Optional WHERE clause for filtering the entity table.
|
|
349
|
+
where_params: Parameters for the WHERE clause.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
One `Row` per entity, carrying the entity columns plus one column
|
|
353
|
+
per aliased expression.
|
|
354
|
+
"""
|
|
355
|
+
entity_params = entity_params or []
|
|
356
|
+
where_params = where_params or []
|
|
357
|
+
|
|
358
|
+
select_cols = list(entity_select_cols)
|
|
359
|
+
joins: list[str] = []
|
|
360
|
+
all_params: list[object] = []
|
|
361
|
+
|
|
362
|
+
for ae in exprs:
|
|
363
|
+
select_col, params, join_sql = _compile_enrich_sql(
|
|
364
|
+
ae.expr,
|
|
365
|
+
ae.name,
|
|
366
|
+
scope_type,
|
|
367
|
+
scope_filter=scope_filter,
|
|
368
|
+
)
|
|
369
|
+
select_cols.append(select_col)
|
|
370
|
+
joins.append(join_sql)
|
|
371
|
+
all_params.extend(params)
|
|
372
|
+
|
|
373
|
+
where_clause = f" WHERE {where_sql}" if where_sql else ""
|
|
374
|
+
|
|
375
|
+
sql = (
|
|
376
|
+
f"SELECT {', '.join(select_cols)}"
|
|
377
|
+
f" {entity_sql}"
|
|
378
|
+
f" {''.join(f' {j}' for j in joins)}"
|
|
379
|
+
f"{where_clause}"
|
|
380
|
+
f" ORDER BY {entity_id_col}"
|
|
381
|
+
)
|
|
382
|
+
final_params = [*entity_params, *all_params, *where_params]
|
|
383
|
+
|
|
384
|
+
rows = conn.execute(sql, final_params).fetchall()
|
|
385
|
+
n_base = len(entity_select_cols)
|
|
386
|
+
results = []
|
|
387
|
+
for row in rows:
|
|
388
|
+
item = Row()
|
|
389
|
+
for i, col_expr in enumerate(entity_select_cols):
|
|
390
|
+
# Derive key from "t.id" → "id", "t.label" → "label",
|
|
391
|
+
# "COALESCE(...) AS label" → "label"
|
|
392
|
+
if " AS " in col_expr.upper():
|
|
393
|
+
key = col_expr.rsplit(" ", 1)[-1].strip()
|
|
394
|
+
else:
|
|
395
|
+
key = col_expr.rsplit(".", 1)[-1].strip()
|
|
396
|
+
item[key] = row[i]
|
|
397
|
+
for i, ae in enumerate(exprs):
|
|
398
|
+
item[ae.name] = row[n_base + i]
|
|
399
|
+
results.append(item)
|
|
400
|
+
return results
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
# DimensionInfo — plain data class replacing DimensionView
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@define
|
|
409
|
+
class DimensionInfo:
|
|
410
|
+
"""Plain data object describing a dimension of a dataset.
|
|
411
|
+
|
|
412
|
+
Unlike the old ``DimensionView``, this has no lazy properties.
|
|
413
|
+
Navigation to the codelist is done via ``DimensionScope.codelist``.
|
|
414
|
+
|
|
415
|
+
Mutable rather than frozen so it satisfies sdmxlib's ``SupportsId``,
|
|
416
|
+
the bound on ``ItemList[T]`` since 0.61 — a protocol declaring a
|
|
417
|
+
writable ``id``, which a frozen attrs class cannot provide. This is
|
|
418
|
+
also the project convention (CLAUDE.md: domain objects use ``@define``
|
|
419
|
+
and copy with ``attrs.evolve()``); the frozen spelling was the outlier.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
id: str
|
|
423
|
+
label: InternationalString
|
|
424
|
+
position: int
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# Scope classes — Themes
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@define(frozen=True)
|
|
433
|
+
class ThemesScope:
|
|
434
|
+
"""Scope over all SKOS concept schemes in the catalog.
|
|
435
|
+
|
|
436
|
+
Entry point: ``store.themes``.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
440
|
+
|
|
441
|
+
def __getitem__(self, scheme_uri: str) -> SchemeScope:
|
|
442
|
+
return SchemeScope(scheme=scheme_uri, store=self._store)
|
|
443
|
+
|
|
444
|
+
def collect(self) -> list[SchemeScope]:
|
|
445
|
+
"""Terminal: all concept scheme handles.
|
|
446
|
+
|
|
447
|
+
A plain list, not an ``ItemList``: a `SchemeScope` is keyed by
|
|
448
|
+
``scheme`` URI and has no ``id``, so it never satisfied what an
|
|
449
|
+
item list is for. Use ``store.themes[uri]`` to address one.
|
|
450
|
+
"""
|
|
451
|
+
rows = self._store.connection.execute("SELECT uri FROM statcat.concept_scheme ORDER BY uri").fetchall()
|
|
452
|
+
return [SchemeScope(scheme=r[0], store=self._store) for r in rows]
|
|
453
|
+
|
|
454
|
+
def __iter__(self) -> Iterator[SchemeScope]:
|
|
455
|
+
return iter(self.collect())
|
|
456
|
+
|
|
457
|
+
def __len__(self) -> int:
|
|
458
|
+
row = self._store.connection.execute("SELECT COUNT(*) FROM statcat.concept_scheme").fetchone()
|
|
459
|
+
return row[0] if row else 0
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@define(frozen=True)
|
|
463
|
+
class SchemeScope:
|
|
464
|
+
"""Scope over concepts within a single SKOS concept scheme.
|
|
465
|
+
|
|
466
|
+
Entry point: ``store.themes["https://example.com/themes"]``.
|
|
467
|
+
The ``scheme`` field is the scheme URI.
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
scheme: str
|
|
471
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
472
|
+
_exprs: tuple[AliasedExpr, ...] = field(default=(), repr=False)
|
|
473
|
+
_filters: tuple[Expr, ...] = field(default=(), repr=False)
|
|
474
|
+
_sort: tuple[tuple[Expr, bool], ...] = field(default=(), repr=False)
|
|
475
|
+
|
|
476
|
+
@property
|
|
477
|
+
def uri(self) -> str: # noqa: D102
|
|
478
|
+
return self.scheme
|
|
479
|
+
|
|
480
|
+
def __getitem__(self, notation: str) -> ConceptScope:
|
|
481
|
+
return ConceptScope(
|
|
482
|
+
scheme=self.scheme,
|
|
483
|
+
notation=notation,
|
|
484
|
+
store=self._store,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def collect(self) -> list[ConceptRef]:
|
|
488
|
+
"""Terminal: materialize concepts in this scheme as ConceptRef objects.
|
|
489
|
+
|
|
490
|
+
Use ``.rows()`` for enriched tabular results after ``.enrich()``.
|
|
491
|
+
"""
|
|
492
|
+
if self._exprs:
|
|
493
|
+
msg = ".collect() cannot be used when .enrich() is active — use .rows() instead"
|
|
494
|
+
raise ValueError(msg)
|
|
495
|
+
conn = self._store.connection
|
|
496
|
+
rows = conn.execute(
|
|
497
|
+
"SELECT uri, notation, label FROM statcat.concept WHERE in_scheme = ? ORDER BY notation",
|
|
498
|
+
[self.scheme],
|
|
499
|
+
).fetchall()
|
|
500
|
+
return [
|
|
501
|
+
ConceptRef(
|
|
502
|
+
uri=r[0],
|
|
503
|
+
notation=r[1],
|
|
504
|
+
scheme_uri=self.scheme,
|
|
505
|
+
label=InternationalString.of(r[2]) if r[2] else InternationalString(),
|
|
506
|
+
)
|
|
507
|
+
for r in rows
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
def rows(self) -> list[Row]:
|
|
511
|
+
"""Terminal: return enriched results as ``Row`` objects (requires ``.enrich()``)."""
|
|
512
|
+
if not self._exprs:
|
|
513
|
+
msg = ".rows() requires .enrich() — call .enrich(...) first, or use .collect() for domain objects"
|
|
514
|
+
raise ValueError(msg)
|
|
515
|
+
return _compile_enriched_query(
|
|
516
|
+
scope_type="concept",
|
|
517
|
+
entity_sql="FROM statcat.concept t",
|
|
518
|
+
entity_id_col="t.uri",
|
|
519
|
+
entity_select_cols=["t.uri", "t.notation", "t.label"],
|
|
520
|
+
scope_filter={},
|
|
521
|
+
exprs=self._exprs,
|
|
522
|
+
conn=self._store.connection,
|
|
523
|
+
where_sql="t.in_scheme = ?",
|
|
524
|
+
where_params=[self.scheme],
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
def resolve(self) -> ConceptScheme | None:
|
|
528
|
+
"""Terminal: fully hydrate the scheme including all its concepts."""
|
|
529
|
+
return self._store.get_concept_scheme(self.scheme)
|
|
530
|
+
|
|
531
|
+
def count(self) -> int:
|
|
532
|
+
"""Terminal: number of concepts in this scheme."""
|
|
533
|
+
row = self._store.connection.execute(
|
|
534
|
+
"SELECT COUNT(*) FROM statcat.concept WHERE in_scheme = ?",
|
|
535
|
+
[self.scheme],
|
|
536
|
+
).fetchone()
|
|
537
|
+
return row[0] if row else 0
|
|
538
|
+
|
|
539
|
+
def labels(self, lang: str = "en") -> dict[str, str]:
|
|
540
|
+
"""Return ``{notation: display_label}`` for concepts in this scheme."""
|
|
541
|
+
rows = self._store.connection.execute(
|
|
542
|
+
"SELECT notation, label[?] FROM statcat.concept WHERE in_scheme = ? AND label[?] IS NOT NULL",
|
|
543
|
+
[lang, self.scheme, lang],
|
|
544
|
+
).fetchall()
|
|
545
|
+
return {r[0]: r[1] for r in rows}
|
|
546
|
+
|
|
547
|
+
@property
|
|
548
|
+
def datasets(self) -> QueryBuilder[BaseDataset]:
|
|
549
|
+
"""Navigate to datasets tagged with any concept in this scheme (lazy).
|
|
550
|
+
|
|
551
|
+
Returns a ``QueryBuilder`` pre-filtered to datasets citing concepts
|
|
552
|
+
from this scheme.
|
|
553
|
+
"""
|
|
554
|
+
# Collect all concept URIs in this scheme, then filter datasets
|
|
555
|
+
# that have any of them as themes.
|
|
556
|
+
rows = self._store.connection.execute(
|
|
557
|
+
"SELECT uri FROM statcat.concept WHERE in_scheme = ?",
|
|
558
|
+
[self.scheme],
|
|
559
|
+
).fetchall()
|
|
560
|
+
concept_uris = [r[0] for r in rows]
|
|
561
|
+
|
|
562
|
+
from statcatlib.model.dataset import BaseDataset # noqa: PLC0415
|
|
563
|
+
|
|
564
|
+
qb = self._store.query(BaseDataset)
|
|
565
|
+
if not concept_uris:
|
|
566
|
+
return qb
|
|
567
|
+
# ExistsFilter with a list value generates IN (...) — matches any concept in scheme
|
|
568
|
+
ef = ExistsFilter(table="dataset_theme", conditions={"concept_uri": concept_uris})
|
|
569
|
+
return qb.filter(ef)
|
|
570
|
+
|
|
571
|
+
# -- Scope-creating operations (lazy, return new scope) --
|
|
572
|
+
|
|
573
|
+
def enrich(self, *exprs: AliasedExpr, **kwargs: Expr) -> SchemeScope:
|
|
574
|
+
"""Add computed fields. Returns new scope (lazy)."""
|
|
575
|
+
all_exprs = exprs + tuple(e.alias(k) for k, e in kwargs.items())
|
|
576
|
+
return attrs.evolve(self, exprs=self._exprs + all_exprs)
|
|
577
|
+
|
|
578
|
+
def filter(self, *exprs: Expr) -> SchemeScope:
|
|
579
|
+
"""Keep items where expressions are truthy. Returns new scope (lazy)."""
|
|
580
|
+
return attrs.evolve(self, filters=self._filters + exprs)
|
|
581
|
+
|
|
582
|
+
def sort_by(self, expr: Expr, *, desc: bool = False) -> SchemeScope:
|
|
583
|
+
"""Order items by expression. Returns new scope (lazy)."""
|
|
584
|
+
return attrs.evolve(self, sort=(*self._sort, (expr, desc)))
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@define(frozen=True)
|
|
588
|
+
class ConceptScope:
|
|
589
|
+
"""Scope for a single concept within a scheme, identified by notation.
|
|
590
|
+
|
|
591
|
+
Entry point: ``store.themes["https://example.com/themes"]["13"]``.
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
scheme: str
|
|
595
|
+
notation: str
|
|
596
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
597
|
+
|
|
598
|
+
def collect(self) -> Concept:
|
|
599
|
+
"""Terminal: materialize this concept."""
|
|
600
|
+
row = self._store.connection.execute(
|
|
601
|
+
"""
|
|
602
|
+
SELECT notation, label, alt_label, definition
|
|
603
|
+
FROM statcat.concept
|
|
604
|
+
WHERE in_scheme = ? AND notation = ?
|
|
605
|
+
""",
|
|
606
|
+
[self.scheme, self.notation],
|
|
607
|
+
).fetchone()
|
|
608
|
+
if row is None:
|
|
609
|
+
msg = f"Concept not found: notation={self.notation!r} in scheme {self.scheme!r}"
|
|
610
|
+
raise LookupError(msg)
|
|
611
|
+
return Concept(
|
|
612
|
+
notation=row[0],
|
|
613
|
+
label=InternationalString.of(row[1]) if row[1] else InternationalString(),
|
|
614
|
+
alt_label=InternationalString.of(row[2]) if row[2] else InternationalString(),
|
|
615
|
+
definition=InternationalString.of(row[3]) if row[3] else None,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
@property
|
|
619
|
+
def datasets(self) -> QueryBuilder[BaseDataset]:
|
|
620
|
+
"""Navigate to datasets tagged with this concept (lazy)."""
|
|
621
|
+
# Resolve notation → URI first
|
|
622
|
+
row = self._store.connection.execute(
|
|
623
|
+
"SELECT uri FROM statcat.concept WHERE in_scheme = ? AND notation = ?",
|
|
624
|
+
[self.scheme, self.notation],
|
|
625
|
+
).fetchone()
|
|
626
|
+
from statcatlib.model.dataset import BaseDataset # noqa: PLC0415
|
|
627
|
+
|
|
628
|
+
qb = self._store.query(BaseDataset)
|
|
629
|
+
if row is None:
|
|
630
|
+
return qb.filter(ExistsFilter(table="dataset_theme", conditions={"concept_uri": "__none__"}))
|
|
631
|
+
return qb.filter(ExistsFilter(table="dataset_theme", conditions={"concept_uri": row[0]}))
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---------------------------------------------------------------------------
|
|
635
|
+
# Scope classes — Codelists
|
|
636
|
+
# ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@define(frozen=True)
|
|
640
|
+
class CodelistsScope:
|
|
641
|
+
"""Scope over all codelists in the shared structural tables.
|
|
642
|
+
|
|
643
|
+
Entry point: ``store.codelists``.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
647
|
+
_exprs: tuple[AliasedExpr, ...] = field(default=(), repr=False)
|
|
648
|
+
_lang: str = field(default="en", repr=False)
|
|
649
|
+
_filter_text: str | None = field(default=None, repr=False)
|
|
650
|
+
|
|
651
|
+
def __getitem__(self, urn: str) -> CodelistScope:
|
|
652
|
+
return CodelistScope(urn=urn, store=self._store)
|
|
653
|
+
|
|
654
|
+
def lang(self, lang: str) -> CodelistsScope:
|
|
655
|
+
"""Set language for label resolution. Returns new scope (lazy)."""
|
|
656
|
+
return attrs.evolve(self, lang=lang)
|
|
657
|
+
|
|
658
|
+
def filter(self, *, text_contains: str) -> CodelistsScope:
|
|
659
|
+
"""Filter codelists by label text (case-insensitive). Returns new scope (lazy)."""
|
|
660
|
+
return attrs.evolve(self, filter_text=text_contains)
|
|
661
|
+
|
|
662
|
+
def collect(self) -> list[CodelistScope]:
|
|
663
|
+
"""Terminal: all codelists as scope handles.
|
|
664
|
+
|
|
665
|
+
Use ``.rows()`` for enriched tabular results after ``.enrich()``.
|
|
666
|
+
"""
|
|
667
|
+
if self._exprs:
|
|
668
|
+
msg = ".collect() cannot be used when .enrich() is active — use .rows() instead"
|
|
669
|
+
raise ValueError(msg)
|
|
670
|
+
try:
|
|
671
|
+
rows = self._store.connection.execute(
|
|
672
|
+
"SELECT DISTINCT urn FROM sdmx.artefact WHERE artefact_type = 'Codelist' ORDER BY urn"
|
|
673
|
+
).fetchall()
|
|
674
|
+
except duckdb.CatalogException:
|
|
675
|
+
return []
|
|
676
|
+
return [CodelistScope(urn=r[0], store=self._store) for r in rows]
|
|
677
|
+
|
|
678
|
+
def rows(self) -> list[Row]:
|
|
679
|
+
"""Terminal: return enriched results as ``Row`` objects (requires ``.enrich()``)."""
|
|
680
|
+
if not self._exprs:
|
|
681
|
+
msg = ".rows() requires .enrich() — call .enrich(...) first, or use .collect() for domain objects"
|
|
682
|
+
raise ValueError(msg)
|
|
683
|
+
entity_sql = (
|
|
684
|
+
"FROM sdmx.artefact t"
|
|
685
|
+
" LEFT JOIN sdmx.localized_text lt"
|
|
686
|
+
" ON lt.owner_urn = t.urn AND lt.field = 'name' AND lt.lang = ?"
|
|
687
|
+
)
|
|
688
|
+
entity_params = [self._lang]
|
|
689
|
+
entity_select_cols = ["t.urn", "COALESCE(lt.text, t.urn) AS label"]
|
|
690
|
+
|
|
691
|
+
where_parts: list[str] = ["t.artefact_type = 'Codelist'"]
|
|
692
|
+
where_params: list[object] = []
|
|
693
|
+
if self._filter_text is not None:
|
|
694
|
+
# Codelist name text is matched accent- AND punctuation-insensitively
|
|
695
|
+
# (Montréal ~ Montreal, Trois-Rivières ~ Trois Rivieres); the URN is
|
|
696
|
+
# an ASCII identifier, left as a plain ILIKE.
|
|
697
|
+
where_parts.append(
|
|
698
|
+
f"({label_norm_sql('lt.text')} ILIKE '%' || {label_norm_sql('?')} || '%' OR t.urn ILIKE ?)"
|
|
699
|
+
)
|
|
700
|
+
where_params.extend([self._filter_text, f"%{self._filter_text}%"])
|
|
701
|
+
|
|
702
|
+
return _compile_enriched_query(
|
|
703
|
+
scope_type="codelist",
|
|
704
|
+
entity_sql=entity_sql,
|
|
705
|
+
entity_id_col="t.urn",
|
|
706
|
+
entity_select_cols=entity_select_cols,
|
|
707
|
+
scope_filter={},
|
|
708
|
+
exprs=self._exprs,
|
|
709
|
+
conn=self._store.connection,
|
|
710
|
+
entity_params=entity_params,
|
|
711
|
+
where_sql=" AND ".join(where_parts),
|
|
712
|
+
where_params=where_params,
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
def batch_labels(
|
|
716
|
+
self,
|
|
717
|
+
pairs: list[tuple[str, str]],
|
|
718
|
+
lang: str | None = None,
|
|
719
|
+
) -> dict[tuple[str, str], str]:
|
|
720
|
+
"""Terminal: resolve labels for ``(codelist_urn, code_id)`` pairs in one query.
|
|
721
|
+
|
|
722
|
+
Returns a dict mapping ``(urn, code_id) → label``. Falls back to the
|
|
723
|
+
code ID itself when the requested language has no entry.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
pairs: Sequence of ``(codelist_urn, code_id)`` tuples to resolve.
|
|
727
|
+
lang: Language code; defaults to the scope's ``.lang()`` setting.
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
``{(codelist_urn, code_id): label}`` for every pair that resolved.
|
|
731
|
+
A pair with no label in *lang* falls back to its code id.
|
|
732
|
+
"""
|
|
733
|
+
if not pairs:
|
|
734
|
+
return {}
|
|
735
|
+
resolved_lang = lang if lang is not None else self._lang
|
|
736
|
+
# Build a VALUES table and JOIN against sdmx.code.
|
|
737
|
+
# resolved_lang is the first ? (MAP subscript); VALUES pairs follow.
|
|
738
|
+
values_rows = ", ".join("(?, ?)" for _ in pairs)
|
|
739
|
+
flat_params: list[object] = [resolved_lang]
|
|
740
|
+
for urn, cid in pairs:
|
|
741
|
+
flat_params.extend([urn, cid])
|
|
742
|
+
try:
|
|
743
|
+
rows = self._store.connection.execute(
|
|
744
|
+
f"""
|
|
745
|
+
SELECT c.codelist_urn, c.code_id, COALESCE(c.name[?], c.code_id) AS label
|
|
746
|
+
FROM sdmx.code c
|
|
747
|
+
JOIN (VALUES {values_rows}) AS v(cl_urn, cd_id)
|
|
748
|
+
ON c.codelist_urn = v.cl_urn AND c.code_id = v.cd_id
|
|
749
|
+
""",
|
|
750
|
+
flat_params,
|
|
751
|
+
).fetchall()
|
|
752
|
+
except duckdb.CatalogException:
|
|
753
|
+
return {pair: pair[1] for pair in pairs}
|
|
754
|
+
result: dict[tuple[str, str], str] = {(r[0], r[1]): r[2] for r in rows}
|
|
755
|
+
# Fill any pairs not found in the table with the code ID itself
|
|
756
|
+
for pair in pairs:
|
|
757
|
+
result.setdefault(pair, pair[1])
|
|
758
|
+
return result
|
|
759
|
+
|
|
760
|
+
def enrich(self, *exprs: AliasedExpr, **kwargs: Expr) -> CodelistsScope:
|
|
761
|
+
"""Add computed fields. Returns new scope (lazy)."""
|
|
762
|
+
all_exprs = exprs + tuple(e.alias(k) for k, e in kwargs.items())
|
|
763
|
+
return attrs.evolve(self, exprs=self._exprs + all_exprs)
|
|
764
|
+
|
|
765
|
+
def __iter__(self) -> Iterator[CodelistScope]:
|
|
766
|
+
return iter(self.collect())
|
|
767
|
+
|
|
768
|
+
def __len__(self) -> int:
|
|
769
|
+
try:
|
|
770
|
+
row = self._store.connection.execute(
|
|
771
|
+
"SELECT COUNT(DISTINCT urn) FROM sdmx.artefact WHERE artefact_type = 'Codelist'"
|
|
772
|
+
).fetchone()
|
|
773
|
+
except duckdb.CatalogException:
|
|
774
|
+
return 0
|
|
775
|
+
return row[0] if row else 0
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
@define(frozen=True)
|
|
779
|
+
class CodelistScope:
|
|
780
|
+
"""Scope for a codelist identified by URN.
|
|
781
|
+
|
|
782
|
+
Entry point: ``store.codelist("urn:...:CL_GEO(1.0)")``
|
|
783
|
+
or ``store.codelists["urn:...:CL_GEO(1.0)"]``.
|
|
784
|
+
"""
|
|
785
|
+
|
|
786
|
+
urn: str
|
|
787
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
788
|
+
_exprs: tuple[AliasedExpr, ...] = field(default=(), repr=False)
|
|
789
|
+
_lang: str = field(default="en", repr=False)
|
|
790
|
+
|
|
791
|
+
def __getitem__(self, code_id: str) -> CodeScope:
|
|
792
|
+
return CodeScope(codelist_urn=self.urn, code_id=code_id, store=self._store)
|
|
793
|
+
|
|
794
|
+
def lang(self, lang: str) -> CodelistScope:
|
|
795
|
+
"""Set language for code label resolution. Returns new scope (lazy)."""
|
|
796
|
+
return attrs.evolve(self, lang=lang)
|
|
797
|
+
|
|
798
|
+
def collect(self) -> ItemList[Code]:
|
|
799
|
+
"""Terminal: resolve codelist and return all codes.
|
|
800
|
+
|
|
801
|
+
Use ``.rows()`` for enriched tabular results after ``.enrich()``.
|
|
802
|
+
"""
|
|
803
|
+
if self._exprs:
|
|
804
|
+
msg = ".collect() cannot be used when .enrich() is active — use .rows() instead"
|
|
805
|
+
raise ValueError(msg)
|
|
806
|
+
return self._store._resolve_codelist(self.urn).codes # noqa: SLF001
|
|
807
|
+
|
|
808
|
+
def rows(self) -> list[Row]:
|
|
809
|
+
"""Terminal: return enriched results as ``Row`` objects (requires ``.enrich()``)."""
|
|
810
|
+
if not self._exprs:
|
|
811
|
+
msg = ".rows() requires .enrich() — call .enrich(...) first, or use .collect() for domain objects"
|
|
812
|
+
raise ValueError(msg)
|
|
813
|
+
return _compile_enriched_query(
|
|
814
|
+
scope_type="code",
|
|
815
|
+
entity_sql="FROM sdmx.code t",
|
|
816
|
+
entity_id_col="t.code_id",
|
|
817
|
+
entity_select_cols=[
|
|
818
|
+
"t.code_id",
|
|
819
|
+
"COALESCE(t.name[?], t.code_id) AS label",
|
|
820
|
+
],
|
|
821
|
+
scope_filter={"codelist_urn": self.urn},
|
|
822
|
+
exprs=self._exprs,
|
|
823
|
+
conn=self._store.connection,
|
|
824
|
+
entity_params=[self._lang],
|
|
825
|
+
where_sql="t.codelist_urn = ?",
|
|
826
|
+
where_params=[self.urn],
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
def count(self) -> int:
|
|
830
|
+
"""Terminal: number of codes in this codelist."""
|
|
831
|
+
cl = self._store._resolve_codelist(self.urn) # noqa: SLF001
|
|
832
|
+
return len(cl.codes)
|
|
833
|
+
|
|
834
|
+
def label(self, lang: str = "en") -> str | None:
|
|
835
|
+
"""Terminal: human-readable label for this codelist in *lang*.
|
|
836
|
+
|
|
837
|
+
Resolves via the sdmxlib localized_text table.
|
|
838
|
+
"""
|
|
839
|
+
try:
|
|
840
|
+
row = self._store.connection.execute(
|
|
841
|
+
"""
|
|
842
|
+
SELECT lt.text FROM sdmx.localized_text lt
|
|
843
|
+
WHERE lt.owner_urn = ? AND lt.field = 'name' AND lt.lang = ?
|
|
844
|
+
LIMIT 1
|
|
845
|
+
""",
|
|
846
|
+
[self.urn, lang],
|
|
847
|
+
).fetchone()
|
|
848
|
+
except duckdb.CatalogException:
|
|
849
|
+
return None
|
|
850
|
+
return row[0] if row else None
|
|
851
|
+
|
|
852
|
+
def batch_labels(self, code_ids: list[str], lang: str | None = None) -> dict[str, str]:
|
|
853
|
+
"""Terminal: resolve labels for *code_ids* in a single SQL query.
|
|
854
|
+
|
|
855
|
+
Returns a dict mapping ``code_id → label``. Falls back to the
|
|
856
|
+
code ID itself when the requested language has no entry.
|
|
857
|
+
|
|
858
|
+
Args:
|
|
859
|
+
code_ids: Code IDs to resolve (e.g. ``["ON", "QC", "BC"]``).
|
|
860
|
+
lang: Language code; defaults to the scope's ``.lang()`` setting.
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
``{code_id: label}`` for every id that resolved. An id with no
|
|
864
|
+
label in *lang* falls back to the id itself.
|
|
865
|
+
"""
|
|
866
|
+
if not code_ids:
|
|
867
|
+
return {}
|
|
868
|
+
resolved_lang = lang if lang is not None else self._lang
|
|
869
|
+
placeholders = ", ".join("?" * len(code_ids))
|
|
870
|
+
try:
|
|
871
|
+
rows = self._store.connection.execute(
|
|
872
|
+
f"""
|
|
873
|
+
SELECT code_id, COALESCE(name[?], code_id) AS label
|
|
874
|
+
FROM sdmx.code
|
|
875
|
+
WHERE codelist_urn = ?
|
|
876
|
+
AND code_id IN ({placeholders})
|
|
877
|
+
""",
|
|
878
|
+
[resolved_lang, self.urn, *code_ids],
|
|
879
|
+
).fetchall()
|
|
880
|
+
except duckdb.CatalogException:
|
|
881
|
+
return {cid: cid for cid in code_ids}
|
|
882
|
+
result = {r[0]: r[1] for r in rows}
|
|
883
|
+
# Fill any code_ids not found in the table with the ID itself
|
|
884
|
+
for cid in code_ids:
|
|
885
|
+
result.setdefault(cid, cid)
|
|
886
|
+
return result
|
|
887
|
+
|
|
888
|
+
def enrich(self, *exprs: AliasedExpr, **kwargs: Expr) -> CodelistScope:
|
|
889
|
+
"""Add computed fields to per-code results. Returns new scope (lazy)."""
|
|
890
|
+
all_exprs = exprs + tuple(e.alias(k) for k, e in kwargs.items())
|
|
891
|
+
return attrs.evolve(self, exprs=self._exprs + all_exprs)
|
|
892
|
+
|
|
893
|
+
@property
|
|
894
|
+
def datasets(self) -> QueryBuilder[BaseDataset]:
|
|
895
|
+
"""Navigate to datasets using this codelist (lazy)."""
|
|
896
|
+
from statcatlib.model.dataset import BaseDataset # noqa: PLC0415
|
|
897
|
+
from statcatlib.query import SdmxCodelistFilter # noqa: PLC0415
|
|
898
|
+
|
|
899
|
+
return self._store.query(BaseDataset).filter(SdmxCodelistFilter(codelist_urn=self.urn))
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
@define(frozen=True)
|
|
903
|
+
class CodeScope:
|
|
904
|
+
"""Scope for a single code within a codelist.
|
|
905
|
+
|
|
906
|
+
Entry point: ``store.codelist("urn:...")["ON"]``.
|
|
907
|
+
"""
|
|
908
|
+
|
|
909
|
+
codelist_urn: str
|
|
910
|
+
code_id: str
|
|
911
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
912
|
+
|
|
913
|
+
def collect(self) -> Code:
|
|
914
|
+
"""Terminal: resolve this code."""
|
|
915
|
+
return self._store._resolve_codelist(self.codelist_urn).codes[self.code_id] # noqa: SLF001
|
|
916
|
+
|
|
917
|
+
def dataset_count(self) -> int:
|
|
918
|
+
"""Terminal: number of datasets whose DSD uses the codelist containing this code."""
|
|
919
|
+
try:
|
|
920
|
+
row = self._store.connection.execute(
|
|
921
|
+
"""
|
|
922
|
+
SELECT COUNT(DISTINCT d.identifier)
|
|
923
|
+
FROM statcat.dataset d
|
|
924
|
+
JOIN sdmx.dataflow df ON df.urn = d.sdmx_dataflow_urn
|
|
925
|
+
JOIN sdmx.dsd_component dc ON dc.dsd_urn = df.dsd_urn
|
|
926
|
+
WHERE dc.codelist_urn = ?
|
|
927
|
+
""",
|
|
928
|
+
[self.codelist_urn],
|
|
929
|
+
).fetchone()
|
|
930
|
+
except duckdb.Error:
|
|
931
|
+
return 0
|
|
932
|
+
return row[0] if row else 0
|
|
933
|
+
|
|
934
|
+
@property
|
|
935
|
+
def datasets(self) -> QueryBuilder[BaseDataset]:
|
|
936
|
+
"""Navigate to datasets whose DSD uses the codelist containing this code (lazy)."""
|
|
937
|
+
from statcatlib.model.dataset import BaseDataset # noqa: PLC0415
|
|
938
|
+
from statcatlib.query import SdmxCodelistFilter # noqa: PLC0415
|
|
939
|
+
|
|
940
|
+
return self._store.query(BaseDataset).filter(SdmxCodelistFilter(codelist_urn=self.codelist_urn))
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
# ---------------------------------------------------------------------------
|
|
944
|
+
# Scope classes — Dimensions
|
|
945
|
+
# ---------------------------------------------------------------------------
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
@define(frozen=True)
|
|
949
|
+
class DimensionsScope:
|
|
950
|
+
"""Scope over dimensions of a dataset.
|
|
951
|
+
|
|
952
|
+
Entry point: ``store.dimensions(ds)``.
|
|
953
|
+
"""
|
|
954
|
+
|
|
955
|
+
dataflow_urn: str
|
|
956
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
957
|
+
|
|
958
|
+
def __getitem__(self, dim_id: str) -> DimensionScope:
|
|
959
|
+
return DimensionScope(
|
|
960
|
+
dataflow_urn=self.dataflow_urn,
|
|
961
|
+
dimension_id=dim_id,
|
|
962
|
+
store=self._store,
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
def collect(self) -> ItemList[DimensionInfo]:
|
|
966
|
+
"""Terminal: all dimensions for this dataset's DSD."""
|
|
967
|
+
if not self.dataflow_urn:
|
|
968
|
+
return ItemList([])
|
|
969
|
+
try:
|
|
970
|
+
rows = self._store.connection.execute(
|
|
971
|
+
"""
|
|
972
|
+
SELECT dc.component_id,
|
|
973
|
+
COALESCE(con.name['en'], dc.component_id),
|
|
974
|
+
dc.position
|
|
975
|
+
FROM sdmx.dataflow df
|
|
976
|
+
JOIN sdmx.dsd_component dc ON dc.dsd_urn = df.dsd_urn
|
|
977
|
+
LEFT JOIN sdmx.concept con ON con.concept_urn = dc.concept_urn
|
|
978
|
+
WHERE df.urn = ?
|
|
979
|
+
AND dc.component_type = 'Dimension'
|
|
980
|
+
ORDER BY dc.position
|
|
981
|
+
""",
|
|
982
|
+
[self.dataflow_urn],
|
|
983
|
+
).fetchall()
|
|
984
|
+
except duckdb.CatalogException:
|
|
985
|
+
return ItemList([])
|
|
986
|
+
return ItemList(
|
|
987
|
+
[
|
|
988
|
+
DimensionInfo(
|
|
989
|
+
id=r[0],
|
|
990
|
+
label=InternationalString(en=r[1] or ""),
|
|
991
|
+
position=r[2],
|
|
992
|
+
)
|
|
993
|
+
for r in rows
|
|
994
|
+
]
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
def count(self) -> int:
|
|
998
|
+
"""Terminal: number of dimensions."""
|
|
999
|
+
return len(self.collect())
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
@define(frozen=True)
|
|
1003
|
+
class DimensionScope:
|
|
1004
|
+
"""Scope for a single dimension of a dataset.
|
|
1005
|
+
|
|
1006
|
+
Entry point: ``store.dimensions(ds)["GEO"]``.
|
|
1007
|
+
"""
|
|
1008
|
+
|
|
1009
|
+
dataflow_urn: str
|
|
1010
|
+
dimension_id: str
|
|
1011
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
1012
|
+
|
|
1013
|
+
@property
|
|
1014
|
+
def codelist(self) -> CodelistScope | None:
|
|
1015
|
+
"""Navigate to this dimension's codelist (lazy)."""
|
|
1016
|
+
urn = self._store._resolve_codelist_urn( # noqa: SLF001
|
|
1017
|
+
self.dataflow_urn,
|
|
1018
|
+
self.dimension_id,
|
|
1019
|
+
)
|
|
1020
|
+
return CodelistScope(urn=urn, store=self._store) if urn else None
|
|
1021
|
+
|
|
1022
|
+
def collect(self) -> DimensionInfo:
|
|
1023
|
+
"""Terminal: materialize this dimension."""
|
|
1024
|
+
row = self._store.connection.execute(
|
|
1025
|
+
"""
|
|
1026
|
+
SELECT dc.component_id,
|
|
1027
|
+
COALESCE(con.name['en'], dc.component_id),
|
|
1028
|
+
dc.position
|
|
1029
|
+
FROM sdmx.dataflow df
|
|
1030
|
+
JOIN sdmx.dsd_component dc ON dc.dsd_urn = df.dsd_urn
|
|
1031
|
+
LEFT JOIN sdmx.concept con ON con.concept_urn = dc.concept_urn
|
|
1032
|
+
WHERE df.urn = ?
|
|
1033
|
+
AND dc.component_id = ?
|
|
1034
|
+
AND dc.component_type = 'Dimension'
|
|
1035
|
+
LIMIT 1
|
|
1036
|
+
""",
|
|
1037
|
+
[self.dataflow_urn, self.dimension_id],
|
|
1038
|
+
).fetchone()
|
|
1039
|
+
if row is None:
|
|
1040
|
+
msg = f"Dimension not found: {self.dimension_id}"
|
|
1041
|
+
raise LookupError(msg)
|
|
1042
|
+
return DimensionInfo(
|
|
1043
|
+
id=row[0],
|
|
1044
|
+
label=InternationalString(en=row[1] or ""),
|
|
1045
|
+
position=row[2],
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
# ---------------------------------------------------------------------------
|
|
1050
|
+
# Scope classes — Dataset
|
|
1051
|
+
# ---------------------------------------------------------------------------
|
|
1052
|
+
|
|
1053
|
+
_RELATED_SQL: dict[str, str] = {
|
|
1054
|
+
"codelist": """
|
|
1055
|
+
SELECT DISTINCT d2.identifier
|
|
1056
|
+
FROM statcat.dataset d1
|
|
1057
|
+
JOIN sdmx.dataflow df1 ON df1.urn = d1.sdmx_dataflow_urn
|
|
1058
|
+
JOIN sdmx.dsd_component dc1 ON dc1.dsd_urn = df1.dsd_urn
|
|
1059
|
+
JOIN sdmx.dsd_component dc2 ON dc2.codelist_urn = dc1.codelist_urn
|
|
1060
|
+
JOIN sdmx.dataflow df2 ON df2.dsd_urn = dc2.dsd_urn
|
|
1061
|
+
JOIN statcat.dataset d2 ON d2.sdmx_dataflow_urn = df2.urn
|
|
1062
|
+
WHERE d1.identifier = ?
|
|
1063
|
+
AND d2.identifier != ?
|
|
1064
|
+
AND dc1.codelist_urn IS NOT NULL
|
|
1065
|
+
""",
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
@define(frozen=True)
|
|
1070
|
+
class DatasetScope:
|
|
1071
|
+
"""Scope for a single dataset — enables graph navigation.
|
|
1072
|
+
|
|
1073
|
+
Entry point: ``store.dataset("housing-stats")``.
|
|
1074
|
+
|
|
1075
|
+
Navigation::
|
|
1076
|
+
|
|
1077
|
+
ds = store.dataset("housing-stats")
|
|
1078
|
+
ds.codelists # CodelistsScope
|
|
1079
|
+
ds.themes # ThemesScope
|
|
1080
|
+
ds.related("codelist") # list[BaseDataset]
|
|
1081
|
+
ds.upstream(depth=5) # list[BaseDataset]
|
|
1082
|
+
ds.downstream() # list[BaseDataset]
|
|
1083
|
+
ds.lineage_records() # list[dict]
|
|
1084
|
+
"""
|
|
1085
|
+
|
|
1086
|
+
identifier: str
|
|
1087
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
1088
|
+
|
|
1089
|
+
@property
|
|
1090
|
+
def codelists(self) -> _FilteredCodelistsScope:
|
|
1091
|
+
"""Navigate to codelists used by this dataset (lazy).
|
|
1092
|
+
|
|
1093
|
+
Resolves via the dataset's SDMX dataflow → DSD → codelist URNs.
|
|
1094
|
+
"""
|
|
1095
|
+
conn = self._store.connection
|
|
1096
|
+
try:
|
|
1097
|
+
rows = conn.execute(
|
|
1098
|
+
"""
|
|
1099
|
+
SELECT DISTINCT dc.codelist_urn
|
|
1100
|
+
FROM statcat.dataset d
|
|
1101
|
+
JOIN sdmx.dataflow df ON df.urn = d.sdmx_dataflow_urn
|
|
1102
|
+
JOIN sdmx.dsd_component dc ON dc.dsd_urn = df.dsd_urn
|
|
1103
|
+
WHERE d.identifier = ? AND dc.codelist_urn IS NOT NULL
|
|
1104
|
+
""",
|
|
1105
|
+
[self.identifier],
|
|
1106
|
+
).fetchall()
|
|
1107
|
+
except duckdb.CatalogException:
|
|
1108
|
+
# No sdmx schema on this connection — no codelists to scope to.
|
|
1109
|
+
return _FilteredCodelistsScope(store=self._store, codelist_urns=[])
|
|
1110
|
+
urns = [r[0] for r in rows]
|
|
1111
|
+
return _FilteredCodelistsScope(store=self._store, codelist_urns=urns)
|
|
1112
|
+
|
|
1113
|
+
@property
|
|
1114
|
+
def themes(self) -> _FilteredThemesScope:
|
|
1115
|
+
"""Navigate to themes (concepts) assigned to this dataset (lazy)."""
|
|
1116
|
+
conn = self._store.connection
|
|
1117
|
+
rows = conn.execute(
|
|
1118
|
+
"SELECT concept_uri FROM statcat.dataset_theme WHERE dataset_id = ?",
|
|
1119
|
+
[self.identifier],
|
|
1120
|
+
).fetchall()
|
|
1121
|
+
concept_uris = [r[0] for r in rows]
|
|
1122
|
+
return _FilteredThemesScope(store=self._store, concept_uris=concept_uris)
|
|
1123
|
+
|
|
1124
|
+
def related(self, edge: str) -> QueryBuilder[BaseDataset]:
|
|
1125
|
+
"""Datasets sharing an entity via *edge* type.
|
|
1126
|
+
|
|
1127
|
+
Returns a lazy ``QueryBuilder`` — chain ``.include()``, ``.filter()``,
|
|
1128
|
+
``.limit()``, ``.collect()`` etc. before materializing.
|
|
1129
|
+
|
|
1130
|
+
Gremlin equivalent: ``g.V(id).out(edge).in(edge).dedup()``
|
|
1131
|
+
|
|
1132
|
+
Supported edges: ``"codelist"``.
|
|
1133
|
+
|
|
1134
|
+
Example::
|
|
1135
|
+
|
|
1136
|
+
store.dataset("id").related("codelist").include("publisher").limit(10).collect()
|
|
1137
|
+
"""
|
|
1138
|
+
from statcatlib.model.dataset import BaseDataset # noqa: PLC0415
|
|
1139
|
+
|
|
1140
|
+
sql = _RELATED_SQL.get(edge)
|
|
1141
|
+
if sql is None:
|
|
1142
|
+
msg = f"Unknown edge type: {edge!r}. Supported: {list(_RELATED_SQL)}"
|
|
1143
|
+
raise ValueError(msg)
|
|
1144
|
+
conn = self._store.connection
|
|
1145
|
+
try:
|
|
1146
|
+
rows = conn.execute(sql, [self.identifier, self.identifier]).fetchall()
|
|
1147
|
+
except duckdb.CatalogException:
|
|
1148
|
+
# No sdmx schema on this connection — nothing to traverse to.
|
|
1149
|
+
return self._store.query(BaseDataset).from_ids([])
|
|
1150
|
+
identifiers = [r[0] for r in rows]
|
|
1151
|
+
return self._store.query(BaseDataset).from_ids(identifiers)
|
|
1152
|
+
|
|
1153
|
+
def upstream(
|
|
1154
|
+
self,
|
|
1155
|
+
*,
|
|
1156
|
+
relationship: str | None = None,
|
|
1157
|
+
depth: int = 10,
|
|
1158
|
+
) -> QueryBuilder[BaseDataset]:
|
|
1159
|
+
"""Transitive ancestors via lineage (recursive CTE).
|
|
1160
|
+
|
|
1161
|
+
Returns a lazy ``QueryBuilder`` — chain ``.include()``, ``.filter()``,
|
|
1162
|
+
``.limit()``, ``.collect()`` etc. before materializing.
|
|
1163
|
+
|
|
1164
|
+
Gremlin equivalent: ``g.V(id).repeat(in('derivedFrom')).times(depth)``
|
|
1165
|
+
|
|
1166
|
+
Example::
|
|
1167
|
+
|
|
1168
|
+
store.dataset("id").upstream(depth=3).include("publisher").collect()
|
|
1169
|
+
"""
|
|
1170
|
+
from statcatlib.model.dataset import BaseDataset # noqa: PLC0415
|
|
1171
|
+
|
|
1172
|
+
rel_clause = "AND relationship = ?" if relationship is not None else ""
|
|
1173
|
+
base_params: list[object] = [self.identifier, *([relationship] if relationship else [])]
|
|
1174
|
+
rec_params: list[object] = [depth, *([relationship] if relationship else [])]
|
|
1175
|
+
|
|
1176
|
+
rows = self._store.connection.execute(
|
|
1177
|
+
f"""
|
|
1178
|
+
WITH RECURSIVE ancestors(identifier, depth) AS (
|
|
1179
|
+
SELECT source_id, 1
|
|
1180
|
+
FROM statcat.lineage
|
|
1181
|
+
WHERE target_id = ?
|
|
1182
|
+
{rel_clause}
|
|
1183
|
+
UNION
|
|
1184
|
+
SELECT l.source_id, a.depth + 1
|
|
1185
|
+
FROM statcat.lineage l
|
|
1186
|
+
JOIN ancestors a ON l.target_id = a.identifier
|
|
1187
|
+
WHERE a.depth < ?
|
|
1188
|
+
{rel_clause}
|
|
1189
|
+
)
|
|
1190
|
+
SELECT DISTINCT identifier FROM ancestors
|
|
1191
|
+
""",
|
|
1192
|
+
base_params + rec_params,
|
|
1193
|
+
).fetchall()
|
|
1194
|
+
identifiers = [r[0] for r in rows]
|
|
1195
|
+
return self._store.query(BaseDataset).from_ids(identifiers)
|
|
1196
|
+
|
|
1197
|
+
def downstream(
|
|
1198
|
+
self,
|
|
1199
|
+
*,
|
|
1200
|
+
relationship: str | None = None,
|
|
1201
|
+
depth: int = 10,
|
|
1202
|
+
) -> QueryBuilder[BaseDataset]:
|
|
1203
|
+
"""Transitive descendants via lineage (recursive CTE).
|
|
1204
|
+
|
|
1205
|
+
Returns a lazy ``QueryBuilder`` — chain ``.include()``, ``.filter()``,
|
|
1206
|
+
``.limit()``, ``.collect()`` etc. before materializing.
|
|
1207
|
+
|
|
1208
|
+
Gremlin equivalent: ``g.V(id).repeat(out('derivedFrom')).times(depth)``
|
|
1209
|
+
|
|
1210
|
+
Example::
|
|
1211
|
+
|
|
1212
|
+
store.dataset("id").downstream().include("publisher").collect()
|
|
1213
|
+
"""
|
|
1214
|
+
from statcatlib.model.dataset import BaseDataset # noqa: PLC0415
|
|
1215
|
+
|
|
1216
|
+
rel_clause = "AND relationship = ?" if relationship is not None else ""
|
|
1217
|
+
base_params: list[object] = [self.identifier, *([relationship] if relationship else [])]
|
|
1218
|
+
rec_params: list[object] = [depth, *([relationship] if relationship else [])]
|
|
1219
|
+
|
|
1220
|
+
rows = self._store.connection.execute(
|
|
1221
|
+
f"""
|
|
1222
|
+
WITH RECURSIVE descendants(identifier, depth) AS (
|
|
1223
|
+
SELECT target_id, 1
|
|
1224
|
+
FROM statcat.lineage
|
|
1225
|
+
WHERE source_id = ?
|
|
1226
|
+
{rel_clause}
|
|
1227
|
+
UNION
|
|
1228
|
+
SELECT l.target_id, dn.depth + 1
|
|
1229
|
+
FROM statcat.lineage l
|
|
1230
|
+
JOIN descendants dn ON l.source_id = dn.identifier
|
|
1231
|
+
WHERE dn.depth < ?
|
|
1232
|
+
{rel_clause}
|
|
1233
|
+
)
|
|
1234
|
+
SELECT DISTINCT identifier FROM descendants
|
|
1235
|
+
""",
|
|
1236
|
+
base_params + rec_params,
|
|
1237
|
+
).fetchall()
|
|
1238
|
+
identifiers = [r[0] for r in rows]
|
|
1239
|
+
return self._store.query(BaseDataset).from_ids(identifiers)
|
|
1240
|
+
|
|
1241
|
+
def lineage_records(
|
|
1242
|
+
self,
|
|
1243
|
+
*,
|
|
1244
|
+
role: str = "either",
|
|
1245
|
+
relationship: str | None = None,
|
|
1246
|
+
confidence: str | None = None,
|
|
1247
|
+
) -> list[dict[str, object]]:
|
|
1248
|
+
"""Return lineage rows involving this dataset as plain dicts."""
|
|
1249
|
+
wheres: list[str] = []
|
|
1250
|
+
params: list[object] = []
|
|
1251
|
+
|
|
1252
|
+
if role == "source":
|
|
1253
|
+
wheres.append("source_id = ?")
|
|
1254
|
+
params.append(self.identifier)
|
|
1255
|
+
elif role == "target":
|
|
1256
|
+
wheres.append("target_id = ?")
|
|
1257
|
+
params.append(self.identifier)
|
|
1258
|
+
else:
|
|
1259
|
+
wheres.append("(source_id = ? OR target_id = ?)")
|
|
1260
|
+
params.extend([self.identifier, self.identifier])
|
|
1261
|
+
|
|
1262
|
+
if relationship is not None:
|
|
1263
|
+
wheres.append("relationship = ?")
|
|
1264
|
+
params.append(relationship)
|
|
1265
|
+
if confidence is not None:
|
|
1266
|
+
wheres.append("confidence = ?")
|
|
1267
|
+
params.append(confidence)
|
|
1268
|
+
|
|
1269
|
+
where_clause = "WHERE " + " AND ".join(wheres)
|
|
1270
|
+
rows = self._store.connection.execute(
|
|
1271
|
+
f"""
|
|
1272
|
+
SELECT id, source_id, target_id, relationship, activity_label,
|
|
1273
|
+
activity_type, agent_id, timestamp, confidence, notes
|
|
1274
|
+
FROM statcat.lineage {where_clause}
|
|
1275
|
+
ORDER BY timestamp NULLS LAST
|
|
1276
|
+
""",
|
|
1277
|
+
params,
|
|
1278
|
+
).fetchall()
|
|
1279
|
+
cols = [
|
|
1280
|
+
"id",
|
|
1281
|
+
"source_id",
|
|
1282
|
+
"target_id",
|
|
1283
|
+
"relationship",
|
|
1284
|
+
"activity_label",
|
|
1285
|
+
"activity_type",
|
|
1286
|
+
"agent_id",
|
|
1287
|
+
"timestamp",
|
|
1288
|
+
"confidence",
|
|
1289
|
+
"notes",
|
|
1290
|
+
]
|
|
1291
|
+
return [dict(zip(cols, row, strict=True)) for row in rows]
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
# ---------------------------------------------------------------------------
|
|
1295
|
+
# Internal helper scopes for DatasetScope navigation
|
|
1296
|
+
# ---------------------------------------------------------------------------
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
@define(frozen=True)
|
|
1300
|
+
class _FilteredCodelistsScope:
|
|
1301
|
+
"""CodelistsScope filtered to specific codelist URNs."""
|
|
1302
|
+
|
|
1303
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
1304
|
+
codelist_urns: list[str] = field(factory=list)
|
|
1305
|
+
_exprs: tuple[AliasedExpr, ...] = field(default=(), repr=False)
|
|
1306
|
+
_lang: str = field(default="en", repr=False)
|
|
1307
|
+
|
|
1308
|
+
def __getitem__(self, urn: str) -> CodelistScope:
|
|
1309
|
+
return CodelistScope(urn=urn, store=self._store)
|
|
1310
|
+
|
|
1311
|
+
def lang(self, lang: str) -> _FilteredCodelistsScope:
|
|
1312
|
+
"""Set language for label resolution. Returns new scope (lazy)."""
|
|
1313
|
+
return attrs.evolve(self, lang=lang)
|
|
1314
|
+
|
|
1315
|
+
def collect(self) -> list[CodelistScope]:
|
|
1316
|
+
"""Terminal: materialize codelists as scope handles.
|
|
1317
|
+
|
|
1318
|
+
Use ``.rows()`` for enriched tabular results after ``.enrich()``.
|
|
1319
|
+
"""
|
|
1320
|
+
if self._exprs:
|
|
1321
|
+
msg = ".collect() cannot be used when .enrich() is active — use .rows() instead"
|
|
1322
|
+
raise ValueError(msg)
|
|
1323
|
+
if not self.codelist_urns:
|
|
1324
|
+
return []
|
|
1325
|
+
return [CodelistScope(urn=urn, store=self._store) for urn in self.codelist_urns]
|
|
1326
|
+
|
|
1327
|
+
def rows(self) -> list[Row]:
|
|
1328
|
+
"""Terminal: return enriched results as ``Row`` objects (requires ``.enrich()``)."""
|
|
1329
|
+
if not self._exprs:
|
|
1330
|
+
msg = ".rows() requires .enrich() — call .enrich(...) first, or use .collect() for domain objects"
|
|
1331
|
+
raise ValueError(msg)
|
|
1332
|
+
if not self.codelist_urns:
|
|
1333
|
+
return []
|
|
1334
|
+
placeholders = ", ".join("?" * len(self.codelist_urns))
|
|
1335
|
+
entity_sql = (
|
|
1336
|
+
"FROM sdmx.artefact t"
|
|
1337
|
+
" LEFT JOIN sdmx.localized_text lt"
|
|
1338
|
+
" ON lt.owner_urn = t.urn AND lt.field = 'name' AND lt.lang = ?"
|
|
1339
|
+
)
|
|
1340
|
+
entity_params = [self._lang]
|
|
1341
|
+
entity_select_cols = ["t.urn", "COALESCE(lt.text, t.urn) AS label"]
|
|
1342
|
+
|
|
1343
|
+
return _compile_enriched_query(
|
|
1344
|
+
scope_type="codelist",
|
|
1345
|
+
entity_sql=entity_sql,
|
|
1346
|
+
entity_id_col="t.urn",
|
|
1347
|
+
entity_select_cols=entity_select_cols,
|
|
1348
|
+
scope_filter={},
|
|
1349
|
+
exprs=self._exprs,
|
|
1350
|
+
conn=self._store.connection,
|
|
1351
|
+
entity_params=entity_params,
|
|
1352
|
+
where_sql=f"t.artefact_type = 'Codelist' AND t.urn IN ({placeholders})",
|
|
1353
|
+
where_params=self.codelist_urns,
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
def enrich(self, *exprs: AliasedExpr, **kwargs: Expr) -> _FilteredCodelistsScope:
|
|
1357
|
+
"""Add computed fields. Returns new scope (lazy)."""
|
|
1358
|
+
all_exprs = exprs + tuple(e.alias(k) for k, e in kwargs.items())
|
|
1359
|
+
return attrs.evolve(self, exprs=self._exprs + all_exprs)
|
|
1360
|
+
|
|
1361
|
+
def __iter__(self) -> Iterator[CodelistScope]:
|
|
1362
|
+
return iter(self.collect())
|
|
1363
|
+
|
|
1364
|
+
def __len__(self) -> int:
|
|
1365
|
+
return len(self.codelist_urns)
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
@define(frozen=True)
|
|
1369
|
+
class _FilteredThemesScope:
|
|
1370
|
+
"""ThemesScope filtered to specific concept URIs."""
|
|
1371
|
+
|
|
1372
|
+
_store: Catalog = field(alias="store", repr=False)
|
|
1373
|
+
concept_uris: list[str] = field(factory=list)
|
|
1374
|
+
|
|
1375
|
+
def collect(self) -> list[ConceptRef]:
|
|
1376
|
+
"""Terminal: materialize concepts as ConceptRef objects."""
|
|
1377
|
+
if not self.concept_uris:
|
|
1378
|
+
return []
|
|
1379
|
+
placeholders = ", ".join("?" * len(self.concept_uris))
|
|
1380
|
+
rows = self._store.connection.execute(
|
|
1381
|
+
f"""
|
|
1382
|
+
SELECT c.uri, c.notation, c.in_scheme, c.label
|
|
1383
|
+
FROM statcat.concept c
|
|
1384
|
+
WHERE c.uri IN ({placeholders})
|
|
1385
|
+
ORDER BY c.notation
|
|
1386
|
+
""",
|
|
1387
|
+
self.concept_uris,
|
|
1388
|
+
).fetchall()
|
|
1389
|
+
# Return refs for materialized concepts; fall back to bare URI for unknowns
|
|
1390
|
+
found_uris = {r[0] for r in rows}
|
|
1391
|
+
result: list[ConceptRef] = [
|
|
1392
|
+
ConceptRef(
|
|
1393
|
+
uri=r[0],
|
|
1394
|
+
notation=r[1],
|
|
1395
|
+
scheme_uri=r[2],
|
|
1396
|
+
label=InternationalString.of(r[3]) if r[3] else InternationalString(),
|
|
1397
|
+
)
|
|
1398
|
+
for r in rows
|
|
1399
|
+
]
|
|
1400
|
+
# Append bare refs for concept URIs not in the concept table
|
|
1401
|
+
result.extend(ConceptRef(uri=uri) for uri in self.concept_uris if uri not in found_uris)
|
|
1402
|
+
return result
|
|
1403
|
+
|
|
1404
|
+
def __iter__(self) -> Iterator[ConceptRef]:
|
|
1405
|
+
return iter(self.collect())
|
|
1406
|
+
|
|
1407
|
+
def __len__(self) -> int:
|
|
1408
|
+
return len(self.concept_uris)
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
# ---------------------------------------------------------------------------
|
|
1412
|
+
# Public aliases — SKOS-named equivalents for ThemesScope / SchemeScope
|
|
1413
|
+
# ---------------------------------------------------------------------------
|
|
1414
|
+
|
|
1415
|
+
#: Alias: scope over all SKOS concept schemes. Prefer this name in new code.
|
|
1416
|
+
ConceptSchemesScope = ThemesScope
|
|
1417
|
+
|
|
1418
|
+
#: Alias: scope for a single SKOS concept scheme. Prefer this name in new code.
|
|
1419
|
+
ConceptSchemeScope = SchemeScope
|