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.
Files changed (75) hide show
  1. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/PKG-INFO +1 -1
  2. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/pyproject.toml +1 -1
  3. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/local/_registry.py +329 -60
  4. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/storage/readers.py +188 -50
  5. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/README.md +0 -0
  6. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/__init__.py +0 -0
  7. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/__init__.py +0 -0
  8. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/client.py +0 -0
  9. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/federated.py +0 -0
  10. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/filters.py +0 -0
  11. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/policy.py +0 -0
  12. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/providers.py +0 -0
  13. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/query.py +0 -0
  14. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/registry.py +0 -0
  15. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/api/session.py +0 -0
  16. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/catalog.py +0 -0
  17. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/data_store.py +0 -0
  18. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/__init__.py +0 -0
  19. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  20. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  21. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  22. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  23. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  24. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  25. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  26. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  27. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  28. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  29. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  30. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  31. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  32. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  33. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  34. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  35. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/local/__init__.py +0 -0
  36. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/local/_data_store.py +0 -0
  37. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/__init__.py +0 -0
  38. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/annotations.py +0 -0
  39. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/base.py +0 -0
  40. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/binding.py +0 -0
  41. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/category.py +0 -0
  42. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/code_query.py +0 -0
  43. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/codelist.py +0 -0
  44. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/collections.py +0 -0
  45. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/concept.py +0 -0
  46. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/constraint.py +0 -0
  47. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/convert.py +0 -0
  48. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/dataflow.py +0 -0
  49. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/dataset.py +0 -0
  50. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/datastructure.py +0 -0
  51. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/expr.py +0 -0
  52. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/hierarchy.py +0 -0
  53. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/hierarchy_query.py +0 -0
  54. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/in_memory_registry.py +0 -0
  55. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/istring.py +0 -0
  56. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/mapping.py +0 -0
  57. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/message.py +0 -0
  58. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/metadataflow.py +0 -0
  59. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/metadataset.py +0 -0
  60. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/metadatastructure.py +0 -0
  61. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/organisation.py +0 -0
  62. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/provision.py +0 -0
  63. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/ref.py +0 -0
  64. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/registry.py +0 -0
  65. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/representation.py +0 -0
  66. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/urn.py +0 -0
  67. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/model/validation.py +0 -0
  68. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/polars.py +0 -0
  69. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/py.typed +0 -0
  70. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/rest.py +0 -0
  71. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/sql.py +0 -0
  72. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/storage/__init__.py +0 -0
  73. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/storage/lazy.py +0 -0
  74. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/storage/schema.py +0 -0
  75. {sdmxlib-0.27.0 → sdmxlib-0.27.2}/src/sdmxlib/storage/writers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.27.0
3
+ Version: 0.27.2
4
4
  Summary: SDMX structural metadata library for Python
5
5
  Keywords: sdmx,statistics,metadata,datastructure
6
6
  Author: gabrielgellner
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.27.0"
3
+ version = "0.27.2"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.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._resolve_refs(result, set())
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, set())
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(self, obj: Any, visited: set[int]) -> None:
273
- """Walk attrs fields, resolving ``Ref`` targets from DuckDB."""
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 visited:
463
+ if oid in walked:
278
464
  return
279
- visited.add(oid)
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
- self._resolve_ref_field(obj, f.name, value)
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._resolve_refs(item, visited)
476
+ self._collect_refs(item, walked, pending)
290
477
  elif attrs.has(type(value)) and not isinstance(value, type):
291
- self._resolve_refs(value, visited)
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
- urn_str = str(ref.urn)
299
- target = self._resolve_target(urn_str)
300
- if target is not None:
301
- resolved = Ref.of(ref.urn, target)
302
- setattr(obj, name, resolved)
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
- def _resolve_target(self, urn_str: str) -> Any | None:
305
- """Load an artefact by URN string for Ref resolution."""
306
- parsed = SdmxUrn.parse(urn_str)
307
- if parsed.item_id is not None:
308
- return self._resolve_item_urn(parsed)
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
- return readers.get_artefact(self._c, urn_str)
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 _resolve_item_urn(self, parsed: "SdmxUrn[Any]") -> Any | None:
313
- """Resolve an item-level URN by loading its parent scheme."""
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
- parent_urn_str = str(parent_urn)
546
+ return str(parent_urn)
332
547
 
333
- # Try exact match first
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
- # Fallback for version="latest" — find by agency+id
337
- if parent is None and parsed.version == "latest":
338
- row = self._c.execute(
339
- """
340
- SELECT urn FROM artefact
341
- WHERE artefact_type = ? AND agency_id = ? AND resource_id = ?
342
- """,
343
- [scheme_class_name, parsed.agency, parsed.id],
344
- ).fetchone()
345
- if row:
346
- parent = readers.get_artefact(self._c, row[0], eager=True)
347
-
348
- if parent is None:
349
- return None
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
- return writers.drop_artefact(self._c, str(urn))
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 not None:
428
- reg.add(obj)
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 any annotated entity."""
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
- FROM annotation
107
- WHERE owner_urn = ?
108
- ORDER BY ordinal
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
- for row in rows:
116
- db_id, ann_id, title, ann_type, url = row
117
- text_rows = conn.execute(
118
- "SELECT lang, text FROM annotation_text WHERE annotation_id = ?",
119
- [db_id],
120
- ).fetchall()
121
- text = InternationalString.of(dict(text_rows)) if text_rows else InternationalString()
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
- anns = _read_annotations(conn, urn)
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=code_anns,
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
- anns = _read_annotations(conn, urn)
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 = _read_annotations(conn, _component_urn(urn, urn_class, comp_id_raw))
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
- anns = _read_annotations(conn, urn)
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 = _read_annotations(conn, c_urn)
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
- anns = _read_annotations(conn, urn)
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 = _read_annotations(conn, node_urn)
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
- anns = _read_annotations(conn, urn)
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 = _read_annotations(conn, cat_urn)
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
- anns = _read_annotations(conn, urn)
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
- agencies: list[Agency] = []
785
- for row in rows:
786
- ag_urn, ag_id, _parent_id, ag_name, ag_desc = row
787
- # Contacts
788
- contact_rows = conn.execute(
789
- """
790
- SELECT contact_name, department, role, phones, uris, emails
791
- FROM agency_contact WHERE agency_urn = ?
792
- ORDER BY ordinal
793
- """,
794
- [ag_urn],
795
- ).fetchall()
796
- contacts = [
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(cr[0]),
799
- department=_map_to_istring(cr[1]),
800
- role=_map_to_istring(cr[2]),
801
- phones=tuple(cr[3] or []),
802
- uris=tuple(cr[4] or []),
803
- emails=tuple(cr[5] or []),
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
- for cr in contact_rows
806
- ]
938
+ )
807
939
 
808
- ag_anns = _read_annotations(conn, ag_urn)
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
- anns = _read_annotations(conn, urn)
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 = _read_annotations(conn, p_urn)
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
- anns = _read_annotations(conn, urn)
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 = _read_annotations(conn, attr_urn)
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