sdmxlib 0.63.0__tar.gz → 0.65.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.63.0 → sdmxlib-0.65.0}/PKG-INFO +1 -1
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/pyproject.toml +2 -2
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/pyproject.toml.orig +11 -2
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/__init__.py +24 -1
- {sdmxlib-0.63.0/src/sdmxlib/storage → sdmxlib-0.65.0/src/sdmxlib}/_time.py +12 -1
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/_freshness.py +1 -1
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/client.py +1 -1
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_csv/writer.py +6 -5
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/_data_common.py +78 -62
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/data_v1.py +2 -3
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/data_v2.py +2 -3
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/local/sql.py +69 -2
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/codelist.py +26 -4
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/dataset.py +14 -2
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/polars.py +4 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_compile.py +56 -21
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_expr.py +97 -9
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/observations.py +21 -3
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/query.py +66 -5
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/session.py +37 -31
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/lazy.py +13 -1
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/readers.py +1 -1
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/resolve.py +50 -14
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/schema.py +1 -1
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/writers.py +3 -3
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/README.md +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/_duckdb.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/_singleflight.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/federated.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/filters.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/policy.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/providers.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/query.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/registry.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/api/session.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/builders/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/builders/contentconstraint.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/builders/metadata_annotation.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/data_store.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/_coerce.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/_index.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/data_format.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/gaps.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/_dto.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/_resolve.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/_structure_dto.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/_structure_ingest.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/structure_values.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml/_common.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml/_v3_reader.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml/_v3_writer.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml21/_synthetic.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/local/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/local/data_store.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/local/registry.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/_interning.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/_items.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/_label_match.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/_refshape.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/annotations.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/base.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/binding.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/category.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/collections.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/concept.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/constraint.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/convert.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/dataflow.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/datastructure.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/errors.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/expr.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/hierarchy.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/in_memory_registry.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/istring.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/mapping.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/message.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/metadata_provision.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/metadataflow.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/metadataset.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/metadatastructure.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/organisation.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/provision.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/ref.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/registry.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/representation.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/rest_api.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/rollup.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/urn.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/validation.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/model/version.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/py.typed +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_closure.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_common.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_edges.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_execute.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_joins.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_plan.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_pushdown.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_rebase.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/_rest.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/accessors.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/raw.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/query/schema.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/rest.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/sql.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/__init__.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/_kinds.py +0 -0
- {sdmxlib-0.63.0 → sdmxlib-0.65.0}/src/sdmxlib/storage/_projections.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sdmxlib"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.65.0"
|
|
4
4
|
description = "SDMX structural metadata library for Python"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -140,7 +140,7 @@ mutate_only_covered_lines = false
|
|
|
140
140
|
|
|
141
141
|
[dependency-groups]
|
|
142
142
|
dev = [
|
|
143
|
-
"basedpyright
|
|
143
|
+
"basedpyright==1.39.9",
|
|
144
144
|
"catppuccin[pygments]>=2.5.0",
|
|
145
145
|
"types-lxml>=2025.3.30",
|
|
146
146
|
"mkdocs-material>=9.7.6",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sdmxlib"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.65.0"
|
|
4
4
|
description = "SDMX structural metadata library for Python"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Apache-2.0" }
|
|
@@ -220,7 +220,16 @@ dev = [
|
|
|
220
220
|
# ruff on `select = ["ALL"]` that is not hypothetical: ruff 0.16.0
|
|
221
221
|
# stabilised 12 preview rules and would have turned CI red with no commit
|
|
222
222
|
# of ours in between. Bump these deliberately, in their own MR.
|
|
223
|
-
|
|
223
|
+
#
|
|
224
|
+
# basedpyright is pinned *exactly*, because the bound above was not enough:
|
|
225
|
+
# 1.39.10 is a patch release built on pyright 1.1.412, which stopped solving
|
|
226
|
+
# a generic class passed as a `Callable[[], R]` factory from the declared
|
|
227
|
+
# type — `x: Box[int] = make(Box)` is an error there and fine on 1.1.411.
|
|
228
|
+
# Every `field(factory=ItemList, ...)` in the model is that shape, so a fresh
|
|
229
|
+
# environment failed `just check` with 44 errors and no commit of ours in
|
|
230
|
+
# between. Still broken on pyright 1.1.414 / basedpyright 1.40.2. The pin
|
|
231
|
+
# goes with basedpyright itself in the pyrefly-only migration.
|
|
232
|
+
"basedpyright==1.39.9",
|
|
224
233
|
"catppuccin[pygments]>=2.5.0",
|
|
225
234
|
"types-lxml>=2025.3.30",
|
|
226
235
|
"mkdocs-material>=9.7.6",
|
|
@@ -19,7 +19,7 @@ from sdmxlib.session import Session, SessionHook, ValueSet
|
|
|
19
19
|
from sdmxlib.query import RawSql, RawSqlError, schema
|
|
20
20
|
from sdmxlib.query.accessors import ArtefactRow, CodeRow, HCodeRow, ReportedAttributeRow, q
|
|
21
21
|
from sdmxlib.query._compile import CompiledQuery
|
|
22
|
-
from sdmxlib.query._expr import Agg, AggPred, AnyPred, Col, Cols, Cursor, Pred, RowKind, Win, WinFn, WinPred
|
|
22
|
+
from sdmxlib.query._expr import Agg, AggPred, AnyPred, Col, Cols, Cursor, Pred, RowKind, Untyped, Win, WinFn, WinPred
|
|
23
23
|
from sdmxlib.query.observations import ObsQuery, ObsRow
|
|
24
24
|
from sdmxlib.query.query import Frame, Grouped, Page, Query, union
|
|
25
25
|
from sdmxlib.sql import SqlRelation
|
|
@@ -136,6 +136,27 @@ from sdmxlib.model import (
|
|
|
136
136
|
artefact_urn,
|
|
137
137
|
)
|
|
138
138
|
|
|
139
|
+
|
|
140
|
+
def len[R: RowKind]() -> Agg[R]: # noqa: A001 - polars' `pl.len()`; nothing in this module calls the builtin
|
|
141
|
+
"""The number of rows, as an aggregate — `count(*)`, named for polars' `pl.len()`.
|
|
142
|
+
|
|
143
|
+
For `Query.agg`, which folds a whole query into one row, so the row count
|
|
144
|
+
can come out of the same scan as the other aggregates (#414):
|
|
145
|
+
|
|
146
|
+
obs.agg(SEX=obs.col("SEX").unique(), n=sl.len())
|
|
147
|
+
|
|
148
|
+
A function rather than a column method because it counts *rows*, not the
|
|
149
|
+
values of any column — the same reason `group_by(...).len()` is a method on
|
|
150
|
+
the grouping rather than on a column. The row kind is taken from where it is
|
|
151
|
+
used, so it fits any query's `agg`.
|
|
152
|
+
|
|
153
|
+
Shadows the builtin only as `sl.len`; this module never calls `len()`
|
|
154
|
+
itself, which is why it is defined here rather than re-exported from a
|
|
155
|
+
module that would need a second suppression.
|
|
156
|
+
"""
|
|
157
|
+
return Agg[R]("count", ())
|
|
158
|
+
|
|
159
|
+
|
|
139
160
|
__all__ = [
|
|
140
161
|
"Agency",
|
|
141
162
|
"AgencyScheme",
|
|
@@ -278,6 +299,7 @@ __all__ = [
|
|
|
278
299
|
"SupportsUrn",
|
|
279
300
|
"TimeDimension",
|
|
280
301
|
"UnresolvedRef",
|
|
302
|
+
"Untyped",
|
|
281
303
|
"ValidationError",
|
|
282
304
|
"ValidationIssue",
|
|
283
305
|
"ValueMap",
|
|
@@ -290,6 +312,7 @@ __all__ = [
|
|
|
290
312
|
"dim",
|
|
291
313
|
"from_dict",
|
|
292
314
|
"item",
|
|
315
|
+
"len",
|
|
293
316
|
"parse",
|
|
294
317
|
"parse_components",
|
|
295
318
|
"q",
|
|
@@ -35,12 +35,19 @@ breaking.
|
|
|
35
35
|
from __future__ import annotations
|
|
36
36
|
|
|
37
37
|
from datetime import UTC
|
|
38
|
-
from typing import TYPE_CHECKING
|
|
38
|
+
from typing import TYPE_CHECKING, overload
|
|
39
39
|
|
|
40
40
|
if TYPE_CHECKING:
|
|
41
41
|
from datetime import datetime
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
# Overloaded so a value known to be present stays `datetime` on the way out —
|
|
45
|
+
# a query literal (#415) is never None, and widening it to `datetime | None`
|
|
46
|
+
# would push a pointless check onto every caller.
|
|
47
|
+
@overload
|
|
48
|
+
def to_storage(value: datetime) -> datetime: ...
|
|
49
|
+
@overload
|
|
50
|
+
def to_storage(value: None) -> None: ...
|
|
44
51
|
def to_storage(value: datetime | None) -> datetime | None:
|
|
45
52
|
"""A datetime as this schema stores it: UTC, with `tzinfo` stripped.
|
|
46
53
|
|
|
@@ -60,6 +67,10 @@ def to_storage(value: datetime | None) -> datetime | None:
|
|
|
60
67
|
return value.astimezone(UTC).replace(tzinfo=None)
|
|
61
68
|
|
|
62
69
|
|
|
70
|
+
@overload
|
|
71
|
+
def from_storage(value: datetime) -> datetime: ...
|
|
72
|
+
@overload
|
|
73
|
+
def from_storage(value: None) -> None: ...
|
|
63
74
|
def from_storage(value: datetime | None) -> datetime | None:
|
|
64
75
|
"""A stored datetime as the model sees it: the same instant, tagged UTC.
|
|
65
76
|
|
|
@@ -56,7 +56,7 @@ class Freshness:
|
|
|
56
56
|
"has the pointer moved?", which validators answer without one.
|
|
57
57
|
"""
|
|
58
58
|
|
|
59
|
-
#: When the response was received. Naive UTC, per `
|
|
59
|
+
#: When the response was received. Naive UTC, per `sdmxlib._time`'s
|
|
60
60
|
#: convention, so comparisons cannot shift with a reader's zone.
|
|
61
61
|
stored_at: datetime
|
|
62
62
|
#: `ETag`, for `If-None-Match`. The cheapest revalidation there is.
|
|
@@ -223,7 +223,7 @@ def _raise_for_status(response: httpx.Response, *, url: str) -> None:
|
|
|
223
223
|
def _now() -> datetime:
|
|
224
224
|
"""Now, as naive UTC.
|
|
225
225
|
|
|
226
|
-
`
|
|
226
|
+
`sdmxlib._time`'s convention, so a stored instant means the same thing to
|
|
227
227
|
every reader regardless of their machine's zone.
|
|
228
228
|
"""
|
|
229
229
|
return datetime.now(UTC).replace(tzinfo=None)
|
|
@@ -23,7 +23,7 @@ column maps in their dict-insertion order, which matches the order in
|
|
|
23
23
|
which they were declared on the structure.
|
|
24
24
|
|
|
25
25
|
Performance: rows are serialised via Polars' Rust CSV writer
|
|
26
|
-
(``pl.
|
|
26
|
+
(``pl.DataFrame(batch).write_csv``) rather than Python's `csv`,
|
|
27
27
|
which makes the writer fast enough that bulk SDMX-CSV downloads are
|
|
28
28
|
network-bound rather than writer-bound (#61).
|
|
29
29
|
|
|
@@ -43,7 +43,7 @@ Example:
|
|
|
43
43
|
from __future__ import annotations
|
|
44
44
|
|
|
45
45
|
import io
|
|
46
|
-
from typing import TYPE_CHECKING
|
|
46
|
+
from typing import TYPE_CHECKING
|
|
47
47
|
|
|
48
48
|
import polars as pl
|
|
49
49
|
|
|
@@ -143,10 +143,11 @@ def write_sdmx_csv(
|
|
|
143
143
|
)
|
|
144
144
|
raise KeyError(msg)
|
|
145
145
|
|
|
146
|
-
#
|
|
147
|
-
#
|
|
146
|
+
# The constructor rather than `pl.from_arrow`, which is typed
|
|
147
|
+
# `DataFrame | Series` and since polars 2.0 really does return a Series
|
|
148
|
+
# for stream-only inputs — a `cast` here would have hidden that.
|
|
148
149
|
df = (
|
|
149
|
-
|
|
150
|
+
pl.DataFrame(batch)
|
|
150
151
|
.select(data_cols)
|
|
151
152
|
.with_columns(
|
|
152
153
|
pl.lit(structure).alias("STRUCTURE"),
|
|
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import calendar
|
|
15
15
|
import re
|
|
16
16
|
from datetime import datetime, timedelta
|
|
17
|
-
from typing import TYPE_CHECKING, Literal, NamedTuple, TypedDict
|
|
17
|
+
from typing import TYPE_CHECKING, Literal, NamedTuple, TypedDict
|
|
18
18
|
|
|
19
19
|
import msgspec
|
|
20
20
|
import polars as pl
|
|
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
|
|
|
23
23
|
import pyarrow as pa
|
|
24
24
|
|
|
25
25
|
from sdmxlib.model.annotations import Annotations
|
|
26
|
-
from sdmxlib.model.codelist import
|
|
26
|
+
from sdmxlib.model.codelist import Code, CodelistLike
|
|
27
27
|
from sdmxlib.model.dataflow import Dataflow
|
|
28
28
|
from sdmxlib.model.datastructure import Component, DataStructure, Dimension
|
|
29
29
|
from sdmxlib.model.istring import InternationalString
|
|
@@ -194,28 +194,16 @@ def _frequency_dimension(dsd: DataStructure) -> Dimension | None:
|
|
|
194
194
|
return None
|
|
195
195
|
|
|
196
196
|
|
|
197
|
-
def
|
|
198
|
-
"""
|
|
197
|
+
def frequency_dimension_id(dsd: DataStructure) -> str | None:
|
|
198
|
+
"""The id of the DSD's frequency dimension, or None if it has none.
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
Only the id. The FREQ codes' ``duration`` annotations are read by
|
|
201
|
+
`ComponentTable` from the codes a message actually observed, in the same
|
|
202
|
+
batched lookup that labels them — this used to materialise the whole FREQ
|
|
203
|
+
codelist up front, one of the per-message full reads #410 removed.
|
|
204
204
|
"""
|
|
205
205
|
freq = _frequency_dimension(dsd)
|
|
206
|
-
if freq is None
|
|
207
|
-
return None, {}
|
|
208
|
-
overrides: dict[str, _Duration] = {}
|
|
209
|
-
codelist: CodelistLike | None = getattr(freq, "resolved_codelist", None)
|
|
210
|
-
if codelist is not None:
|
|
211
|
-
# `materialize()` because this reads each code's *annotations*, which
|
|
212
|
-
# is past what `CodelistLike` offers; free when the codelist is already
|
|
213
|
-
# eager, and a frequency codelist is a handful of codes either way.
|
|
214
|
-
for code in codelist.materialize().codes:
|
|
215
|
-
duration = _annotation_duration(code.annotations, lang)
|
|
216
|
-
if duration is not None:
|
|
217
|
-
overrides[code.id] = duration
|
|
218
|
-
return freq.id, overrides
|
|
206
|
+
return freq.id if freq is not None else None
|
|
219
207
|
|
|
220
208
|
|
|
221
209
|
def _add_duration(start: datetime, duration: _Duration) -> datetime:
|
|
@@ -316,6 +304,16 @@ class ComponentTable:
|
|
|
316
304
|
SDMX-JSON spec explicitly allows this) rather than the full
|
|
317
305
|
enumeration. The StatCan census geography codelist (579k codes)
|
|
318
306
|
would otherwise blow up a ``limit=2`` data response to >30 MB.
|
|
307
|
+
|
|
308
|
+
**The cost follows the response too, not just its size.** A value's
|
|
309
|
+
name, ``parent`` and annotations are filled in at `to_json`, by one
|
|
310
|
+
`CodelistLike.codes_for` per component over exactly the ids observed.
|
|
311
|
+
Calling `materialize` here instead read every code of every enumerated
|
|
312
|
+
component on every message once the DSD came from a `LocalRegistry` —
|
|
313
|
+
1.4 s to write one observation against a 200k-code geography, where the
|
|
314
|
+
same DSD with eager codelists took 3 ms (#410). Looking each code up as it
|
|
315
|
+
arrived would be one round trip per distinct value, which is the same
|
|
316
|
+
mistake at the other end of the scale.
|
|
319
317
|
"""
|
|
320
318
|
|
|
321
319
|
def __init__(
|
|
@@ -328,7 +326,6 @@ class ComponentTable:
|
|
|
328
326
|
annotations: AnnotationPool,
|
|
329
327
|
time_dim_id: str | None = None,
|
|
330
328
|
freq_dim_id: str | None = None,
|
|
331
|
-
freq_overrides: dict[str, _Duration] | None = None,
|
|
332
329
|
) -> None:
|
|
333
330
|
self._ids = component_ids
|
|
334
331
|
# Shared with the sibling table — see `AnnotationPool`. Required rather
|
|
@@ -342,11 +339,12 @@ class ComponentTable:
|
|
|
342
339
|
self._kind = kind
|
|
343
340
|
self._time_dim_id = time_dim_id
|
|
344
341
|
# FREQ-driven period bounds (issue #149): the frequency dimension id,
|
|
345
|
-
# its codes' annotation-derived duration overrides
|
|
346
|
-
#
|
|
347
|
-
#
|
|
342
|
+
# its observed codes' annotation-derived duration overrides (filled
|
|
343
|
+
# by `_resolve_codes`, alongside their labels), and the accumulated
|
|
344
|
+
# TIME_PERIOD id → set of co-occurring FREQ ids seen while streaming.
|
|
345
|
+
# Bounds are resolved at to_json() time from these.
|
|
348
346
|
self._freq_dim_id = freq_dim_id
|
|
349
|
-
self._freq_overrides
|
|
347
|
+
self._freq_overrides: dict[str, _Duration] = {}
|
|
350
348
|
self._time_freqs: dict[str, set[str]] = {}
|
|
351
349
|
# ``dsd.dimensions`` is ItemList[Dimension] and does NOT include
|
|
352
350
|
# the time dimension (which lives at ``dsd.time_dimension``).
|
|
@@ -370,14 +368,12 @@ class ComponentTable:
|
|
|
370
368
|
# so position N maps to the Nth-inserted entry. ``to_json``
|
|
371
369
|
# walks this directly.
|
|
372
370
|
self._codes: dict[str, dict[str, dict[str, object]]] = {cid: {} for cid in component_ids}
|
|
373
|
-
# Per-component resolved codelists,
|
|
374
|
-
#
|
|
375
|
-
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
|
|
379
|
-
# than once from.
|
|
380
|
-
self._codelists: dict[str, Codelist] = {}
|
|
371
|
+
# Per-component resolved codelists, held as they came — eager or lazy
|
|
372
|
+
# — and never walked: `_resolve_codes` asks each for the observed ids
|
|
373
|
+
# only. For census-scale codelists (CL_GEOGRAPHY: 579k codes) the walk
|
|
374
|
+
# dominated wall time on small data responses, first as a label cache
|
|
375
|
+
# and again as a per-message `materialize()` (#410).
|
|
376
|
+
self._codelists: dict[str, CodelistLike] = {}
|
|
381
377
|
|
|
382
378
|
for cid in component_ids:
|
|
383
379
|
comp = components.get(cid)
|
|
@@ -395,10 +391,7 @@ class ComponentTable:
|
|
|
395
391
|
# getattr returns Any; the annotation kills it on this line.
|
|
396
392
|
cl: CodelistLike | None = getattr(comp, "resolved_codelist", None)
|
|
397
393
|
if cl is not None:
|
|
398
|
-
|
|
399
|
-
# reads name, parent_id and annotations off each — the full
|
|
400
|
-
# `Code`, not the id-and-label surface `CodelistLike` shares.
|
|
401
|
-
self._codelists[cid] = cl.materialize()
|
|
394
|
+
self._codelists[cid] = cl
|
|
402
395
|
comp_anns = self._annotations.register(comp.annotations)
|
|
403
396
|
if comp_anns:
|
|
404
397
|
self._component_annotations[cid] = comp_anns
|
|
@@ -412,22 +405,39 @@ class ComponentTable:
|
|
|
412
405
|
pos = len(table)
|
|
413
406
|
table[code_id] = pos
|
|
414
407
|
self._position_str[component_id][code_id] = str(pos)
|
|
415
|
-
# Both SDMX-JSON 1.0 and 2.0 schemas require every value entry
|
|
416
|
-
#
|
|
417
|
-
# the
|
|
418
|
-
#
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
408
|
+
# Both SDMX-JSON 1.0 and 2.0 schemas require every value entry to carry
|
|
409
|
+
# id + name. The id stands in for the name until `_resolve_codes`
|
|
410
|
+
# replaces it, and stays for a value the codelist does not hold (or a
|
|
411
|
+
# component with no codelist).
|
|
412
|
+
self._codes[component_id][code_id] = {"id": code_id, "name": code_id}
|
|
413
|
+
return pos
|
|
414
|
+
|
|
415
|
+
def _resolve_codes(self) -> None:
|
|
416
|
+
"""Fill each observed value's name, ``parent`` and annotations.
|
|
417
|
+
|
|
418
|
+
One `CodelistLike.codes_for` per enumerated component, over the ids
|
|
419
|
+
this message observed — the whole point of deferring it here (#410).
|
|
420
|
+
Annotations are registered in position order, so the pool's indices
|
|
421
|
+
are deterministic for a given input.
|
|
422
|
+
"""
|
|
423
|
+
for component_id, cl in self._codelists.items():
|
|
424
|
+
entries = self._codes[component_id]
|
|
425
|
+
if not entries:
|
|
426
|
+
continue
|
|
427
|
+
found = cl.codes_for(entries.keys())
|
|
428
|
+
for code_id, entry in entries.items():
|
|
429
|
+
code = found.get(code_id)
|
|
430
|
+
if code is None:
|
|
431
|
+
continue
|
|
432
|
+
if component_id == self._freq_dim_id:
|
|
433
|
+
duration = _annotation_duration(code.annotations, self._lang)
|
|
434
|
+
if duration is not None:
|
|
435
|
+
self._freq_overrides[code_id] = duration
|
|
436
|
+
self._fill_entry(entry, code)
|
|
437
|
+
|
|
438
|
+
def _fill_entry(self, entry: dict[str, object], code: Code) -> None:
|
|
439
|
+
"""Write one resolved *code*'s name, ``parent`` and annotations into *entry*."""
|
|
440
|
+
entry["name"] = code.name.get(self._lang) or code.id
|
|
431
441
|
# A value's hierarchy edge (#365). `parent` is a first-class property of
|
|
432
442
|
# this object in the 1.0, 2.0 and 2.1 data schemas, and omitting it made
|
|
433
443
|
# every hierarchical codelist arrive flat: the .Stat Data Explorer groups
|
|
@@ -446,20 +456,17 @@ class ComponentTable:
|
|
|
446
456
|
# `IDType`, so an id it rejects was already invalid SDMX upstream. Adding
|
|
447
457
|
# a filter here would turn someone else's malformed codelist into a
|
|
448
458
|
# clean-looking document with a silently missing hierarchy.
|
|
449
|
-
if parent_id is not None:
|
|
450
|
-
entry["parent"] = parent_id
|
|
459
|
+
if code.parent_id is not None:
|
|
460
|
+
entry["parent"] = code.parent_id
|
|
451
461
|
# The code's annotations, as indices into the shared pool (#366). This
|
|
452
462
|
# is how a consumer gets a value's ORDER, NOT_DISPLAYED and the rest —
|
|
453
463
|
# the *type* vocabulary is the annotation creator's, not SDMX's ("the
|
|
454
464
|
# types are not enumerated, as these can be specified by the user or
|
|
455
465
|
# creator", per the AnnotationType documentation in every SDMX-ML
|
|
456
466
|
# version), so the writer emits all of them and filters none.
|
|
467
|
+
value_anns = self._annotations.register(code.annotations)
|
|
457
468
|
if value_anns:
|
|
458
469
|
entry["annotations"] = value_anns
|
|
459
|
-
# TIME_PERIOD start/end is resolved in to_json() once the full set of
|
|
460
|
-
# co-occurring FREQ values is known (see _resolve_time_bounds).
|
|
461
|
-
self._codes[component_id][code_id] = entry
|
|
462
|
-
return pos
|
|
463
470
|
|
|
464
471
|
def tracks_freq(self) -> bool:
|
|
465
472
|
"""True if both a time dimension and a frequency dimension are known."""
|
|
@@ -532,7 +539,12 @@ class ComponentTable:
|
|
|
532
539
|
return {cid: pos_str[cid] for cid in code_ids}
|
|
533
540
|
|
|
534
541
|
def to_json(self) -> list[dict[str, object]]:
|
|
535
|
-
"""Render the per-position metadata array for the structures block.
|
|
542
|
+
"""Render the per-position metadata array for the structures block.
|
|
543
|
+
|
|
544
|
+
Call once, after the last batch: this is where observed values are
|
|
545
|
+
resolved against their codelists.
|
|
546
|
+
"""
|
|
547
|
+
self._resolve_codes()
|
|
536
548
|
out: list[dict[str, object]] = []
|
|
537
549
|
for key_pos, cid in enumerate(self._ids):
|
|
538
550
|
code_entries = self._codes[cid]
|
|
@@ -628,7 +640,11 @@ def encode_batch(
|
|
|
628
640
|
)
|
|
629
641
|
raise KeyError(msg)
|
|
630
642
|
|
|
631
|
-
|
|
643
|
+
# The constructor, not `pl.from_arrow`: the same frame for a RecordBatch, but
|
|
644
|
+
# typed `DataFrame` rather than `DataFrame | Series` — and polars 2.0 made
|
|
645
|
+
# `from_arrow` return a Series for stream-only inputs, which a `cast` would
|
|
646
|
+
# have passed through unchecked.
|
|
647
|
+
df = pl.DataFrame(batch)
|
|
632
648
|
n = df.height
|
|
633
649
|
if n == 0:
|
|
634
650
|
return b""
|
|
@@ -49,7 +49,7 @@ from sdmxlib.formats.sdmx_json._data_common import (
|
|
|
49
49
|
decode_observations,
|
|
50
50
|
display_names,
|
|
51
51
|
encode_batch,
|
|
52
|
-
|
|
52
|
+
frequency_dimension_id,
|
|
53
53
|
resolve_columns,
|
|
54
54
|
rows_to_lazyframe,
|
|
55
55
|
)
|
|
@@ -121,7 +121,7 @@ def write_sdmx_json_data(
|
|
|
121
121
|
dim_cols, dim_phys, attr_cols, attr_phys, _measure_id, measure_col = resolve_columns(dsd)
|
|
122
122
|
|
|
123
123
|
time_dim_id = dsd.time_dimension.id if dsd.time_dimension is not None else "TIME_PERIOD"
|
|
124
|
-
freq_dim_id
|
|
124
|
+
freq_dim_id = frequency_dimension_id(dsd)
|
|
125
125
|
# One pool for the whole structure block — the two tables emit integer
|
|
126
126
|
# indices into it, and an index is meaningless without knowing which array
|
|
127
127
|
# it indexes (#366).
|
|
@@ -134,7 +134,6 @@ def write_sdmx_json_data(
|
|
|
134
134
|
annotations=annotations,
|
|
135
135
|
time_dim_id=time_dim_id,
|
|
136
136
|
freq_dim_id=freq_dim_id,
|
|
137
|
-
freq_overrides=freq_overrides,
|
|
138
137
|
)
|
|
139
138
|
attr_lookup = ComponentTable(attr_cols, dsd, kind="attribute", lang=lang, annotations=annotations)
|
|
140
139
|
|
|
@@ -63,7 +63,7 @@ from sdmxlib.formats.sdmx_json._data_common import (
|
|
|
63
63
|
decode_observations,
|
|
64
64
|
display_names,
|
|
65
65
|
encode_batch,
|
|
66
|
-
|
|
66
|
+
frequency_dimension_id,
|
|
67
67
|
resolve_columns,
|
|
68
68
|
rows_to_lazyframe,
|
|
69
69
|
)
|
|
@@ -142,7 +142,7 @@ def write_sdmx_json_data(
|
|
|
142
142
|
dim_cols, dim_phys, attr_cols, attr_phys, _measure_id, measure_col = resolve_columns(dsd)
|
|
143
143
|
|
|
144
144
|
time_dim_id = dsd.time_dimension.id if dsd.time_dimension is not None else "TIME_PERIOD"
|
|
145
|
-
freq_dim_id
|
|
145
|
+
freq_dim_id = frequency_dimension_id(dsd)
|
|
146
146
|
# One pool for the whole structure block — the two tables emit integer
|
|
147
147
|
# indices into it, and an index is meaningless without knowing which array
|
|
148
148
|
# it indexes (#366).
|
|
@@ -155,7 +155,6 @@ def write_sdmx_json_data(
|
|
|
155
155
|
annotations=annotations,
|
|
156
156
|
time_dim_id=time_dim_id,
|
|
157
157
|
freq_dim_id=freq_dim_id,
|
|
158
|
-
freq_overrides=freq_overrides,
|
|
159
158
|
)
|
|
160
159
|
attr_lookup = ComponentTable(attr_cols, dsd, kind="attribute", lang=lang, annotations=annotations)
|
|
161
160
|
|
|
@@ -59,6 +59,20 @@ if TYPE_CHECKING:
|
|
|
59
59
|
from sdmxlib.model.binding import DataflowBinding
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
class SidecarNotUniqueError(ValueError):
|
|
63
|
+
"""A series-attributes sidecar holds more than one row per join key (#412).
|
|
64
|
+
|
|
65
|
+
The sidecar is joined as `LEFT JOIN … USING (<keys>)`, so every extra row
|
|
66
|
+
per key multiplies each observation it matches. Nothing errors downstream:
|
|
67
|
+
`count()`, `collect()` and every aggregate over a sidecar column come back
|
|
68
|
+
inflated by the fan-out factor, and every row looks valid.
|
|
69
|
+
|
|
70
|
+
A `ValueError` because it is the same kind of refusal `plan` already makes
|
|
71
|
+
for a sidecar sharing no columns — the binding names a table that cannot
|
|
72
|
+
serve as its series attributes — so a caller catching that catches this.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
62
76
|
@frozen(kw_only=True)
|
|
63
77
|
class QueryPlan:
|
|
64
78
|
"""The resolved ``FROM`` side of a `DataflowBinding`'s query.
|
|
@@ -109,6 +123,10 @@ class SchemaInspector:
|
|
|
109
123
|
def __init__(self, conn: duckdb.DuckDBPyConnection) -> None:
|
|
110
124
|
self._conn = conn
|
|
111
125
|
self._columns_cache: dict[str, tuple[str, ...]] = {}
|
|
126
|
+
#: (table, keys) -> (rows, distinct keys), for `key_counts`. Shares
|
|
127
|
+
#: `_columns_cache`'s lock and lifecycle: both describe a table as it
|
|
128
|
+
#: stands, and `invalidate` is the one signal that it changed.
|
|
129
|
+
self._key_counts_cache: dict[tuple[str, tuple[str, ...]], tuple[int, int]] = {}
|
|
112
130
|
self._lock = threading.Lock()
|
|
113
131
|
|
|
114
132
|
def columns(self, table: str) -> tuple[str, ...]:
|
|
@@ -139,15 +157,50 @@ class SchemaInspector:
|
|
|
139
157
|
cols_b = set(self.columns(table_b))
|
|
140
158
|
return tuple(c for c in self.columns(table_a) if c in cols_b)
|
|
141
159
|
|
|
160
|
+
def key_counts(self, table: str, keys: tuple[str, ...]) -> tuple[int, int]:
|
|
161
|
+
"""``(rows, distinct key combinations)`` of ``table`` over ``keys`` (cached).
|
|
162
|
+
|
|
163
|
+
Rows with a NULL in any key are left out of both counts: `USING` never
|
|
164
|
+
matches NULL, so repeated NULL-keyed rows cannot multiply a join, and
|
|
165
|
+
refusing a table for them would be refusing a harmless one.
|
|
166
|
+
|
|
167
|
+
A scan of the table — ~5 ms on cenprof-2021-csip's 619-row geography
|
|
168
|
+
sidecar, ~37 ms cold — so memoised single-flight beside `columns`, and
|
|
169
|
+
dropped by `invalidate` with it.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
table: The table to count, as `columns` takes it.
|
|
173
|
+
keys: The columns whose combinations are counted.
|
|
174
|
+
"""
|
|
175
|
+
cache_key = (table, keys)
|
|
176
|
+
cached = self._key_counts_cache.get(cache_key)
|
|
177
|
+
if cached is not None:
|
|
178
|
+
return cached
|
|
179
|
+
with self._lock:
|
|
180
|
+
cached = self._key_counts_cache.get(cache_key)
|
|
181
|
+
if cached is not None:
|
|
182
|
+
return cached
|
|
183
|
+
tuple_sql = ", ".join(keys)
|
|
184
|
+
not_null = " AND ".join(f"{k} IS NOT NULL" for k in keys)
|
|
185
|
+
row = self._conn.execute(
|
|
186
|
+
f"SELECT count(*), count(DISTINCT ({tuple_sql})) FROM {table} WHERE {not_null}"
|
|
187
|
+
).fetchone()
|
|
188
|
+
counts = (int(row[0]), int(row[1])) if row else (0, 0)
|
|
189
|
+
self._key_counts_cache[cache_key] = counts
|
|
190
|
+
return counts
|
|
191
|
+
|
|
142
192
|
def invalidate(self, table: str) -> None:
|
|
143
|
-
"""Drop
|
|
193
|
+
"""Drop everything cached about ``table`` (e.g. after a rewrite)."""
|
|
144
194
|
with self._lock:
|
|
145
195
|
self._columns_cache.pop(table, None)
|
|
196
|
+
for cache_key in [k for k in self._key_counts_cache if k[0] == table]:
|
|
197
|
+
del self._key_counts_cache[cache_key]
|
|
146
198
|
|
|
147
199
|
def invalidate_all(self) -> None:
|
|
148
|
-
"""Drop
|
|
200
|
+
"""Drop everything cached about every table."""
|
|
149
201
|
with self._lock:
|
|
150
202
|
self._columns_cache.clear()
|
|
203
|
+
self._key_counts_cache.clear()
|
|
151
204
|
|
|
152
205
|
def plan(self, binding: DataflowBinding) -> QueryPlan:
|
|
153
206
|
"""Resolve ``binding``'s main/sidecar tables into a `QueryPlan`.
|
|
@@ -163,6 +216,9 @@ class SchemaInspector:
|
|
|
163
216
|
Raises:
|
|
164
217
|
ValueError: If the binding names a sidecar that shares no columns
|
|
165
218
|
with the main table (no derivable join keys).
|
|
219
|
+
SidecarNotUniqueError: If the sidecar holds more than one row for
|
|
220
|
+
some combination of those keys, which would multiply every
|
|
221
|
+
observation the join matches (#412).
|
|
166
222
|
"""
|
|
167
223
|
main = f"{binding.database_role}.{binding.table_location}"
|
|
168
224
|
sidecar_loc = binding.series_attributes_location
|
|
@@ -176,4 +232,15 @@ class SchemaInspector:
|
|
|
176
232
|
f"shares no columns with {main!r}; cannot derive JOIN keys"
|
|
177
233
|
)
|
|
178
234
|
raise ValueError(msg)
|
|
235
|
+
rows, distinct = self.key_counts(sidecar, join_keys)
|
|
236
|
+
if rows != distinct:
|
|
237
|
+
keys = ", ".join(join_keys)
|
|
238
|
+
msg = (
|
|
239
|
+
f"series-attributes sidecar {sidecar!r} holds {rows:,} rows but only {distinct:,} distinct "
|
|
240
|
+
f"({keys}) — joined on those keys, every observation of a repeated series would be counted "
|
|
241
|
+
f"once per sidecar row, so counts and aggregates over its columns come back inflated (#412). "
|
|
242
|
+
f"The keys are the columns the two tables share; make the sidecar unique on them, or drop the "
|
|
243
|
+
f"shared columns that are not part of the series key."
|
|
244
|
+
)
|
|
245
|
+
raise SidecarNotUniqueError(msg)
|
|
179
246
|
return QueryPlan(main=main, sidecar=sidecar, join_keys=join_keys)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Codelist and Code — enumerated value sets."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Iterable
|
|
3
4
|
from datetime import datetime
|
|
4
5
|
from enum import Enum
|
|
5
6
|
from typing import TYPE_CHECKING, ClassVar, Protocol, runtime_checkable
|
|
@@ -186,14 +187,28 @@ class CodelistLike(Protocol):
|
|
|
186
187
|
"""``{code_id: label}`` in ``lang``, falling back to the id."""
|
|
187
188
|
...
|
|
188
189
|
|
|
190
|
+
def codes_for(self, ids: Iterable[str]) -> dict[str, Code]:
|
|
191
|
+
"""``{code_id: Code}`` for exactly *ids*, each fully built.
|
|
192
|
+
|
|
193
|
+
Ids the codelist does not hold are absent from the result rather than
|
|
194
|
+
an error — a caller resolving observed values meets values outside the
|
|
195
|
+
enumeration, and the id is then its own label.
|
|
196
|
+
|
|
197
|
+
The targeted counterpart to `materialize`: one batched read on a lazy
|
|
198
|
+
codelist, whose cost follows ``len(ids)`` rather than the codelist. The
|
|
199
|
+
SDMX-JSON data writer called `materialize` per component per message
|
|
200
|
+
and paid for every code in a 579,951-code geography to label the
|
|
201
|
+
handful a response observed (#410).
|
|
202
|
+
"""
|
|
203
|
+
...
|
|
204
|
+
|
|
189
205
|
def materialize(self) -> "Codelist":
|
|
190
206
|
"""This codelist with every `Code` built, for callers needing more.
|
|
191
207
|
|
|
192
208
|
Free on an eager `Codelist`, which returns itself; a full read on a
|
|
193
|
-
lazy one
|
|
194
|
-
annotations or ``parent_id``
|
|
195
|
-
|
|
196
|
-
be a `Query`.
|
|
209
|
+
lazy one, whose cost is the whole codelist. A caller that needs some
|
|
210
|
+
codes' annotations or ``parent_id`` rather than all of them wants
|
|
211
|
+
`codes_for` instead — that was the SDMX-JSON data writer (#410).
|
|
197
212
|
"""
|
|
198
213
|
...
|
|
199
214
|
|
|
@@ -385,6 +400,13 @@ class Codelist(MaintainableArtefact):
|
|
|
385
400
|
"""
|
|
386
401
|
return self.codes.ids()
|
|
387
402
|
|
|
403
|
+
def codes_for(self, ids: Iterable[str]) -> dict[str, Code]:
|
|
404
|
+
"""``{code_id: Code}`` for exactly *ids*; unknown ids are absent.
|
|
405
|
+
|
|
406
|
+
The eager half of `CodelistLike.codes_for` — an `ItemList.get` per id.
|
|
407
|
+
"""
|
|
408
|
+
return {cid: code for cid in ids if (code := self.codes.get(cid)) is not None}
|
|
409
|
+
|
|
388
410
|
def materialize(self) -> "Codelist":
|
|
389
411
|
"""This codelist — it is already materialised.
|
|
390
412
|
|