sdmxlib 0.27.0__tar.gz → 0.27.2__tar.gz
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-0.27.0 → sdmxlib-0.27.2}/PKG-INFO +1 -1
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/pyproject.toml +1 -1
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/local/_registry.py +329 -60
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/storage/readers.py +188 -50
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/README.md +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/__init__.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/__init__.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/client.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/federated.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/filters.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/policy.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/providers.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/query.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/registry.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/session.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/catalog.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/data_store.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/__init__.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/local/__init__.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/local/_data_store.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/__init__.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/annotations.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/base.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/binding.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/category.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/code_query.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/codelist.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/collections.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/concept.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/constraint.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/convert.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/dataflow.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/dataset.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/datastructure.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/expr.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/hierarchy.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/hierarchy_query.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/in_memory_registry.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/istring.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/mapping.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/message.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/metadataflow.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/metadataset.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/metadatastructure.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/organisation.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/provision.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/ref.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/registry.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/representation.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/urn.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/validation.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/polars.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/py.typed +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/rest.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/sql.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/storage/__init__.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/storage/lazy.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/storage/schema.py +0 -0
- {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/storage/writers.py +0 -0
|
@@ -30,6 +30,7 @@ stashes the URI; the DuckDB connection is opened by ``open()`` /
|
|
|
30
30
|
local.add(...)
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
|
+
from collections import OrderedDict
|
|
33
34
|
from collections.abc import Iterable
|
|
34
35
|
from pathlib import Path
|
|
35
36
|
from typing import Any, Self
|
|
@@ -57,6 +58,90 @@ _ITEM_TO_SCHEME: dict[str, tuple[str, str]] = {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
DEFAULT_RESOLVE_CACHE_SIZE = 256
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _LRUDict(OrderedDict[str, Any]):
|
|
65
|
+
"""Bounded LRU mapping used as the registry-level resolver cache.
|
|
66
|
+
|
|
67
|
+
Subclasses :class:`OrderedDict` so it can be passed wherever the
|
|
68
|
+
resolver expects ``dict[str, Any]``. Inserts evict least-recently-used
|
|
69
|
+
entries past ``maxsize``; reads via :meth:`get` move keys to the end.
|
|
70
|
+
``maxsize == 0`` disables caching — inserts are silently dropped, so
|
|
71
|
+
the structure stays empty.
|
|
72
|
+
|
|
73
|
+
Tracks ``hits`` / ``misses`` for :meth:`Registry.resolve_cache_info`;
|
|
74
|
+
``__contains__`` is treated as internal control flow (the resolver
|
|
75
|
+
uses it for dedup) and is not counted.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, maxsize: int) -> None:
|
|
79
|
+
super().__init__()
|
|
80
|
+
if maxsize < 0:
|
|
81
|
+
msg = f"resolve_cache_size must be >= 0, got {maxsize}"
|
|
82
|
+
raise ValueError(msg)
|
|
83
|
+
self.maxsize = maxsize
|
|
84
|
+
self.hits = 0
|
|
85
|
+
self.misses = 0
|
|
86
|
+
|
|
87
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
88
|
+
if self.maxsize == 0:
|
|
89
|
+
return
|
|
90
|
+
if key in self:
|
|
91
|
+
self.move_to_end(key)
|
|
92
|
+
super().__setitem__(key, value)
|
|
93
|
+
while len(self) > self.maxsize:
|
|
94
|
+
self.popitem(last=False)
|
|
95
|
+
|
|
96
|
+
def get(self, key: str, default: Any = None) -> Any: # type: ignore[override]
|
|
97
|
+
if super().__contains__(key):
|
|
98
|
+
self.hits += 1
|
|
99
|
+
self.move_to_end(key)
|
|
100
|
+
return super().__getitem__(key)
|
|
101
|
+
self.misses += 1
|
|
102
|
+
return default
|
|
103
|
+
|
|
104
|
+
def reset_stats(self) -> None:
|
|
105
|
+
self.hits = 0
|
|
106
|
+
self.misses = 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _CallScopedMemo(dict[str, Any]):
|
|
110
|
+
"""Per-call resolver memo layered over a persistent LRU cache.
|
|
111
|
+
|
|
112
|
+
Two reasons for the overlay rather than passing the LRU directly:
|
|
113
|
+
|
|
114
|
+
1. **Correctness under tight cache sizes (incl. ``maxsize=0``).** The
|
|
115
|
+
resolver writes URN→target during one phase and reads them back in
|
|
116
|
+
a later phase of the same call. If those writes went straight to
|
|
117
|
+
the LRU, an entry could be evicted before the read — leaving Refs
|
|
118
|
+
unresolved for callers using a small (or disabled) cache. The
|
|
119
|
+
overlay holds in-flight entries in an unbounded per-call dict so
|
|
120
|
+
single-call resolution is independent of cache size.
|
|
121
|
+
2. **Promote-on-success.** Entries written during the call enter the
|
|
122
|
+
LRU only at the end of the call, via :meth:`promote`. That keeps
|
|
123
|
+
partial-failure semantics clean: if a call raises midway, the LRU
|
|
124
|
+
is unchanged.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, lru: _LRUDict) -> None:
|
|
128
|
+
super().__init__()
|
|
129
|
+
self._lru = lru
|
|
130
|
+
|
|
131
|
+
def __contains__(self, key: object) -> bool:
|
|
132
|
+
return super().__contains__(key) or self._lru.__contains__(key)
|
|
133
|
+
|
|
134
|
+
def get(self, key: str, default: Any = None) -> Any: # type: ignore[override]
|
|
135
|
+
if super().__contains__(key):
|
|
136
|
+
return super().__getitem__(key)
|
|
137
|
+
return self._lru.get(key, default)
|
|
138
|
+
|
|
139
|
+
def promote(self) -> None:
|
|
140
|
+
"""Copy this call's resolved entries into the persistent LRU."""
|
|
141
|
+
for k, v in self.items():
|
|
142
|
+
self._lru[k] = v
|
|
143
|
+
|
|
144
|
+
|
|
60
145
|
class Registry:
|
|
61
146
|
"""DuckDB-backed persistent SDMX artefact store.
|
|
62
147
|
|
|
@@ -86,12 +171,22 @@ class Registry:
|
|
|
86
171
|
uri: str | Path,
|
|
87
172
|
*,
|
|
88
173
|
source_registry: str | None = None,
|
|
174
|
+
resolve_cache_size: int = DEFAULT_RESOLVE_CACHE_SIZE,
|
|
89
175
|
) -> None:
|
|
176
|
+
"""Open or wrap a DuckDB-backed registry.
|
|
177
|
+
|
|
178
|
+
``resolve_cache_size`` bounds the LRU of resolved Ref targets that
|
|
179
|
+
:meth:`get` / :meth:`get_all` / :meth:`to_registry` share across
|
|
180
|
+
calls. Pass ``0`` to disable caching (matches pre-v0.27.2 behaviour
|
|
181
|
+
— one full read per Ref). The cache is cleared on every mutation
|
|
182
|
+
(``add`` / ``drop`` / ``replace``) for invalidation correctness.
|
|
183
|
+
"""
|
|
90
184
|
self._uri = Path(uri) if isinstance(uri, str) else uri
|
|
91
185
|
self._source_registry = source_registry
|
|
92
186
|
self._conn: duckdb.DuckDBPyConnection | None = None
|
|
93
187
|
self._cursor: duckdb.DuckDBPyConnection | None = None
|
|
94
188
|
self._owns_connection = True
|
|
189
|
+
self._resolve_cache: _LRUDict = _LRUDict(resolve_cache_size)
|
|
95
190
|
|
|
96
191
|
@property
|
|
97
192
|
def uri(self) -> Path:
|
|
@@ -137,6 +232,7 @@ class Registry:
|
|
|
137
232
|
conn: duckdb.DuckDBPyConnection,
|
|
138
233
|
*,
|
|
139
234
|
source_registry: str | None = None,
|
|
235
|
+
resolve_cache_size: int = DEFAULT_RESOLVE_CACHE_SIZE,
|
|
140
236
|
) -> Self:
|
|
141
237
|
"""Wrap an existing DuckDB connection.
|
|
142
238
|
|
|
@@ -144,6 +240,9 @@ class Registry:
|
|
|
144
240
|
Registry leaves the connection open. Use this when the connection
|
|
145
241
|
is shared with another component (e.g. a co-located DuckLake catalog)
|
|
146
242
|
that owns its lifecycle.
|
|
243
|
+
|
|
244
|
+
``resolve_cache_size`` configures the resolver LRU; see
|
|
245
|
+
:meth:`__init__`.
|
|
147
246
|
"""
|
|
148
247
|
instance = cls.__new__(cls)
|
|
149
248
|
instance._uri = Path(":connection:") # noqa: SLF001 — alternate constructor sets private state
|
|
@@ -151,6 +250,7 @@ class Registry:
|
|
|
151
250
|
instance._conn = conn # noqa: SLF001
|
|
152
251
|
instance._cursor = None # noqa: SLF001
|
|
153
252
|
instance._owns_connection = False # noqa: SLF001
|
|
253
|
+
instance._resolve_cache = _LRUDict(resolve_cache_size) # noqa: SLF001
|
|
154
254
|
instance._initialise_schema() # noqa: SLF001
|
|
155
255
|
return instance
|
|
156
256
|
|
|
@@ -209,9 +309,14 @@ class Registry:
|
|
|
209
309
|
|
|
210
310
|
``source_registry`` overrides the registry-level default for this
|
|
211
311
|
call. ``None`` (default) means "use the registry-level default."
|
|
312
|
+
|
|
313
|
+
Clears the resolver cache: any cached resolved target may now be
|
|
314
|
+
stale (e.g. a freshly-added Codelist replaces the one a previous
|
|
315
|
+
``get`` had cached).
|
|
212
316
|
"""
|
|
213
317
|
tag = source_registry if source_registry is not None else self._source_registry
|
|
214
318
|
writers.put_artefact(self._c, artefact, source_registry=tag)
|
|
319
|
+
self._resolve_cache.clear()
|
|
215
320
|
|
|
216
321
|
def add_all(
|
|
217
322
|
self,
|
|
@@ -256,70 +361,180 @@ class Registry:
|
|
|
256
361
|
if result is None:
|
|
257
362
|
return None
|
|
258
363
|
if not isinstance(result, (LazyCodelist, LazyHierarchy)):
|
|
259
|
-
self.
|
|
364
|
+
memo = _CallScopedMemo(self._resolve_cache)
|
|
365
|
+
self._resolve_refs(result, memo=memo)
|
|
366
|
+
memo.promote()
|
|
260
367
|
return result # type: ignore[return-value]
|
|
261
368
|
|
|
262
369
|
def get_all[T](self, artefact_type: "type[T]") -> "list[T]":
|
|
263
|
-
"""All artefacts of the given Python type.
|
|
370
|
+
"""All artefacts of the given Python type.
|
|
371
|
+
|
|
372
|
+
Resolution shares one call-scoped memo across the batch and is
|
|
373
|
+
backed by the registry's persistent ``_resolve_cache``, so any
|
|
374
|
+
parent scheme referenced by multiple artefacts is loaded once,
|
|
375
|
+
not once per artefact.
|
|
376
|
+
"""
|
|
264
377
|
results = readers.get_all(self._c, artefact_type)
|
|
378
|
+
memo = _CallScopedMemo(self._resolve_cache)
|
|
265
379
|
for r in results:
|
|
266
380
|
if not isinstance(r, (LazyCodelist, LazyHierarchy)):
|
|
267
|
-
self._resolve_refs(r,
|
|
381
|
+
self._resolve_refs(r, memo=memo)
|
|
382
|
+
memo.promote()
|
|
268
383
|
return results # type: ignore[return-value]
|
|
269
384
|
|
|
270
385
|
# ── Ref resolution ─────────────────────────────────────────────────
|
|
271
386
|
|
|
272
|
-
def _resolve_refs(
|
|
273
|
-
|
|
387
|
+
def _resolve_refs( # noqa: C901
|
|
388
|
+
self,
|
|
389
|
+
obj: Any,
|
|
390
|
+
*,
|
|
391
|
+
memo: "dict[str, Any] | None" = None,
|
|
392
|
+
walked: "set[int] | None" = None,
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Resolve every unresolved ``Ref`` in *obj*'s attrs graph.
|
|
395
|
+
|
|
396
|
+
Two-phase batched walk:
|
|
397
|
+
|
|
398
|
+
1. **Collect** — recursive Python-only walk that visits every attrs
|
|
399
|
+
field reachable from *obj*. Records ``(owner, field_name, Ref)``
|
|
400
|
+
tuples for unresolved Refs; no SQL.
|
|
401
|
+
2. **Resolve** — deduplicate URNs, partition into item URNs
|
|
402
|
+
(resolved by loading the parent scheme once and looking up the
|
|
403
|
+
item) vs top-level URNs (one SQL fetch per distinct URN). Both
|
|
404
|
+
paths populate a shared per-call memo keyed by URN string.
|
|
405
|
+
3. **Assign** — mutate the Ref fields with ``Ref.of(urn, target)``.
|
|
406
|
+
|
|
407
|
+
Newly resolved targets enter a new frontier and are walked in turn
|
|
408
|
+
so transitive references resolve in the same call (e.g. a DSD ref
|
|
409
|
+
resolves to a Codelist whose embedded Refs also get resolved).
|
|
410
|
+
|
|
411
|
+
Idempotent: already-resolved Refs are skipped. ``memo`` and
|
|
412
|
+
``walked`` may be passed in to share state across multiple
|
|
413
|
+
artefacts (used by :meth:`get_all` and :meth:`to_registry`).
|
|
414
|
+
"""
|
|
415
|
+
if memo is None:
|
|
416
|
+
memo = {}
|
|
417
|
+
if walked is None:
|
|
418
|
+
walked = set()
|
|
419
|
+
|
|
420
|
+
frontier: list[Any] = [obj]
|
|
421
|
+
while frontier:
|
|
422
|
+
pending: list[tuple[Any, str, Ref[Any]]] = []
|
|
423
|
+
for item in frontier:
|
|
424
|
+
self._collect_refs(item, walked, pending)
|
|
425
|
+
frontier = []
|
|
426
|
+
|
|
427
|
+
if not pending:
|
|
428
|
+
break
|
|
429
|
+
|
|
430
|
+
# Deduplicate the URNs we still need to resolve.
|
|
431
|
+
needed: list[SdmxUrn[Any]] = []
|
|
432
|
+
seen: set[str] = set()
|
|
433
|
+
for _owner, _name, ref in pending:
|
|
434
|
+
urn_str = str(ref.urn)
|
|
435
|
+
if urn_str in memo or urn_str in seen:
|
|
436
|
+
continue
|
|
437
|
+
seen.add(urn_str)
|
|
438
|
+
needed.append(ref.urn)
|
|
439
|
+
|
|
440
|
+
if needed:
|
|
441
|
+
self._resolve_urns(needed, memo)
|
|
442
|
+
|
|
443
|
+
for owner, name, ref in pending:
|
|
444
|
+
if ref._resolved is not None: # noqa: SLF001
|
|
445
|
+
continue
|
|
446
|
+
target = memo.get(str(ref.urn))
|
|
447
|
+
if target is None:
|
|
448
|
+
continue
|
|
449
|
+
setattr(owner, name, Ref.of(ref.urn, target))
|
|
450
|
+
if id(target) not in walked:
|
|
451
|
+
frontier.append(target)
|
|
452
|
+
|
|
453
|
+
def _collect_refs(
|
|
454
|
+
self,
|
|
455
|
+
obj: Any,
|
|
456
|
+
walked: set[int],
|
|
457
|
+
pending: "list[tuple[Any, str, Ref[Any]]]",
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Pure-Python walk that records unresolved Refs into *pending*."""
|
|
274
460
|
if not attrs.has(type(obj)) or isinstance(obj, type):
|
|
275
461
|
return
|
|
276
462
|
oid = id(obj)
|
|
277
|
-
if oid in
|
|
463
|
+
if oid in walked:
|
|
278
464
|
return
|
|
279
|
-
|
|
465
|
+
walked.add(oid)
|
|
280
466
|
|
|
281
467
|
for f in attrs.fields(type(obj)):
|
|
282
468
|
value = getattr(obj, f.name, None)
|
|
283
469
|
if value is None:
|
|
284
470
|
continue
|
|
285
471
|
if isinstance(value, Ref):
|
|
286
|
-
|
|
472
|
+
if value._resolved is None: # noqa: SLF001
|
|
473
|
+
pending.append((obj, f.name, value))
|
|
287
474
|
elif isinstance(value, ItemList):
|
|
288
475
|
for item in value:
|
|
289
|
-
self.
|
|
476
|
+
self._collect_refs(item, walked, pending)
|
|
290
477
|
elif attrs.has(type(value)) and not isinstance(value, type):
|
|
291
|
-
self.
|
|
292
|
-
|
|
293
|
-
def _resolve_ref_field(self, obj: Any, name: str, ref: "Ref[Any]") -> None:
|
|
294
|
-
"""Resolve a single Ref field from DuckDB and set it on *obj*."""
|
|
295
|
-
if ref._resolved is not None: # noqa: SLF001
|
|
296
|
-
return
|
|
478
|
+
self._collect_refs(value, walked, pending)
|
|
297
479
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
480
|
+
def _resolve_urns( # noqa: C901
|
|
481
|
+
self,
|
|
482
|
+
urns: "list[SdmxUrn[Any]]",
|
|
483
|
+
memo: "dict[str, Any]",
|
|
484
|
+
) -> None:
|
|
485
|
+
"""Bulk-load the targets for *urns*, populating *memo* in place.
|
|
303
486
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
487
|
+
Item URNs (Code, Concept, …) are grouped by their parent scheme
|
|
488
|
+
URN so each scheme is loaded at most once per call, regardless of
|
|
489
|
+
how many of its items the caller asked for. This is what makes
|
|
490
|
+
large Hierarchies and wide DSDs O(distinct parent schemes) in SQL
|
|
491
|
+
round-trips instead of O(refs).
|
|
492
|
+
"""
|
|
493
|
+
item_urns_by_parent: dict[str, list[SdmxUrn[Any]]] = {}
|
|
494
|
+
top_urns: list[SdmxUrn[Any]] = []
|
|
495
|
+
for parsed in urns:
|
|
496
|
+
if parsed.item_id is None:
|
|
497
|
+
top_urns.append(parsed)
|
|
498
|
+
continue
|
|
499
|
+
parent_urn_str = self._parent_scheme_urn(parsed)
|
|
500
|
+
if parent_urn_str is None:
|
|
501
|
+
continue
|
|
502
|
+
item_urns_by_parent.setdefault(parent_urn_str, []).append(parsed)
|
|
309
503
|
|
|
310
|
-
|
|
504
|
+
for parent_urn_str, item_urns in item_urns_by_parent.items():
|
|
505
|
+
sample = item_urns[0]
|
|
506
|
+
scheme_info = _ITEM_TO_SCHEME.get(sample.artefact_class)
|
|
507
|
+
if scheme_info is None:
|
|
508
|
+
continue
|
|
509
|
+
_scheme_class_name, items_attr = scheme_info
|
|
510
|
+
parent = self._load_parent_scheme(parent_urn_str, sample, memo)
|
|
511
|
+
if parent is None:
|
|
512
|
+
continue
|
|
513
|
+
items = getattr(parent, items_attr, None)
|
|
514
|
+
if items is None or not hasattr(items, "get"):
|
|
515
|
+
continue
|
|
516
|
+
for parsed in item_urns:
|
|
517
|
+
target = items.get(parsed.item_id)
|
|
518
|
+
if target is not None:
|
|
519
|
+
memo[str(parsed)] = target
|
|
520
|
+
|
|
521
|
+
for parsed in top_urns:
|
|
522
|
+
urn_str = str(parsed)
|
|
523
|
+
if urn_str in memo:
|
|
524
|
+
continue
|
|
525
|
+
target = readers.get_artefact(self._c, urn_str)
|
|
526
|
+
if target is not None:
|
|
527
|
+
memo[urn_str] = target
|
|
311
528
|
|
|
312
|
-
def
|
|
313
|
-
"""
|
|
529
|
+
def _parent_scheme_urn(self, parsed: "SdmxUrn[Any]") -> str | None:
|
|
530
|
+
"""Build the parent scheme URN string for an item-level URN, or None."""
|
|
314
531
|
scheme_info = _ITEM_TO_SCHEME.get(parsed.artefact_class)
|
|
315
532
|
if scheme_info is None:
|
|
316
533
|
return None
|
|
317
|
-
|
|
318
|
-
scheme_class_name, items_attr = scheme_info
|
|
534
|
+
scheme_class_name, _items_attr = scheme_info
|
|
319
535
|
scheme_cls = _TYPE_REGISTRY.get(scheme_class_name)
|
|
320
536
|
if scheme_cls is None:
|
|
321
537
|
return None
|
|
322
|
-
|
|
323
538
|
parent_urn = SdmxUrn.make(
|
|
324
539
|
scheme_cls,
|
|
325
540
|
package=parsed.package,
|
|
@@ -328,31 +543,39 @@ class Registry:
|
|
|
328
543
|
id=parsed.id,
|
|
329
544
|
version=parsed.version,
|
|
330
545
|
)
|
|
331
|
-
|
|
546
|
+
return str(parent_urn)
|
|
332
547
|
|
|
333
|
-
|
|
548
|
+
def _load_parent_scheme(
|
|
549
|
+
self,
|
|
550
|
+
parent_urn_str: str,
|
|
551
|
+
sample: "SdmxUrn[Any]",
|
|
552
|
+
memo: "dict[str, Any]",
|
|
553
|
+
) -> Any | None:
|
|
554
|
+
"""Load (eager) a parent scheme for item resolution, memoized.
|
|
555
|
+
|
|
556
|
+
Honours the version=``"latest"`` fallback that the previous
|
|
557
|
+
per-ref resolver used.
|
|
558
|
+
"""
|
|
559
|
+
cached = memo.get(parent_urn_str)
|
|
560
|
+
if cached is not None:
|
|
561
|
+
return cached
|
|
334
562
|
parent = readers.get_artefact(self._c, parent_urn_str, eager=True)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if parent is None:
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
items = getattr(parent, items_attr, None)
|
|
352
|
-
if items is None:
|
|
353
|
-
return None
|
|
354
|
-
|
|
355
|
-
return items.get(parsed.item_id) if hasattr(items, "get") else None
|
|
563
|
+
if parent is None and sample.version == "latest":
|
|
564
|
+
scheme_info = _ITEM_TO_SCHEME.get(sample.artefact_class)
|
|
565
|
+
if scheme_info is not None:
|
|
566
|
+
scheme_class_name, _ = scheme_info
|
|
567
|
+
row = self._c.execute(
|
|
568
|
+
"""
|
|
569
|
+
SELECT urn FROM artefact
|
|
570
|
+
WHERE artefact_type = ? AND agency_id = ? AND resource_id = ?
|
|
571
|
+
""",
|
|
572
|
+
[scheme_class_name, sample.agency, sample.id],
|
|
573
|
+
).fetchone()
|
|
574
|
+
if row:
|
|
575
|
+
parent = readers.get_artefact(self._c, row[0], eager=True)
|
|
576
|
+
if parent is not None:
|
|
577
|
+
memo[parent_urn_str] = parent
|
|
578
|
+
return parent
|
|
356
579
|
|
|
357
580
|
# ── Ref ────────────────────────────────────────────────────────────────
|
|
358
581
|
|
|
@@ -383,14 +606,51 @@ class Registry:
|
|
|
383
606
|
# ── Mutations ─────────────────────────────────────────────────────────
|
|
384
607
|
|
|
385
608
|
def drop(self, urn: "SdmxUrn[Any]") -> bool:
|
|
386
|
-
"""Remove an artefact by URN. Returns True if it existed.
|
|
387
|
-
|
|
609
|
+
"""Remove an artefact by URN. Returns True if it existed.
|
|
610
|
+
|
|
611
|
+
Clears the resolver cache (cached resolved targets may reference
|
|
612
|
+
the dropped artefact).
|
|
613
|
+
"""
|
|
614
|
+
result = writers.drop_artefact(self._c, str(urn))
|
|
615
|
+
self._resolve_cache.clear()
|
|
616
|
+
return result
|
|
388
617
|
|
|
389
618
|
def replace(self, old_urn: "SdmxUrn[Any]", new: Any) -> None:
|
|
390
|
-
"""Remove the old artefact and add the new one.
|
|
619
|
+
"""Remove the old artefact and add the new one.
|
|
620
|
+
|
|
621
|
+
Each leg clears the resolver cache via :meth:`add` / :meth:`drop`;
|
|
622
|
+
the net effect is a single fully-cleared cache at the end.
|
|
623
|
+
"""
|
|
391
624
|
writers.drop_artefact(self._c, str(old_urn))
|
|
392
625
|
self.add(new)
|
|
393
626
|
|
|
627
|
+
# ── Resolver cache ────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
def clear_resolve_cache(self) -> None:
|
|
630
|
+
"""Purge the resolver cache and reset hit/miss counters.
|
|
631
|
+
|
|
632
|
+
Useful for explicit invalidation hooks (e.g. a webhook telling the
|
|
633
|
+
service that upstream metadata changed without going through this
|
|
634
|
+
registry's mutation API). Cache also clears automatically on every
|
|
635
|
+
``add`` / ``drop`` / ``replace``.
|
|
636
|
+
"""
|
|
637
|
+
self._resolve_cache.clear()
|
|
638
|
+
self._resolve_cache.reset_stats()
|
|
639
|
+
|
|
640
|
+
def resolve_cache_info(self) -> dict[str, int]:
|
|
641
|
+
"""Return resolver cache stats: size, max_size, hits, misses.
|
|
642
|
+
|
|
643
|
+
Intended for ops dashboards and integration tests asserting cache
|
|
644
|
+
behaviour. ``size == 0`` after construction or any mutation; rises
|
|
645
|
+
as ``get`` / ``get_all`` populate it, capped at ``max_size``.
|
|
646
|
+
"""
|
|
647
|
+
return {
|
|
648
|
+
"size": len(self._resolve_cache),
|
|
649
|
+
"max_size": self._resolve_cache.maxsize,
|
|
650
|
+
"hits": self._resolve_cache.hits,
|
|
651
|
+
"misses": self._resolve_cache.misses,
|
|
652
|
+
}
|
|
653
|
+
|
|
394
654
|
# ── Direct SQL helpers ────────────────────────────────────────────────
|
|
395
655
|
|
|
396
656
|
def code_map(self, codelist_urn: "str | SdmxUrn[Any]", *, lang: str) -> dict[str, str]:
|
|
@@ -419,11 +679,20 @@ class Registry:
|
|
|
419
679
|
# ── Escape hatch ──────────────────────────────────────────────────────
|
|
420
680
|
|
|
421
681
|
def to_registry(self) -> InMemoryRegistry:
|
|
422
|
-
"""Load everything into an in-memory Registry with full Ref interning.
|
|
682
|
+
"""Load everything into an in-memory Registry with full Ref interning.
|
|
683
|
+
|
|
684
|
+
Resolution uses the registry's persistent ``_resolve_cache`` so each
|
|
685
|
+
parent scheme is read at most once even when many artefacts
|
|
686
|
+
reference it.
|
|
687
|
+
"""
|
|
423
688
|
reg = InMemoryRegistry()
|
|
689
|
+
memo = _CallScopedMemo(self._resolve_cache)
|
|
424
690
|
rows = self._c.execute("SELECT urn, artefact_type FROM artefact").fetchall()
|
|
425
691
|
for urn_str, _artefact_type in rows:
|
|
426
692
|
obj = readers.get_artefact(self._c, urn_str, eager=True)
|
|
427
|
-
if obj is
|
|
428
|
-
|
|
693
|
+
if obj is None:
|
|
694
|
+
continue
|
|
695
|
+
self._resolve_refs(obj, memo=memo)
|
|
696
|
+
reg.add(obj)
|
|
697
|
+
memo.promote()
|
|
429
698
|
return reg
|
|
@@ -99,30 +99,154 @@ def _read_annotations(
|
|
|
99
99
|
conn: duckdb.DuckDBPyConnection,
|
|
100
100
|
owner_urn: str,
|
|
101
101
|
) -> Annotations:
|
|
102
|
-
"""Read annotations and their text for
|
|
102
|
+
"""Read annotations and their text for a single annotated entity.
|
|
103
|
+
|
|
104
|
+
Prefer :func:`_read_annotations_for_owners` when loading annotations
|
|
105
|
+
for many siblings — this single-owner helper still issues 1+N queries.
|
|
106
|
+
"""
|
|
103
107
|
rows = conn.execute(
|
|
104
108
|
"""
|
|
105
|
-
SELECT id, annotation_id, title, annotation_type, url
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
SELECT a.id, a.annotation_id, a.title, a.annotation_type, a.url,
|
|
110
|
+
atxt.lang, atxt.text
|
|
111
|
+
FROM annotation a
|
|
112
|
+
LEFT JOIN annotation_text atxt ON atxt.annotation_id = a.id
|
|
113
|
+
WHERE a.owner_urn = ?
|
|
114
|
+
ORDER BY a.ordinal, a.id
|
|
109
115
|
""",
|
|
110
116
|
[owner_urn],
|
|
111
117
|
).fetchall()
|
|
112
118
|
if not rows:
|
|
113
119
|
return Annotations()
|
|
120
|
+
return _annotations_from_joined_rows(rows)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _annotations_from_joined_rows(
|
|
124
|
+
rows: list[tuple[Any, ...]],
|
|
125
|
+
) -> Annotations:
|
|
126
|
+
"""Build an :class:`Annotations` from joined annotation+annotation_text rows.
|
|
127
|
+
|
|
128
|
+
Each row is ``(db_id, annotation_id, title, annotation_type, url, lang, text)``.
|
|
129
|
+
Rows for a single annotation are contiguous; LEFT-JOIN nulls in (lang, text)
|
|
130
|
+
indicate annotations with no text.
|
|
131
|
+
"""
|
|
114
132
|
result: list[Annotation] = []
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
133
|
+
current_db_id: object = object()
|
|
134
|
+
texts: dict[str, str] = {}
|
|
135
|
+
current_ann_fields: tuple[Any, ...] | None = None
|
|
136
|
+
|
|
137
|
+
def flush() -> None:
|
|
138
|
+
if current_ann_fields is None:
|
|
139
|
+
return
|
|
140
|
+
ann_id, title, ann_type, url = current_ann_fields
|
|
141
|
+
text = InternationalString.of(texts) if texts else InternationalString()
|
|
122
142
|
result.append(Annotation(id=ann_id, title=title, type=ann_type, url=url, text=text))
|
|
143
|
+
|
|
144
|
+
for row in rows:
|
|
145
|
+
db_id, ann_id, title, ann_type, url, lang, text_val = row
|
|
146
|
+
if db_id != current_db_id:
|
|
147
|
+
flush()
|
|
148
|
+
current_db_id = db_id
|
|
149
|
+
current_ann_fields = (ann_id, title, ann_type, url)
|
|
150
|
+
texts = {}
|
|
151
|
+
if lang is not None:
|
|
152
|
+
texts[lang] = text_val
|
|
153
|
+
flush()
|
|
123
154
|
return Annotations(result)
|
|
124
155
|
|
|
125
156
|
|
|
157
|
+
def _read_annotations_subtree(
|
|
158
|
+
conn: duckdb.DuckDBPyConnection,
|
|
159
|
+
artefact_urn: str,
|
|
160
|
+
) -> dict[str, Annotations]:
|
|
161
|
+
"""Bulk-load annotations for an artefact and every item beneath it.
|
|
162
|
+
|
|
163
|
+
Item URNs reuse the parent artefact's ``agency:id(version)`` suffix and
|
|
164
|
+
append ``.item_id`` (see :func:`writers._make_item_urn`), so a single
|
|
165
|
+
``OR owner_urn LIKE '%=<suffix>.%'`` clause covers the parent plus every
|
|
166
|
+
code / concept / component / hierarchy node, regardless of item class.
|
|
167
|
+
|
|
168
|
+
Returns ``{owner_urn: Annotations}`` for owners that have at least one
|
|
169
|
+
annotation. Callers should default missing keys to ``Annotations()``.
|
|
170
|
+
"""
|
|
171
|
+
suffix = artefact_urn.partition("=")[2]
|
|
172
|
+
pattern = f"%={suffix}.%"
|
|
173
|
+
return _read_annotations_for_owners_predicate(
|
|
174
|
+
conn,
|
|
175
|
+
"owner_urn = ? OR owner_urn LIKE ?",
|
|
176
|
+
[artefact_urn, pattern],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _read_annotations_for_owners_predicate(
|
|
181
|
+
conn: duckdb.DuckDBPyConnection,
|
|
182
|
+
predicate: str,
|
|
183
|
+
params: list[Any],
|
|
184
|
+
) -> dict[str, Annotations]:
|
|
185
|
+
"""Bulk-load annotations matching a WHERE predicate against ``annotation``.
|
|
186
|
+
|
|
187
|
+
Lower-level than :func:`_read_annotations_for_owners` (which expects a
|
|
188
|
+
subquery yielding owner URNs); this one inlines a predicate that
|
|
189
|
+
references ``owner_urn`` directly, so a single ``LIKE`` scan can cover
|
|
190
|
+
"this artefact and every item under it" without a subquery.
|
|
191
|
+
"""
|
|
192
|
+
rows = conn.execute(
|
|
193
|
+
f"""
|
|
194
|
+
SELECT a.owner_urn, a.id, a.annotation_id, a.title, a.annotation_type, a.url,
|
|
195
|
+
atxt.lang, atxt.text
|
|
196
|
+
FROM annotation a
|
|
197
|
+
LEFT JOIN annotation_text atxt ON atxt.annotation_id = a.id
|
|
198
|
+
WHERE {predicate}
|
|
199
|
+
ORDER BY a.owner_urn, a.ordinal, a.id
|
|
200
|
+
""",
|
|
201
|
+
params,
|
|
202
|
+
).fetchall()
|
|
203
|
+
return _group_annotations_by_owner(rows)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _group_annotations_by_owner(
|
|
207
|
+
rows: list[tuple[Any, ...]],
|
|
208
|
+
) -> dict[str, Annotations]:
|
|
209
|
+
"""Group joined annotation rows by ``owner_urn`` (the first column)."""
|
|
210
|
+
if not rows:
|
|
211
|
+
return {}
|
|
212
|
+
result: dict[str, Annotations] = {}
|
|
213
|
+
current_owner: str | None = None
|
|
214
|
+
bucket: list[tuple[Any, ...]] = []
|
|
215
|
+
for row in rows:
|
|
216
|
+
owner = row[0]
|
|
217
|
+
if owner != current_owner:
|
|
218
|
+
if current_owner is not None and bucket:
|
|
219
|
+
result[current_owner] = _annotations_from_joined_rows(bucket)
|
|
220
|
+
current_owner = owner
|
|
221
|
+
bucket = []
|
|
222
|
+
bucket.append(row[1:])
|
|
223
|
+
if current_owner is not None and bucket:
|
|
224
|
+
result[current_owner] = _annotations_from_joined_rows(bucket)
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _read_annotations_for_owners(
|
|
229
|
+
conn: duckdb.DuckDBPyConnection,
|
|
230
|
+
owner_subquery: str,
|
|
231
|
+
params: list[Any],
|
|
232
|
+
) -> dict[str, Annotations]:
|
|
233
|
+
"""Bulk-load annotations for every owner returned by ``owner_subquery``.
|
|
234
|
+
|
|
235
|
+
Issues a single SQL query that joins ``annotation`` with
|
|
236
|
+
``annotation_text``. ``owner_subquery`` is a SELECT fragment that
|
|
237
|
+
yields owner_urn strings — typically scoped by the parent artefact
|
|
238
|
+
(e.g. ``"SELECT concept_urn FROM concept WHERE scheme_urn = ?"``).
|
|
239
|
+
|
|
240
|
+
Returns ``{owner_urn: Annotations}``. Owners with no annotations are
|
|
241
|
+
absent from the result; callers should default to ``Annotations()``.
|
|
242
|
+
"""
|
|
243
|
+
return _read_annotations_for_owners_predicate(
|
|
244
|
+
conn,
|
|
245
|
+
f"a.owner_urn IN ({owner_subquery})",
|
|
246
|
+
params,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
126
250
|
def _read_spine(conn: duckdb.DuckDBPyConnection, urn: str) -> dict[str, Any] | None:
|
|
127
251
|
"""Read artefact metadata from the spine table."""
|
|
128
252
|
row = conn.execute(
|
|
@@ -156,7 +280,8 @@ def _read_codelist_eager(conn: duckdb.DuckDBPyConnection, urn: str) -> Codelist
|
|
|
156
280
|
return None
|
|
157
281
|
name = _read_istring(conn, urn, "name")
|
|
158
282
|
desc = _read_istring(conn, urn, "description")
|
|
159
|
-
|
|
283
|
+
annotations_by_owner = _read_annotations_subtree(conn, urn)
|
|
284
|
+
anns = annotations_by_owner.get(urn, Annotations())
|
|
160
285
|
|
|
161
286
|
rows = conn.execute(
|
|
162
287
|
"""
|
|
@@ -170,14 +295,13 @@ def _read_codelist_eager(conn: duckdb.DuckDBPyConnection, urn: str) -> Codelist
|
|
|
170
295
|
codes: list[Code] = []
|
|
171
296
|
for row in rows:
|
|
172
297
|
code_urn, code_id, parent_id, name_map, desc_map = row
|
|
173
|
-
code_anns = _read_annotations(conn, code_urn)
|
|
174
298
|
codes.append(
|
|
175
299
|
Code(
|
|
176
300
|
id=code_id,
|
|
177
301
|
parent_id=parent_id,
|
|
178
302
|
name=_map_to_istring(name_map),
|
|
179
303
|
description=_map_to_istring(desc_map),
|
|
180
|
-
annotations=
|
|
304
|
+
annotations=annotations_by_owner.get(code_urn, Annotations()),
|
|
181
305
|
)
|
|
182
306
|
)
|
|
183
307
|
|
|
@@ -352,7 +476,8 @@ def _read_data_structure(conn: duckdb.DuckDBPyConnection, urn: str) -> DataStruc
|
|
|
352
476
|
return None
|
|
353
477
|
name = _read_istring(conn, urn, "name")
|
|
354
478
|
desc = _read_istring(conn, urn, "description")
|
|
355
|
-
|
|
479
|
+
annotations_by_owner = _read_annotations_subtree(conn, urn)
|
|
480
|
+
anns = annotations_by_owner.get(urn, Annotations())
|
|
356
481
|
|
|
357
482
|
comp_rows = conn.execute(
|
|
358
483
|
"""
|
|
@@ -390,7 +515,10 @@ def _read_data_structure(conn: duckdb.DuckDBPyConnection, urn: str) -> DataStruc
|
|
|
390
515
|
}
|
|
391
516
|
else "Dimension"
|
|
392
517
|
)
|
|
393
|
-
comp_anns =
|
|
518
|
+
comp_anns = annotations_by_owner.get(
|
|
519
|
+
_component_urn(urn, urn_class, comp_id_raw),
|
|
520
|
+
Annotations(),
|
|
521
|
+
)
|
|
394
522
|
comp_type, comp = _deserialize_component(row, comp_anns)
|
|
395
523
|
match comp_type:
|
|
396
524
|
case "Dimension":
|
|
@@ -455,7 +583,8 @@ def _read_concept_scheme(conn: duckdb.DuckDBPyConnection, urn: str) -> ConceptSc
|
|
|
455
583
|
return None
|
|
456
584
|
name = _read_istring(conn, urn, "name")
|
|
457
585
|
desc = _read_istring(conn, urn, "description")
|
|
458
|
-
|
|
586
|
+
annotations_by_owner = _read_annotations_subtree(conn, urn)
|
|
587
|
+
anns = annotations_by_owner.get(urn, Annotations())
|
|
459
588
|
|
|
460
589
|
rows = conn.execute(
|
|
461
590
|
"""
|
|
@@ -482,7 +611,7 @@ def _read_concept_scheme(conn: duckdb.DuckDBPyConnection, urn: str) -> ConceptSc
|
|
|
482
611
|
max_length=repr_max_length,
|
|
483
612
|
)
|
|
484
613
|
|
|
485
|
-
c_anns =
|
|
614
|
+
c_anns = annotations_by_owner.get(c_urn, Annotations())
|
|
486
615
|
concepts.append(
|
|
487
616
|
Concept(
|
|
488
617
|
id=c_id,
|
|
@@ -511,7 +640,8 @@ def _read_hierarchy(conn: duckdb.DuckDBPyConnection, urn: str) -> "Hierarchy | N
|
|
|
511
640
|
return None
|
|
512
641
|
name = _read_istring(conn, urn, "name")
|
|
513
642
|
desc = _read_istring(conn, urn, "description")
|
|
514
|
-
|
|
643
|
+
annotations_by_owner = _read_annotations_subtree(conn, urn)
|
|
644
|
+
anns = annotations_by_owner.get(urn, Annotations())
|
|
515
645
|
|
|
516
646
|
# SDMX 3.1: read Level definitions
|
|
517
647
|
level_rows = conn.execute(
|
|
@@ -556,7 +686,7 @@ def _read_hierarchy(conn: duckdb.DuckDBPyConnection, urn: str) -> "Hierarchy | N
|
|
|
556
686
|
if level_id and level_id in levels_by_id:
|
|
557
687
|
level = levels_by_id[level_id]
|
|
558
688
|
|
|
559
|
-
node_anns =
|
|
689
|
+
node_anns = annotations_by_owner.get(node_urn, Annotations())
|
|
560
690
|
node = HierarchicalCode(
|
|
561
691
|
id=node_id,
|
|
562
692
|
code=code_ref,
|
|
@@ -670,7 +800,8 @@ def _read_category_scheme(conn: duckdb.DuckDBPyConnection, urn: str) -> "Categor
|
|
|
670
800
|
return None
|
|
671
801
|
name = _read_istring(conn, urn, "name")
|
|
672
802
|
desc = _read_istring(conn, urn, "description")
|
|
673
|
-
|
|
803
|
+
annotations_by_owner = _read_annotations_subtree(conn, urn)
|
|
804
|
+
anns = annotations_by_owner.get(urn, Annotations())
|
|
674
805
|
|
|
675
806
|
rows = conn.execute(
|
|
676
807
|
"""
|
|
@@ -686,7 +817,7 @@ def _read_category_scheme(conn: duckdb.DuckDBPyConnection, urn: str) -> "Categor
|
|
|
686
817
|
|
|
687
818
|
for row in rows:
|
|
688
819
|
cat_urn, cat_id, parent_id, c_name, c_desc = row
|
|
689
|
-
cat_anns =
|
|
820
|
+
cat_anns = annotations_by_owner.get(cat_urn, Annotations())
|
|
690
821
|
cat = Category(
|
|
691
822
|
id=cat_id,
|
|
692
823
|
name=_map_to_istring(c_name),
|
|
@@ -774,38 +905,43 @@ def _read_agency_scheme(conn: duckdb.DuckDBPyConnection, urn: str) -> "AgencySch
|
|
|
774
905
|
return None
|
|
775
906
|
name = _read_istring(conn, urn, "name")
|
|
776
907
|
desc = _read_istring(conn, urn, "description")
|
|
777
|
-
|
|
908
|
+
annotations_by_owner = _read_annotations_subtree(conn, urn)
|
|
909
|
+
anns = annotations_by_owner.get(urn, Annotations())
|
|
778
910
|
|
|
779
911
|
rows = conn.execute(
|
|
780
912
|
"SELECT agency_urn, agency_id, parent_id, name, description FROM agency WHERE scheme_urn = ?",
|
|
781
913
|
[urn],
|
|
782
914
|
).fetchall()
|
|
783
915
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
916
|
+
# Bulk-load contacts once, grouped by agency_urn.
|
|
917
|
+
contacts_by_agency: dict[str, list[Contact]] = {}
|
|
918
|
+
contact_rows = conn.execute(
|
|
919
|
+
"""
|
|
920
|
+
SELECT agency_urn, contact_name, department, role, phones, uris, emails
|
|
921
|
+
FROM agency_contact
|
|
922
|
+
WHERE agency_urn IN (SELECT agency_urn FROM agency WHERE scheme_urn = ?)
|
|
923
|
+
ORDER BY agency_urn, ordinal
|
|
924
|
+
""",
|
|
925
|
+
[urn],
|
|
926
|
+
).fetchall()
|
|
927
|
+
for cr in contact_rows:
|
|
928
|
+
ag_urn_c, c_name, c_dept, c_role, c_phones, c_uris, c_emails = cr
|
|
929
|
+
contacts_by_agency.setdefault(ag_urn_c, []).append(
|
|
797
930
|
Contact(
|
|
798
|
-
name=_map_to_istring(
|
|
799
|
-
department=_map_to_istring(
|
|
800
|
-
role=_map_to_istring(
|
|
801
|
-
phones=tuple(
|
|
802
|
-
uris=tuple(
|
|
803
|
-
emails=tuple(
|
|
931
|
+
name=_map_to_istring(c_name),
|
|
932
|
+
department=_map_to_istring(c_dept),
|
|
933
|
+
role=_map_to_istring(c_role),
|
|
934
|
+
phones=tuple(c_phones or []),
|
|
935
|
+
uris=tuple(c_uris or []),
|
|
936
|
+
emails=tuple(c_emails or []),
|
|
804
937
|
)
|
|
805
|
-
|
|
806
|
-
]
|
|
938
|
+
)
|
|
807
939
|
|
|
808
|
-
|
|
940
|
+
agencies: list[Agency] = []
|
|
941
|
+
for row in rows:
|
|
942
|
+
ag_urn, ag_id, _parent_id, ag_name, ag_desc = row
|
|
943
|
+
contacts = contacts_by_agency.get(ag_urn, [])
|
|
944
|
+
ag_anns = annotations_by_owner.get(ag_urn, Annotations())
|
|
809
945
|
agencies.append(
|
|
810
946
|
Agency(
|
|
811
947
|
id=ag_id,
|
|
@@ -835,7 +971,8 @@ def _read_data_provider_scheme(conn: duckdb.DuckDBPyConnection, urn: str) -> "Da
|
|
|
835
971
|
return None
|
|
836
972
|
name = _read_istring(conn, urn, "name")
|
|
837
973
|
desc = _read_istring(conn, urn, "description")
|
|
838
|
-
|
|
974
|
+
annotations_by_owner = _read_annotations_subtree(conn, urn)
|
|
975
|
+
anns = annotations_by_owner.get(urn, Annotations())
|
|
839
976
|
|
|
840
977
|
rows = conn.execute(
|
|
841
978
|
"SELECT provider_urn, provider_id, name, description FROM data_provider WHERE scheme_urn = ?",
|
|
@@ -845,7 +982,7 @@ def _read_data_provider_scheme(conn: duckdb.DuckDBPyConnection, urn: str) -> "Da
|
|
|
845
982
|
providers: list[DataProvider] = []
|
|
846
983
|
for row in rows:
|
|
847
984
|
p_urn, p_id, p_name, p_desc = row
|
|
848
|
-
p_anns =
|
|
985
|
+
p_anns = annotations_by_owner.get(p_urn, Annotations())
|
|
849
986
|
providers.append(
|
|
850
987
|
DataProvider(
|
|
851
988
|
id=p_id,
|
|
@@ -925,7 +1062,8 @@ def _read_metadata_structure(conn: duckdb.DuckDBPyConnection, urn: str) -> "Meta
|
|
|
925
1062
|
return None
|
|
926
1063
|
name = _read_istring(conn, urn, "name")
|
|
927
1064
|
desc = _read_istring(conn, urn, "description")
|
|
928
|
-
|
|
1065
|
+
annotations_by_owner = _read_annotations_subtree(conn, urn)
|
|
1066
|
+
anns = annotations_by_owner.get(urn, Annotations())
|
|
929
1067
|
|
|
930
1068
|
rows = conn.execute(
|
|
931
1069
|
"""
|
|
@@ -983,7 +1121,7 @@ def _read_metadata_structure(conn: duckdb.DuckDBPyConnection, urn: str) -> "Meta
|
|
|
983
1121
|
start_value,
|
|
984
1122
|
end_value,
|
|
985
1123
|
)
|
|
986
|
-
attr_anns =
|
|
1124
|
+
attr_anns = annotations_by_owner.get(attr_urn, Annotations())
|
|
987
1125
|
|
|
988
1126
|
node = MetadataAttribute(
|
|
989
1127
|
id=attr_id,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|