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.
Files changed (92) hide show
  1. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/PKG-INFO +1 -1
  2. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/pyproject.toml +1 -1
  3. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/__init__.py +3 -1
  4. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +8 -4
  5. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +8 -6
  6. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/local/__init__.py +2 -1
  7. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/local/data_store.py +38 -32
  8. sdmxlib-0.45.0/src/sdmxlib/local/sql.py +166 -0
  9. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/README.md +0 -0
  10. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/_duckdb.py +0 -0
  11. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/__init__.py +0 -0
  12. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/client.py +0 -0
  13. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/federated.py +0 -0
  14. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/filters.py +0 -0
  15. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/policy.py +0 -0
  16. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/providers.py +0 -0
  17. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/query.py +0 -0
  18. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/registry.py +0 -0
  19. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/api/session.py +0 -0
  20. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/builders/__init__.py +0 -0
  21. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/builders/contentconstraint.py +0 -0
  22. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/catalog.py +0 -0
  23. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/data_store.py +0 -0
  24. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/__init__.py +0 -0
  25. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  26. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  27. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  28. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  29. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/_data_common.py +0 -0
  30. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/data_v1.py +0 -0
  31. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/data_v2.py +0 -0
  32. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  33. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  34. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/structure_values.py +0 -0
  35. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  36. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
  37. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
  38. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
  39. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml/_common.py +0 -0
  40. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  41. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  42. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  43. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  44. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  45. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  46. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  47. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
  48. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
  49. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
  50. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
  51. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/local/registry.py +0 -0
  52. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/__init__.py +0 -0
  53. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/annotations.py +0 -0
  54. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/base.py +0 -0
  55. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/binding.py +0 -0
  56. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/category.py +0 -0
  57. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/code_query.py +0 -0
  58. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/codelist.py +0 -0
  59. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/collections.py +0 -0
  60. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/concept.py +0 -0
  61. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/constraint.py +0 -0
  62. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/convert.py +0 -0
  63. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/dataflow.py +0 -0
  64. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/dataset.py +0 -0
  65. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/datastructure.py +0 -0
  66. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/expr.py +0 -0
  67. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/hierarchy.py +0 -0
  68. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/hierarchy_query.py +0 -0
  69. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/in_memory_registry.py +0 -0
  70. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/istring.py +0 -0
  71. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/mapping.py +0 -0
  72. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/message.py +0 -0
  73. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/metadataflow.py +0 -0
  74. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/metadataset.py +0 -0
  75. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/metadatastructure.py +0 -0
  76. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/organisation.py +0 -0
  77. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/provision.py +0 -0
  78. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/ref.py +0 -0
  79. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/registry.py +0 -0
  80. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/representation.py +0 -0
  81. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/urn.py +0 -0
  82. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/model/validation.py +0 -0
  83. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/polars.py +0 -0
  84. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/py.typed +0 -0
  85. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/rest.py +0 -0
  86. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/sql.py +0 -0
  87. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/__init__.py +0 -0
  88. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/_kinds.py +0 -0
  89. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/lazy.py +0 -0
  90. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/readers.py +0 -0
  91. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/schema.py +0 -0
  92. {sdmxlib-0.44.1 → sdmxlib-0.45.0}/src/sdmxlib/storage/writers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.44.1
3
+ Version: 0.45.0
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.44.1"
3
+ version = "0.45.0"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.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 (from the Ref
87
- ``id=`` attribute, or the item id of a URN-text payload).
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>``. Name is common-namespaced; Department /
105
- Role are structure-namespaced with ``xml:lang``.
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, referencing the SDMX cross-
88
- domain concept scheme (the canonical home for the standard roles).
87
+ """Emit one ``<str:ConceptRole>`` per role.
89
88
 
90
- Per the v2.1 XSD the component sequence places ConceptRole after
91
- LocalRepresentation.
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>``. Name is common-namespaced; Department /
106
- Role / the communication channels are structure-namespaced per ContactType.
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
- conn=conn,
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
- conn=conn,
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
- conn: duckdb.DuckDBPyConnection,
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. When
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
- main = f"{binding.database_role}.{binding.table_location}"
501
- sidecar_loc = binding.series_attributes_location
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