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.
@@ -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