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.
Files changed (58) hide show
  1. sdmxlib/__init__.py +155 -0
  2. sdmxlib/api/__init__.py +7 -0
  3. sdmxlib/api/client.py +246 -0
  4. sdmxlib/api/filters.py +67 -0
  5. sdmxlib/api/providers.py +66 -0
  6. sdmxlib/api/query.py +428 -0
  7. sdmxlib/api/registry.py +666 -0
  8. sdmxlib/api/session.py +124 -0
  9. sdmxlib/formats/__init__.py +166 -0
  10. sdmxlib/formats/sdmx_csv/__init__.py +5 -0
  11. sdmxlib/formats/sdmx_csv/reader.py +147 -0
  12. sdmxlib/formats/sdmx_json/__init__.py +1 -0
  13. sdmxlib/formats/sdmx_json/reader.py +897 -0
  14. sdmxlib/formats/sdmx_json/writer.py +698 -0
  15. sdmxlib/formats/sdmx_ml21/__init__.py +5 -0
  16. sdmxlib/formats/sdmx_ml21/namespaces.py +12 -0
  17. sdmxlib/formats/sdmx_ml21/reader.py +1147 -0
  18. sdmxlib/formats/sdmx_ml21/writer.py +709 -0
  19. sdmxlib/formats/sdmx_ml30/__init__.py +1 -0
  20. sdmxlib/formats/sdmx_ml30/namespaces.py +12 -0
  21. sdmxlib/formats/sdmx_ml30/reader.py +1138 -0
  22. sdmxlib/formats/sdmx_ml30/writer.py +730 -0
  23. sdmxlib/local/__init__.py +6 -0
  24. sdmxlib/local/_registry.py +368 -0
  25. sdmxlib/model/__init__.py +122 -0
  26. sdmxlib/model/annotations.py +103 -0
  27. sdmxlib/model/base.py +61 -0
  28. sdmxlib/model/category.py +88 -0
  29. sdmxlib/model/codelist.py +91 -0
  30. sdmxlib/model/collections.py +382 -0
  31. sdmxlib/model/concept.py +121 -0
  32. sdmxlib/model/constraint.py +92 -0
  33. sdmxlib/model/convert.py +268 -0
  34. sdmxlib/model/dataflow.py +49 -0
  35. sdmxlib/model/dataset.py +337 -0
  36. sdmxlib/model/datastructure.py +390 -0
  37. sdmxlib/model/expr.py +120 -0
  38. sdmxlib/model/hierarchy.py +111 -0
  39. sdmxlib/model/istring.py +81 -0
  40. sdmxlib/model/mapping.py +134 -0
  41. sdmxlib/model/message.py +70 -0
  42. sdmxlib/model/organisation.py +149 -0
  43. sdmxlib/model/provision.py +45 -0
  44. sdmxlib/model/ref.py +195 -0
  45. sdmxlib/model/registry.py +191 -0
  46. sdmxlib/model/representation.py +99 -0
  47. sdmxlib/model/urn.py +130 -0
  48. sdmxlib/model/validation.py +338 -0
  49. sdmxlib/polars.py +392 -0
  50. sdmxlib/py.typed +0 -0
  51. sdmxlib/storage/__init__.py +28 -0
  52. sdmxlib/storage/lazy.py +329 -0
  53. sdmxlib/storage/readers.py +310 -0
  54. sdmxlib/storage/schema.py +289 -0
  55. sdmxlib/storage/writers.py +503 -0
  56. sdmxlib-0.8.0.dist-info/METADATA +102 -0
  57. sdmxlib-0.8.0.dist-info/RECORD +58 -0
  58. sdmxlib-0.8.0.dist-info/WHEEL +4 -0
@@ -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