clickhouse-orm 3.2.0__tar.gz → 4.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/PKG-INFO +4 -1
  2. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/clickhouse_orm/__init__.py +3 -0
  3. clickhouse_orm-4.0.0/clickhouse_orm/codec.py +260 -0
  4. clickhouse_orm-4.0.0/clickhouse_orm/compiler.py +85 -0
  5. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/clickhouse_orm/database.py +120 -169
  6. clickhouse_orm-4.0.0/clickhouse_orm/driver.py +119 -0
  7. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/clickhouse_orm/engines.py +38 -29
  8. clickhouse_orm-4.0.0/clickhouse_orm/exceptions.py +79 -0
  9. clickhouse_orm-4.0.0/clickhouse_orm/executor.py +59 -0
  10. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/clickhouse_orm/fields.py +182 -39
  11. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/clickhouse_orm/migrations.py +11 -7
  12. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/clickhouse_orm/models.py +51 -42
  13. clickhouse_orm-4.0.0/clickhouse_orm/native.py +381 -0
  14. clickhouse_orm-4.0.0/clickhouse_orm/params.py +161 -0
  15. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/clickhouse_orm/query.py +98 -22
  16. clickhouse_orm-4.0.0/clickhouse_orm/utils.py +276 -0
  17. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/pyproject.toml +5 -1
  18. clickhouse_orm-3.2.0/clickhouse_orm/utils.py +0 -176
  19. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/LICENSE +0 -0
  20. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/README.md +0 -0
  21. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/clickhouse_orm/funcs.py +0 -0
  22. {clickhouse_orm-3.2.0 → clickhouse_orm-4.0.0}/clickhouse_orm/system_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clickhouse_orm
3
- Version: 3.2.0
3
+ Version: 4.0.0
4
4
  Summary: A simple ORM for working with the Clickhouse database. Maintainance fork of infi.clickhouse_orm.
5
5
  Author-email: Oliver Margetts <oliver.margetts@gmail.com>
6
6
  Description-Content-Type: text/markdown
@@ -18,12 +18,15 @@ Classifier: Topic :: Database
18
18
  License-File: LICENSE
19
19
  Requires-Dist: requests
20
20
  Requires-Dist: pytz
21
+ Requires-Dist: clickhouse-driver==0.2.10 ; extra == "dev"
21
22
  Requires-Dist: docker==7.1.0 ; extra == "dev"
22
23
  Requires-Dist: pytest==9.0.2 ; extra == "dev"
23
24
  Requires-Dist: ruff==0.14.14 ; extra == "dev"
25
+ Requires-Dist: clickhouse-driver>=0.2.7 ; extra == "native"
24
26
  Project-URL: Homepage, https://github.com/SuadeLabs/clickhouse_orm
25
27
  Project-URL: Repository, https://github.com/SuadeLabs/clickhouse_orm
26
28
  Provides-Extra: dev
29
+ Provides-Extra: native
27
30
 
