sdmxlib 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.
- sdmxlib/__init__.py +155 -0
- sdmxlib/api/__init__.py +7 -0
- sdmxlib/api/client.py +246 -0
- sdmxlib/api/filters.py +67 -0
- sdmxlib/api/providers.py +66 -0
- sdmxlib/api/query.py +428 -0
- sdmxlib/api/registry.py +666 -0
- sdmxlib/api/session.py +124 -0
- sdmxlib/formats/__init__.py +166 -0
- sdmxlib/formats/sdmx_csv/__init__.py +5 -0
- sdmxlib/formats/sdmx_csv/reader.py +147 -0
- sdmxlib/formats/sdmx_json/__init__.py +1 -0
- sdmxlib/formats/sdmx_json/reader.py +897 -0
- sdmxlib/formats/sdmx_json/writer.py +698 -0
- sdmxlib/formats/sdmx_ml21/__init__.py +5 -0
- sdmxlib/formats/sdmx_ml21/namespaces.py +12 -0
- sdmxlib/formats/sdmx_ml21/reader.py +1147 -0
- sdmxlib/formats/sdmx_ml21/writer.py +709 -0
- sdmxlib/formats/sdmx_ml30/__init__.py +1 -0
- sdmxlib/formats/sdmx_ml30/namespaces.py +12 -0
- sdmxlib/formats/sdmx_ml30/reader.py +1138 -0
- sdmxlib/formats/sdmx_ml30/writer.py +730 -0
- sdmxlib/local/__init__.py +6 -0
- sdmxlib/local/_registry.py +368 -0
- sdmxlib/model/__init__.py +122 -0
- sdmxlib/model/annotations.py +103 -0
- sdmxlib/model/base.py +61 -0
- sdmxlib/model/category.py +88 -0
- sdmxlib/model/codelist.py +91 -0
- sdmxlib/model/collections.py +382 -0
- sdmxlib/model/concept.py +121 -0
- sdmxlib/model/constraint.py +92 -0
- sdmxlib/model/convert.py +268 -0
- sdmxlib/model/dataflow.py +49 -0
- sdmxlib/model/dataset.py +337 -0
- sdmxlib/model/datastructure.py +390 -0
- sdmxlib/model/expr.py +120 -0
- sdmxlib/model/hierarchy.py +111 -0
- sdmxlib/model/istring.py +81 -0
- sdmxlib/model/mapping.py +134 -0
- sdmxlib/model/message.py +70 -0
- sdmxlib/model/organisation.py +149 -0
- sdmxlib/model/provision.py +45 -0
- sdmxlib/model/ref.py +195 -0
- sdmxlib/model/registry.py +191 -0
- sdmxlib/model/representation.py +99 -0
- sdmxlib/model/urn.py +130 -0
- sdmxlib/model/validation.py +338 -0
- sdmxlib/polars.py +392 -0
- sdmxlib/py.typed +0 -0
- sdmxlib/storage/__init__.py +28 -0
- sdmxlib/storage/lazy.py +329 -0
- sdmxlib/storage/readers.py +310 -0
- sdmxlib/storage/schema.py +289 -0
- sdmxlib/storage/writers.py +503 -0
- sdmxlib-0.8.0.dist-info/METADATA +102 -0
- sdmxlib-0.8.0.dist-info/RECORD +58 -0
- sdmxlib-0.8.0.dist-info/WHEEL +4 -0
sdmxlib/storage/lazy.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""LazyCodelist and LazyItemList — DuckDB-backed lazy code access.
|
|
2
|
+
|
|
3
|
+
DuckDB adaptation of the SQLite-based LazyCodelist. Uses flat
|
|
4
|
+
``label_{lang}`` columns instead of JSON, and ``ORDER BY position``
|
|
5
|
+
instead of ``ORDER BY rowid``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from typing import Any, overload
|
|
11
|
+
|
|
12
|
+
import duckdb
|
|
13
|
+
|
|
14
|
+
from sdmxlib.model.annotations import Annotation, Annotations
|
|
15
|
+
from sdmxlib.model.codelist import Code, Codelist
|
|
16
|
+
from sdmxlib.model.collections import ItemList
|
|
17
|
+
from sdmxlib.model.istring import InternationalString
|
|
18
|
+
from sdmxlib.model.organisation import Agency
|
|
19
|
+
from sdmxlib.storage.schema import get_languages
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _row_to_code(
|
|
23
|
+
row: tuple[Any, ...],
|
|
24
|
+
languages: list[str],
|
|
25
|
+
) -> Code:
|
|
26
|
+
"""Convert a DuckDB row to a Code object.
|
|
27
|
+
|
|
28
|
+
Expected column order: code_id, parent_code_id, label_{lang}...,
|
|
29
|
+
description_{lang}..., _annotations
|
|
30
|
+
"""
|
|
31
|
+
n_langs = len(languages)
|
|
32
|
+
code_id = row[0]
|
|
33
|
+
parent_id = row[1]
|
|
34
|
+
label_vals = row[2 : 2 + n_langs]
|
|
35
|
+
desc_vals = row[2 + n_langs : 2 + 2 * n_langs]
|
|
36
|
+
ann_json = row[2 + 2 * n_langs]
|
|
37
|
+
|
|
38
|
+
name_dict = {lang: val for lang, val in zip(languages, label_vals, strict=True) if val is not None}
|
|
39
|
+
name = InternationalString.of(name_dict) if name_dict else InternationalString()
|
|
40
|
+
|
|
41
|
+
desc_dict = {lang: val for lang, val in zip(languages, desc_vals, strict=True) if val is not None}
|
|
42
|
+
desc = InternationalString.of(desc_dict) if desc_dict else InternationalString()
|
|
43
|
+
|
|
44
|
+
annotations: list[Annotation] = []
|
|
45
|
+
if ann_json:
|
|
46
|
+
for a in json.loads(ann_json):
|
|
47
|
+
text = InternationalString.of(a["text"]) if a.get("text") else InternationalString()
|
|
48
|
+
annotations.append(
|
|
49
|
+
Annotation(
|
|
50
|
+
id=a.get("id"),
|
|
51
|
+
title=a.get("title"),
|
|
52
|
+
type=a.get("type"),
|
|
53
|
+
url=a.get("url"),
|
|
54
|
+
text=text,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return Code(
|
|
59
|
+
id=code_id,
|
|
60
|
+
name=name,
|
|
61
|
+
description=desc,
|
|
62
|
+
parent_id=parent_id,
|
|
63
|
+
annotations=Annotations(annotations),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _code_select_cols(languages: list[str]) -> str:
|
|
68
|
+
"""Build the SELECT column list for code queries."""
|
|
69
|
+
label_cols = ", ".join(f"label_{lang}" for lang in languages)
|
|
70
|
+
desc_cols = ", ".join(f"description_{lang}" for lang in languages)
|
|
71
|
+
return f"code_id, parent_code_id, {label_cols}, {desc_cols}, _annotations"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class LazyItemList:
|
|
75
|
+
"""A read-only sequence of Codes backed by DuckDB queries.
|
|
76
|
+
|
|
77
|
+
Supports ``__getitem__`` by int index or string id, ``__len__``,
|
|
78
|
+
``__iter__``, and ``__contains__``.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
__slots__ = ("_conn", "_langs", "_urn")
|
|
82
|
+
|
|
83
|
+
def __init__(self, conn: duckdb.DuckDBPyConnection, codelist_urn: str) -> None:
|
|
84
|
+
self._conn = conn
|
|
85
|
+
self._urn = codelist_urn
|
|
86
|
+
self._langs = get_languages(conn)
|
|
87
|
+
|
|
88
|
+
@overload
|
|
89
|
+
def __getitem__(self, index: int) -> Code: ...
|
|
90
|
+
|
|
91
|
+
@overload
|
|
92
|
+
def __getitem__(self, index: str) -> Code: ...
|
|
93
|
+
|
|
94
|
+
@overload
|
|
95
|
+
def __getitem__(self, index: slice) -> ItemList[Code]: ...
|
|
96
|
+
|
|
97
|
+
def __getitem__(self, index: int | str | slice) -> "Code | ItemList[Code]":
|
|
98
|
+
cols = _code_select_cols(self._langs)
|
|
99
|
+
if isinstance(index, str):
|
|
100
|
+
row = self._conn.execute(
|
|
101
|
+
f"""
|
|
102
|
+
SELECT {cols} FROM codes
|
|
103
|
+
WHERE codelist_urn = $urn AND code_id = $code_id
|
|
104
|
+
""", # noqa: S608
|
|
105
|
+
{"urn": self._urn, "code_id": index},
|
|
106
|
+
).fetchone()
|
|
107
|
+
if row is None:
|
|
108
|
+
raise KeyError(index)
|
|
109
|
+
return _row_to_code(row, self._langs)
|
|
110
|
+
if isinstance(index, slice):
|
|
111
|
+
all_codes = list(self)
|
|
112
|
+
return ItemList(all_codes[index])
|
|
113
|
+
# int index
|
|
114
|
+
offset = index if index >= 0 else len(self) + index
|
|
115
|
+
row = self._conn.execute(
|
|
116
|
+
f"""
|
|
117
|
+
SELECT {cols} FROM codes
|
|
118
|
+
WHERE codelist_urn = $urn
|
|
119
|
+
ORDER BY position LIMIT 1 OFFSET $offset
|
|
120
|
+
""", # noqa: S608
|
|
121
|
+
{"urn": self._urn, "offset": offset},
|
|
122
|
+
).fetchone()
|
|
123
|
+
if row is None:
|
|
124
|
+
raise IndexError(index)
|
|
125
|
+
return _row_to_code(row, self._langs)
|
|
126
|
+
|
|
127
|
+
def __len__(self) -> int:
|
|
128
|
+
row = self._conn.execute(
|
|
129
|
+
"""
|
|
130
|
+
SELECT COUNT(*) FROM codes WHERE codelist_urn = $urn
|
|
131
|
+
""",
|
|
132
|
+
{"urn": self._urn},
|
|
133
|
+
).fetchone()
|
|
134
|
+
return row[0] # type: ignore[index]
|
|
135
|
+
|
|
136
|
+
def __iter__(self) -> Iterator[Code]:
|
|
137
|
+
cols = _code_select_cols(self._langs)
|
|
138
|
+
rows = self._conn.execute(
|
|
139
|
+
f"""
|
|
140
|
+
SELECT {cols} FROM codes
|
|
141
|
+
WHERE codelist_urn = $urn
|
|
142
|
+
ORDER BY position
|
|
143
|
+
""", # noqa: S608
|
|
144
|
+
{"urn": self._urn},
|
|
145
|
+
).fetchall()
|
|
146
|
+
for row in rows:
|
|
147
|
+
yield _row_to_code(row, self._langs)
|
|
148
|
+
|
|
149
|
+
def __contains__(self, key: object) -> bool:
|
|
150
|
+
if isinstance(key, str):
|
|
151
|
+
row = self._conn.execute(
|
|
152
|
+
"""
|
|
153
|
+
SELECT 1 FROM codes
|
|
154
|
+
WHERE codelist_urn = $urn AND code_id = $code_id
|
|
155
|
+
""",
|
|
156
|
+
{"urn": self._urn, "code_id": key},
|
|
157
|
+
).fetchone()
|
|
158
|
+
return row is not None
|
|
159
|
+
if isinstance(key, Code):
|
|
160
|
+
return key.id in self # type: ignore[operator]
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
def get(self, code_id: str) -> Code | None:
|
|
164
|
+
"""Look up a code by id, returning None if absent."""
|
|
165
|
+
try:
|
|
166
|
+
return self[code_id]
|
|
167
|
+
except KeyError:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def ids(self) -> list[str]:
|
|
171
|
+
"""All code ids in position order."""
|
|
172
|
+
rows = self._conn.execute(
|
|
173
|
+
"""
|
|
174
|
+
SELECT code_id FROM codes
|
|
175
|
+
WHERE codelist_urn = $urn
|
|
176
|
+
ORDER BY position
|
|
177
|
+
""",
|
|
178
|
+
{"urn": self._urn},
|
|
179
|
+
).fetchall()
|
|
180
|
+
return [r[0] for r in rows]
|
|
181
|
+
|
|
182
|
+
def filter(self, predicate: "callable") -> ItemList[Code]: # type: ignore[type-arg]
|
|
183
|
+
"""Filter codes, returning a materialized ItemList."""
|
|
184
|
+
return ItemList([c for c in self if predicate(c)])
|
|
185
|
+
|
|
186
|
+
def first_where(self, predicate: "callable") -> Code | None: # type: ignore[type-arg]
|
|
187
|
+
"""First code matching predicate, or None."""
|
|
188
|
+
for c in self:
|
|
189
|
+
if predicate(c):
|
|
190
|
+
return c
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
def to_list(self) -> ItemList[Code]:
|
|
194
|
+
"""Materialize all codes into an ItemList."""
|
|
195
|
+
return ItemList(list(self))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class LazyCodelist:
|
|
199
|
+
"""Codelist facade backed by DuckDB. Codes load on demand.
|
|
200
|
+
|
|
201
|
+
Duck-types as ``Codelist`` for read operations. Uses flat
|
|
202
|
+
``label_{lang}`` columns for direct SQL access.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
__slots__ = (
|
|
206
|
+
"_agency",
|
|
207
|
+
"_conn",
|
|
208
|
+
"_description",
|
|
209
|
+
"_id",
|
|
210
|
+
"_is_final",
|
|
211
|
+
"_name",
|
|
212
|
+
"_urn",
|
|
213
|
+
"_version",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
*,
|
|
219
|
+
urn: str,
|
|
220
|
+
conn: duckdb.DuckDBPyConnection,
|
|
221
|
+
id: str, # noqa: A002
|
|
222
|
+
agency: Agency,
|
|
223
|
+
version: str,
|
|
224
|
+
name: InternationalString,
|
|
225
|
+
description: InternationalString,
|
|
226
|
+
is_final: bool,
|
|
227
|
+
) -> None:
|
|
228
|
+
self._urn = urn
|
|
229
|
+
self._conn = conn
|
|
230
|
+
self._id = id
|
|
231
|
+
self._agency = agency
|
|
232
|
+
self._version = version
|
|
233
|
+
self._name = name
|
|
234
|
+
self._description = description
|
|
235
|
+
self._is_final = is_final
|
|
236
|
+
|
|
237
|
+
# ── MaintainableArtefact-compatible properties ────────────────────────
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def id(self) -> str: # noqa: D102
|
|
241
|
+
return self._id
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def maintainer(self) -> Agency: # noqa: D102
|
|
245
|
+
return self._agency
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def agency_id(self) -> str: # noqa: D102
|
|
249
|
+
return self._agency.id
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def version(self) -> str: # noqa: D102
|
|
253
|
+
return self._version
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def name(self) -> InternationalString: # noqa: D102
|
|
257
|
+
return self._name
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def description(self) -> InternationalString: # noqa: D102
|
|
261
|
+
return self._description
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def is_final(self) -> bool: # noqa: D102
|
|
265
|
+
return self._is_final
|
|
266
|
+
|
|
267
|
+
# ── SDMX class vars ──────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
sdmx_package = "codelist"
|
|
270
|
+
sdmx_class = "Codelist"
|
|
271
|
+
|
|
272
|
+
# ── Lazy code access ─────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def codes(self) -> LazyItemList:
|
|
276
|
+
"""Lazy sequence of codes backed by DuckDB."""
|
|
277
|
+
return LazyItemList(self._conn, self._urn)
|
|
278
|
+
|
|
279
|
+
def __len__(self) -> int:
|
|
280
|
+
return len(self.codes)
|
|
281
|
+
|
|
282
|
+
def code_map(self, lang: str = "en") -> dict[str, str]:
|
|
283
|
+
"""Direct SQL: {code_id: label} without creating Code objects."""
|
|
284
|
+
from sdmxlib.storage.readers import code_map # noqa: PLC0415
|
|
285
|
+
|
|
286
|
+
return code_map(self._conn, self._urn, lang)
|
|
287
|
+
|
|
288
|
+
def roots(self) -> ItemList[Code]:
|
|
289
|
+
"""Top-level codes (no parent)."""
|
|
290
|
+
langs = get_languages(self._conn)
|
|
291
|
+
cols = _code_select_cols(langs)
|
|
292
|
+
rows = self._conn.execute(
|
|
293
|
+
f"""
|
|
294
|
+
SELECT {cols} FROM codes
|
|
295
|
+
WHERE codelist_urn = $urn AND parent_code_id IS NULL
|
|
296
|
+
ORDER BY position
|
|
297
|
+
""", # noqa: S608
|
|
298
|
+
{"urn": self._urn},
|
|
299
|
+
).fetchall()
|
|
300
|
+
return ItemList([_row_to_code(r, langs) for r in rows])
|
|
301
|
+
|
|
302
|
+
def children_of(self, code_id: str) -> ItemList[Code]:
|
|
303
|
+
"""Direct children of a code."""
|
|
304
|
+
langs = get_languages(self._conn)
|
|
305
|
+
cols = _code_select_cols(langs)
|
|
306
|
+
rows = self._conn.execute(
|
|
307
|
+
f"""
|
|
308
|
+
SELECT {cols} FROM codes
|
|
309
|
+
WHERE codelist_urn = $urn AND parent_code_id = $parent_id
|
|
310
|
+
ORDER BY position
|
|
311
|
+
""", # noqa: S608
|
|
312
|
+
{"urn": self._urn, "parent_id": code_id},
|
|
313
|
+
).fetchall()
|
|
314
|
+
return ItemList([_row_to_code(r, langs) for r in rows])
|
|
315
|
+
|
|
316
|
+
def materialize(self) -> Codelist:
|
|
317
|
+
"""Load all codes and return a real Codelist."""
|
|
318
|
+
return Codelist(
|
|
319
|
+
id=self._id,
|
|
320
|
+
maintainer=self._agency,
|
|
321
|
+
version=self._version,
|
|
322
|
+
name=self._name,
|
|
323
|
+
description=self._description,
|
|
324
|
+
is_final=self._is_final,
|
|
325
|
+
codes=list(self.codes),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def __repr__(self) -> str:
|
|
329
|
+
return f"LazyCodelist(id={self._id!r}, agency={self.agency_id!r}, codes=~{len(self)})"
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Read SDMX domain objects from DuckDB normalized tables.
|
|
2
|
+
|
|
3
|
+
Provides functions to reconstruct domain objects from the normalized
|
|
4
|
+
schema, including lazy codelist access and direct SQL helpers like
|
|
5
|
+
``code_map`` that avoid materializing Python objects.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from sdmxlib.storage import schema, readers
|
|
10
|
+
|
|
11
|
+
conn = duckdb.connect("store.duckdb")
|
|
12
|
+
cl = readers.get_artefact(conn, codelist_urn) # LazyCodelist
|
|
13
|
+
cm = readers.code_map(conn, codelist_urn, "en") # {code_id: label}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import duckdb
|
|
20
|
+
|
|
21
|
+
from sdmxlib.model.convert import from_dict
|
|
22
|
+
from sdmxlib.model.istring import InternationalString
|
|
23
|
+
from sdmxlib.model.organisation import Agency
|
|
24
|
+
from sdmxlib.storage.lazy import LazyCodelist
|
|
25
|
+
from sdmxlib.storage.schema import get_languages
|
|
26
|
+
from sdmxlib.storage.writers import _CLASS_TO_NAME
|
|
27
|
+
|
|
28
|
+
# Reverse map: type_name → Python class
|
|
29
|
+
_TYPE_REGISTRY: dict[str, type] = {v: k for k, v in _CLASS_TO_NAME.items()}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Code loading ────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_code_dicts(conn: duckdb.DuckDBPyConnection, codelist_urn: str) -> list[dict[str, Any]]:
|
|
36
|
+
"""Load all codes for a codelist as plain dicts suitable for ``from_dict``.
|
|
37
|
+
|
|
38
|
+
Returns dicts with keys: id, parent_id, name, description, annotations.
|
|
39
|
+
"""
|
|
40
|
+
languages = get_languages(conn)
|
|
41
|
+
|
|
42
|
+
label_cols = ", ".join(f"label_{lang}" for lang in languages)
|
|
43
|
+
desc_cols = ", ".join(f"description_{lang}" for lang in languages)
|
|
44
|
+
|
|
45
|
+
rows = conn.execute(
|
|
46
|
+
f"""
|
|
47
|
+
SELECT code_id, parent_code_id, {label_cols}, {desc_cols}, _annotations
|
|
48
|
+
FROM codes
|
|
49
|
+
WHERE codelist_urn = $codelist_urn
|
|
50
|
+
ORDER BY position
|
|
51
|
+
""", # noqa: S608
|
|
52
|
+
{"codelist_urn": codelist_urn},
|
|
53
|
+
).fetchall()
|
|
54
|
+
|
|
55
|
+
result = []
|
|
56
|
+
n_langs = len(languages)
|
|
57
|
+
for row in rows:
|
|
58
|
+
code_id = row[0]
|
|
59
|
+
parent_id = row[1]
|
|
60
|
+
label_vals = row[2 : 2 + n_langs]
|
|
61
|
+
desc_vals = row[2 + n_langs : 2 + 2 * n_langs]
|
|
62
|
+
ann_json = row[2 + 2 * n_langs]
|
|
63
|
+
|
|
64
|
+
d: dict[str, Any] = {"id": code_id}
|
|
65
|
+
if parent_id is not None:
|
|
66
|
+
d["parent_id"] = parent_id
|
|
67
|
+
|
|
68
|
+
name_dict = {lang: val for lang, val in zip(languages, label_vals, strict=True) if val is not None}
|
|
69
|
+
if name_dict:
|
|
70
|
+
d["name"] = name_dict
|
|
71
|
+
|
|
72
|
+
desc_dict = {lang: val for lang, val in zip(languages, desc_vals, strict=True) if val is not None}
|
|
73
|
+
if desc_dict:
|
|
74
|
+
d["description"] = desc_dict
|
|
75
|
+
|
|
76
|
+
if ann_json:
|
|
77
|
+
d["annotations"] = json.loads(ann_json)
|
|
78
|
+
|
|
79
|
+
result.append(d)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── Hierarchy tree loading ──────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_hierarchy_tree(conn: duckdb.DuckDBPyConnection, hierarchy_urn: str) -> list[dict[str, Any]]:
|
|
87
|
+
"""Load hierarchy nodes and reconstruct the tree as nested dicts."""
|
|
88
|
+
languages = get_languages(conn)
|
|
89
|
+
|
|
90
|
+
name_cols = ", ".join(f"name_{lang}" for lang in languages)
|
|
91
|
+
desc_cols = ", ".join(f"description_{lang}" for lang in languages)
|
|
92
|
+
|
|
93
|
+
rows = conn.execute(
|
|
94
|
+
f"""
|
|
95
|
+
SELECT node_id, parent_node_id, code_urn,
|
|
96
|
+
{name_cols}, {desc_cols}, _annotations, valid_from, valid_to
|
|
97
|
+
FROM hierarchy_nodes
|
|
98
|
+
WHERE hierarchy_urn = $hierarchy_urn
|
|
99
|
+
""", # noqa: S608
|
|
100
|
+
{"hierarchy_urn": hierarchy_urn},
|
|
101
|
+
).fetchall()
|
|
102
|
+
|
|
103
|
+
n_langs = len(languages)
|
|
104
|
+
nodes_by_id: dict[str, dict[str, Any]] = {}
|
|
105
|
+
children_map: dict[str | None, list[str]] = {}
|
|
106
|
+
|
|
107
|
+
for row in rows:
|
|
108
|
+
node_id = row[0]
|
|
109
|
+
parent_node_id = row[1]
|
|
110
|
+
code_urn = row[2]
|
|
111
|
+
name_vals = row[3 : 3 + n_langs]
|
|
112
|
+
desc_vals = row[3 + n_langs : 3 + 2 * n_langs]
|
|
113
|
+
ann_json = row[3 + 2 * n_langs]
|
|
114
|
+
vf = row[3 + 2 * n_langs + 1]
|
|
115
|
+
vt = row[3 + 2 * n_langs + 2]
|
|
116
|
+
|
|
117
|
+
node: dict[str, Any] = {"id": node_id}
|
|
118
|
+
if code_urn:
|
|
119
|
+
node["code"] = code_urn
|
|
120
|
+
|
|
121
|
+
name_dict = {lang: val for lang, val in zip(languages, name_vals, strict=True) if val is not None}
|
|
122
|
+
if name_dict:
|
|
123
|
+
node["name"] = name_dict
|
|
124
|
+
|
|
125
|
+
desc_dict = {lang: val for lang, val in zip(languages, desc_vals, strict=True) if val is not None}
|
|
126
|
+
if desc_dict:
|
|
127
|
+
node["description"] = desc_dict
|
|
128
|
+
|
|
129
|
+
if ann_json:
|
|
130
|
+
node["annotations"] = json.loads(ann_json)
|
|
131
|
+
if vf:
|
|
132
|
+
node["valid_from"] = vf
|
|
133
|
+
if vt:
|
|
134
|
+
node["valid_to"] = vt
|
|
135
|
+
|
|
136
|
+
node["children"] = []
|
|
137
|
+
nodes_by_id[node_id] = node
|
|
138
|
+
children_map.setdefault(parent_node_id, []).append(node_id)
|
|
139
|
+
|
|
140
|
+
# Build tree from adjacency list
|
|
141
|
+
for parent_id, child_ids in children_map.items():
|
|
142
|
+
if parent_id is not None and parent_id in nodes_by_id:
|
|
143
|
+
nodes_by_id[parent_id]["children"] = [nodes_by_id[cid] for cid in child_ids]
|
|
144
|
+
|
|
145
|
+
root_ids = children_map.get(None, [])
|
|
146
|
+
return [nodes_by_id[rid] for rid in root_ids]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── Artefact reconstruction ────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_artefact(
|
|
153
|
+
conn: duckdb.DuckDBPyConnection,
|
|
154
|
+
urn: str,
|
|
155
|
+
*,
|
|
156
|
+
eager: bool = False,
|
|
157
|
+
) -> Any | None:
|
|
158
|
+
"""Look up an artefact by URN string.
|
|
159
|
+
|
|
160
|
+
For Codelists, returns a ``LazyCodelist`` by default. Pass
|
|
161
|
+
``eager=True`` to get a real ``Codelist`` with all codes loaded.
|
|
162
|
+
|
|
163
|
+
For Hierarchies, nodes are loaded from the normalized table and
|
|
164
|
+
injected into the dict before reconstruction.
|
|
165
|
+
|
|
166
|
+
Returns ``None`` if the URN is not in the store.
|
|
167
|
+
"""
|
|
168
|
+
row = conn.execute(
|
|
169
|
+
"""
|
|
170
|
+
SELECT artefact_type, _document
|
|
171
|
+
FROM artefacts
|
|
172
|
+
WHERE urn = $urn
|
|
173
|
+
""",
|
|
174
|
+
{"urn": urn},
|
|
175
|
+
).fetchone()
|
|
176
|
+
if row is None:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
artefact_type, doc_str = row
|
|
180
|
+
data = json.loads(doc_str)
|
|
181
|
+
|
|
182
|
+
if artefact_type == "Codelist" and not eager:
|
|
183
|
+
return make_lazy_codelist(conn, urn, data)
|
|
184
|
+
|
|
185
|
+
cls = _TYPE_REGISTRY.get(artefact_type)
|
|
186
|
+
if cls is None:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
if artefact_type == "Codelist":
|
|
190
|
+
data["codes"] = load_code_dicts(conn, urn)
|
|
191
|
+
|
|
192
|
+
if artefact_type == "Hierarchy":
|
|
193
|
+
data["tree"] = load_hierarchy_tree(conn, urn)
|
|
194
|
+
|
|
195
|
+
return from_dict(cls, data)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_all(
|
|
199
|
+
conn: duckdb.DuckDBPyConnection,
|
|
200
|
+
artefact_type: type,
|
|
201
|
+
*,
|
|
202
|
+
eager: bool = False,
|
|
203
|
+
) -> list[Any]:
|
|
204
|
+
"""Return all artefacts of a given type.
|
|
205
|
+
|
|
206
|
+
For Codelists, returns ``LazyCodelist`` instances by default unless
|
|
207
|
+
``eager=True``.
|
|
208
|
+
"""
|
|
209
|
+
type_name = _CLASS_TO_NAME.get(artefact_type)
|
|
210
|
+
if type_name is None:
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
rows = conn.execute(
|
|
214
|
+
"""
|
|
215
|
+
SELECT urn, _document
|
|
216
|
+
FROM artefacts
|
|
217
|
+
WHERE artefact_type = $type_name
|
|
218
|
+
""",
|
|
219
|
+
{"type_name": type_name},
|
|
220
|
+
).fetchall()
|
|
221
|
+
|
|
222
|
+
results: list[Any] = []
|
|
223
|
+
for urn_str, doc_str in rows:
|
|
224
|
+
data = json.loads(doc_str)
|
|
225
|
+
if type_name == "Codelist" and not eager:
|
|
226
|
+
results.append(make_lazy_codelist(conn, urn_str, data))
|
|
227
|
+
elif type_name == "Hierarchy":
|
|
228
|
+
data["tree"] = load_hierarchy_tree(conn, urn_str)
|
|
229
|
+
results.append(from_dict(artefact_type, data))
|
|
230
|
+
elif type_name == "Codelist":
|
|
231
|
+
data["codes"] = load_code_dicts(conn, urn_str)
|
|
232
|
+
results.append(from_dict(artefact_type, data))
|
|
233
|
+
else:
|
|
234
|
+
results.append(from_dict(artefact_type, data))
|
|
235
|
+
return results
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── LazyCodelist construction ───────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def make_lazy_codelist(conn: duckdb.DuckDBPyConnection, urn: str, data: dict[str, Any]) -> LazyCodelist:
|
|
242
|
+
"""Construct a LazyCodelist from metadata dict and DuckDB connection."""
|
|
243
|
+
name = InternationalString.of(data["name"]) if data.get("name") else InternationalString()
|
|
244
|
+
desc = InternationalString.of(data["description"]) if data.get("description") else InternationalString()
|
|
245
|
+
agency = Agency(id=data.get("agency_id", data.get("maintainer", "")))
|
|
246
|
+
return LazyCodelist(
|
|
247
|
+
urn=urn,
|
|
248
|
+
conn=conn,
|
|
249
|
+
id=data["id"],
|
|
250
|
+
agency=agency,
|
|
251
|
+
version=data.get("version", "1.0"),
|
|
252
|
+
name=name,
|
|
253
|
+
description=desc,
|
|
254
|
+
is_final=data.get("is_final", False),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ── Direct SQL helpers ──────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def code_map(
|
|
262
|
+
conn: duckdb.DuckDBPyConnection,
|
|
263
|
+
codelist_urn: str,
|
|
264
|
+
lang: str = "en",
|
|
265
|
+
) -> dict[str, str]:
|
|
266
|
+
"""Return ``{code_id: label}`` for a codelist via direct SQL.
|
|
267
|
+
|
|
268
|
+
If the requested language column doesn't exist, returns an empty dict.
|
|
269
|
+
"""
|
|
270
|
+
languages = get_languages(conn)
|
|
271
|
+
if lang not in languages:
|
|
272
|
+
return {}
|
|
273
|
+
|
|
274
|
+
col = f"label_{lang}"
|
|
275
|
+
rows = conn.execute(
|
|
276
|
+
f"""
|
|
277
|
+
SELECT code_id, {col}
|
|
278
|
+
FROM codes
|
|
279
|
+
WHERE codelist_urn = $codelist_urn
|
|
280
|
+
""", # noqa: S608
|
|
281
|
+
{"codelist_urn": codelist_urn},
|
|
282
|
+
).fetchall()
|
|
283
|
+
return {r[0]: r[1] for r in rows if r[1] is not None}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def code_map_for_dsd(
|
|
287
|
+
conn: duckdb.DuckDBPyConnection,
|
|
288
|
+
dsd_urn: str,
|
|
289
|
+
lang: str = "en",
|
|
290
|
+
) -> dict[str, dict[str, str]]:
|
|
291
|
+
"""Return ``{component_id: {code_id: label}}`` for all enumerated components.
|
|
292
|
+
|
|
293
|
+
Joins ``dsd_components`` with ``codes`` to build the map without
|
|
294
|
+
materializing Python objects.
|
|
295
|
+
"""
|
|
296
|
+
rows = conn.execute(
|
|
297
|
+
"""
|
|
298
|
+
SELECT component_id, codelist_urn
|
|
299
|
+
FROM dsd_components
|
|
300
|
+
WHERE dsd_urn = $dsd_urn AND codelist_urn IS NOT NULL
|
|
301
|
+
""",
|
|
302
|
+
{"dsd_urn": dsd_urn},
|
|
303
|
+
).fetchall()
|
|
304
|
+
|
|
305
|
+
result: dict[str, dict[str, str]] = {}
|
|
306
|
+
for comp_id, cl_urn in rows:
|
|
307
|
+
cm = code_map(conn, cl_urn, lang)
|
|
308
|
+
if cm:
|
|
309
|
+
result[comp_id] = cm
|
|
310
|
+
return result
|