sdmxlib 0.44.1__tar.gz → 0.45.0__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.44.1 → sdmxlib-0.45.0}/PKG-INFO +1 -1
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/pyproject.toml +1 -1
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/__init__.py +3 -1
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +8 -4
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +8 -6
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/local/__init__.py +2 -1
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/local/data_store.py +38 -32
- sdmxlib-0.45.0/src/sdmxlib/local/sql.py +166 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/README.md +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/_duckdb.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/client.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/federated.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/filters.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/policy.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/providers.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/query.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/registry.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/session.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/builders/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/builders/contentconstraint.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/catalog.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/data_store.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/_data_common.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/data_v1.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/data_v2.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/structure_values.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml/_common.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/local/registry.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/annotations.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/base.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/binding.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/category.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/code_query.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/codelist.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/collections.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/concept.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/constraint.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/convert.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/dataflow.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/dataset.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/datastructure.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/expr.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/hierarchy.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/hierarchy_query.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/in_memory_registry.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/istring.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/mapping.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/message.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/metadataflow.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/metadataset.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/metadatastructure.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/organisation.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/provision.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/ref.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/registry.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/representation.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/urn.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/validation.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/polars.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/py.typed +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/rest.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/sql.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/__init__.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/_kinds.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/lazy.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/readers.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/schema.py +0 -0
- {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/writers.py +0 -0
|
@@ -10,7 +10,7 @@ from sdmxlib.catalog import DataflowEntry, FederatedCatalog
|
|
|
10
10
|
from sdmxlib.data_store import DataStore
|
|
11
11
|
from sdmxlib.sql import CompileContext, Predicate, SqlRelation
|
|
12
12
|
from sdmxlib.formats import parse, read, serialize, to_json, to_xml
|
|
13
|
-
from sdmxlib.local import LazyCodelist, LazyHierarchy, Registry
|
|
13
|
+
from sdmxlib.local import LazyCodelist, LazyHierarchy, QueryPlan, Registry, SchemaInspector
|
|
14
14
|
from sdmxlib.local.data_store import DuckDBDataStore, DuckLakeVersionError
|
|
15
15
|
from sdmxlib.rest import ComponentFilter, parse_components
|
|
16
16
|
from sdmxlib.model.convert import from_dict, to_dict
|
|
@@ -206,6 +206,7 @@ __all__ = [
|
|
|
206
206
|
"Predicate",
|
|
207
207
|
"Provider",
|
|
208
208
|
"ProvisionAgreement",
|
|
209
|
+
"QueryPlan",
|
|
209
210
|
"Ref",
|
|
210
211
|
"ReferenceIssue",
|
|
211
212
|
"ReferencePeriod",
|
|
@@ -220,6 +221,7 @@ __all__ = [
|
|
|
220
221
|
"Representation",
|
|
221
222
|
"RepresentationMap",
|
|
222
223
|
"RestRegistry",
|
|
224
|
+
"SchemaInspector",
|
|
223
225
|
"SdmxRef",
|
|
224
226
|
"SdmxUrn",
|
|
225
227
|
"SqlRelation",
|
|
@@ -83,8 +83,10 @@ _read_constraints = _helpers.read_constraints
|
|
|
83
83
|
|
|
84
84
|
|
|
85
85
|
def _read_concept_role(component_el: etree._Element) -> tuple[str, ...]:
|
|
86
|
-
"""Read ``<str:ConceptRole>`` children → tuple of role ids
|
|
87
|
-
|
|
86
|
+
"""Read ``<str:ConceptRole>`` children → tuple of role ids.
|
|
87
|
+
|
|
88
|
+
Ids come from the Ref ``id=`` attribute, or the item id of a URN-text
|
|
89
|
+
payload.
|
|
88
90
|
"""
|
|
89
91
|
roles: list[str] = []
|
|
90
92
|
for role_el in component_el.findall(_s("ConceptRole")):
|
|
@@ -101,8 +103,10 @@ def _read_concept_role(component_el: etree._Element) -> tuple[str, ...]:
|
|
|
101
103
|
|
|
102
104
|
|
|
103
105
|
def _read_contact(c_el: etree._Element) -> Contact:
|
|
104
|
-
"""Read a v2.1 ``<str:Contact>``.
|
|
105
|
-
|
|
106
|
+
"""Read a v2.1 ``<str:Contact>``.
|
|
107
|
+
|
|
108
|
+
Name is common-namespaced; Department / Role are structure-namespaced
|
|
109
|
+
with ``xml:lang``.
|
|
106
110
|
"""
|
|
107
111
|
|
|
108
112
|
def _str_istring(tag: str) -> InternationalString:
|
|
@@ -84,11 +84,11 @@ _write_scheme_map = _helpers.write_scheme_map
|
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
def _write_concept_roles(parent: etree._Element, roles: tuple[str, ...]) -> None:
|
|
87
|
-
"""Emit one ``<str:ConceptRole>`` per role
|
|
88
|
-
domain concept scheme (the canonical home for the standard roles).
|
|
87
|
+
"""Emit one ``<str:ConceptRole>`` per role.
|
|
89
88
|
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
References the SDMX cross-domain concept scheme (the canonical home for
|
|
90
|
+
the standard roles). Per the v2.1 XSD the component sequence places
|
|
91
|
+
ConceptRole after LocalRepresentation.
|
|
92
92
|
"""
|
|
93
93
|
for role in roles:
|
|
94
94
|
cr = etree.SubElement(parent, _s("ConceptRole"))
|
|
@@ -102,8 +102,10 @@ def _write_concept_roles(parent: etree._Element, roles: tuple[str, ...]) -> None
|
|
|
102
102
|
|
|
103
103
|
|
|
104
104
|
def _write_contact(parent: etree._Element, contact: Contact) -> None:
|
|
105
|
-
"""Emit a v2.1 ``<str:Contact>``.
|
|
106
|
-
|
|
105
|
+
"""Emit a v2.1 ``<str:Contact>``.
|
|
106
|
+
|
|
107
|
+
Name is common-namespaced; Department / Role / the communication
|
|
108
|
+
channels are structure-namespaced per ContactType.
|
|
107
109
|
"""
|
|
108
110
|
el = etree.SubElement(parent, _s("Contact"))
|
|
109
111
|
if contact.name:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Local persistent SDMX artefact store backed by DuckDB."""
|
|
2
2
|
|
|
3
3
|
from sdmxlib.local.registry import Registry
|
|
4
|
+
from sdmxlib.local.sql import QueryPlan, SchemaInspector
|
|
4
5
|
from sdmxlib.storage.lazy import LazyCodelist, LazyHierarchy
|
|
5
6
|
|
|
6
|
-
__all__ = ["LazyCodelist", "LazyHierarchy", "Registry"]
|
|
7
|
+
__all__ = ["LazyCodelist", "LazyHierarchy", "QueryPlan", "Registry", "SchemaInspector"]
|
|
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Literal, Self
|
|
|
23
23
|
import duckdb
|
|
24
24
|
import polars as pl
|
|
25
25
|
|
|
26
|
+
from sdmxlib.local.sql import SchemaInspector
|
|
26
27
|
from sdmxlib.model.binding import DataflowBinding
|
|
27
28
|
|
|
28
29
|
if TYPE_CHECKING:
|
|
@@ -123,6 +124,9 @@ class DuckDBDataStore:
|
|
|
123
124
|
self._owns_connection = True
|
|
124
125
|
# Per-thread read cursor (#80); see Registry._read_cursor.
|
|
125
126
|
self._thread_local: threading.local = threading.local()
|
|
127
|
+
# Lazily-bound schema inspector, shared by the internal query path
|
|
128
|
+
# and consumers' custom SQL (#163). Reset on close().
|
|
129
|
+
self._inspector: SchemaInspector | None = None
|
|
126
130
|
|
|
127
131
|
@property
|
|
128
132
|
def uri(self) -> Path:
|
|
@@ -197,6 +201,7 @@ class DuckDBDataStore:
|
|
|
197
201
|
instance._conn = registry.connection # noqa: SLF001
|
|
198
202
|
instance._owns_connection = False # noqa: SLF001
|
|
199
203
|
instance._thread_local = threading.local() # noqa: SLF001
|
|
204
|
+
instance._inspector = None # noqa: SLF001
|
|
200
205
|
instance._configure_session() # noqa: SLF001
|
|
201
206
|
return instance
|
|
202
207
|
|
|
@@ -306,6 +311,25 @@ class DuckDBDataStore:
|
|
|
306
311
|
self._conn.close()
|
|
307
312
|
self._conn = None
|
|
308
313
|
self._thread_local = threading.local()
|
|
314
|
+
self._inspector = None
|
|
315
|
+
|
|
316
|
+
def inspect(self) -> SchemaInspector:
|
|
317
|
+
"""Return this store's `SchemaInspector` — the custom-SQL seam (#163).
|
|
318
|
+
|
|
319
|
+
The inspector reflects the live DuckDB session (cached ``DESCRIBE``)
|
|
320
|
+
and turns a `DataflowBinding` into a `QueryPlan`, so consumers who
|
|
321
|
+
build their own SQL against `connection` reuse the store's table-name
|
|
322
|
+
convention and sidecar join-key derivation. The returned inspector is
|
|
323
|
+
the same instance the internal ``query`` path uses, so its column
|
|
324
|
+
cache is shared.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
RuntimeError: If the data store hasn't been opened.
|
|
328
|
+
"""
|
|
329
|
+
conn = self.connection # raises if not open
|
|
330
|
+
if self._inspector is None:
|
|
331
|
+
self._inspector = SchemaInspector(conn)
|
|
332
|
+
return self._inspector
|
|
309
333
|
|
|
310
334
|
def _read_cursor(self) -> duckdb.DuckDBPyConnection:
|
|
311
335
|
"""Return this thread's read cursor for ``query`` / ``query_stream``.
|
|
@@ -350,9 +374,8 @@ class DuckDBDataStore:
|
|
|
350
374
|
attributes live on a sibling table rather than denormalised into
|
|
351
375
|
the fact table.
|
|
352
376
|
"""
|
|
353
|
-
conn = self.connection
|
|
354
377
|
sql, sql_params = _build_query(
|
|
355
|
-
|
|
378
|
+
inspector=self.inspect(),
|
|
356
379
|
binding=binding,
|
|
357
380
|
where=where,
|
|
358
381
|
params=params,
|
|
@@ -430,6 +453,13 @@ class DuckDBDataStore:
|
|
|
430
453
|
raise ValueError(msg)
|
|
431
454
|
finally:
|
|
432
455
|
conn.execute(f"DROP VIEW IF EXISTS {view_name}")
|
|
456
|
+
# A rewrite can change the target table's schema; drop cached column
|
|
457
|
+
# lists so the inspector (internal query path + custom SQL) re-reflects.
|
|
458
|
+
# The target may be cached under a differently-qualified name than the
|
|
459
|
+
# one written here (2- vs 3-part, alias skew), so clear the whole
|
|
460
|
+
# (tiny) cache rather than guess the exact key. Writes are rare.
|
|
461
|
+
if self._inspector is not None:
|
|
462
|
+
self._inspector.invalidate_all()
|
|
433
463
|
_ = partition_by # silence unused — see docstring
|
|
434
464
|
|
|
435
465
|
def query_stream(
|
|
@@ -451,7 +481,7 @@ class DuckDBDataStore:
|
|
|
451
481
|
"""
|
|
452
482
|
conn = self.connection
|
|
453
483
|
sql, sql_params = _build_query(
|
|
454
|
-
|
|
484
|
+
inspector=self.inspect(),
|
|
455
485
|
binding=binding,
|
|
456
486
|
where=where,
|
|
457
487
|
params=params,
|
|
@@ -474,7 +504,7 @@ class DuckDBDataStore:
|
|
|
474
504
|
|
|
475
505
|
def _build_query(
|
|
476
506
|
*,
|
|
477
|
-
|
|
507
|
+
inspector: SchemaInspector,
|
|
478
508
|
binding: DataflowBinding,
|
|
479
509
|
where: list[str] | None,
|
|
480
510
|
params: list[object] | None,
|
|
@@ -491,27 +521,15 @@ def _build_query(
|
|
|
491
521
|
for parameterisation — never f-stringed.
|
|
492
522
|
|
|
493
523
|
When ``binding.series_attributes_location`` is set, the sidecar table
|
|
494
|
-
is LEFT JOINed on the columns common to both tables
|
|
524
|
+
is LEFT JOINed on the columns common to both tables (derived by
|
|
525
|
+
``inspector.plan``, which shares the store's DESCRIBE cache). When
|
|
495
526
|
``component_filter`` is provided, it is translated to additional
|
|
496
527
|
``col IN (?, ?, ...)`` fragments via the binding's dimension and
|
|
497
528
|
attribute column maps and ANDed in.
|
|
498
529
|
"""
|
|
499
530
|
cols_sql = ", ".join(columns) if columns else "*"
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
sidecar = f"{binding.database_role}.{sidecar_loc}" if sidecar_loc is not None else None
|
|
503
|
-
if sidecar is None:
|
|
504
|
-
sql = f"SELECT {cols_sql} FROM {main}"
|
|
505
|
-
else:
|
|
506
|
-
join_keys = _shared_columns(conn, main, sidecar)
|
|
507
|
-
if not join_keys:
|
|
508
|
-
msg = (
|
|
509
|
-
f"DataflowBinding.series_attributes_location {sidecar!r} "
|
|
510
|
-
f"shares no columns with {main!r}; cannot derive JOIN keys"
|
|
511
|
-
)
|
|
512
|
-
raise ValueError(msg)
|
|
513
|
-
keys_sql = ", ".join(join_keys)
|
|
514
|
-
sql = f"SELECT {cols_sql} FROM {main} LEFT JOIN {sidecar} USING ({keys_sql})"
|
|
531
|
+
plan = inspector.plan(binding, columns=columns)
|
|
532
|
+
sql = f"SELECT {cols_sql} FROM {plan.from_clause}"
|
|
515
533
|
|
|
516
534
|
fragments = list(where or [])
|
|
517
535
|
sql_params: list[object] = list(params or [])
|
|
@@ -532,18 +550,6 @@ def _build_query(
|
|
|
532
550
|
return sql, sql_params
|
|
533
551
|
|
|
534
552
|
|
|
535
|
-
def _describe_columns(conn: duckdb.DuckDBPyConnection, table: str) -> list[str]:
|
|
536
|
-
"""Return column names for ``table`` via DuckDB ``DESCRIBE``."""
|
|
537
|
-
rows = conn.execute(f"DESCRIBE {table}").fetchall()
|
|
538
|
-
return [str(r[0]) for r in rows]
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def _shared_columns(conn: duckdb.DuckDBPyConnection, table_a: str, table_b: str) -> list[str]:
|
|
542
|
-
"""Columns present in both tables, in ``table_a`` order."""
|
|
543
|
-
cols_b = set(_describe_columns(conn, table_b))
|
|
544
|
-
return [c for c in _describe_columns(conn, table_a) if c in cols_b]
|
|
545
|
-
|
|
546
|
-
|
|
547
553
|
def _sql_identifier(name: str) -> str:
|
|
548
554
|
"""Escape a SQL identifier (extension name, ATTACH alias).
|
|
549
555
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Custom-SQL seam for `DuckDBDataStore` — schema reflection + query planning.
|
|
2
|
+
|
|
3
|
+
Most callers should go through `DuckDBDataStore.query` / `query_stream`. This
|
|
4
|
+
module is the supported escape hatch for consumers that build their **own** SQL
|
|
5
|
+
against the store's DuckDB connection (per-value ``printf`` formatting,
|
|
6
|
+
Hive-partition-pruning filters, wide pivots, Range pagination — things the
|
|
7
|
+
high-level query methods don't express).
|
|
8
|
+
|
|
9
|
+
Two pieces:
|
|
10
|
+
|
|
11
|
+
- `SchemaInspector` — reflect a live DuckDB session's schema
|
|
12
|
+
(``DESCRIBE``-backed, cached) and turn a `DataflowBinding` into a `QueryPlan`
|
|
13
|
+
so consumers reuse the store's table-name convention and sidecar join-key
|
|
14
|
+
derivation instead of re-implementing it.
|
|
15
|
+
- `QueryPlan` — the resolved ``FROM`` side of a binding's query (main table,
|
|
16
|
+
optional sidecar ``LEFT JOIN``, join keys). Splice your own projection and
|
|
17
|
+
``WHERE`` onto ``plan.from_clause``.
|
|
18
|
+
|
|
19
|
+
Obtain an inspector via ``store.inspect()`` (it shares the store's cache with
|
|
20
|
+
the internal query path) rather than constructing one directly.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
insp = store.inspect()
|
|
24
|
+
plan = insp.plan(binding)
|
|
25
|
+
sql = (
|
|
26
|
+
f"SELECT REF_AREA, printf('%.2f', OBS_VALUE) AS value "
|
|
27
|
+
f"FROM {plan.from_clause} WHERE GEO_LEVEL = ?"
|
|
28
|
+
)
|
|
29
|
+
rows = store.connection.execute(sql, ["CMA"]).fetchall()
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
from attrs import frozen
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
import duckdb
|
|
40
|
+
|
|
41
|
+
from sdmxlib.model.binding import DataflowBinding
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@frozen(kw_only=True)
|
|
45
|
+
class QueryPlan:
|
|
46
|
+
"""The resolved ``FROM`` side of a `DataflowBinding`'s query.
|
|
47
|
+
|
|
48
|
+
Produced by `SchemaInspector.plan`. Consumers build their custom SQL by
|
|
49
|
+
splicing a projection and ``WHERE`` onto `from_clause`:
|
|
50
|
+
|
|
51
|
+
f"SELECT {cols} FROM {plan.from_clause} WHERE {predicate}"
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
main: Fully-qualified main table (``<database_role>.<table_location>``).
|
|
55
|
+
sidecar: Fully-qualified series-attributes table, or ``None`` when the
|
|
56
|
+
binding has no ``series_attributes_location``.
|
|
57
|
+
join_keys: Columns common to main and sidecar (in main order) that the
|
|
58
|
+
``LEFT JOIN ... USING (...)`` is keyed on. Empty when no sidecar.
|
|
59
|
+
columns: The projected column names the plan was built for, or ``()``
|
|
60
|
+
for ``SELECT *``. Informational — `from_clause` never includes the
|
|
61
|
+
projection.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
main: str
|
|
65
|
+
sidecar: str | None
|
|
66
|
+
join_keys: tuple[str, ...]
|
|
67
|
+
columns: tuple[str, ...] = ()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def from_clause(self) -> str:
|
|
71
|
+
"""The ``FROM`` fragment: ``main`` alone, or ``main LEFT JOIN sidecar USING (...)``."""
|
|
72
|
+
if self.sidecar is None:
|
|
73
|
+
return self.main
|
|
74
|
+
keys_sql = ", ".join(self.join_keys)
|
|
75
|
+
return f"{self.main} LEFT JOIN {self.sidecar} USING ({keys_sql})"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SchemaInspector:
|
|
79
|
+
"""Cached schema reflection + binding→query planning for a DuckDB session.
|
|
80
|
+
|
|
81
|
+
Bound to a single DuckDB connection. ``DESCRIBE`` results are memoized per
|
|
82
|
+
table for the inspector's lifetime — the schema of a read table is stable,
|
|
83
|
+
and re-running ``DESCRIBE`` on every query is pure redundant work. When a
|
|
84
|
+
table is rewritten (e.g. `DuckDBDataStore.write_observations`), call
|
|
85
|
+
`invalidate` so a stale column list isn't served.
|
|
86
|
+
|
|
87
|
+
Obtain one via ``store.inspect()`` so the internal query path and your
|
|
88
|
+
custom SQL share the same cache; constructing one directly is supported but
|
|
89
|
+
gets a private cache.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, conn: duckdb.DuckDBPyConnection) -> None:
|
|
93
|
+
self._conn = conn
|
|
94
|
+
self._columns_cache: dict[str, list[str]] = {}
|
|
95
|
+
|
|
96
|
+
def columns(self, table: str) -> list[str]:
|
|
97
|
+
"""Column names for ``table`` via DuckDB ``DESCRIBE`` (cached).
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
table: A table reference DuckDB can ``DESCRIBE`` — bare name or
|
|
101
|
+
``schema.table`` / ``database.schema.table``.
|
|
102
|
+
"""
|
|
103
|
+
cached = self._columns_cache.get(table)
|
|
104
|
+
if cached is not None:
|
|
105
|
+
return cached
|
|
106
|
+
rows = self._conn.execute(f"DESCRIBE {table}").fetchall()
|
|
107
|
+
cols = [str(r[0]) for r in rows]
|
|
108
|
+
self._columns_cache[table] = cols
|
|
109
|
+
return cols
|
|
110
|
+
|
|
111
|
+
def shared_columns(self, table_a: str, table_b: str) -> list[str]:
|
|
112
|
+
"""Columns present in both tables, in ``table_a`` order.
|
|
113
|
+
|
|
114
|
+
These are the join keys for a main⋈sidecar ``LEFT JOIN``.
|
|
115
|
+
"""
|
|
116
|
+
cols_b = set(self.columns(table_b))
|
|
117
|
+
return [c for c in self.columns(table_a) if c in cols_b]
|
|
118
|
+
|
|
119
|
+
def invalidate(self, table: str) -> None:
|
|
120
|
+
"""Drop the cached column list for ``table`` (e.g. after a rewrite)."""
|
|
121
|
+
self._columns_cache.pop(table, None)
|
|
122
|
+
|
|
123
|
+
def invalidate_all(self) -> None:
|
|
124
|
+
"""Drop all cached column lists."""
|
|
125
|
+
self._columns_cache.clear()
|
|
126
|
+
|
|
127
|
+
def plan(self, binding: DataflowBinding, *, columns: list[str] | None = None) -> QueryPlan:
|
|
128
|
+
"""Resolve ``binding``'s main/sidecar tables into a `QueryPlan`.
|
|
129
|
+
|
|
130
|
+
Mirrors what `DuckDBDataStore.query` builds for the ``FROM`` side:
|
|
131
|
+
``<database_role>.<table_location>`` for the main table, and — when
|
|
132
|
+
``binding.series_attributes_location`` is set — a ``LEFT JOIN`` onto the
|
|
133
|
+
sidecar keyed on their shared columns.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
binding: The dataflow binding to resolve.
|
|
137
|
+
columns: Optional projection recorded on the plan (informational;
|
|
138
|
+
does not affect `QueryPlan.from_clause`).
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If the binding names a sidecar that shares no columns
|
|
142
|
+
with the main table (no derivable join keys).
|
|
143
|
+
"""
|
|
144
|
+
main = f"{binding.database_role}.{binding.table_location}"
|
|
145
|
+
sidecar_loc = binding.series_attributes_location
|
|
146
|
+
if sidecar_loc is None:
|
|
147
|
+
return QueryPlan(
|
|
148
|
+
main=main,
|
|
149
|
+
sidecar=None,
|
|
150
|
+
join_keys=(),
|
|
151
|
+
columns=tuple(columns or ()),
|
|
152
|
+
)
|
|
153
|
+
sidecar = f"{binding.database_role}.{sidecar_loc}"
|
|
154
|
+
join_keys = self.shared_columns(main, sidecar)
|
|
155
|
+
if not join_keys:
|
|
156
|
+
msg = (
|
|
157
|
+
f"DataflowBinding.series_attributes_location {sidecar!r} "
|
|
158
|
+
f"shares no columns with {main!r}; cannot derive JOIN keys"
|
|
159
|
+
)
|
|
160
|
+
raise ValueError(msg)
|
|
161
|
+
return QueryPlan(
|
|
162
|
+
main=main,
|
|
163
|
+
sidecar=sidecar,
|
|
164
|
+
join_keys=tuple(join_keys),
|
|
165
|
+
columns=tuple(columns or ()),
|
|
166
|
+
)
|
|
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
|
|
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
|