smart-data-engine-sdk 0.1.0__py3-none-any.whl
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.
- sde/__init__.py +318 -0
- sde/_cutover_project.py +179 -0
- sde/_local_state.py +188 -0
- sde/_operator_deadline.py +50 -0
- sde/_usage.py +314 -0
- sde/bulk.py +79 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/cutover.py +286 -0
- sde/engines/__init__.py +0 -0
- sde/engines/_clickhouse_connection.py +224 -0
- sde/engines/_index_build.py +294 -0
- sde/engines/_operator.py +394 -0
- sde/engines/_staging.py +222 -0
- sde/engines/_storage.py +22 -0
- sde/engines/_write_fences.py +271 -0
- sde/engines/clickhouse.py +1115 -0
- sde/engines/orderbook.py +457 -0
- sde/engines/postgres.py +967 -0
- sde/entity.py +170 -0
- sde/errors.py +103 -0
- sde/explain.py +300 -0
- sde/frozen_verification.py +152 -0
- sde/generation.py +131 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/index_build.py +313 -0
- sde/index_operator.py +347 -0
- sde/infer.py +461 -0
- sde/inspection.py +62 -0
- sde/internal.py +90 -0
- sde/layout.py +669 -0
- sde/local_cutover.py +801 -0
- sde/logging.py +143 -0
- sde/migration.py +856 -0
- sde/model.py +482 -0
- sde/physical.py +531 -0
- sde/placement.py +1010 -0
- sde/provisioning.py +63 -0
- sde/py.typed +0 -0
- sde/query.py +521 -0
- sde/routing.py +85 -0
- sde/schema.py +466 -0
- sde/session.py +993 -0
- sde/shapes.py +153 -0
- sde/staging.py +264 -0
- sde/staging_operator.py +393 -0
- sde/telemetry.py +1087 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +331 -0
- sde/types.py +228 -0
- sde/verification.py +220 -0
- sde/watermark.py +222 -0
- sde/write_fence.py +283 -0
- sde_demo/__init__.py +1 -0
- sde_demo/__main__.py +183 -0
- sde_demo/diagnostics.py +92 -0
- sde_demo/model.py +75 -0
- sde_demo/project.py +312 -0
- sde_demo/py.typed +0 -0
- sde_demo/query_count.py +301 -0
- sde_demo/resources.py +969 -0
- sde_demo/runtime.py +419 -0
- sde_demo/verification.py +242 -0
- sde_operator/__init__.py +1 -0
- sde_operator/__main__.py +210 -0
- smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
- smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
- smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/NOTICE +13 -0
sde/physical.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""The physical design vocabulary a placement map can carry, and the rules each element obeys.
|
|
2
|
+
|
|
3
|
+
Placement map contract 5 lets a layout say three things about storage beyond tables and column
|
|
4
|
+
types: the physical order of an entity's key, a time partition, and indexes of a named method. They
|
|
5
|
+
are chosen by the control plane - since 23 September 2026 by a model whose proposal is validated -
|
|
6
|
+
and a library only renders them. Every element is a closed vocabulary rendered by this library;
|
|
7
|
+
nothing in a map is pasted into DDL.
|
|
8
|
+
|
|
9
|
+
This module is the one place the vocabulary lives. The map parser, the DDL renderer, the adapters'
|
|
10
|
+
verification and the control plane's description of what a dialect can render all read it, because
|
|
11
|
+
a second copy of "which index methods ClickHouse has" is how a map gets signed that no library can
|
|
12
|
+
apply.
|
|
13
|
+
|
|
14
|
+
Two rules here are about data rather than performance, and both were measured before they were
|
|
15
|
+
written (PostgreSQL 15.19, ClickHouse 24.8.14.39, 23 September 2026):
|
|
16
|
+
|
|
17
|
+
- **A partition follows the key.** `ReplacingMergeTree` collapses rows with one key only inside a
|
|
18
|
+
partition. Partitioned on a column outside the key, two writes of one key that land in different
|
|
19
|
+
partitions stay two rows *physically forever*: ``FINAL`` hid the duplicate only because
|
|
20
|
+
``do_not_merge_across_partitions_select_final`` was 0, and after ``OPTIMIZE ... FINAL`` both rows
|
|
21
|
+
remained. Partitioned on a key column, one key always lands in one partition.
|
|
22
|
+
- **The logical key is the identity; `key_order` only reorders it.** A permutation of the declared
|
|
23
|
+
key keeps the same deduplication and the same uniqueness, measured in both engines, while choosing
|
|
24
|
+
which prefix the engine can prune or seek by.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from collections.abc import Mapping, Sequence
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Any, Final, TypeGuard
|
|
33
|
+
|
|
34
|
+
from .errors import MapError
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CLICKHOUSE_METHODS",
|
|
38
|
+
"GRANULARITIES",
|
|
39
|
+
"INDEX_METHODS",
|
|
40
|
+
"PHYSICAL_DESIGN_SINCE",
|
|
41
|
+
"POSTGRES_METHODS",
|
|
42
|
+
"TEMPORAL_TYPES",
|
|
43
|
+
"capabilities",
|
|
44
|
+
"index_method",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
PHYSICAL_DESIGN_SINCE: Final = 5
|
|
48
|
+
"""The placement map contract that introduced ``key_order``, ``partition_by`` and index methods."""
|
|
49
|
+
|
|
50
|
+
GRANULARITIES: Final[tuple[str, ...]] = ("day", "month", "year")
|
|
51
|
+
"""Time partition sizes.
|
|
52
|
+
|
|
53
|
+
No week: ClickHouse's ``toStartOfWeek`` depends on a mode (Sunday or Monday first), and a closed
|
|
54
|
+
vocabulary has no modes. A week that meant two different things in two deployments would be exactly
|
|
55
|
+
the ambiguity this list exists to remove.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
TEMPORAL_TYPES: Final[tuple[str, ...]] = ("date", "timestamptz")
|
|
59
|
+
"""Neutral types a partition may be derived from.
|
|
60
|
+
|
|
61
|
+
``timestamp`` is absent on purpose. Its ClickHouse column carries no zone, so the day, month or
|
|
62
|
+
year a value falls into is computed in the server's configured timezone at insert time - a server
|
|
63
|
+
reconfigured later would put the same key into another partition, and two partitions of one key
|
|
64
|
+
are two rows for ever. A session's ``session_timezone`` does not move it (measured, 24.8); the
|
|
65
|
+
server's zone is documented to. ``date`` and ``timestamptz`` (``DateTime64(6, 'UTC')``) do not
|
|
66
|
+
depend on any configuration.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
POSTGRES_METHODS: Final[tuple[str, ...]] = ("brin", "btree")
|
|
70
|
+
CLICKHOUSE_METHODS: Final[tuple[str, ...]] = ("bloom_filter", "minmax", "set")
|
|
71
|
+
"""ClickHouse data-skipping index types. It has no B-tree; its primary index is ``ORDER BY``."""
|
|
72
|
+
|
|
73
|
+
INDEX_METHODS: Final[tuple[str, ...]] = tuple(sorted(POSTGRES_METHODS + CLICKHOUSE_METHODS))
|
|
74
|
+
|
|
75
|
+
GRANULARITY_RANGE: Final[tuple[int, int]] = (1, 1024)
|
|
76
|
+
"""Granules summarised by one entry of a data-skipping index. Bounded so a typo cannot ask for an
|
|
77
|
+
index that summarises nothing (0) or the whole table."""
|
|
78
|
+
|
|
79
|
+
SET_ROWS_RANGE: Final[tuple[int, int]] = (1, 65536)
|
|
80
|
+
"""``set(N)`` keeps up to N distinct values per entry. ``set(0)`` means unlimited, which is an
|
|
81
|
+
unbounded memory commitment, so zero is outside the range rather than a default."""
|
|
82
|
+
|
|
83
|
+
LEGACY_INDEX_KEYS: Final = frozenset({"entity", "name", "columns"})
|
|
84
|
+
INDEX_KEYS: Final = LEGACY_INDEX_KEYS | {"method", "granularity", "max_rows"}
|
|
85
|
+
|
|
86
|
+
PARTITION_FUNCTIONS: Final[Mapping[str, str]] = {
|
|
87
|
+
"day": "toDate",
|
|
88
|
+
"month": "toYYYYMM",
|
|
89
|
+
"year": "toYear",
|
|
90
|
+
}
|
|
91
|
+
"""ClickHouse partition expressions, as its catalogue reports them back."""
|
|
92
|
+
|
|
93
|
+
PARTITIONING_DIALECTS: Final = frozenset({"clickhouse"})
|
|
94
|
+
"""Dialects that render ``partition_by``.
|
|
95
|
+
|
|
96
|
+
PostgreSQL is absent on purpose. Declarative partitioning there needs every partition created
|
|
97
|
+
before a row can arrive, which is a lifecycle this product does not manage; a map that declared it
|
|
98
|
+
and got an unpartitioned table would be the silent drop this vocabulary refuses everywhere else.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
METHODS_BY_DIALECT: Final[Mapping[str, tuple[str, ...]]] = {
|
|
102
|
+
"clickhouse": CLICKHOUSE_METHODS,
|
|
103
|
+
"postgres": POSTGRES_METHODS,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def index_method(index: Mapping[str, Any]) -> str:
|
|
108
|
+
"""An index's method. Absent means ``btree``, what every map before contract 5 meant."""
|
|
109
|
+
return str(index.get("method", "btree"))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _integer(value: Any) -> TypeGuard[int]:
|
|
113
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_key_order(
|
|
117
|
+
raw: Any, where: str, *, tables: Mapping[str, str]
|
|
118
|
+
) -> dict[str, tuple[str, ...]]:
|
|
119
|
+
"""``key_order``: entity -> the physical order of that entity's key columns."""
|
|
120
|
+
if not isinstance(raw, dict):
|
|
121
|
+
raise MapError(f"{where}: key_order maps an entity to a list of its key columns")
|
|
122
|
+
out: dict[str, tuple[str, ...]] = {}
|
|
123
|
+
for entity in sorted(raw):
|
|
124
|
+
columns = raw[entity]
|
|
125
|
+
if entity not in tables:
|
|
126
|
+
raise MapError(
|
|
127
|
+
f"{where}: key_order names {entity!r}, which has no table in this layout. It has "
|
|
128
|
+
f"{sorted(tables)}."
|
|
129
|
+
)
|
|
130
|
+
# A tuple is a JSON array too: a loaded layout is frozen into tuples, and a controller
|
|
131
|
+
# building the next map from the one in force passes them straight back.
|
|
132
|
+
if (
|
|
133
|
+
not isinstance(columns, list | tuple)
|
|
134
|
+
or not columns
|
|
135
|
+
or not all(isinstance(column, str) and column for column in columns)
|
|
136
|
+
):
|
|
137
|
+
raise MapError(
|
|
138
|
+
f"{where}: key_order[{entity!r}] must be a non-empty list of column names"
|
|
139
|
+
)
|
|
140
|
+
if len(set(columns)) != len(columns):
|
|
141
|
+
raise MapError(
|
|
142
|
+
f"{where}: key_order[{entity!r}] names a column twice: {columns}. A key order is a "
|
|
143
|
+
f"permutation of the key, so each key column appears exactly once."
|
|
144
|
+
)
|
|
145
|
+
out[entity] = tuple(columns)
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_partition_by(
|
|
150
|
+
raw: Any, where: str, *, tables: Mapping[str, str]
|
|
151
|
+
) -> dict[str, dict[str, str]]:
|
|
152
|
+
"""``partition_by``: entity -> ``{"field": ..., "granularity": ...}``, and nothing else."""
|
|
153
|
+
if not isinstance(raw, dict):
|
|
154
|
+
raise MapError(f"{where}: partition_by maps an entity to a {{field, granularity}} object")
|
|
155
|
+
out: dict[str, dict[str, str]] = {}
|
|
156
|
+
for entity in sorted(raw):
|
|
157
|
+
spec = raw[entity]
|
|
158
|
+
if entity not in tables:
|
|
159
|
+
raise MapError(
|
|
160
|
+
f"{where}: partition_by names {entity!r}, which has no table in this layout. It "
|
|
161
|
+
f"has {sorted(tables)}."
|
|
162
|
+
)
|
|
163
|
+
if not isinstance(spec, dict) or set(spec) != {"field", "granularity"}:
|
|
164
|
+
raise MapError(
|
|
165
|
+
f"{where}: partition_by[{entity!r}] must be exactly "
|
|
166
|
+
f'{{"field": ..., "granularity": ...}}; found keys '
|
|
167
|
+
f"{sorted(spec) if isinstance(spec, dict) else type(spec).__name__}. The "
|
|
168
|
+
f"vocabulary is closed so that nothing in a map is pasted into DDL."
|
|
169
|
+
)
|
|
170
|
+
field_name, granularity = spec["field"], spec["granularity"]
|
|
171
|
+
if not isinstance(field_name, str) or not field_name:
|
|
172
|
+
raise MapError(f"{where}: partition_by[{entity!r}].field must name a column")
|
|
173
|
+
if granularity not in GRANULARITIES:
|
|
174
|
+
raise MapError(
|
|
175
|
+
f"{where}: partition_by[{entity!r}].granularity is {granularity!r}; it is one of "
|
|
176
|
+
f"{list(GRANULARITIES)}."
|
|
177
|
+
)
|
|
178
|
+
out[entity] = {"field": field_name, "granularity": str(granularity)}
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_indexes(
|
|
183
|
+
raw: Any,
|
|
184
|
+
where: str,
|
|
185
|
+
*,
|
|
186
|
+
tables: Mapping[str, str],
|
|
187
|
+
columns: Mapping[str, Mapping[str, str]],
|
|
188
|
+
contract: int,
|
|
189
|
+
) -> tuple[dict[str, Any], ...]:
|
|
190
|
+
"""Index definitions, validated where the map arrives rather than when DDL is rendered.
|
|
191
|
+
|
|
192
|
+
The structural half applies to every contract - an index without an entity, a name or columns
|
|
193
|
+
used to surface as a bare ``KeyError`` from the renderer, in the client's process - and is a
|
|
194
|
+
tightening. The method, granularity and ``max_rows`` keys are contract 5: an earlier library
|
|
195
|
+
ignores them, so a contract-4 document carrying ``"method": "brin"`` would be applied as a
|
|
196
|
+
B-tree by one library and as BRIN by another.
|
|
197
|
+
"""
|
|
198
|
+
if raw is None:
|
|
199
|
+
return ()
|
|
200
|
+
if not isinstance(raw, list | tuple):
|
|
201
|
+
raise MapError(f"{where}: indexes is a list of index definitions")
|
|
202
|
+
allowed = INDEX_KEYS if contract >= PHYSICAL_DESIGN_SINCE else LEGACY_INDEX_KEYS
|
|
203
|
+
out: list[dict[str, Any]] = []
|
|
204
|
+
names: set[str] = set()
|
|
205
|
+
for position, index in enumerate(raw):
|
|
206
|
+
at = f"{where}: indexes[{position}]"
|
|
207
|
+
if not isinstance(index, dict):
|
|
208
|
+
raise MapError(f"{at} must be an object")
|
|
209
|
+
unknown = sorted(set(index) - allowed)
|
|
210
|
+
if unknown:
|
|
211
|
+
later = sorted(set(unknown) & (INDEX_KEYS - LEGACY_INDEX_KEYS))
|
|
212
|
+
if later:
|
|
213
|
+
raise MapError(
|
|
214
|
+
f"{at} uses {later}, which placement map contract {PHYSICAL_DESIGN_SINCE} "
|
|
215
|
+
f"introduced, in a document declaring contract {contract}. A library of that "
|
|
216
|
+
f"contract would ignore the key and create a different index from the same "
|
|
217
|
+
f"document, which is the difference the version exists to prevent."
|
|
218
|
+
)
|
|
219
|
+
raise MapError(f"{at} has keys this format does not define: {unknown}")
|
|
220
|
+
entity, name, cols = index.get("entity"), index.get("name"), index.get("columns")
|
|
221
|
+
if not isinstance(entity, str) or entity not in tables:
|
|
222
|
+
raise MapError(
|
|
223
|
+
f"{at} names entity {entity!r}, which has no table in this layout. It has "
|
|
224
|
+
f"{sorted(tables)}."
|
|
225
|
+
)
|
|
226
|
+
if not isinstance(name, str) or not name:
|
|
227
|
+
raise MapError(f"{at} needs a non-empty name")
|
|
228
|
+
if name in names:
|
|
229
|
+
raise MapError(f"{at} reuses the index name {name!r}")
|
|
230
|
+
names.add(name)
|
|
231
|
+
if (
|
|
232
|
+
not isinstance(cols, list | tuple)
|
|
233
|
+
or not cols
|
|
234
|
+
or not all(isinstance(column, str) and column for column in cols)
|
|
235
|
+
or len(set(cols)) != len(cols)
|
|
236
|
+
):
|
|
237
|
+
raise MapError(f"{at} needs a non-empty list of distinct column names")
|
|
238
|
+
declared = columns.get(entity)
|
|
239
|
+
if declared:
|
|
240
|
+
missing = [column for column in cols if column not in declared]
|
|
241
|
+
if missing:
|
|
242
|
+
raise MapError(
|
|
243
|
+
f"{at} indexes {missing}, which {entity!r} does not have in this layout. An "
|
|
244
|
+
f"index on a missing column is refused by the engine at CREATE INDEX, in the "
|
|
245
|
+
f"client's process, rather than here."
|
|
246
|
+
)
|
|
247
|
+
method = index.get("method", "btree")
|
|
248
|
+
if method not in INDEX_METHODS:
|
|
249
|
+
raise MapError(f"{at} has method {method!r}; the methods are {list(INDEX_METHODS)}")
|
|
250
|
+
granularity, max_rows = index.get("granularity"), index.get("max_rows")
|
|
251
|
+
if method in CLICKHOUSE_METHODS:
|
|
252
|
+
low, high = GRANULARITY_RANGE
|
|
253
|
+
if not _integer(granularity) or not low <= granularity <= high:
|
|
254
|
+
raise MapError(
|
|
255
|
+
f"{at}: a {method} index needs an integer granularity from {low} to {high}; "
|
|
256
|
+
f"found {granularity!r}"
|
|
257
|
+
)
|
|
258
|
+
if len(cols) != 1:
|
|
259
|
+
raise MapError(
|
|
260
|
+
f"{at}: a {method} index summarises exactly one column, found {cols}"
|
|
261
|
+
)
|
|
262
|
+
elif "granularity" in index:
|
|
263
|
+
# Presence, not `is not None`: a `null` here is still a key this method does not
|
|
264
|
+
# have, and TypeScript cannot tell the two apart the way `dict.get` blurs them.
|
|
265
|
+
raise MapError(f"{at}: granularity belongs to data-skipping indexes, not {method}")
|
|
266
|
+
if method == "set":
|
|
267
|
+
low, high = SET_ROWS_RANGE
|
|
268
|
+
if not _integer(max_rows) or not low <= max_rows <= high:
|
|
269
|
+
raise MapError(
|
|
270
|
+
f"{at}: a set index needs an integer max_rows from {low} to {high}; found "
|
|
271
|
+
f"{max_rows!r}. Zero means unlimited in ClickHouse, which is not a choice this "
|
|
272
|
+
f"format offers."
|
|
273
|
+
)
|
|
274
|
+
elif "max_rows" in index:
|
|
275
|
+
raise MapError(f"{at}: max_rows belongs to set indexes, not {method}")
|
|
276
|
+
out.append(dict(index))
|
|
277
|
+
return tuple(out)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def check_against_model(
|
|
281
|
+
where: str,
|
|
282
|
+
*,
|
|
283
|
+
entity: str,
|
|
284
|
+
key: Sequence[str],
|
|
285
|
+
field_types: Mapping[str, str],
|
|
286
|
+
key_order: Sequence[str] | None,
|
|
287
|
+
partition: Mapping[str, str] | None,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""The two rules that need the model: a permutation of the key, and a partition on a key column.
|
|
290
|
+
|
|
291
|
+
Run when a map is loaded with its model. A renderer without the model repeats the key half
|
|
292
|
+
against the keys it is given (:func:`effective_key`, :func:`partition_expression`), which is
|
|
293
|
+
what keeps the data rule true for a caller that skipped the model.
|
|
294
|
+
"""
|
|
295
|
+
if key_order is not None and sorted(key_order) != sorted(key):
|
|
296
|
+
raise MapError(
|
|
297
|
+
f"{where}: key_order[{entity!r}] is {list(key_order)} and the declared key is "
|
|
298
|
+
f"{list(key)}. A key order reorders the key; it cannot add, drop or replace a column, "
|
|
299
|
+
f"because the key is what makes a row the same row in every engine."
|
|
300
|
+
)
|
|
301
|
+
if partition is not None:
|
|
302
|
+
field_name = partition["field"]
|
|
303
|
+
if field_name not in key:
|
|
304
|
+
raise MapError(
|
|
305
|
+
f"{where}: partition_by[{entity!r}] partitions on {field_name!r}, which is not in "
|
|
306
|
+
f"the key {list(key)}. ClickHouse collapses rows of one key only inside one "
|
|
307
|
+
f"partition, so two writes of one key could land in two partitions and stay two "
|
|
308
|
+
f"rows physically for ever - measured: after OPTIMIZE FINAL both remained."
|
|
309
|
+
)
|
|
310
|
+
kind = field_types.get(field_name)
|
|
311
|
+
if kind not in TEMPORAL_TYPES:
|
|
312
|
+
raise MapError(
|
|
313
|
+
f"{where}: partition_by[{entity!r}] partitions on {field_name!r}, which is "
|
|
314
|
+
f"{kind!r}; a time partition needs one of {list(TEMPORAL_TYPES)}."
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def effective_key(
|
|
319
|
+
where: str,
|
|
320
|
+
*,
|
|
321
|
+
entity: str,
|
|
322
|
+
key: Sequence[str],
|
|
323
|
+
key_order: Mapping[str, Sequence[str]],
|
|
324
|
+
error: type[Exception] = MapError,
|
|
325
|
+
) -> list[str]:
|
|
326
|
+
"""The key in physical order: ``key_order`` when present, the declared order otherwise."""
|
|
327
|
+
ordered = key_order.get(entity)
|
|
328
|
+
if ordered is None:
|
|
329
|
+
return list(key)
|
|
330
|
+
if sorted(ordered) != sorted(key):
|
|
331
|
+
raise error(
|
|
332
|
+
f"{where}: key_order[{entity!r}] is {list(ordered)} and the key is {list(key)}. A key "
|
|
333
|
+
f"order must be a permutation of the key."
|
|
334
|
+
)
|
|
335
|
+
return list(ordered)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def partition_expression(
|
|
339
|
+
where: str,
|
|
340
|
+
*,
|
|
341
|
+
entity: str,
|
|
342
|
+
key: Sequence[str],
|
|
343
|
+
partition: Mapping[str, str] | None,
|
|
344
|
+
error: type[Exception] = MapError,
|
|
345
|
+
) -> tuple[str, str] | None:
|
|
346
|
+
"""``(function, field)`` for a ClickHouse partition, after the key rule is checked again."""
|
|
347
|
+
if partition is None:
|
|
348
|
+
return None
|
|
349
|
+
field_name = partition["field"]
|
|
350
|
+
if field_name not in key:
|
|
351
|
+
raise error(
|
|
352
|
+
f"{where}: partition_by[{entity!r}] partitions on {field_name!r}, outside the key "
|
|
353
|
+
f"{list(key)}; duplicates of one key would survive merges in two partitions."
|
|
354
|
+
)
|
|
355
|
+
return PARTITION_FUNCTIONS[partition["granularity"]], field_name
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def capabilities(dialect: str) -> dict[str, Any]:
|
|
359
|
+
"""What a dialect renders, for whoever has to propose a physical design for it.
|
|
360
|
+
|
|
361
|
+
The control plane hands this to a model so the vocabulary it is offered is the vocabulary this
|
|
362
|
+
library will render - derived here, not restated there.
|
|
363
|
+
"""
|
|
364
|
+
if dialect not in METHODS_BY_DIALECT:
|
|
365
|
+
return {"physical_design": False}
|
|
366
|
+
methods: dict[str, Any] = {}
|
|
367
|
+
for method in METHODS_BY_DIALECT[dialect]:
|
|
368
|
+
entry: dict[str, Any] = {}
|
|
369
|
+
if method in CLICKHOUSE_METHODS:
|
|
370
|
+
entry["granularity"] = list(GRANULARITY_RANGE)
|
|
371
|
+
entry["columns"] = 1
|
|
372
|
+
if method == "set":
|
|
373
|
+
entry["max_rows"] = list(SET_ROWS_RANGE)
|
|
374
|
+
methods[method] = entry
|
|
375
|
+
return {
|
|
376
|
+
"physical_design": True,
|
|
377
|
+
"key_order": True,
|
|
378
|
+
"partition_granularities": (
|
|
379
|
+
list(GRANULARITIES) if dialect in PARTITIONING_DIALECTS else []
|
|
380
|
+
),
|
|
381
|
+
"partition_on": "a key column of type date or timestamptz",
|
|
382
|
+
"index_methods": methods,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ── Reading the physical design back from an engine ──────────────────────────────────────────────
|
|
387
|
+
#
|
|
388
|
+
# `CREATE TABLE IF NOT EXISTS` keeps an existing table whatever its sort key or partition, and
|
|
389
|
+
# PostgreSQL's `CREATE INDEX IF NOT EXISTS ... USING brin` keeps an existing B-tree of that name -
|
|
390
|
+
# both measured. So what an engine holds is read from its catalogue after the statements run, and
|
|
391
|
+
# compared with what the layout declares. Where the catalogue speaks a formatted expression
|
|
392
|
+
# (ClickHouse), it is parsed into names rather than compared as a string we predict: ClickHouse
|
|
393
|
+
# 24.8 leaves `select` and `order` bare and quotes `null` and `ząb`, and a formatting rule that
|
|
394
|
+
# changes between releases would turn a correct table into a refusal.
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass(frozen=True)
|
|
398
|
+
class PhysicalFinding:
|
|
399
|
+
"""One way an existing table differs from the physical design its layout declares."""
|
|
400
|
+
|
|
401
|
+
table: str
|
|
402
|
+
aspect: str
|
|
403
|
+
declared: str
|
|
404
|
+
found: str
|
|
405
|
+
|
|
406
|
+
def __str__(self) -> str:
|
|
407
|
+
return f"{self.table}: {self.aspect} is {self.found} and the map declares {self.declared}"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@dataclass(frozen=True)
|
|
411
|
+
class DeclaredIndex:
|
|
412
|
+
name: str
|
|
413
|
+
method: str
|
|
414
|
+
columns: tuple[str, ...]
|
|
415
|
+
granularity: int | None = None
|
|
416
|
+
type_full: str = ""
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@dataclass(frozen=True)
|
|
420
|
+
class DeclaredTable:
|
|
421
|
+
"""What one table should look like physically, derived from a layout and the model's keys."""
|
|
422
|
+
|
|
423
|
+
table: str
|
|
424
|
+
key: tuple[str, ...]
|
|
425
|
+
partition: tuple[str, str] | None
|
|
426
|
+
indexes: tuple[DeclaredIndex, ...]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def declared_tables(layout: Any, keys: Mapping[str, Sequence[str]]) -> tuple[DeclaredTable, ...]:
|
|
430
|
+
"""The physical expectations of every table a layout names, in table order."""
|
|
431
|
+
out: list[DeclaredTable] = []
|
|
432
|
+
for entity, table in sorted(layout.tables.items(), key=lambda item: item[1]):
|
|
433
|
+
key = list(keys.get(entity, ()))
|
|
434
|
+
ordered = effective_key(
|
|
435
|
+
f"table {table!r}", entity=entity, key=key, key_order=layout.key_order
|
|
436
|
+
)
|
|
437
|
+
partition = partition_expression(
|
|
438
|
+
f"table {table!r}", entity=entity, key=key, partition=layout.partition_by.get(entity)
|
|
439
|
+
)
|
|
440
|
+
indexes = []
|
|
441
|
+
for index in sorted(layout.indexes, key=lambda item: str(item["name"])):
|
|
442
|
+
if str(index["entity"]) != entity:
|
|
443
|
+
continue
|
|
444
|
+
method = index_method(index)
|
|
445
|
+
granularity = index.get("granularity")
|
|
446
|
+
type_full = f"set({index['max_rows']})" if method == "set" else method
|
|
447
|
+
indexes.append(
|
|
448
|
+
DeclaredIndex(
|
|
449
|
+
name=str(index["name"]),
|
|
450
|
+
method=method,
|
|
451
|
+
columns=tuple(str(column) for column in index["columns"]),
|
|
452
|
+
granularity=int(granularity) if granularity is not None else None,
|
|
453
|
+
type_full=type_full,
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
out.append(DeclaredTable(str(table), tuple(ordered), partition, tuple(indexes)))
|
|
457
|
+
return tuple(out)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
_BARE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def parse_identifier_list(text: str) -> tuple[str, ...]:
|
|
464
|
+
"""``a, `b c`, d`` → ``("a", "b c", "d")``: names as ClickHouse's catalogue writes them.
|
|
465
|
+
|
|
466
|
+
A name is either bare or backtick-quoted with ``\\`` escaping ``\\`` and the backtick - the
|
|
467
|
+
same rule this library writes (``_quote_backtick``). Anything else raises ``ValueError``: an
|
|
468
|
+
expression the parser does not understand is not evidence that the table matches.
|
|
469
|
+
"""
|
|
470
|
+
names: list[str] = []
|
|
471
|
+
position, length = 0, len(text)
|
|
472
|
+
while position < length:
|
|
473
|
+
if text[position] == "`":
|
|
474
|
+
position += 1
|
|
475
|
+
name: list[str] = []
|
|
476
|
+
while True:
|
|
477
|
+
if position >= length:
|
|
478
|
+
raise ValueError(f"unterminated quoted name in {text!r}")
|
|
479
|
+
char = text[position]
|
|
480
|
+
if char == "\\":
|
|
481
|
+
if position + 1 >= length:
|
|
482
|
+
raise ValueError(f"dangling escape in {text!r}")
|
|
483
|
+
name.append(text[position + 1])
|
|
484
|
+
position += 2
|
|
485
|
+
continue
|
|
486
|
+
if char == "`":
|
|
487
|
+
position += 1
|
|
488
|
+
break
|
|
489
|
+
name.append(char)
|
|
490
|
+
position += 1
|
|
491
|
+
names.append("".join(name))
|
|
492
|
+
else:
|
|
493
|
+
match = _BARE.match(text, position)
|
|
494
|
+
if match is None:
|
|
495
|
+
raise ValueError(f"not a list of column names: {text!r}")
|
|
496
|
+
names.append(match.group(0))
|
|
497
|
+
position = match.end()
|
|
498
|
+
if position < length:
|
|
499
|
+
# A separator is only ever between two names: ", " at the end is not a list.
|
|
500
|
+
if not text.startswith(", ", position) or position + 2 >= length:
|
|
501
|
+
raise ValueError(f"not a list of column names: {text!r}")
|
|
502
|
+
position += 2
|
|
503
|
+
return tuple(names)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def parse_partition_key(text: str) -> tuple[str, str] | None:
|
|
507
|
+
"""``toYYYYMM(`at`)`` → ``("toYYYYMM", "at")``; an empty key → ``None``."""
|
|
508
|
+
if text == "":
|
|
509
|
+
return None
|
|
510
|
+
match = re.fullmatch(r"([A-Za-z][A-Za-z0-9]*)\((.*)\)", text)
|
|
511
|
+
if match is None:
|
|
512
|
+
raise ValueError(f"not a single-function partition key: {text!r}")
|
|
513
|
+
(field_name,) = parse_identifier_list(match.group(2))
|
|
514
|
+
return match.group(1), field_name
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def refuse_findings(findings: Sequence[PhysicalFinding], error: type[Exception]) -> None:
|
|
518
|
+
"""Raise when a table differs physically. The provisioning paths call this; sessions do not.
|
|
519
|
+
|
|
520
|
+
A session only reports (``Session.physical``): the difference is performance, and requirement
|
|
521
|
+
3.6 forbids turning it into the application's outage. Provisioning is where a person applying a
|
|
522
|
+
map can act on it, so there it is a refusal naming the table, the aspect and both values.
|
|
523
|
+
"""
|
|
524
|
+
if findings:
|
|
525
|
+
details = "; ".join(str(finding) for finding in findings)
|
|
526
|
+
raise error(
|
|
527
|
+
f"existing tables differ from the physical design this map declares: {details}. "
|
|
528
|
+
f"`CREATE ... IF NOT EXISTS` keeps whatever table or index already has the name, so "
|
|
529
|
+
f"this came from an earlier map or from outside SDE. A new layout needs fresh tables "
|
|
530
|
+
f"(staging), not the old ones under the new declaration."
|
|
531
|
+
)
|