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.
Files changed (54) hide show
  1. collate_data_diff-0.11.2.dist-info/LICENSE +18 -0
  2. collate_data_diff-0.11.2.dist-info/METADATA +77 -0
  3. collate_data_diff-0.11.2.dist-info/RECORD +54 -0
  4. collate_data_diff-0.11.2.dist-info/WHEEL +4 -0
  5. collate_data_diff-0.11.2.dist-info/entry_points.txt +3 -0
  6. data_diff/__init__.py +180 -0
  7. data_diff/__main__.py +618 -0
  8. data_diff/abcs/__init__.py +0 -0
  9. data_diff/abcs/compiler.py +13 -0
  10. data_diff/abcs/database_types.py +308 -0
  11. data_diff/cloud/__init__.py +2 -0
  12. data_diff/cloud/data_source.py +318 -0
  13. data_diff/cloud/datafold_api.py +304 -0
  14. data_diff/config.py +127 -0
  15. data_diff/databases/__init__.py +17 -0
  16. data_diff/databases/_connect.py +306 -0
  17. data_diff/databases/base.py +1291 -0
  18. data_diff/databases/bigquery.py +315 -0
  19. data_diff/databases/clickhouse.py +203 -0
  20. data_diff/databases/databricks.py +248 -0
  21. data_diff/databases/duckdb.py +192 -0
  22. data_diff/databases/mssql.py +229 -0
  23. data_diff/databases/mysql.py +159 -0
  24. data_diff/databases/oracle.py +195 -0
  25. data_diff/databases/postgresql.py +258 -0
  26. data_diff/databases/presto.py +197 -0
  27. data_diff/databases/redshift.py +217 -0
  28. data_diff/databases/snowflake.py +207 -0
  29. data_diff/databases/trino.py +50 -0
  30. data_diff/databases/vertica.py +160 -0
  31. data_diff/dbt.py +604 -0
  32. data_diff/dbt_config_validators.py +65 -0
  33. data_diff/dbt_parser.py +523 -0
  34. data_diff/diff_tables.py +416 -0
  35. data_diff/errors.py +74 -0
  36. data_diff/format.py +359 -0
  37. data_diff/hashdiff_tables.py +264 -0
  38. data_diff/info_tree.py +62 -0
  39. data_diff/joindiff_tables.py +399 -0
  40. data_diff/lexicographic_space.py +240 -0
  41. data_diff/parse_time.py +74 -0
  42. data_diff/py.typed +0 -0
  43. data_diff/queries/__init__.py +0 -0
  44. data_diff/queries/api.py +200 -0
  45. data_diff/queries/ast_classes.py +798 -0
  46. data_diff/queries/base.py +24 -0
  47. data_diff/queries/extras.py +29 -0
  48. data_diff/query_utils.py +56 -0
  49. data_diff/schema.py +52 -0
  50. data_diff/table_segment.py +286 -0
  51. data_diff/thread_utils.py +98 -0
  52. data_diff/tracking.py +237 -0
  53. data_diff/utils.py +625 -0
  54. 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