28
31
  A fork of [infi.clikchouse_orm](https://github.com/Infinidat/infi.clickhouse_orm) aimed at more frequent maintenance and bugfixes.
29
32
 
@@ -2,8 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  from inspect import isclass
4
4
 
5
+ from .codec import * # noqa: F401, F403
6
+ from .compiler import * # noqa: F401, F403
5
7
  from .database import * # noqa: F401, F403
6
8
  from .engines import * # noqa: F401, F403
9
+ from .executor import * # noqa: F401, F403
7
10
  from .fields import * # noqa: F401, F403
8
11
  from .funcs import * # noqa: F401, F403
9
12
  from .migrations import * # noqa: F401, F403
@@ -0,0 +1,260 @@
1
+ """A codec converts model instances to and from the wire format used to exchange data with ClickHouse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ import codecs
7
+ import datetime
8
+ import re
9
+ from io import BytesIO
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import pytz
13
+
14
+ from .compiler import quote_identifier
15
+ from .fields import BaseEnumField
16
+ from .models import Model, ModelBase
17
+ from .utils import parse_array, parse_tsv, parse_tuple_type, split_type_args, unescape
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable, Iterable, Iterator
21
+
22
+ #: A column description: its name and ClickHouse type, e.g. `("id", "UInt64")`.
23
+ Column = tuple[str, str]
24
+
25
+
26
+ class RowResult:
27
+ """
28
+ The result of a query whose rows are returned as plain tuples rather than model instances.
29
+
30
+ The layout matches `clickhouse_driver.Client.execute(..., with_column_types=True)`:
31
+ `columns` is a list of `(name, type)` pairs, and iterating yields one tuple per row.
32
+ Rows are streamed from the server, so the result can only be iterated once.
33
+ """
34
+
35
+ def __init__(self, columns: list[Column], rows: Iterator[tuple[Any, ...]]):
36
+ self.columns = columns
37
+ self._rows = rows
38
+
39
+ @property
40
+ def column_names(self) -> list[str]:
41
+ """The names of the result columns, in order. Names are not necessarily unique."""
42
+ return [name for name, _ in self.columns]
43
+
44
+ def __iter__(self) -> Iterator[tuple[Any, ...]]:
45
+ return self._rows
46
+
47
+ def __repr__(self):
48
+ return f"<{self.__class__.__name__} columns={self.columns!r}>"
49
+
50
+
51
+ class Codec(abc.ABC):
52
+ """
53
+ Base class for codecs. A codec is paired with a driver (see `Driver.codec`): it builds the statements whose data
54
+ it encodes, and decodes the responses returned by the driver's `send`.
55
+ """
56
+
57
+ #: The ClickHouse format name appended to SELECT queries in a `FORMAT` clause, or `None` to send queries as is.
58
+ select_format: str | None
59
+
60
+ @abc.abstractmethod
61
+ def encode_inserts(
62
+ self, model_class: type[Model], instances: Iterable[Model], batch_size: int = 1000
63
+ ) -> Iterator[tuple[str, Any]]:
64
+ """
65
+ Serialises model instances for insertion. Yields `(statement, data)` pairs, each to be sent to the driver
66
+ as `send(statement, data=data)`. Statements use the `$table` placeholder for the model's table.
67
+
68
+ - `model_class`: the model class of all the instances.
69
+ - `instances`: the instances to serialise.
70
+ - `batch_size`: the maximum number of instances per chunk of data, for codecs which send data in chunks.
71
+ """
72
+
73
+ @abc.abstractmethod
74
+ def decode(
75
+ self,
76
+ response: Any,
77
+ model_class: type[Model] | None = None,
78
+ timezone: datetime.tzinfo = pytz.utc,
79
+ ) -> Iterator[Model]:
80
+ """
81
+ Deserialises the response to a SELECT query into model instances.
82
+
83
+ - `response`: the response returned by the driver's `send`.
84
+ - `model_class`: the model class matching the query's columns,
85
+ or `None` for getting back instances of an ad-hoc model.
86
+ - `timezone`: the server's timezone, passed to the `to_python` of custom fields.
87
+
88
+ Datetimes are converted according to their column's type, as in `decode_rows`, before being assigned.
89
+ """
90
+
91
+ @abc.abstractmethod
92
+ def decode_rows(self, response: Any, timezone: datetime.tzinfo = pytz.utc) -> RowResult:
93
+ """
94
+ Deserialises the response to a SELECT query into a `RowResult`.
95
+
96
+ Values use the same Python types as `clickhouse_driver`, so that results do not depend on the driver.
97
+ In particular, `DateTime` columns without an explicit timezone are returned as naive datetimes (wall-clock
98
+ times in the server's timezone), columns with a timezone as aware datetimes in that timezone, and enums
99
+ as their names.
100
+
101
+ - `response`: the response returned by the driver's `send`.
102
+ - `timezone`: the server's timezone.
103
+ """
104
+
105
+
106
+ class TSVCodec(Codec):
107
+ """
108
+ A codec using ClickHouse's tab-separated formats: `TabSeparatedWithNamesAndTypes` for reading,
109
+ and `TabSeparated` (or `TSKV` for models with function expressions as defaults) for writing.
110
+ Responses must provide `iter_lines()` (see `DriverResponse`), and insert data is sent as chunks of bytes.
111
+ """
112
+
113
+ select_format = "TabSeparatedWithNamesAndTypes"
114
+
115
+ def insert_format(self, model_class: type[Model]) -> str:
116
+ """Returns the ClickHouse format name used when inserting `model_class` instances."""
117
+ # TSKV lets ClickHouse evaluate defaults for fields omitted from the row
118
+ return "TSKV" if model_class.has_funcs_as_defaults() else "TabSeparated"
119
+
120
+ def encode_inserts(self, model_class, instances, batch_size=1000):
121
+ fields_list = ",".join(quote_identifier(name) for name in model_class.fields(writable=True))
122
+ statement = "INSERT INTO $table (%s) FORMAT %s" % (fields_list, self.insert_format(model_class))
123
+ yield statement, self.encode(model_class, instances, batch_size)
124
+
125
+ def encode(self, model_class: type[Model], instances: Iterable[Model], batch_size: int = 1000) -> Iterator[bytes]:
126
+ """Serialises model instances into chunks of at most `batch_size` lines in the model's insert format."""
127
+ buf = BytesIO()
128
+ lines = 0
129
+ for instance in instances:
130
+ buf.write(instance.to_db_string())
131
+ lines += 1
132
+ if lines >= batch_size:
133
+ yield buf.getvalue()
134
+ buf = BytesIO()
135
+ lines = 0
136
+ if lines:
137
+ yield buf.getvalue()
138
+
139
+ def decode(self, response, model_class=None, timezone=pytz.utc):
140
+ lines = response.iter_lines()
141
+ field_names = parse_tsv(next(lines))
142
+ field_types = parse_tsv(next(lines))
143
+ model_class = model_class or ModelBase.create_ad_hoc_model(zip(field_names, field_types))
144
+ parsers = [_cell_parser(db_type, timezone) for db_type in field_types]
145
+ for line in lines:
146
+ # skip blank line left by WITH TOTALS modifier
147
+ if line:
148
+ cells = line.split(b"\t")
149
+ yield model_class(**{name: parse(cell) for name, parse, cell in zip(field_names, parsers, cells)})
150
+
151
+ def decode_rows(self, response, timezone=pytz.utc):
152
+ lines = response.iter_lines()
153
+ columns = list(zip(parse_tsv(next(lines)), parse_tsv(next(lines))))
154
+ converters = [(_row_converter(db_type, timezone), _is_quoted_type(db_type)) for _, db_type in columns]
155
+ return RowResult(columns, self._iter_rows(lines, converters))
156
+
157
+ @staticmethod
158
+ def _iter_rows(lines, converters):
159
+ for line in lines:
160
+ # skip blank line left by WITH TOTALS modifier
161
+ if line:
162
+ yield tuple(
163
+ None if value == b"\\N" else convert(value if quoted else codecs.escape_decode(value)[0])
164
+ for (convert, quoted), value in zip(converters, line.split(b"\t"))
165
+ )
166
+
167
+
168
+ # Types the ORM cannot parse yet are returned as their ClickHouse text representation
169
+ _TEXT_TYPE_PREFIXES = ("Map(", "Nested(", "Variant(", "Dynamic", "JSON", "Object(", "AggregateFunction(")
170
+ _DATETIME_TYPE = re.compile(r"\bDateTime(64)?\b")
171
+ _QUOTED_STRING = re.compile(r"'(?:[^'\\]|\\.)*'")
172
+ _QUOTED_TYPE_PREFIXES = ("Array(", "Tuple(", "Map(", "Nested(")
173
+ _BIG_INT_TYPES = frozenset(["Int128", "UInt128", "Int256", "UInt256"])
174
+
175
+
176
+ def _decode_string(value: bytes) -> str | bytes:
177
+ # Like clickhouse_driver, fall back to bytes for strings which are not valid UTF-8
178
+ try:
179
+ return value.decode("utf-8")
180
+ except UnicodeDecodeError:
181
+ return value
182
+
183
+
184
+ def _is_quoted_type(db_type: str) -> bool:
185
+ """
186
+ Whether TSV cells of `db_type` are written in the quoted text format, without escaping them for TSV: this is
187
+ the case for composite types such as arrays, whose elements are quoted and escaped as in SQL.
188
+ """
189
+ if db_type.startswith("SimpleAggregateFunction("):
190
+ return _is_quoted_type(split_type_args(db_type[24:-1])[1])
191
+ return db_type.startswith(_QUOTED_TYPE_PREFIXES)
192
+
193
+
194
+ def _cell_parser(db_type: str, timezone: datetime.tzinfo) -> Callable[[bytes], Any]:
195
+ """
196
+ Returns a function converting a TSV cell of `db_type` to the value assigned to a model field: its text, except
197
+ for datetimes, which are converted according to the column type (naive for columns without a timezone, and
198
+ aware in the column's timezone otherwise), like `select_rows` and `clickhouse_driver` do.
199
+ """
200
+ quoted = _is_quoted_type(db_type)
201
+ if _has_datetime(db_type):
202
+ convert = _row_converter(db_type, timezone)
203
+ if quoted:
204
+ return convert
205
+ return lambda cell: None if cell == b"\\N" else convert(codecs.escape_decode(cell)[0])
206
+ if quoted:
207
+ return lambda cell: cell.decode("utf-8")
208
+ return lambda cell: unescape(cell.decode("utf-8"))
209
+
210
+
211
+ def _has_datetime(db_type: str) -> bool:
212
+ """Whether `db_type` is or contains a `DateTime` / `DateTime64` type (ignoring quoted strings, such as enum labels)."""
213
+ return _DATETIME_TYPE.search(_QUOTED_STRING.sub("", db_type)) is not None
214
+
215
+
216
+ def _row_converter(db_type: str, timezone: datetime.tzinfo) -> Callable[[bytes], Any]:
217
+ """
218
+ Returns a function converting a non-NULL TSV value of `db_type` to its Python value. The value must be
219
+ unescaped, unless it is written in the quoted text format (see `_is_quoted_type`).
220
+ """
221
+ for wrapper in ("Nullable(", "LowCardinality("):
222
+ if db_type.startswith(wrapper):
223
+ return _row_converter(db_type[len(wrapper) : -1], timezone)
224
+ if db_type.startswith("SimpleAggregateFunction("):
225
+ return _row_converter(split_type_args(db_type[24:-1])[1], timezone)
226
+ if db_type == "String" or db_type.startswith(_TEXT_TYPE_PREFIXES):
227
+ return _decode_string
228
+ if db_type.startswith("FixedString("):
229
+ return lambda value: _decode_string(value.rstrip(b"\0"))
230
+ if db_type == "Bool":
231
+ return lambda value: value == b"true"
232
+ if db_type in _BIG_INT_TYPES:
233
+ return int
234
+ if db_type == "Date32":
235
+ return lambda value: datetime.date.fromisoformat(value.decode())
236
+ if db_type == "Nothing":
237
+ return lambda value: None
238
+ if db_type.startswith("Array("):
239
+ # Convert the elements like values of the inner type (e.g. enums to names and datetimes to naive datetimes)
240
+ convert = _row_converter(db_type[len("Array(") : -1], timezone)
241
+ return lambda value: [None if item is None else convert(item.encode()) for item in parse_array(value.decode())]
242
+ if db_type.startswith("Tuple("):
243
+ # Each element is converted like a value of its type (elements of unsupported types are returned as text)
244
+ converters = [_row_converter(element_type, timezone) for _, element_type in parse_tuple_type(db_type)]
245
+ return lambda value: tuple(
246
+ None if item is None else convert(item.encode())
247
+ for convert, item in zip(converters, parse_array(value.decode()))
248
+ )
249
+ try:
250
+ field = ModelBase.create_ad_hoc_field(db_type)
251
+ except (NotImplementedError, AssertionError):
252
+ # Unsupported types (e.g. Map or Dynamic) are rejected by the field classes with either exception
253
+ return _decode_string
254
+ if isinstance(field, BaseEnumField):
255
+ return lambda value: field.to_python(value.decode(), timezone).name
256
+ # Datetimes are naive, unless the column has a timezone (which the ad-hoc field then has too)
257
+ return lambda value: field.to_python(value.decode(), timezone)
258
+
259
+
260
+ __all__ = ["Codec", "RowResult", "TSVCodec"]
@@ -0,0 +1,85 @@
1
+ """SQL generation helpers that do not require a connection to a ClickHouse server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import warnings
7
+ from string import Template
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from .models import Model
12
+
13
+
14
+ @dataclasses.dataclass(frozen=True)
15
+ class ServerCapabilities:
16
+ """
17
+ Optional ClickHouse server features that affect the generated DDL.
18
+
19
+ The defaults describe a modern server. Use `ServerCapabilities.from_version()` to
20
+ derive the flags from a server version, or override individual flags as needed.
21
+ """
22
+
23
+ # Custom partitioning keys (PARTITION BY / ORDER BY) were introduced in 1.1.54310
24
+ has_custom_partitioning: bool = True
25
+ # Column compression codecs were introduced in 19.1.16
26
+ has_codec_support: bool = True
27
+ # LowCardinality columns were introduced in 19.0
28
+ has_low_cardinality_support: bool = True
29
+
30
+ @classmethod
31
+ def from_version(cls, server_version: tuple[int, ...]) -> ServerCapabilities:
32
+ """Returns the capabilities of a ClickHouse server with the given version tuple."""
33
+ return cls(
34
+ has_custom_partitioning=server_version >= (1, 1, 54310),
35
+ has_codec_support=server_version >= (19, 1, 16),
36
+ has_low_cardinality_support=server_version >= (19, 0),
37
+ )
38
+
39
+
40
+ def quote_identifier(name: str) -> str:
41
+ """Wraps an identifier (database, table, column name) in backticks."""
42
+ return "`%s`" % name
43
+
44
+
45
+ def qualified_name(db_name: str, table_name: str) -> str:
46
+ """Returns the backtick-quoted, database-qualified name of a table."""
47
+ return "%s.%s" % (quote_identifier(db_name), quote_identifier(table_name))
48
+
49
+
50
+ def model_table_ref(db_name: str, model_class: type[Model]) -> str:
51
+ """Returns the qualified table name of a model, taking system models into account."""
52
+ return qualified_name("system" if model_class.is_system_model() else db_name, model_class.table_name())
53
+
54
+
55
+ def substitute(query: str, db_name: str, model_class: type[Model] | None = None) -> str:
56
+ """
57
+ Replaces the `$db` and (if `model_class` is given) `$table` placeholders in a query
58
+ with fully-qualified, backtick-quoted names.
59
+ """
60
+ if "$" not in query:
61
+ return query
62
+ mapping = {"db": quote_identifier(db_name)}
63
+ if model_class:
64
+ mapping["table"] = model_table_ref(db_name, model_class)
65
+ return Template(query).safe_substitute(mapping)
66
+
67
+
68
+ def resolve_ddl_target(db_name: Any, capabilities: ServerCapabilities | None, caller: str):
69
+ """
70
+ Normalises the arguments of the DDL generation methods into a `(db_name, capabilities)` pair.
71
+
72
+ Passing a `Database` instance instead of a database name is supported for backwards
73
+ compatibility, but is deprecated.
74
+ """
75
+ if isinstance(db_name, str):
76
+ return db_name, capabilities or ServerCapabilities()
77
+ warnings.warn(
78
+ f"Passing a Database to {caller}() is deprecated, pass the database name and a ServerCapabilities instead",
79
+ DeprecationWarning,
80
+ stacklevel=3,
81
+ )
82
+ return db_name.db_name, capabilities or db_name.capabilities
83
+
84
+
85
+ __all__ = ["ServerCapabilities"]