collate-data-diff 0.11.2__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.
- collate_data_diff-0.11.2.dist-info/LICENSE +18 -0
- collate_data_diff-0.11.2.dist-info/METADATA +77 -0
- collate_data_diff-0.11.2.dist-info/RECORD +54 -0
- collate_data_diff-0.11.2.dist-info/WHEEL +4 -0
- collate_data_diff-0.11.2.dist-info/entry_points.txt +3 -0
- data_diff/__init__.py +180 -0
- data_diff/__main__.py +618 -0
- data_diff/abcs/__init__.py +0 -0
- data_diff/abcs/compiler.py +13 -0
- data_diff/abcs/database_types.py +308 -0
- data_diff/cloud/__init__.py +2 -0
- data_diff/cloud/data_source.py +318 -0
- data_diff/cloud/datafold_api.py +304 -0
- data_diff/config.py +127 -0
- data_diff/databases/__init__.py +17 -0
- data_diff/databases/_connect.py +306 -0
- data_diff/databases/base.py +1291 -0
- data_diff/databases/bigquery.py +315 -0
- data_diff/databases/clickhouse.py +203 -0
- data_diff/databases/databricks.py +248 -0
- data_diff/databases/duckdb.py +192 -0
- data_diff/databases/mssql.py +229 -0
- data_diff/databases/mysql.py +159 -0
- data_diff/databases/oracle.py +195 -0
- data_diff/databases/postgresql.py +258 -0
- data_diff/databases/presto.py +197 -0
- data_diff/databases/redshift.py +217 -0
- data_diff/databases/snowflake.py +207 -0
- data_diff/databases/trino.py +50 -0
- data_diff/databases/vertica.py +160 -0
- data_diff/dbt.py +604 -0
- data_diff/dbt_config_validators.py +65 -0
- data_diff/dbt_parser.py +523 -0
- data_diff/diff_tables.py +416 -0
- data_diff/errors.py +74 -0
- data_diff/format.py +359 -0
- data_diff/hashdiff_tables.py +264 -0
- data_diff/info_tree.py +62 -0
- data_diff/joindiff_tables.py +399 -0
- data_diff/lexicographic_space.py +240 -0
- data_diff/parse_time.py +74 -0
- data_diff/py.typed +0 -0
- data_diff/queries/__init__.py +0 -0
- data_diff/queries/api.py +200 -0
- data_diff/queries/ast_classes.py +798 -0
- data_diff/queries/base.py +24 -0
- data_diff/queries/extras.py +29 -0
- data_diff/query_utils.py +56 -0
- data_diff/schema.py +52 -0
- data_diff/table_segment.py +286 -0
- data_diff/thread_utils.py +98 -0
- data_diff/tracking.py +237 -0
- data_diff/utils.py +625 -0
- data_diff/version.py +1 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, ClassVar, List, Union, Type
|
|
3
|
+
|
|
4
|
+
import attrs
|
|
5
|
+
|
|
6
|
+
from data_diff.abcs.database_types import (
|
|
7
|
+
ColType,
|
|
8
|
+
Array,
|
|
9
|
+
JSON,
|
|
10
|
+
Struct,
|
|
11
|
+
Timestamp,
|
|
12
|
+
Datetime,
|
|
13
|
+
Integer,
|
|
14
|
+
Decimal,
|
|
15
|
+
Float,
|
|
16
|
+
Text,
|
|
17
|
+
DbPath,
|
|
18
|
+
FractionalType,
|
|
19
|
+
TemporalType,
|
|
20
|
+
Boolean,
|
|
21
|
+
UnknownColType,
|
|
22
|
+
Time,
|
|
23
|
+
Date,
|
|
24
|
+
)
|
|
25
|
+
from data_diff.databases.base import (
|
|
26
|
+
BaseDialect,
|
|
27
|
+
Database,
|
|
28
|
+
import_helper,
|
|
29
|
+
parse_table_name,
|
|
30
|
+
ConnectError,
|
|
31
|
+
apply_query,
|
|
32
|
+
QueryResult,
|
|
33
|
+
CHECKSUM_OFFSET,
|
|
34
|
+
CHECKSUM_HEXDIGITS,
|
|
35
|
+
MD5_HEXDIGITS,
|
|
36
|
+
)
|
|
37
|
+
from data_diff.databases.base import TIMESTAMP_PRECISION_POS, ThreadLocalInterpreter
|
|
38
|
+
from data_diff.schema import RawColumnInfo
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@import_helper(text="Please install BigQuery and configure your google-cloud access.")
|
|
42
|
+
def import_bigquery():
|
|
43
|
+
from google.cloud import bigquery
|
|
44
|
+
|
|
45
|
+
return bigquery
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def import_bigquery_service_account():
|
|
49
|
+
from google.oauth2 import service_account
|
|
50
|
+
|
|
51
|
+
return service_account
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def import_bigquery_service_account_impersonation():
|
|
55
|
+
from google.auth import impersonated_credentials
|
|
56
|
+
|
|
57
|
+
return impersonated_credentials
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@attrs.define(frozen=False)
|
|
61
|
+
class Dialect(BaseDialect):
|
|
62
|
+
name = "BigQuery"
|
|
63
|
+
ROUNDS_ON_PREC_LOSS = False # Technically BigQuery doesn't allow implicit rounding or truncation
|
|
64
|
+
TYPE_CLASSES = {
|
|
65
|
+
# Dates
|
|
66
|
+
"TIMESTAMP": Timestamp,
|
|
67
|
+
"DATETIME": Datetime,
|
|
68
|
+
"DATE": Date,
|
|
69
|
+
"TIME": Time,
|
|
70
|
+
# Numbers
|
|
71
|
+
"INT64": Integer,
|
|
72
|
+
"INT32": Integer,
|
|
73
|
+
"NUMERIC": Decimal,
|
|
74
|
+
"BIGNUMERIC": Decimal,
|
|
75
|
+
"FLOAT64": Float,
|
|
76
|
+
"FLOAT32": Float,
|
|
77
|
+
"STRING": Text,
|
|
78
|
+
"BOOL": Boolean,
|
|
79
|
+
"JSON": JSON,
|
|
80
|
+
}
|
|
81
|
+
TYPE_ARRAY_RE = re.compile(r"ARRAY<(.+)>")
|
|
82
|
+
TYPE_STRUCT_RE = re.compile(r"STRUCT<(.+)>")
|
|
83
|
+
# [BIG]NUMERIC, [BIG]NUMERIC(precision, scale), [BIG]NUMERIC(precision)
|
|
84
|
+
TYPE_NUMERIC_RE = re.compile(r"^((BIG)?NUMERIC)(?:\((\d+)(?:, (\d+))?\))?$")
|
|
85
|
+
# https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#parameterized_decimal_type
|
|
86
|
+
# The default scale is 9, which means a number can have up to 9 digits after the decimal point.
|
|
87
|
+
DEFAULT_NUMERIC_PRECISION = 9
|
|
88
|
+
|
|
89
|
+
def random(self) -> str:
|
|
90
|
+
return "RAND()"
|
|
91
|
+
|
|
92
|
+
def quote(self, s: str) -> str:
|
|
93
|
+
return f"`{s}`"
|
|
94
|
+
|
|
95
|
+
def to_string(self, s: str) -> str:
|
|
96
|
+
return f"cast({s} as string)"
|
|
97
|
+
|
|
98
|
+
def type_repr(self, t) -> str:
|
|
99
|
+
try:
|
|
100
|
+
return {str: "STRING", float: "FLOAT64"}[t]
|
|
101
|
+
except KeyError:
|
|
102
|
+
return super().type_repr(t)
|
|
103
|
+
|
|
104
|
+
def parse_type(self, table_path: DbPath, info: RawColumnInfo) -> ColType:
|
|
105
|
+
col_type = super().parse_type(table_path, info)
|
|
106
|
+
if not isinstance(col_type, UnknownColType):
|
|
107
|
+
return col_type
|
|
108
|
+
|
|
109
|
+
m = self.TYPE_ARRAY_RE.fullmatch(info.data_type)
|
|
110
|
+
if m:
|
|
111
|
+
item_info = attrs.evolve(info, data_type=m.group(1))
|
|
112
|
+
item_type = self.parse_type(table_path, item_info)
|
|
113
|
+
col_type = Array(item_type=item_type)
|
|
114
|
+
return col_type
|
|
115
|
+
|
|
116
|
+
# We currently ignore structs' structure, but later can parse it too. Examples:
|
|
117
|
+
# - STRUCT<INT64, STRING(10)> (unnamed)
|
|
118
|
+
# - STRUCT<foo INT64, bar STRING(10)> (named)
|
|
119
|
+
# - STRUCT<foo INT64, bar ARRAY<INT64>> (with complex fields)
|
|
120
|
+
# - STRUCT<foo INT64, bar STRUCT<a INT64, b INT64>> (nested)
|
|
121
|
+
m = self.TYPE_STRUCT_RE.fullmatch(info.data_type)
|
|
122
|
+
if m:
|
|
123
|
+
col_type = Struct()
|
|
124
|
+
return col_type
|
|
125
|
+
|
|
126
|
+
m = self.TYPE_NUMERIC_RE.fullmatch(info.data_type)
|
|
127
|
+
if m:
|
|
128
|
+
precision = int(m.group(3)) if m.group(3) else None
|
|
129
|
+
scale = int(m.group(4)) if m.group(4) else None
|
|
130
|
+
|
|
131
|
+
if scale is not None:
|
|
132
|
+
# NUMERIC(..., scale) — scale is set explicitly
|
|
133
|
+
effective_precision = scale
|
|
134
|
+
elif precision is not None:
|
|
135
|
+
# NUMERIC(...) — scale is missing but precision is set
|
|
136
|
+
# effectively the same as NUMERIC(..., 0)
|
|
137
|
+
effective_precision = 0
|
|
138
|
+
else:
|
|
139
|
+
# NUMERIC → default scale is 9
|
|
140
|
+
effective_precision = 9
|
|
141
|
+
col_type = Decimal(precision=effective_precision)
|
|
142
|
+
return col_type
|
|
143
|
+
|
|
144
|
+
return col_type
|
|
145
|
+
|
|
146
|
+
def to_comparable(self, value: str, coltype: ColType) -> str:
|
|
147
|
+
"""Ensure that the expression is comparable in ``IS DISTINCT FROM``."""
|
|
148
|
+
if isinstance(coltype, (JSON, Array, Struct)):
|
|
149
|
+
return self.normalize_value_by_type(value, coltype)
|
|
150
|
+
else:
|
|
151
|
+
return super().to_comparable(value, coltype)
|
|
152
|
+
|
|
153
|
+
def set_timezone_to_utc(self) -> str:
|
|
154
|
+
raise NotImplementedError()
|
|
155
|
+
|
|
156
|
+
def parse_table_name(self, name: str) -> DbPath:
|
|
157
|
+
path = parse_table_name(name)
|
|
158
|
+
return tuple(i for i in path if i is not None)
|
|
159
|
+
|
|
160
|
+
def md5_as_int(self, s: str) -> str:
|
|
161
|
+
return f"cast(cast( ('0x' || substr(TO_HEX(md5({s})), {1+MD5_HEXDIGITS-CHECKSUM_HEXDIGITS})) as int64) as numeric) - {CHECKSUM_OFFSET}"
|
|
162
|
+
|
|
163
|
+
def md5_as_hex(self, s: str) -> str:
|
|
164
|
+
return f"md5({s})"
|
|
165
|
+
|
|
166
|
+
def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
|
|
167
|
+
try:
|
|
168
|
+
is_date = coltype.is_date
|
|
169
|
+
is_time = coltype.is_time
|
|
170
|
+
except:
|
|
171
|
+
is_date = False
|
|
172
|
+
is_time = False
|
|
173
|
+
if isinstance(coltype, Date) or is_date:
|
|
174
|
+
return f"FORMAT_DATE('%F', {value})"
|
|
175
|
+
if isinstance(coltype, Time) or is_time:
|
|
176
|
+
microseconds = f"TIME_DIFF( {value}, cast('00:00:00' as time), microsecond)"
|
|
177
|
+
rounded = f"ROUND({microseconds}, -6 + {coltype.precision})"
|
|
178
|
+
time_value = f"TIME_ADD(cast('00:00:00' as time), interval cast({rounded} as int64) microsecond)"
|
|
179
|
+
converted = f"FORMAT_TIME('%H:%M:%E6S', {time_value})"
|
|
180
|
+
return converted
|
|
181
|
+
|
|
182
|
+
if coltype.rounds:
|
|
183
|
+
timestamp = f"timestamp_micros(cast(round(unix_micros(cast({value} as timestamp))/1000000, {coltype.precision})*1000000 as int))"
|
|
184
|
+
return f"FORMAT_TIMESTAMP('%F %H:%M:%E6S', {timestamp})"
|
|
185
|
+
|
|
186
|
+
if coltype.precision == 0:
|
|
187
|
+
return f"FORMAT_TIMESTAMP('%F %H:%M:%S.000000', {value})"
|
|
188
|
+
elif coltype.precision == 6:
|
|
189
|
+
return f"FORMAT_TIMESTAMP('%F %H:%M:%E6S', {value})"
|
|
190
|
+
|
|
191
|
+
timestamp6 = f"FORMAT_TIMESTAMP('%F %H:%M:%E6S', {value})"
|
|
192
|
+
return (
|
|
193
|
+
f"RPAD(LEFT({timestamp6}, {TIMESTAMP_PRECISION_POS+coltype.precision}), {TIMESTAMP_PRECISION_POS+6}, '0')"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def normalize_number(self, value: str, coltype: FractionalType) -> str:
|
|
197
|
+
return f"format('%.{coltype.precision}f', {value})"
|
|
198
|
+
|
|
199
|
+
def normalize_boolean(self, value: str, _coltype: Boolean) -> str:
|
|
200
|
+
return self.to_string(f"cast({value} as int)")
|
|
201
|
+
|
|
202
|
+
def normalize_json(self, value: str, _coltype: JSON) -> str:
|
|
203
|
+
# BigQuery is unable to compare arrays & structs with ==/!=/distinct from, e.g.:
|
|
204
|
+
# Got error: 400 Grouping is not defined for arguments of type ARRAY<INT64> at …
|
|
205
|
+
# So we do the best effort and compare it as strings, hoping that the JSON forms
|
|
206
|
+
# match on both sides: i.e. have properly ordered keys, same spacing, same quotes, etc.
|
|
207
|
+
return f"to_json_string({value})"
|
|
208
|
+
|
|
209
|
+
def normalize_array(self, value: str, _coltype: Array) -> str:
|
|
210
|
+
# BigQuery is unable to compare arrays & structs with ==/!=/distinct from, e.g.:
|
|
211
|
+
# Got error: 400 Grouping is not defined for arguments of type ARRAY<INT64> at …
|
|
212
|
+
# So we do the best effort and compare it as strings, hoping that the JSON forms
|
|
213
|
+
# match on both sides: i.e. have properly ordered keys, same spacing, same quotes, etc.
|
|
214
|
+
return f"to_json_string({value})"
|
|
215
|
+
|
|
216
|
+
def normalize_struct(self, value: str, _coltype: Struct) -> str:
|
|
217
|
+
# BigQuery is unable to compare arrays & structs with ==/!=/distinct from, e.g.:
|
|
218
|
+
# Got error: 400 Grouping is not defined for arguments of type ARRAY<INT64> at …
|
|
219
|
+
# So we do the best effort and compare it as strings, hoping that the JSON forms
|
|
220
|
+
# match on both sides: i.e. have properly ordered keys, same spacing, same quotes, etc.
|
|
221
|
+
return f"to_json_string({value})"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@attrs.define(frozen=False, init=False, kw_only=True)
|
|
225
|
+
class BigQuery(Database):
|
|
226
|
+
DIALECT_CLASS: ClassVar[Type[BaseDialect]] = Dialect
|
|
227
|
+
CONNECT_URI_HELP = "bigquery://<project>/<dataset>"
|
|
228
|
+
CONNECT_URI_PARAMS = ["dataset"]
|
|
229
|
+
|
|
230
|
+
project: str
|
|
231
|
+
dataset: str
|
|
232
|
+
_client: Any
|
|
233
|
+
|
|
234
|
+
def __init__(self, project, *, dataset, bigquery_credentials=None, **kw) -> None:
|
|
235
|
+
super().__init__()
|
|
236
|
+
credentials = bigquery_credentials
|
|
237
|
+
bigquery = import_bigquery()
|
|
238
|
+
|
|
239
|
+
keyfile = kw.pop("keyfile", None)
|
|
240
|
+
impersonate_service_account = kw.pop("impersonate_service_account", None)
|
|
241
|
+
if keyfile:
|
|
242
|
+
bigquery_service_account = import_bigquery_service_account()
|
|
243
|
+
credentials = bigquery_service_account.Credentials.from_service_account_file(
|
|
244
|
+
keyfile,
|
|
245
|
+
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
|
246
|
+
)
|
|
247
|
+
elif impersonate_service_account:
|
|
248
|
+
bigquery_service_account_impersonation = import_bigquery_service_account_impersonation()
|
|
249
|
+
credentials = bigquery_service_account_impersonation.Credentials(
|
|
250
|
+
source_credentials=credentials,
|
|
251
|
+
target_principal=impersonate_service_account,
|
|
252
|
+
target_scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
self._client = bigquery.Client(project=project, credentials=credentials, **kw)
|
|
256
|
+
self.project = project
|
|
257
|
+
self.dataset = dataset
|
|
258
|
+
|
|
259
|
+
self.default_schema = dataset
|
|
260
|
+
|
|
261
|
+
def _normalize_returned_value(self, value):
|
|
262
|
+
if isinstance(value, bytes):
|
|
263
|
+
return value.decode()
|
|
264
|
+
return value
|
|
265
|
+
|
|
266
|
+
def _query_atom(self, sql_code: str):
|
|
267
|
+
from google.cloud import bigquery
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
result = self._client.query(sql_code).result()
|
|
271
|
+
columns = [c.name for c in result.schema]
|
|
272
|
+
rows = list(result)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
msg = "Exception when trying to execute SQL code:\n %s\n\nGot error: %s"
|
|
275
|
+
raise ConnectError(msg % (sql_code, e))
|
|
276
|
+
|
|
277
|
+
if rows and isinstance(rows[0], bigquery.table.Row):
|
|
278
|
+
rows = [tuple(self._normalize_returned_value(v) for v in row.values()) for row in rows]
|
|
279
|
+
return QueryResult(rows, columns)
|
|
280
|
+
|
|
281
|
+
def _query(self, sql_code: Union[str, ThreadLocalInterpreter]) -> QueryResult:
|
|
282
|
+
return apply_query(self._query_atom, sql_code)
|
|
283
|
+
|
|
284
|
+
def close(self):
|
|
285
|
+
super().close()
|
|
286
|
+
self._client.close()
|
|
287
|
+
|
|
288
|
+
def select_table_schema(self, path: DbPath) -> str:
|
|
289
|
+
project, schema, name = self._normalize_table_path(path)
|
|
290
|
+
return (
|
|
291
|
+
"SELECT column_name, data_type, 6 as datetime_precision, 38 as numeric_precision, 9 as numeric_scale "
|
|
292
|
+
f"FROM `{project}`.`{schema}`.INFORMATION_SCHEMA.COLUMNS "
|
|
293
|
+
f"WHERE table_name = '{name}' AND table_schema = '{schema}'"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def query_table_unique_columns(self, path: DbPath) -> List[str]:
|
|
297
|
+
return []
|
|
298
|
+
|
|
299
|
+
def _normalize_table_path(self, path: DbPath) -> DbPath:
|
|
300
|
+
if len(path) == 0:
|
|
301
|
+
raise ValueError(f"{self.name}: Bad table path for {self}: ()")
|
|
302
|
+
elif len(path) == 1:
|
|
303
|
+
return (self.project, self.default_schema, path[0])
|
|
304
|
+
elif len(path) == 2:
|
|
305
|
+
return (self.project,) + path
|
|
306
|
+
elif len(path) == 3:
|
|
307
|
+
return path
|
|
308
|
+
else:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
f"{self.name}: Bad table path for {self}: '{'.'.join(path)}'. Expected form: [project.]schema.table"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def is_autocommit(self) -> bool:
|
|
315
|
+
return True
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from typing import Any, ClassVar, Dict, Optional, Type
|
|
2
|
+
|
|
3
|
+
import attrs
|
|
4
|
+
|
|
5
|
+
from data_diff.databases.base import (
|
|
6
|
+
MD5_HEXDIGITS,
|
|
7
|
+
CHECKSUM_HEXDIGITS,
|
|
8
|
+
TIMESTAMP_PRECISION_POS,
|
|
9
|
+
CHECKSUM_OFFSET,
|
|
10
|
+
BaseDialect,
|
|
11
|
+
ThreadedDatabase,
|
|
12
|
+
import_helper,
|
|
13
|
+
ConnectError,
|
|
14
|
+
)
|
|
15
|
+
from data_diff.abcs.database_types import (
|
|
16
|
+
ColType,
|
|
17
|
+
DbPath,
|
|
18
|
+
Decimal,
|
|
19
|
+
Float,
|
|
20
|
+
Integer,
|
|
21
|
+
FractionalType,
|
|
22
|
+
Native_UUID,
|
|
23
|
+
TemporalType,
|
|
24
|
+
Text,
|
|
25
|
+
Timestamp,
|
|
26
|
+
Boolean,
|
|
27
|
+
)
|
|
28
|
+
from data_diff.schema import RawColumnInfo
|
|
29
|
+
|
|
30
|
+
# https://clickhouse.com/docs/en/operations/server-configuration-parameters/settings/#default-database
|
|
31
|
+
DEFAULT_DATABASE = "default"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@import_helper("clickhouse")
|
|
35
|
+
def import_clickhouse():
|
|
36
|
+
import clickhouse_driver
|
|
37
|
+
|
|
38
|
+
return clickhouse_driver
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@attrs.define(frozen=False)
|
|
42
|
+
class Dialect(BaseDialect):
|
|
43
|
+
name = "Clickhouse"
|
|
44
|
+
ROUNDS_ON_PREC_LOSS = False
|
|
45
|
+
TYPE_CLASSES = {
|
|
46
|
+
"Int8": Integer,
|
|
47
|
+
"Int16": Integer,
|
|
48
|
+
"Int32": Integer,
|
|
49
|
+
"Int64": Integer,
|
|
50
|
+
"Int128": Integer,
|
|
51
|
+
"Int256": Integer,
|
|
52
|
+
"UInt8": Integer,
|
|
53
|
+
"UInt16": Integer,
|
|
54
|
+
"UInt32": Integer,
|
|
55
|
+
"UInt64": Integer,
|
|
56
|
+
"UInt128": Integer,
|
|
57
|
+
"UInt256": Integer,
|
|
58
|
+
"Float32": Float,
|
|
59
|
+
"Float64": Float,
|
|
60
|
+
"Decimal": Decimal,
|
|
61
|
+
"UUID": Native_UUID,
|
|
62
|
+
"String": Text,
|
|
63
|
+
"FixedString": Text,
|
|
64
|
+
"DateTime": Timestamp,
|
|
65
|
+
"DateTime64": Timestamp,
|
|
66
|
+
"Bool": Boolean,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def quote(self, s: str) -> str:
|
|
70
|
+
return f'"{s}"'
|
|
71
|
+
|
|
72
|
+
def to_string(self, s: str) -> str:
|
|
73
|
+
return f"toString({s})"
|
|
74
|
+
|
|
75
|
+
def _convert_db_precision_to_digits(self, p: int) -> int:
|
|
76
|
+
# Done the same as for PostgreSQL but need to rewrite in another way
|
|
77
|
+
# because it does not help for float with a big integer part.
|
|
78
|
+
return super()._convert_db_precision_to_digits(p) - 2
|
|
79
|
+
|
|
80
|
+
def parse_type(self, table_path: DbPath, info: RawColumnInfo) -> ColType:
|
|
81
|
+
nullable_prefix = "Nullable("
|
|
82
|
+
if info.data_type.startswith(nullable_prefix):
|
|
83
|
+
info = attrs.evolve(info, data_type=info.data_type[len(nullable_prefix) :].rstrip(")"))
|
|
84
|
+
|
|
85
|
+
if info.data_type.startswith("Decimal"):
|
|
86
|
+
info = attrs.evolve(info, data_type="Decimal")
|
|
87
|
+
elif info.data_type.startswith("FixedString"):
|
|
88
|
+
info = attrs.evolve(info, data_type="FixedString")
|
|
89
|
+
elif info.data_type.startswith("DateTime64"):
|
|
90
|
+
info = attrs.evolve(info, data_type="DateTime64")
|
|
91
|
+
|
|
92
|
+
return super().parse_type(table_path, info)
|
|
93
|
+
|
|
94
|
+
# def timestamp_value(self, t: DbTime) -> str:
|
|
95
|
+
# # return f"'{t}'"
|
|
96
|
+
# return f"'{str(t)[:19]}'"
|
|
97
|
+
|
|
98
|
+
def set_timezone_to_utc(self) -> str:
|
|
99
|
+
raise NotImplementedError()
|
|
100
|
+
|
|
101
|
+
def current_timestamp(self) -> str:
|
|
102
|
+
return "now()"
|
|
103
|
+
|
|
104
|
+
def md5_as_int(self, s: str) -> str:
|
|
105
|
+
substr_idx = 1 + MD5_HEXDIGITS - CHECKSUM_HEXDIGITS
|
|
106
|
+
return (
|
|
107
|
+
f"reinterpretAsUInt128(reverse(unhex(lowerUTF8(substr(hex(MD5({s})), {substr_idx}))))) - {CHECKSUM_OFFSET}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def md5_as_hex(self, s: str) -> str:
|
|
111
|
+
return f"hex(MD5({s}))"
|
|
112
|
+
|
|
113
|
+
def normalize_number(self, value: str, coltype: FractionalType) -> str:
|
|
114
|
+
# If a decimal value has trailing zeros in a fractional part, when casting to string they are dropped.
|
|
115
|
+
# For example:
|
|
116
|
+
# select toString(toDecimal128(1.10, 2)); -- the result is 1.1
|
|
117
|
+
# select toString(toDecimal128(1.00, 2)); -- the result is 1
|
|
118
|
+
# So, we should use some custom approach to save these trailing zeros.
|
|
119
|
+
# To avoid it, we can add a small value like 0.000001 to prevent dropping of zeros from the end when casting.
|
|
120
|
+
# For examples above it looks like:
|
|
121
|
+
# select toString(toDecimal128(1.10, 2 + 1) + toDecimal128(0.001, 3)); -- the result is 1.101
|
|
122
|
+
# After that, cut an extra symbol from the string, i.e. 1.101 -> 1.10
|
|
123
|
+
# So, the algorithm is:
|
|
124
|
+
# 1. Cast to decimal with precision + 1
|
|
125
|
+
# 2. Add a small value 10^(-precision-1)
|
|
126
|
+
# 3. Cast the result to string
|
|
127
|
+
# 4. Drop the extra digit from the string. To do that, we need to slice the string
|
|
128
|
+
# with length = digits in an integer part + 1 (symbol of ".") + precision
|
|
129
|
+
|
|
130
|
+
if coltype.precision == 0:
|
|
131
|
+
return self.to_string(f"round({value})")
|
|
132
|
+
|
|
133
|
+
precision = coltype.precision
|
|
134
|
+
# TODO: too complex, is there better performance way?
|
|
135
|
+
value = f"""
|
|
136
|
+
if({value} >= 0, '', '-') || left(
|
|
137
|
+
toString(
|
|
138
|
+
toDecimal128(
|
|
139
|
+
round(abs({value}), {precision}),
|
|
140
|
+
{precision} + 1
|
|
141
|
+
)
|
|
142
|
+
+
|
|
143
|
+
toDecimal128(
|
|
144
|
+
exp10(-{precision + 1}),
|
|
145
|
+
{precision} + 1
|
|
146
|
+
)
|
|
147
|
+
),
|
|
148
|
+
toUInt8(
|
|
149
|
+
greatest(
|
|
150
|
+
floor(log10(abs({value}))) + 1,
|
|
151
|
+
1
|
|
152
|
+
)
|
|
153
|
+
) + 1 + {precision}
|
|
154
|
+
)
|
|
155
|
+
"""
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
|
|
159
|
+
prec = coltype.precision
|
|
160
|
+
if coltype.rounds:
|
|
161
|
+
timestamp = f"toDateTime64(round(toUnixTimestamp64Micro(toDateTime64({value}, 6)) / 1000000, {prec}), 6)"
|
|
162
|
+
return self.to_string(timestamp)
|
|
163
|
+
|
|
164
|
+
fractional = f"toUnixTimestamp64Micro(toDateTime64({value}, {prec})) % 1000000"
|
|
165
|
+
fractional = f"lpad({self.to_string(fractional)}, 6, '0')"
|
|
166
|
+
value = f"formatDateTime({value}, '%Y-%m-%d %H:%M:%S') || '.' || {self.to_string(fractional)}"
|
|
167
|
+
return f"rpad({value}, {TIMESTAMP_PRECISION_POS + 6}, '0')"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@attrs.define(frozen=False, init=False, kw_only=True)
|
|
171
|
+
class Clickhouse(ThreadedDatabase):
|
|
172
|
+
DIALECT_CLASS: ClassVar[Type[BaseDialect]] = Dialect
|
|
173
|
+
CONNECT_URI_HELP = "clickhouse://<user>:<password>@<host>/<database>"
|
|
174
|
+
CONNECT_URI_PARAMS = ["database?"]
|
|
175
|
+
|
|
176
|
+
_args: Dict[str, Any]
|
|
177
|
+
|
|
178
|
+
def __init__(self, *, thread_count: int, **kw) -> None:
|
|
179
|
+
super().__init__(thread_count=thread_count)
|
|
180
|
+
|
|
181
|
+
self._args = kw
|
|
182
|
+
# In Clickhouse database and schema are the same
|
|
183
|
+
self.default_schema = kw.get("database", DEFAULT_DATABASE)
|
|
184
|
+
|
|
185
|
+
def create_connection(self):
|
|
186
|
+
clickhouse = import_clickhouse()
|
|
187
|
+
|
|
188
|
+
class SingleConnection(clickhouse.dbapi.connection.Connection):
|
|
189
|
+
"""Not thread-safe connection to Clickhouse"""
|
|
190
|
+
|
|
191
|
+
def cursor(self, cursor_factory=None):
|
|
192
|
+
if not len(self.cursors):
|
|
193
|
+
_ = super().cursor()
|
|
194
|
+
return self.cursors[0]
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
return SingleConnection(**self._args)
|
|
198
|
+
except clickhouse.OperationError as e:
|
|
199
|
+
raise ConnectError(*e.args) from e
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def is_autocommit(self) -> bool:
|
|
203
|
+
return True
|