chtypes 0.3.2__tar.gz → 0.4.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.
- {chtypes-0.3.2 → chtypes-0.4.0}/CHANGELOG.md +15 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/PKG-INFO +2 -2
- {chtypes-0.3.2 → chtypes-0.4.0}/README.md +1 -1
- {chtypes-0.3.2 → chtypes-0.4.0}/pyproject.toml +1 -1
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/__init__.py +3 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/_document.py +11 -0
- chtypes-0.4.0/src/chtypes/_error_codes.py +153 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/_native.py +35 -2
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/registry.py +56 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/results.py +12 -0
- chtypes-0.4.0/tests/test_error_codes.py +250 -0
- chtypes-0.4.0/tests/test_partition_key.py +169 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/uv.lock +1 -1
- {chtypes-0.3.2 → chtypes-0.4.0}/.gitignore +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/LICENSE +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/__main__.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/_ed25519.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/_manifest.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/_rawjson.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/discover.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/errors.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/fetch.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/py.typed +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/transform.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/conftest.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_abi_revision.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_cli.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_csv_reader.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_discover_reconstruct.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_ed25519.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_errors.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_fetch.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_fetch_abi_revision.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_fetch_sh.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_formats_withnames.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_golden.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_lazy.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_parity.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_quoting_boundaries.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_rawjson.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_registry.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_results_rules.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_rows_export_with.py +0 -0
- {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_transform.py +0 -0
|
@@ -6,6 +6,21 @@ The four bindings in this repository are released together and give one answer,
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] — 2026-09-30
|
|
10
|
+
|
|
11
|
+
Speaks ABI revision 6 and refuses revision-5 artifacts. Revision 6 adds the error-code table and the partition key; revision-6 artifacts are published for every ClickHouse line this SDK supports, beside the revision-5 builds that 0.3.x keeps loading.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`Library.error_codes()`** — THIS build's own ClickHouse error-code table (`chs_error_codes`), as an `ErrorCodeTable`: `name(code) -> str | None`, `code(name) -> int | None`, `all()` (ascending) and iteration, over `ErrorCodeEntry(code, name)`. Per `Library` and never package-level, because the table moves between lines — one number names two different errors on 25.8 and 26.2. An unknown or negative code and an unknown name are `None`, never synthesized; names match exactly. Built on the first call and kept on success only: a `NULL` answer raises `ChtypesError` and is never cached; an artifact that predates the symbol raises `UnsupportedError`.
|
|
16
|
+
- **`Schema.set_partition_by(expr)`** — declare the table's partition key; `""` removes it. It follows `set_engine`'s sign rule, not `set_ttl`'s: `SchemaError` for the server's own CREATE-path refusal (e.g. 36 BAD_ARGUMENTS for a non-deterministic key, 549), `UnsupportedError` for a decline (-2 is a key the server accepts but this build will not evaluate).
|
|
17
|
+
- **`RowResult.partition_id`** and **`BatchResult.partition_count`**, both `None` unless the schema declared a key. A batch over `max_partitions_per_insert_block` is an ordinary `Outcome.REJECTED` with `err_code` 252.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Speaks ABI revision 6, and refuses revision-5 artifacts** — and a revision-5 binding refuses revision-6 ones. Revision 6 only ADDS `chs_error_codes` and `chs_schema_partition_by`; no existing declaration changed. It is still a new number, so a fetch after upgrading downloads revision-6 artifacts into their own `abi6` cache directory, and a directory holding only revision-5 artifacts is refused at load, naming both revisions. Revision-6 artifacts are published by the artifact producer; until they are, this binding has nothing to load.
|
|
22
|
+
- **A TTL on a schema with a `CHECK` constraint is now evaluated.** `Schema.set_ttl` on such a schema used to answer `UnsupportedError`; against revision-6 artifacts it answers as on any other schema, accepting the TTL or giving the server's own refusal. That matches a server, whose TTL validation does not read constraints. The change is in the artifacts, so it arrives with them.
|
|
23
|
+
|
|
9
24
|
## [0.3.2] — 2026-09-28
|
|
10
25
|
|
|
11
26
|
Speaks ABI revision 5, unchanged from 0.3.1: every artifact 0.3.1 loads, this release loads.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: chtypes
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python binding for the chtypes artifacts: ClickHouse's own type system, per version, behind the frozen chs_* C ABI
|
|
5
5
|
Project-URL: Homepage, https://github.com/wave-rf/chtypes
|
|
6
6
|
Project-URL: Repository, https://github.com/wave-rf/chtypes
|
|
@@ -84,7 +84,7 @@ A bad **row** is a verdict, not an exception: `outcome` becomes `Outcome.REJECTE
|
|
|
84
84
|
|
|
85
85
|
**`ctypes` releases the GIL for the whole duration of a foreign call**, so the GIL is not the exclusion. The package uses a writer-preferring readers-writer lock per loaded image plus one plain lock per `Schema`; `set_default_settings` and `close` take it exclusively, as the ABI requires. Measured under contention: 27,770 batch reads across 8 threads against 566 concurrent settings swaps, every answer byte-identical to the uncontended one.
|
|
86
86
|
|
|
87
|
-
**The INSERT column list (ABI revision 5) is a keyword-only `columns` on the same calls** — `schema.row(fmt, raw, columns=["id", "e"])`, and the same argument on `Schema.rows` and `Schema.parse_block`. `None` or an empty sequence is the no-list behavior of every earlier revision; a list makes the data supply exactly those columns, with a listed `EPHEMERAL` value read and in scope for the DEFAULTs that reference it but never stored.
|
|
87
|
+
**The INSERT column list (ABI revision 5) is a keyword-only `columns` on the same calls** — `schema.row(fmt, raw, columns=["id", "e"])`, and the same argument on `Schema.rows` and `Schema.parse_block`. `None` or an empty sequence is the no-list behavior of every earlier revision; a list makes the data supply exactly those columns, with a listed `EPHEMERAL` value read and in scope for the DEFAULTs that reference it but never stored. The column list needs an artifact of revision 5 or later, and this binding loads only artifacts at its own `chtypes.ABI_REVISION` — any other is refused at load, naming both revisions.
|
|
88
88
|
|
|
89
89
|
## Tests
|
|
90
90
|
|
|
@@ -66,7 +66,7 @@ A bad **row** is a verdict, not an exception: `outcome` becomes `Outcome.REJECTE
|
|
|
66
66
|
|
|
67
67
|
**`ctypes` releases the GIL for the whole duration of a foreign call**, so the GIL is not the exclusion. The package uses a writer-preferring readers-writer lock per loaded image plus one plain lock per `Schema`; `set_default_settings` and `close` take it exclusively, as the ABI requires. Measured under contention: 27,770 batch reads across 8 threads against 566 concurrent settings swaps, every answer byte-identical to the uncontended one.
|
|
68
68
|
|
|
69
|
-
**The INSERT column list (ABI revision 5) is a keyword-only `columns` on the same calls** — `schema.row(fmt, raw, columns=["id", "e"])`, and the same argument on `Schema.rows` and `Schema.parse_block`. `None` or an empty sequence is the no-list behavior of every earlier revision; a list makes the data supply exactly those columns, with a listed `EPHEMERAL` value read and in scope for the DEFAULTs that reference it but never stored.
|
|
69
|
+
**The INSERT column list (ABI revision 5) is a keyword-only `columns` on the same calls** — `schema.row(fmt, raw, columns=["id", "e"])`, and the same argument on `Schema.rows` and `Schema.parse_block`. `None` or an empty sequence is the no-list behavior of every earlier revision; a list makes the data supply exactly those columns, with a listed `EPHEMERAL` value read and in scope for the DEFAULTs that reference it but never stored. The column list needs an artifact of revision 5 or later, and this binding loads only artifacts at its own `chtypes.ABI_REVISION` — any other is refused at load, naming both revisions.
|
|
70
70
|
|
|
71
71
|
## Tests
|
|
72
72
|
|
|
@@ -43,6 +43,7 @@ from __future__ import annotations
|
|
|
43
43
|
from importlib.metadata import PackageNotFoundError as _PackageNotFound
|
|
44
44
|
from importlib.metadata import version as _package_version
|
|
45
45
|
|
|
46
|
+
from ._error_codes import ErrorCodeEntry, ErrorCodeTable
|
|
46
47
|
from ._native import ABI_REVISION
|
|
47
48
|
from ._rawjson import RawNumber, quote_bare_denormals
|
|
48
49
|
from .discover import (
|
|
@@ -165,6 +166,8 @@ __all__ = [
|
|
|
165
166
|
"Computed",
|
|
166
167
|
"DefaultKind",
|
|
167
168
|
"DiscoveredColumn",
|
|
169
|
+
"ErrorCodeEntry",
|
|
170
|
+
"ErrorCodeTable",
|
|
168
171
|
"Filter",
|
|
169
172
|
"FilterOutcome",
|
|
170
173
|
"FilterResult",
|
|
@@ -196,6 +196,10 @@ def _row_result(doc: RawObject) -> RowResult:
|
|
|
196
196
|
verdict_code = _count(doc, "verdict_code")
|
|
197
197
|
verdict_err = _text(doc, "verdict_err")
|
|
198
198
|
|
|
199
|
+
# `partition_id` (revision 6) is present exactly when the schema declared
|
|
200
|
+
# a partition key and this row would be stored; absent is None.
|
|
201
|
+
partition_id = doc.get("partition_id")
|
|
202
|
+
|
|
199
203
|
return RowResult(
|
|
200
204
|
outcome=outcome,
|
|
201
205
|
err_code=_count(doc, "code"),
|
|
@@ -209,6 +213,7 @@ def _row_result(doc: RawObject) -> RowResult:
|
|
|
209
213
|
verdict=verdict,
|
|
210
214
|
verdict_code=verdict_code,
|
|
211
215
|
verdict_err=verdict_err,
|
|
216
|
+
partition_id=partition_id if isinstance(partition_id, str) else None,
|
|
212
217
|
)
|
|
213
218
|
|
|
214
219
|
|
|
@@ -244,6 +249,7 @@ def parse_batch_document(raw: bytes, payload: bytes | None = None) -> BatchResul
|
|
|
244
249
|
verdict=row.verdict,
|
|
245
250
|
verdict_code=row.verdict_code,
|
|
246
251
|
verdict_err=row.verdict_err,
|
|
252
|
+
partition_id=row.partition_id,
|
|
247
253
|
)
|
|
248
254
|
)
|
|
249
255
|
transformed.extend(indexed)
|
|
@@ -297,6 +303,11 @@ def parse_batch_document(raw: bytes, payload: bytes | None = None) -> BatchResul
|
|
|
297
303
|
export_declined=_text(doc, "export_declined"),
|
|
298
304
|
rows_passed=_count(doc, "rows_passed"),
|
|
299
305
|
rows_cut=_count(doc, "rows_cut"),
|
|
306
|
+
# Revision 6: present exactly when the schema declared a partition
|
|
307
|
+
# key; absent is None, never a guessed 0.
|
|
308
|
+
partition_count=_count(doc, "partition_count")
|
|
309
|
+
if isinstance(doc.get("partition_count"), RawNumber)
|
|
310
|
+
else None,
|
|
300
311
|
)
|
|
301
312
|
|
|
302
313
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The error-code table (revision 6): one loaded build's own code -> name map.
|
|
2
|
+
|
|
3
|
+
There is no table in this package, and there must never be one. The table is a
|
|
4
|
+
property of the BUILD: codes join and leave between ClickHouse lines, and one
|
|
5
|
+
number can name two different errors on two lines (903 is LICENSE_EXPIRED on
|
|
6
|
+
25.3 and 25.8, absent on 25.10, and DISTRIBUTED_CACHE_REGISTRY_SHUTDOWN on 26.2
|
|
7
|
+
through 26.9). Every answer
|
|
8
|
+
therefore comes from `Library.error_codes()`, i.e. from `chs_error_codes` of
|
|
9
|
+
the library being asked, and `scripts/check-no-error-code-table.py` fails the
|
|
10
|
+
build if a literal code -> name table appears in any binding.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Callable, Iterator
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from .errors import ChtypesError
|
|
21
|
+
|
|
22
|
+
__all__ = ["ErrorCodeEntry", "ErrorCodeTable"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class ErrorCodeEntry:
|
|
27
|
+
"""One row of a build's error-code table: a ClickHouse error code and the
|
|
28
|
+
name THAT BUILD gives it."""
|
|
29
|
+
|
|
30
|
+
code: int
|
|
31
|
+
name: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ErrorCodeTable:
|
|
35
|
+
"""One loaded library's own error-code table, from `chs_error_codes` —
|
|
36
|
+
obtained from `Library.error_codes()` and valid for that library's
|
|
37
|
+
ClickHouse line only. Immutable, so safe to share between threads.
|
|
38
|
+
|
|
39
|
+
Lookups answer only what the build's own table holds: an unknown code, a
|
|
40
|
+
negative code (the ABI's -1 and -2 sentinels included — they are not
|
|
41
|
+
ClickHouse codes) or an unknown name is `None`, never a synthesized
|
|
42
|
+
spelling. Names match exactly and case-sensitively, as the server prints
|
|
43
|
+
them. Iterating yields the entries in ascending code order, as `all()`
|
|
44
|
+
returns them.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("_by_code", "_by_name", "_entries")
|
|
48
|
+
|
|
49
|
+
def __init__(self, entries: tuple[ErrorCodeEntry, ...]) -> None:
|
|
50
|
+
by_code: dict[int, str] = {}
|
|
51
|
+
by_name: dict[str, int] = {}
|
|
52
|
+
kept: list[ErrorCodeEntry] = []
|
|
53
|
+
for e in entries:
|
|
54
|
+
# Not a name, not a ClickHouse code, or a repeat: the first entry
|
|
55
|
+
# for a code or a name wins, and nothing else is invented.
|
|
56
|
+
if not e.name or e.code < 0 or e.code in by_code or e.name in by_name:
|
|
57
|
+
continue
|
|
58
|
+
by_code[e.code] = e.name
|
|
59
|
+
by_name[e.name] = e.code
|
|
60
|
+
kept.append(e)
|
|
61
|
+
self._entries = tuple(sorted(kept, key=lambda e: e.code))
|
|
62
|
+
self._by_code = by_code
|
|
63
|
+
self._by_name = by_name
|
|
64
|
+
|
|
65
|
+
def name(self, code: int) -> str | None:
|
|
66
|
+
"""The name this build gives `code`, or None."""
|
|
67
|
+
if code < 0:
|
|
68
|
+
return None
|
|
69
|
+
return self._by_code.get(code)
|
|
70
|
+
|
|
71
|
+
def code(self, name: str) -> int | None:
|
|
72
|
+
"""The code this build gives `name` — an exact, case-sensitive match —
|
|
73
|
+
or None."""
|
|
74
|
+
return self._by_name.get(name)
|
|
75
|
+
|
|
76
|
+
def all(self) -> tuple[ErrorCodeEntry, ...]:
|
|
77
|
+
"""Every entry, in ascending code order."""
|
|
78
|
+
return self._entries
|
|
79
|
+
|
|
80
|
+
def __iter__(self) -> Iterator[ErrorCodeEntry]:
|
|
81
|
+
return iter(self._entries)
|
|
82
|
+
|
|
83
|
+
def __len__(self) -> int:
|
|
84
|
+
return len(self._entries)
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return f"<chtypes.ErrorCodeTable {len(self._entries)} codes>"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_error_codes_document(raw: bytes) -> ErrorCodeTable:
|
|
91
|
+
"""Build a table from a `chs_error_codes` document,
|
|
92
|
+
`{"error_codes":[{"code":N,"name":"…"}, …]}`.
|
|
93
|
+
|
|
94
|
+
Unknown keys are ignored and an absent (or null) key is its default, like
|
|
95
|
+
every document this ABI hands back. A value of the wrong type is a bad
|
|
96
|
+
document, never a guess.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
doc = json.loads(raw)
|
|
100
|
+
except ValueError as exc:
|
|
101
|
+
raise ChtypesError(f"chtypes: bad chs_error_codes document: {exc}") from exc
|
|
102
|
+
if not isinstance(doc, dict):
|
|
103
|
+
raise ChtypesError("chtypes: bad chs_error_codes document: not a JSON object")
|
|
104
|
+
rows = doc.get("error_codes")
|
|
105
|
+
if rows is None:
|
|
106
|
+
rows = []
|
|
107
|
+
if not isinstance(rows, list):
|
|
108
|
+
raise ChtypesError("chtypes: bad chs_error_codes document: error_codes is not an array")
|
|
109
|
+
entries: list[ErrorCodeEntry] = []
|
|
110
|
+
for row in rows:
|
|
111
|
+
if not isinstance(row, dict):
|
|
112
|
+
raise ChtypesError("chtypes: bad chs_error_codes document: an entry is not an object")
|
|
113
|
+
code = row.get("code")
|
|
114
|
+
name = row.get("name")
|
|
115
|
+
if code is None:
|
|
116
|
+
code = 0
|
|
117
|
+
if name is None:
|
|
118
|
+
name = ""
|
|
119
|
+
if isinstance(code, bool) or not isinstance(code, int) or not isinstance(name, str):
|
|
120
|
+
raise ChtypesError(f"chtypes: bad chs_error_codes document: entry {row!r}")
|
|
121
|
+
entries.append(ErrorCodeEntry(code=code, name=name))
|
|
122
|
+
return ErrorCodeTable(tuple(entries))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _ErrorCodeCache:
|
|
126
|
+
"""One library's table once it has been built, and only then.
|
|
127
|
+
|
|
128
|
+
A NULL answer from `chs_error_codes` is a guarded exception inside the
|
|
129
|
+
library — transient by definition — so it raises `ChtypesError` and is NOT
|
|
130
|
+
remembered: the next call asks again. A missing symbol raises the decline
|
|
131
|
+
type and is not remembered either. Only a table that was actually built is
|
|
132
|
+
kept.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
__slots__ = ("_mu", "_table")
|
|
136
|
+
|
|
137
|
+
def __init__(self) -> None:
|
|
138
|
+
self._mu = threading.Lock()
|
|
139
|
+
self._table: ErrorCodeTable | None = None
|
|
140
|
+
|
|
141
|
+
def get(self, fetch: Callable[[], bytes | None]) -> ErrorCodeTable:
|
|
142
|
+
with self._mu:
|
|
143
|
+
if self._table is not None:
|
|
144
|
+
return self._table
|
|
145
|
+
raw = fetch()
|
|
146
|
+
if raw is None:
|
|
147
|
+
raise ChtypesError(
|
|
148
|
+
"chtypes: chs_error_codes returned no document (a guarded exception "
|
|
149
|
+
"inside the library); nothing was cached, so the next call asks again"
|
|
150
|
+
)
|
|
151
|
+
table = parse_error_codes_document(raw)
|
|
152
|
+
self._table = table
|
|
153
|
+
return table
|
|
@@ -78,6 +78,11 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
|
|
|
78
78
|
),
|
|
79
79
|
"chs_registered_families": (ctypes.c_void_p, []),
|
|
80
80
|
"chs_function_flags": (ctypes.c_void_p, []),
|
|
81
|
+
# Revision 6: the build's own error-code table, an owned JSON document.
|
|
82
|
+
# Optional: an artifact that predates it degrades to UnsupportedError at
|
|
83
|
+
# call time. NULL from a PRESENT symbol is a guarded exception, which is a
|
|
84
|
+
# different answer — see `error_codes`.
|
|
85
|
+
"chs_error_codes": (ctypes.c_void_p, []),
|
|
81
86
|
# settings_json + mode compile a column list under a DECLARED settings
|
|
82
87
|
# profile; NULL/"{}" settings_json and mode 0 answer identically to the
|
|
83
88
|
# pre-consolidation, settings-less compile — structurally, not just by
|
|
@@ -94,6 +99,9 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
|
|
|
94
99
|
[ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, _c_owned_p],
|
|
95
100
|
),
|
|
96
101
|
"chs_schema_ttl": (ctypes.c_int, [ctypes.c_void_p, ctypes.c_char_p, _c_owned_p]),
|
|
102
|
+
# Revision 6: the partition key — chs_schema_ttl's exact C shape. The SIGN
|
|
103
|
+
# rule its return follows is chs_schema_engine's (see Schema.set_partition_by).
|
|
104
|
+
"chs_schema_partition_by": (ctypes.c_int, [ctypes.c_void_p, ctypes.c_char_p, _c_owned_p]),
|
|
97
105
|
"chs_schema_column_count": (ctypes.c_int, [ctypes.c_void_p]),
|
|
98
106
|
"chs_schema_column_name": (ctypes.c_char_p, [ctypes.c_void_p, ctypes.c_int]),
|
|
99
107
|
"chs_schema_column_type": (ctypes.c_char_p, [ctypes.c_void_p, ctypes.c_int]),
|
|
@@ -189,7 +197,10 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
|
|
|
189
197
|
# (chs_block_parse / chs_block_free / chs_filter_eval).
|
|
190
198
|
# 5 = the explicit INSERT column list, 2026-09-15: chs_row, chs_rows and
|
|
191
199
|
# chs_block_parse each gained a trailing columns_json.
|
|
192
|
-
|
|
200
|
+
# 6 = the error-code table and the partition key: chs_error_codes and
|
|
201
|
+
# chs_schema_partition_by joined the surface. Purely additive, and still a
|
|
202
|
+
# new number: a revision-6 binding refuses a revision-5 artifact.
|
|
203
|
+
ABI_REVISION: Final = 6
|
|
193
204
|
|
|
194
205
|
_MANDATORY: Final = (
|
|
195
206
|
"chs_clickhouse_version",
|
|
@@ -217,7 +228,8 @@ class _RWLock:
|
|
|
217
228
|
|
|
218
229
|
**Readers** are every call that reaches `chs_row`, `chs_rows`,
|
|
219
230
|
`chs_schema_compile`, `chs_schema_engine`, `chs_schema_ttl`,
|
|
220
|
-
`chs_validate_type` and the
|
|
231
|
+
`chs_schema_partition_by`, `chs_validate_type`, `chs_error_codes` and the
|
|
232
|
+
column accessors. The C ABI contract
|
|
221
233
|
§Thread-safety declares those safe together **on distinct handles**, so
|
|
222
234
|
per-handle serialization is `Schema`'s own lock and not this one.
|
|
223
235
|
|
|
@@ -507,6 +519,16 @@ class NativeLibrary:
|
|
|
507
519
|
raw = self._take(fn())
|
|
508
520
|
return (raw or b"").decode("utf-8", "surrogateescape")
|
|
509
521
|
|
|
522
|
+
def error_codes(self) -> bytes | None:
|
|
523
|
+
"""The build's own error-code table as the raw JSON document, or None
|
|
524
|
+
when the library could not build it (a guarded exception — transient,
|
|
525
|
+
so the caller must not remember it). A missing symbol raises
|
|
526
|
+
`UnsupportedError`, which is a different answer: the artifact predates
|
|
527
|
+
revision 6."""
|
|
528
|
+
fn = self._need("chs_error_codes", "this artifact predates chs_error_codes (rebuild it)")
|
|
529
|
+
with self._lock.read():
|
|
530
|
+
return self._take(fn())
|
|
531
|
+
|
|
510
532
|
def function_flags(self) -> str:
|
|
511
533
|
"""The function-volatility TSV audit, verbatim. Requires chs_init."""
|
|
512
534
|
fn = self._need(
|
|
@@ -576,6 +598,17 @@ class NativeLibrary:
|
|
|
576
598
|
rc = int(fn(ctypes.c_void_p(handle), ttl_sql.encode(), ctypes.byref(err)))
|
|
577
599
|
return rc, self._take_err(err)
|
|
578
600
|
|
|
601
|
+
def schema_partition_by(self, handle: int, partition_by: str) -> tuple[int, str]:
|
|
602
|
+
"""(rc, err) from chs_schema_partition_by. The caller maps rc by the
|
|
603
|
+
SIGN rule chs_schema_engine uses, not chs_schema_ttl's."""
|
|
604
|
+
fn = self._need(
|
|
605
|
+
"chs_schema_partition_by", "this artifact predates chs_schema_partition_by (rebuild it)"
|
|
606
|
+
)
|
|
607
|
+
err = ctypes.c_void_p()
|
|
608
|
+
with self._lock.read():
|
|
609
|
+
rc = int(fn(ctypes.c_void_p(handle), partition_by.encode(), ctypes.byref(err)))
|
|
610
|
+
return rc, self._take_err(err)
|
|
611
|
+
|
|
579
612
|
def schema_columns(self, handle: int) -> list[tuple[str, str, str, str, bool]]:
|
|
580
613
|
"""(name, type, default_kind, default_expr, default_is_literal) per column."""
|
|
581
614
|
if not self.has_columns:
|
|
@@ -19,6 +19,7 @@ from types import TracebackType
|
|
|
19
19
|
from typing import Final
|
|
20
20
|
|
|
21
21
|
from ._document import parse_batch_document, parse_filter_document, parse_row_document
|
|
22
|
+
from ._error_codes import ErrorCodeTable, _ErrorCodeCache
|
|
22
23
|
from ._manifest import (
|
|
23
24
|
Manifest,
|
|
24
25
|
cache_registry_dir,
|
|
@@ -349,6 +350,38 @@ class Schema:
|
|
|
349
350
|
if rc != 0:
|
|
350
351
|
raise UnsupportedError(err or "TTL form not modeled by this build")
|
|
351
352
|
|
|
353
|
+
def set_partition_by(self, expr: str) -> None:
|
|
354
|
+
"""Declare the table's partition key — the `PARTITION BY` clause after
|
|
355
|
+
the engine, e.g. "toYYYYMM(ts)" or "(toDate(ts), tenant)"
|
|
356
|
+
(`chs_schema_partition_by`, revision 6).
|
|
357
|
+
|
|
358
|
+
The key is built by the server's own CREATE-path call over this
|
|
359
|
+
schema's columns, under the handle's compile profile. A second call
|
|
360
|
+
REPLACES the first; "" removes the declaration, and the schema then
|
|
361
|
+
answers exactly as one that never declared a key.
|
|
362
|
+
|
|
363
|
+
With a key declared, `row` and `rows` answer `RowResult.partition_id`
|
|
364
|
+
for every row that would be stored and `BatchResult.partition_count`
|
|
365
|
+
for the batch, and a body that would split into more partitions than
|
|
366
|
+
the call's `max_partitions_per_insert_block` allows is an ordinary
|
|
367
|
+
`Outcome.REJECTED` with `err_code` 252 (TOO_MANY_PARTS), the server's
|
|
368
|
+
own — a verdict, never an exception.
|
|
369
|
+
|
|
370
|
+
The return follows `set_engine`'s SIGN rule, not `set_ttl`'s: raises
|
|
371
|
+
`SchemaError` when the server's own CREATE path refuses the key (e.g.
|
|
372
|
+
36 BAD_ARGUMENTS for a non-deterministic key, 549
|
|
373
|
+
DATA_TYPE_CANNOT_BE_USED_IN_KEY, with the server's message), and
|
|
374
|
+
`UnsupportedError` when this build declines (-1, a guarded exception)
|
|
375
|
+
or the artifact predates the symbol. -2 is a key the server accepts but
|
|
376
|
+
this build will not evaluate; a non-deterministic key is the server's
|
|
377
|
+
own rejection, 36 BAD_ARGUMENTS.
|
|
378
|
+
"""
|
|
379
|
+
with self._mu:
|
|
380
|
+
rc, err = self._library._native.schema_partition_by(self._live(), expr)
|
|
381
|
+
if rc == 0:
|
|
382
|
+
return
|
|
383
|
+
raise _error_for(rc, err or "partition key refused")
|
|
384
|
+
|
|
352
385
|
# -- rows ---------------------------------------------------------------
|
|
353
386
|
|
|
354
387
|
def row(
|
|
@@ -823,6 +856,7 @@ class Library:
|
|
|
823
856
|
|
|
824
857
|
__slots__ = (
|
|
825
858
|
"_closed",
|
|
859
|
+
"_error_codes",
|
|
826
860
|
"_image_key",
|
|
827
861
|
"_native",
|
|
828
862
|
"abi_revision",
|
|
@@ -836,6 +870,9 @@ class Library:
|
|
|
836
870
|
self.path = path
|
|
837
871
|
self.manifest = manifest
|
|
838
872
|
self._closed = False
|
|
873
|
+
# This library's own error-code table, once built (see error_codes).
|
|
874
|
+
# Per Library and never shared: the table is a property of the build.
|
|
875
|
+
self._error_codes = _ErrorCodeCache()
|
|
839
876
|
self._native = NativeLibrary(path)
|
|
840
877
|
# The library names itself; nothing is inferred from the directory or
|
|
841
878
|
# the file name.
|
|
@@ -1013,6 +1050,25 @@ class Library:
|
|
|
1013
1050
|
"""
|
|
1014
1051
|
return [line for line in self._native.registered_families().split("\n") if line]
|
|
1015
1052
|
|
|
1053
|
+
def error_codes(self) -> ErrorCodeTable:
|
|
1054
|
+
"""THIS library's own error-code table (`chs_error_codes`, revision 6):
|
|
1055
|
+
every code the vendored ClickHouse names, with the name it gives it —
|
|
1056
|
+
the table the server's `system.errors` enumerates and the name it
|
|
1057
|
+
prints after "Code: N." in an exception message.
|
|
1058
|
+
|
|
1059
|
+
The table belongs to the build, not to this package: codes join and
|
|
1060
|
+
leave between lines, and one number can name different errors on two
|
|
1061
|
+
lines (903 differs between 25.8 and 26.2). Ask the library whose line
|
|
1062
|
+
you are answering for; there is no package-level table.
|
|
1063
|
+
|
|
1064
|
+
Built on the first call and kept for this library's life — the answer
|
|
1065
|
+
never changes for a loaded library. Only a table that was actually
|
|
1066
|
+
built is kept: a NULL answer (a guarded exception inside the library)
|
|
1067
|
+
raises `ChtypesError` and the next call asks again. Raises
|
|
1068
|
+
`UnsupportedError` when the artifact predates `chs_error_codes`.
|
|
1069
|
+
"""
|
|
1070
|
+
return self._error_codes.get(self._native.error_codes)
|
|
1071
|
+
|
|
1016
1072
|
def function_flags(self) -> str:
|
|
1017
1073
|
"""TSV audit of every registered function's volatility, VERBATIM: one
|
|
1018
1074
|
function per line, six tab-separated fields (`name`, `deterministic`,
|
|
@@ -492,6 +492,12 @@ class RowResult:
|
|
|
492
492
|
# parse error, reported as DECLINE). 0/"" otherwise.
|
|
493
493
|
verdict_code: int = 0
|
|
494
494
|
verdict_err: str = ""
|
|
495
|
+
# Revision 6: the partition this row lands in, as the loaded build's own
|
|
496
|
+
# MergeTreePartition::getID spells it — present only when the schema
|
|
497
|
+
# declared a partition key (`Schema.set_partition_by`) and the row would
|
|
498
|
+
# be stored; None otherwise. Grouping an accepted batch's `spans` by it
|
|
499
|
+
# gives per-partition bodies, each directly INSERT-able.
|
|
500
|
+
partition_id: str | None = None
|
|
495
501
|
|
|
496
502
|
@property
|
|
497
503
|
def accepted(self) -> bool:
|
|
@@ -596,6 +602,12 @@ class BatchResult:
|
|
|
596
602
|
# these being nonzero.
|
|
597
603
|
rows_passed: int = 0
|
|
598
604
|
rows_cut: int = 0
|
|
605
|
+
# Revision 6: the distinct partitions this batch's stored rows span —
|
|
606
|
+
# present only when the schema declared a partition key
|
|
607
|
+
# (`Schema.set_partition_by`); None otherwise. A batch over the call's
|
|
608
|
+
# max_partitions_per_insert_block is an ordinary REJECTED with err_code
|
|
609
|
+
# 252 (TOO_MANY_PARTS), the server's own.
|
|
610
|
+
partition_count: int | None = None
|
|
599
611
|
|
|
600
612
|
@property
|
|
601
613
|
def lossy_transforms(self) -> tuple[Transform, ...]:
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Revision 6's error-code table.
|
|
2
|
+
|
|
3
|
+
The first half needs no artifact: it parses documents in the shape
|
|
4
|
+
`chs_error_codes` returns through the SAME parser `Library.error_codes()` uses,
|
|
5
|
+
and drives the success-only cache with a stand-in for the native call. What it
|
|
6
|
+
pins is the binding's own contract — unknown is absent, `all()` is ascending,
|
|
7
|
+
names match exactly, a NULL answer is never remembered — and none of it is a
|
|
8
|
+
ClickHouse fact.
|
|
9
|
+
|
|
10
|
+
The second half asks a loaded revision-6 library and skips LOUDLY, by name,
|
|
11
|
+
without one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
import chtypes
|
|
23
|
+
from chtypes._error_codes import _ErrorCodeCache, parse_error_codes_document
|
|
24
|
+
|
|
25
|
+
# Out of order on purpose, with unknown keys at both levels, an entry with no
|
|
26
|
+
# name and a negative code — none of which may reach a lookup.
|
|
27
|
+
FAKE = b"""{
|
|
28
|
+
"error_codes": [
|
|
29
|
+
{"code": 252, "name": "TOO_MANY_PARTS", "since": "whatever"},
|
|
30
|
+
{"code": 0, "name": "OK"},
|
|
31
|
+
{"code": 1, "name": "UNSUPPORTED_METHOD"},
|
|
32
|
+
{"code": 7, "name": ""},
|
|
33
|
+
{"code": -2, "name": "NOT_A_CLICKHOUSE_CODE"},
|
|
34
|
+
{"code": 47, "name": "UNKNOWN_IDENTIFIER"}
|
|
35
|
+
],
|
|
36
|
+
"generator": "ignored"
|
|
37
|
+
}"""
|
|
38
|
+
|
|
39
|
+
KNOWN = {0: "OK", 1: "UNSUPPORTED_METHOD", 47: "UNKNOWN_IDENTIFIER", 252: "TOO_MANY_PARTS"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_lookups_answer_the_document_and_nothing_else() -> None:
|
|
43
|
+
table = parse_error_codes_document(FAKE)
|
|
44
|
+
for code, name in KNOWN.items():
|
|
45
|
+
assert table.name(code) == name
|
|
46
|
+
assert table.code(name) == code
|
|
47
|
+
# Absent, never synthesized: an unknown code, the unnamed entry, a
|
|
48
|
+
# negative code even though the document carried one, the ABI sentinels.
|
|
49
|
+
for code in (2, 7, 999_999, -1, -2, -3):
|
|
50
|
+
assert table.name(code) is None, code
|
|
51
|
+
# Exact and case-sensitive; no trimming, no folding.
|
|
52
|
+
for name in (
|
|
53
|
+
"too_many_parts",
|
|
54
|
+
"Too_Many_Parts",
|
|
55
|
+
" TOO_MANY_PARTS",
|
|
56
|
+
"TOO_MANY_PARTS ",
|
|
57
|
+
"",
|
|
58
|
+
"NOT_A_CLICKHOUSE_CODE",
|
|
59
|
+
"NO_SUCH_ERROR",
|
|
60
|
+
):
|
|
61
|
+
assert table.code(name) is None, name
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_all_and_iteration_are_ascending() -> None:
|
|
65
|
+
table = parse_error_codes_document(FAKE)
|
|
66
|
+
want = tuple(chtypes.ErrorCodeEntry(code=c, name=n) for c, n in sorted(KNOWN.items()))
|
|
67
|
+
assert table.all() == want
|
|
68
|
+
assert tuple(table) == want
|
|
69
|
+
assert len(table) == len(want)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_the_first_entry_wins_on_a_repeat() -> None:
|
|
73
|
+
table = parse_error_codes_document(
|
|
74
|
+
b'{"error_codes":[{"code":5,"name":"A_NAME"},{"code":5,"name":"B_NAME"},'
|
|
75
|
+
b'{"code":6,"name":"A_NAME"}]}'
|
|
76
|
+
)
|
|
77
|
+
assert table.name(5) == "A_NAME"
|
|
78
|
+
assert table.name(6) is None
|
|
79
|
+
assert len(table.all()) == 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize(
|
|
83
|
+
"doc",
|
|
84
|
+
[b"{}", b'{"error_codes":null}', b'{"error_codes":[]}', b'{"something_else":[1,2,3]}'],
|
|
85
|
+
)
|
|
86
|
+
def test_absent_keys_are_an_empty_table(doc: bytes) -> None:
|
|
87
|
+
table = parse_error_codes_document(doc)
|
|
88
|
+
assert table.all() == ()
|
|
89
|
+
assert table.name(0) is None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize(
|
|
93
|
+
"doc",
|
|
94
|
+
# The same list every binding's bad-document test runs: a truncated
|
|
95
|
+
# document, a top-level value that is not an object, error_codes that is not
|
|
96
|
+
# an array, an entry that is not an object, and a field of the wrong type.
|
|
97
|
+
[
|
|
98
|
+
b'{"error_codes":[',
|
|
99
|
+
b"[]",
|
|
100
|
+
b"null",
|
|
101
|
+
b"42",
|
|
102
|
+
b'"x"',
|
|
103
|
+
b'{"error_codes":{}}',
|
|
104
|
+
b'{"error_codes":[null]}',
|
|
105
|
+
b'{"error_codes":[[252,"X"]]}',
|
|
106
|
+
b'{"error_codes":[{"code":"252","name":"X"}]}',
|
|
107
|
+
b'{"error_codes":[{"code":252.5,"name":"X"}]}',
|
|
108
|
+
b'{"error_codes":[{"code":1,"name":5}]}',
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
def test_a_bad_document_is_the_general_error(doc: bytes) -> None:
|
|
112
|
+
with pytest.raises(chtypes.ChtypesError) as info:
|
|
113
|
+
parse_error_codes_document(doc)
|
|
114
|
+
assert not isinstance(info.value, (chtypes.SchemaError, chtypes.UnsupportedError))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_the_cache_keeps_a_built_table_and_nothing_else() -> None:
|
|
118
|
+
cache = _ErrorCodeCache()
|
|
119
|
+
calls: list[str] = []
|
|
120
|
+
|
|
121
|
+
def null() -> bytes | None:
|
|
122
|
+
calls.append("null")
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def missing() -> bytes | None:
|
|
126
|
+
calls.append("missing")
|
|
127
|
+
raise chtypes.UnsupportedError("this artifact predates chs_error_codes (rebuild it)")
|
|
128
|
+
|
|
129
|
+
def built() -> bytes | None:
|
|
130
|
+
calls.append("built")
|
|
131
|
+
return FAKE
|
|
132
|
+
|
|
133
|
+
# A NULL answer: the general error, not a decline, and not remembered.
|
|
134
|
+
with pytest.raises(chtypes.ChtypesError) as info:
|
|
135
|
+
cache.get(null)
|
|
136
|
+
assert not isinstance(info.value, chtypes.UnsupportedError)
|
|
137
|
+
# A missing symbol: the decline type, not remembered either.
|
|
138
|
+
with pytest.raises(chtypes.UnsupportedError):
|
|
139
|
+
cache.get(missing)
|
|
140
|
+
first = cache.get(built)
|
|
141
|
+
assert first.name(252) == "TOO_MANY_PARTS"
|
|
142
|
+
|
|
143
|
+
def never() -> bytes | None:
|
|
144
|
+
raise AssertionError("the cache asked the library again after a table was built")
|
|
145
|
+
|
|
146
|
+
assert cache.get(never) is first
|
|
147
|
+
assert calls == ["null", "missing", "built"]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_no_package_level_table() -> None:
|
|
151
|
+
"""The table hangs off a Library; the package carries none."""
|
|
152
|
+
assert not hasattr(chtypes, "ERROR_CODES")
|
|
153
|
+
assert not hasattr(chtypes, "error_codes")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------- through the ABI fixture
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_error_codes_through_the_abi_fixture(isolated_search_path: Path) -> None:
|
|
160
|
+
"""`chs_error_codes` through the REAL ctypes path, on the at-revision stub
|
|
161
|
+
the ABI fixture builds from THIS header — the one library that exists
|
|
162
|
+
before any revision-6 artifact does, and the one every pull request's
|
|
163
|
+
abi-fixtures job loads. The stub answers by return type, not with a table,
|
|
164
|
+
so this pins the wiring rather than an answer: the symbol resolves (never
|
|
165
|
+
the decline type, never a refusal), and whatever it answers takes the rule
|
|
166
|
+
— a NULL is the general error and is not kept, a document is kept.
|
|
167
|
+
|
|
168
|
+
Nothing here closes the library: reopening after a close is measured to
|
|
169
|
+
segfault (docs/reference/bindings.md §Teardown)."""
|
|
170
|
+
env = os.environ.get("CHTYPES_ABI_FIXTURES")
|
|
171
|
+
if not env:
|
|
172
|
+
pytest.skip(
|
|
173
|
+
"no ABI revision fixture: $CHTYPES_ABI_FIXTURES is unset (build one with "
|
|
174
|
+
"abi-revision/gen.py build --out DIR --header include/chtypes.h)"
|
|
175
|
+
)
|
|
176
|
+
root = Path(env)
|
|
177
|
+
doc = json.loads((root / "fixture.json").read_text())
|
|
178
|
+
library = chtypes.Registry(root / "at-revision").for_version(doc["clickhouse_minor"])
|
|
179
|
+
try:
|
|
180
|
+
first = library.error_codes()
|
|
181
|
+
except (chtypes.UnsupportedError, chtypes.SchemaError) as exc:
|
|
182
|
+
pytest.fail(
|
|
183
|
+
f"error_codes() on a stub built from this header raised {exc!r}; the symbol is "
|
|
184
|
+
f"declared there, so neither a decline nor a refusal is a possible answer"
|
|
185
|
+
)
|
|
186
|
+
except chtypes.ChtypesError as exc:
|
|
187
|
+
assert "returned no document" in str(exc), exc
|
|
188
|
+
# Not kept: the next call asks the library again, and gets NULL again.
|
|
189
|
+
with pytest.raises(chtypes.ChtypesError, match="returned no document"):
|
|
190
|
+
library.error_codes()
|
|
191
|
+
else:
|
|
192
|
+
assert library.error_codes() is first, "a built table was not kept"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------- with artifacts
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _rev6_libraries(registry: chtypes.Registry) -> list[chtypes.Library]:
|
|
199
|
+
libs: list[chtypes.Library] = []
|
|
200
|
+
for version in registry.versions():
|
|
201
|
+
try:
|
|
202
|
+
lib = registry.for_version(version)
|
|
203
|
+
except chtypes.ChtypesError:
|
|
204
|
+
continue
|
|
205
|
+
if lib.abi_revision >= 6:
|
|
206
|
+
libs.append(lib)
|
|
207
|
+
if not libs:
|
|
208
|
+
pytest.skip(
|
|
209
|
+
f"{registry.directory} holds no ABI revision-6 artifact: every case here needs one — "
|
|
210
|
+
f"fetch one with `scripts/fetch.sh` (docs/guides/fetch.md)"
|
|
211
|
+
)
|
|
212
|
+
return libs
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_error_codes_from_the_loaded_library(registry: chtypes.Registry) -> None:
|
|
216
|
+
for lib in _rev6_libraries(registry):
|
|
217
|
+
table = lib.error_codes()
|
|
218
|
+
entries = table.all()
|
|
219
|
+
assert entries, lib.minor
|
|
220
|
+
codes = [e.code for e in entries]
|
|
221
|
+
assert codes == sorted(set(codes)), f"{lib.minor}: all() is not strictly ascending"
|
|
222
|
+
for e in entries:
|
|
223
|
+
assert table.name(e.code) == e.name
|
|
224
|
+
assert table.code(e.name) == e.code
|
|
225
|
+
assert table.name(252) == "TOO_MANY_PARTS", lib.minor
|
|
226
|
+
assert table.name(-1) is None and table.name(-2) is None
|
|
227
|
+
assert lib.error_codes() is table, f"{lib.minor}: the table was not kept"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# The header's own example of one number naming different errors on different
|
|
231
|
+
# lines, per the producer's measurement of every served line. None is ABSENT (on
|
|
232
|
+
# 25.10), never a synthesized name. A line not named here (26.10 and later) has
|
|
233
|
+
# no expectation and is not checked.
|
|
234
|
+
ERROR_CODE_903: dict[str, str | None] = {
|
|
235
|
+
"25.3": "LICENSE_EXPIRED",
|
|
236
|
+
"25.8": "LICENSE_EXPIRED",
|
|
237
|
+
"25.10": None,
|
|
238
|
+
**{f"26.{m}": "DISTRIBUTED_CACHE_REGISTRY_SHUTDOWN" for m in range(2, 10)},
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_code_903_differs_across_lines(registry: chtypes.Registry) -> None:
|
|
243
|
+
checked = 0
|
|
244
|
+
for lib in _rev6_libraries(registry):
|
|
245
|
+
if lib.minor not in ERROR_CODE_903:
|
|
246
|
+
continue
|
|
247
|
+
assert lib.error_codes().name(903) == ERROR_CODE_903[lib.minor], lib.minor
|
|
248
|
+
checked += 1
|
|
249
|
+
if not checked:
|
|
250
|
+
pytest.skip("no loaded revision-6 line has a documented expectation for code 903")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Revision 6's partition key: the two result fields, the setter's sign rule,
|
|
2
|
+
and — with a revision-6 artifact — the key end to end.
|
|
3
|
+
|
|
4
|
+
The unit half parses documents through the SAME parsers every `row`/`rows`
|
|
5
|
+
call uses, and drives `Schema.set_partition_by` through a stand-in native
|
|
6
|
+
layer, so the rc -> exception mapping that ships is the one tested. The
|
|
7
|
+
artifact half skips LOUDLY, by name, without a revision-6 registry.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import weakref
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
import chtypes
|
|
18
|
+
from chtypes._document import parse_batch_document, parse_row_document
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_partition_fields_parse() -> None:
|
|
22
|
+
batch = parse_batch_document(
|
|
23
|
+
b'{"outcome":"accepted","rows_read":3,"partition_count":2,"rows":['
|
|
24
|
+
b'{"outcome":"accepted","cols":[],"partition_id":"202601"},'
|
|
25
|
+
b'{"outcome":"accepted","cols":[],"partition_id":"202602"},'
|
|
26
|
+
b'{"outcome":"rejected","code":27,"err":"x","cols":[]}]}'
|
|
27
|
+
)
|
|
28
|
+
assert batch.partition_count == 2
|
|
29
|
+
assert [r.partition_id for r in batch.rows] == ["202601", "202602", None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_partition_fields_absent_are_none() -> None:
|
|
33
|
+
batch = parse_batch_document(
|
|
34
|
+
b'{"outcome":"accepted","rows":[{"outcome":"accepted","cols":[]}]}'
|
|
35
|
+
)
|
|
36
|
+
assert batch.partition_count is None
|
|
37
|
+
assert batch.rows[0].partition_id is None
|
|
38
|
+
row = parse_row_document(b'{"outcome":"accepted","cols":[],"partition_id":"all"}')
|
|
39
|
+
assert row.partition_id == "all"
|
|
40
|
+
# A present zero is a real answer (a key, and no stored row), not absence.
|
|
41
|
+
assert parse_batch_document(b'{"outcome":"accepted","partition_count":0}').partition_count == 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_too_many_parts_is_an_ordinary_rejection() -> None:
|
|
45
|
+
batch = parse_batch_document(
|
|
46
|
+
b'{"outcome":"rejected","code":252,"err":"Too many partitions for single INSERT block",'
|
|
47
|
+
b'"rows_read":2,"rows":[{"outcome":"accepted","cols":[],"partition_id":"1"},'
|
|
48
|
+
b'{"outcome":"accepted","cols":[],"partition_id":"2"}]}'
|
|
49
|
+
)
|
|
50
|
+
assert batch.outcome is chtypes.Outcome.REJECTED
|
|
51
|
+
assert batch.err_code == 252
|
|
52
|
+
assert len(batch.rows) == 2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _StubNative:
|
|
56
|
+
"""Just enough of NativeLibrary for Schema.set_partition_by to reach."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, rc: int, err: str) -> None:
|
|
59
|
+
self.rc, self.err = rc, err
|
|
60
|
+
self.calls: list[str] = []
|
|
61
|
+
|
|
62
|
+
def schema_partition_by(self, handle: int, expr: str) -> tuple[int, str]:
|
|
63
|
+
self.calls.append(expr)
|
|
64
|
+
return self.rc, self.err
|
|
65
|
+
|
|
66
|
+
def schema_free(self, handle: int) -> None:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _StubLibrary:
|
|
71
|
+
def __init__(self, native: _StubNative) -> None:
|
|
72
|
+
self._native = native
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _schema_over(native: _StubNative) -> chtypes.Schema:
|
|
76
|
+
"""A Schema whose native layer is the stub: the real method, a fake C side."""
|
|
77
|
+
schema = object.__new__(chtypes.Schema)
|
|
78
|
+
schema._library = _StubLibrary(native)
|
|
79
|
+
schema._handle = 1
|
|
80
|
+
schema._mu = threading.Lock()
|
|
81
|
+
schema._filters = weakref.WeakSet()
|
|
82
|
+
schema._blocks = weakref.WeakSet()
|
|
83
|
+
return schema
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_set_partition_by_follows_the_engine_sign_rule() -> None:
|
|
87
|
+
ok = _StubNative(0, "")
|
|
88
|
+
_schema_over(ok).set_partition_by("toYYYYMM(ts)")
|
|
89
|
+
assert ok.calls == ["toYYYYMM(ts)"]
|
|
90
|
+
|
|
91
|
+
# Positive codes are the server's own refusal: 36 BAD_ARGUMENTS is what a
|
|
92
|
+
# non-deterministic key gets on every served line, 549 a key over a type
|
|
93
|
+
# the line will not key on.
|
|
94
|
+
for rc, expr in ((36, "rand()"), (549, "m")):
|
|
95
|
+
with pytest.raises(chtypes.SchemaError) as refused:
|
|
96
|
+
_schema_over(_StubNative(rc, "the server's own message")).set_partition_by(expr)
|
|
97
|
+
assert refused.value.code == rc
|
|
98
|
+
assert "the server's own message" in str(refused.value)
|
|
99
|
+
|
|
100
|
+
# Negative codes are declines: -2 a key the server accepts but this build
|
|
101
|
+
# will not evaluate, -1 a guarded exception.
|
|
102
|
+
for rc in (-1, -2):
|
|
103
|
+
with pytest.raises(chtypes.UnsupportedError) as declined:
|
|
104
|
+
_schema_over(_StubNative(rc, "why")).set_partition_by("k")
|
|
105
|
+
assert not isinstance(declined.value, chtypes.SchemaError)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_set_partition_by_empty_string_is_passed_through() -> None:
|
|
109
|
+
native = _StubNative(0, "")
|
|
110
|
+
_schema_over(native).set_partition_by("")
|
|
111
|
+
assert native.calls == [""]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------- with artifacts
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _rev6_libraries(registry: chtypes.Registry) -> list[chtypes.Library]:
|
|
118
|
+
libs: list[chtypes.Library] = []
|
|
119
|
+
for version in registry.versions():
|
|
120
|
+
try:
|
|
121
|
+
lib = registry.for_version(version)
|
|
122
|
+
except chtypes.ChtypesError:
|
|
123
|
+
continue
|
|
124
|
+
if lib.abi_revision >= 6:
|
|
125
|
+
libs.append(lib)
|
|
126
|
+
if not libs:
|
|
127
|
+
pytest.skip(
|
|
128
|
+
f"{registry.directory} holds no ABI revision-6 artifact: every case here needs one — "
|
|
129
|
+
f"fetch one with `scripts/fetch.sh` (docs/guides/fetch.md)"
|
|
130
|
+
)
|
|
131
|
+
return libs
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
BODY = (
|
|
135
|
+
b'{"ts":"2026-01-15 10:00:00","tenant":"a"}\n'
|
|
136
|
+
b'{"ts":"2026-01-20 10:00:00","tenant":"b"}\n'
|
|
137
|
+
b'{"ts":"2026-02-01 10:00:00","tenant":"a"}\n'
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_partition_key_end_to_end(registry: chtypes.Registry) -> None:
|
|
142
|
+
for lib in _rev6_libraries(registry):
|
|
143
|
+
with lib.compile_ddl("ts DateTime, tenant String") as schema:
|
|
144
|
+
plain = schema.rows(chtypes.Format.JSON_EACH_ROW, BODY)
|
|
145
|
+
assert plain.partition_count is None and plain.rows[0].partition_id is None
|
|
146
|
+
|
|
147
|
+
schema.set_partition_by("toYYYYMM(ts)")
|
|
148
|
+
keyed = schema.rows(chtypes.Format.JSON_EACH_ROW, BODY)
|
|
149
|
+
assert keyed.outcome is chtypes.Outcome.ACCEPTED, lib.minor
|
|
150
|
+
assert keyed.partition_count == 2, lib.minor
|
|
151
|
+
p0, p1, p2 = (r.partition_id for r in keyed.rows)
|
|
152
|
+
assert p0 and p0 == p1 and p0 != p2, (lib.minor, p0, p1, p2)
|
|
153
|
+
|
|
154
|
+
over = schema.rows(
|
|
155
|
+
chtypes.Format.JSON_EACH_ROW, BODY, {"max_partitions_per_insert_block": "1"}
|
|
156
|
+
)
|
|
157
|
+
assert over.outcome is chtypes.Outcome.REJECTED, lib.minor
|
|
158
|
+
assert over.err_code == 252, lib.minor
|
|
159
|
+
assert len(over.rows) == 3, lib.minor
|
|
160
|
+
|
|
161
|
+
schema.set_partition_by("")
|
|
162
|
+
cleared = schema.rows(chtypes.Format.JSON_EACH_ROW, BODY)
|
|
163
|
+
assert cleared.partition_count is None and cleared.rows[0].partition_id is None
|
|
164
|
+
|
|
165
|
+
# A non-deterministic key is the server's own rejection — 36
|
|
166
|
+
# BAD_ARGUMENTS on every served line — not a decline.
|
|
167
|
+
with pytest.raises(chtypes.SchemaError) as refused:
|
|
168
|
+
schema.set_partition_by("rand()")
|
|
169
|
+
assert refused.value.code == 36, lib.minor
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|