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,197 @@
|
|
|
1
|
+
from functools import partial
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, ClassVar, Type
|
|
4
|
+
|
|
5
|
+
import attrs
|
|
6
|
+
|
|
7
|
+
from data_diff.schema import RawColumnInfo
|
|
8
|
+
from data_diff.utils import match_regexps
|
|
9
|
+
|
|
10
|
+
from data_diff.abcs.database_types import (
|
|
11
|
+
Timestamp,
|
|
12
|
+
TimestampTZ,
|
|
13
|
+
Integer,
|
|
14
|
+
Float,
|
|
15
|
+
Text,
|
|
16
|
+
FractionalType,
|
|
17
|
+
DbPath,
|
|
18
|
+
DbTime,
|
|
19
|
+
Decimal,
|
|
20
|
+
ColType,
|
|
21
|
+
ColType_UUID,
|
|
22
|
+
TemporalType,
|
|
23
|
+
Boolean,
|
|
24
|
+
)
|
|
25
|
+
from data_diff.databases.base import (
|
|
26
|
+
BaseDialect,
|
|
27
|
+
Database,
|
|
28
|
+
import_helper,
|
|
29
|
+
ThreadLocalInterpreter,
|
|
30
|
+
)
|
|
31
|
+
from data_diff.databases.base import (
|
|
32
|
+
MD5_HEXDIGITS,
|
|
33
|
+
CHECKSUM_HEXDIGITS,
|
|
34
|
+
CHECKSUM_OFFSET,
|
|
35
|
+
TIMESTAMP_PRECISION_POS,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def query_cursor(c, sql_code):
|
|
40
|
+
c.execute(sql_code)
|
|
41
|
+
if sql_code.lower().startswith("select"):
|
|
42
|
+
return c.fetchall()
|
|
43
|
+
# Required for the query to actually run 🤯
|
|
44
|
+
if re.match(r"(insert|create|truncate|drop|explain)", sql_code, re.IGNORECASE):
|
|
45
|
+
return c.fetchone()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@import_helper("presto")
|
|
49
|
+
def import_presto():
|
|
50
|
+
import prestodb
|
|
51
|
+
|
|
52
|
+
return prestodb
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Dialect(BaseDialect):
|
|
56
|
+
name = "Presto"
|
|
57
|
+
ROUNDS_ON_PREC_LOSS = True
|
|
58
|
+
TYPE_CLASSES = {
|
|
59
|
+
# Timestamps
|
|
60
|
+
"timestamp with time zone": TimestampTZ,
|
|
61
|
+
"timestamp without time zone": Timestamp,
|
|
62
|
+
"timestamp": Timestamp,
|
|
63
|
+
# Numbers
|
|
64
|
+
"integer": Integer,
|
|
65
|
+
"bigint": Integer,
|
|
66
|
+
"real": Float,
|
|
67
|
+
"double": Float,
|
|
68
|
+
# Text
|
|
69
|
+
"varchar": Text,
|
|
70
|
+
# Boolean
|
|
71
|
+
"boolean": Boolean,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def explain_as_text(self, query: str) -> str:
|
|
75
|
+
return f"EXPLAIN (FORMAT TEXT) {query}"
|
|
76
|
+
|
|
77
|
+
def type_repr(self, t) -> str:
|
|
78
|
+
if isinstance(t, TimestampTZ):
|
|
79
|
+
return f"timestamp with time zone"
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
return {float: "REAL"}[t]
|
|
83
|
+
except KeyError:
|
|
84
|
+
return super().type_repr(t)
|
|
85
|
+
|
|
86
|
+
def timestamp_value(self, t: DbTime) -> str:
|
|
87
|
+
return f"timestamp '{t.isoformat(' ')}'"
|
|
88
|
+
|
|
89
|
+
def quote(self, s: str):
|
|
90
|
+
return f'"{s}"'
|
|
91
|
+
|
|
92
|
+
def to_string(self, s: str):
|
|
93
|
+
return f"cast({s} as varchar)"
|
|
94
|
+
|
|
95
|
+
def parse_type(self, table_path: DbPath, info: RawColumnInfo) -> ColType:
|
|
96
|
+
timestamp_regexps = {
|
|
97
|
+
r"timestamp\((\d)\)": Timestamp,
|
|
98
|
+
r"timestamp\((\d)\) with time zone": TimestampTZ,
|
|
99
|
+
}
|
|
100
|
+
for m, t_cls in match_regexps(timestamp_regexps, info.data_type):
|
|
101
|
+
precision = int(m.group(1))
|
|
102
|
+
return t_cls(precision=precision, rounds=self.ROUNDS_ON_PREC_LOSS)
|
|
103
|
+
|
|
104
|
+
number_regexps = {r"decimal\((\d+),(\d+)\)": Decimal}
|
|
105
|
+
for m, n_cls in match_regexps(number_regexps, info.data_type):
|
|
106
|
+
_prec, scale = map(int, m.groups())
|
|
107
|
+
return n_cls(scale)
|
|
108
|
+
|
|
109
|
+
string_regexps = {r"varchar\((\d+)\)": Text, r"char\((\d+)\)": Text}
|
|
110
|
+
for m, n_cls in match_regexps(string_regexps, info.data_type):
|
|
111
|
+
return n_cls()
|
|
112
|
+
|
|
113
|
+
return super().parse_type(table_path, info)
|
|
114
|
+
|
|
115
|
+
def set_timezone_to_utc(self) -> str:
|
|
116
|
+
return "SET TIME ZONE '+00:00'"
|
|
117
|
+
|
|
118
|
+
def current_timestamp(self) -> str:
|
|
119
|
+
return "current_timestamp"
|
|
120
|
+
|
|
121
|
+
def md5_as_int(self, s: str) -> str:
|
|
122
|
+
return f"cast(from_base(substr(to_hex(md5(to_utf8({s}))), {1+MD5_HEXDIGITS-CHECKSUM_HEXDIGITS}), 16) as decimal(38, 0)) - {CHECKSUM_OFFSET}"
|
|
123
|
+
|
|
124
|
+
def md5_as_hex(self, s: str) -> str:
|
|
125
|
+
return f"to_hex(md5(to_utf8({s})))"
|
|
126
|
+
|
|
127
|
+
def normalize_uuid(self, value: str, coltype: ColType_UUID) -> str:
|
|
128
|
+
# Trim doesn't work on CHAR type
|
|
129
|
+
return f"TRIM(CAST({value} AS VARCHAR))"
|
|
130
|
+
|
|
131
|
+
def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
|
|
132
|
+
# TODO rounds
|
|
133
|
+
if coltype.rounds:
|
|
134
|
+
s = f"date_format(cast({value} as timestamp(6)), '%Y-%m-%d %H:%i:%S.%f')"
|
|
135
|
+
else:
|
|
136
|
+
s = f"date_format(cast({value} as timestamp(6)), '%Y-%m-%d %H:%i:%S.%f')"
|
|
137
|
+
|
|
138
|
+
return f"RPAD(RPAD({s}, {TIMESTAMP_PRECISION_POS+coltype.precision}, '.'), {TIMESTAMP_PRECISION_POS+6}, '0')"
|
|
139
|
+
|
|
140
|
+
def normalize_number(self, value: str, coltype: FractionalType) -> str:
|
|
141
|
+
return self.to_string(f"cast({value} as decimal(38,{coltype.precision}))")
|
|
142
|
+
|
|
143
|
+
def normalize_boolean(self, value: str, _coltype: Boolean) -> str:
|
|
144
|
+
return self.to_string(f"cast ({value} as int)")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@attrs.define(frozen=False, init=False, kw_only=True)
|
|
148
|
+
class Presto(Database):
|
|
149
|
+
DIALECT_CLASS: ClassVar[Type[BaseDialect]] = Dialect
|
|
150
|
+
CONNECT_URI_HELP = "presto://<user>@<host>/<catalog>/<schema>"
|
|
151
|
+
CONNECT_URI_PARAMS = ["catalog", "schema"]
|
|
152
|
+
|
|
153
|
+
_conn: Any
|
|
154
|
+
|
|
155
|
+
def __init__(self, **kw) -> None:
|
|
156
|
+
super().__init__()
|
|
157
|
+
self.default_schema = "public"
|
|
158
|
+
prestodb = import_presto()
|
|
159
|
+
|
|
160
|
+
if kw.get("schema"):
|
|
161
|
+
self.default_schema = kw.get("schema")
|
|
162
|
+
|
|
163
|
+
if kw.get("auth") == "basic": # if auth=basic, add basic authenticator for Presto
|
|
164
|
+
kw["auth"] = prestodb.auth.BasicAuthentication(kw["user"], kw.pop("password"))
|
|
165
|
+
|
|
166
|
+
if "cert" in kw: # if a certificate was specified in URI, verify session with cert
|
|
167
|
+
cert = kw.pop("cert")
|
|
168
|
+
self._conn = prestodb.dbapi.connect(**kw)
|
|
169
|
+
self._conn._http_session.verify = cert
|
|
170
|
+
else:
|
|
171
|
+
self._conn = prestodb.dbapi.connect(**kw)
|
|
172
|
+
|
|
173
|
+
def _query(self, sql_code: str) -> list:
|
|
174
|
+
"Uses the standard SQL cursor interface"
|
|
175
|
+
c = self._conn.cursor()
|
|
176
|
+
|
|
177
|
+
if isinstance(sql_code, ThreadLocalInterpreter):
|
|
178
|
+
return sql_code.apply_queries(partial(query_cursor, c))
|
|
179
|
+
|
|
180
|
+
return query_cursor(c, sql_code)
|
|
181
|
+
|
|
182
|
+
def close(self):
|
|
183
|
+
super().close()
|
|
184
|
+
self._conn.close()
|
|
185
|
+
|
|
186
|
+
def select_table_schema(self, path: DbPath) -> str:
|
|
187
|
+
schema, table = self._normalize_table_path(path)
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
"SELECT column_name, data_type, 3 as datetime_precision, 3 as numeric_precision, NULL as numeric_scale "
|
|
191
|
+
"FROM INFORMATION_SCHEMA.COLUMNS "
|
|
192
|
+
f"WHERE table_name = '{table}' AND table_schema = '{schema}'"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def is_autocommit(self) -> bool:
|
|
197
|
+
return False
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from typing import Any, ClassVar, Iterable, List, Dict, Tuple, Type
|
|
2
|
+
|
|
3
|
+
import attrs
|
|
4
|
+
|
|
5
|
+
from data_diff.abcs.database_types import (
|
|
6
|
+
ColType,
|
|
7
|
+
Float,
|
|
8
|
+
JSON,
|
|
9
|
+
TemporalType,
|
|
10
|
+
FractionalType,
|
|
11
|
+
DbPath,
|
|
12
|
+
TimestampTZ,
|
|
13
|
+
Integer,
|
|
14
|
+
)
|
|
15
|
+
from data_diff.databases.postgresql import (
|
|
16
|
+
BaseDialect,
|
|
17
|
+
PostgreSQL,
|
|
18
|
+
MD5_HEXDIGITS,
|
|
19
|
+
CHECKSUM_HEXDIGITS,
|
|
20
|
+
CHECKSUM_OFFSET,
|
|
21
|
+
TIMESTAMP_PRECISION_POS,
|
|
22
|
+
PostgresqlDialect,
|
|
23
|
+
)
|
|
24
|
+
from data_diff.schema import RawColumnInfo
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@attrs.define(frozen=False)
|
|
28
|
+
class Dialect(PostgresqlDialect):
|
|
29
|
+
name = "Redshift"
|
|
30
|
+
TYPE_CLASSES: ClassVar[Dict[str, Type[ColType]]] = {
|
|
31
|
+
**PostgresqlDialect.TYPE_CLASSES,
|
|
32
|
+
"double": Float,
|
|
33
|
+
"real": Float,
|
|
34
|
+
"super": JSON,
|
|
35
|
+
"int": Integer, # Redshift Spectrum
|
|
36
|
+
"float": Float, # Redshift Spectrum
|
|
37
|
+
}
|
|
38
|
+
SUPPORTS_INDEXES = False
|
|
39
|
+
|
|
40
|
+
def concat(self, items: List[str]) -> str:
|
|
41
|
+
joined_exprs = " || ".join(items)
|
|
42
|
+
return f"({joined_exprs})"
|
|
43
|
+
|
|
44
|
+
def is_distinct_from(self, a: str, b: str) -> str:
|
|
45
|
+
return f"({a} IS NULL != {b} IS NULL) OR ({a}!={b})"
|
|
46
|
+
|
|
47
|
+
def type_repr(self, t) -> str:
|
|
48
|
+
if isinstance(t, TimestampTZ):
|
|
49
|
+
return f"timestamptz"
|
|
50
|
+
return super().type_repr(t)
|
|
51
|
+
|
|
52
|
+
def md5_as_int(self, s: str) -> str:
|
|
53
|
+
return f"strtol(substring(md5({s}), {1+MD5_HEXDIGITS-CHECKSUM_HEXDIGITS}), 16)::decimal(38) - {CHECKSUM_OFFSET}"
|
|
54
|
+
|
|
55
|
+
def md5_as_hex(self, s: str) -> str:
|
|
56
|
+
return f"md5({s})"
|
|
57
|
+
|
|
58
|
+
def normalize_number(self, value: str, coltype: FractionalType) -> str:
|
|
59
|
+
return self.to_string(f"{value}::decimal(38,{coltype.precision})")
|
|
60
|
+
|
|
61
|
+
def normalize_json(self, value: str, _coltype: JSON) -> str:
|
|
62
|
+
return f"nvl2({value}, json_serialize({value}), NULL)"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@attrs.define(frozen=False, init=False, kw_only=True)
|
|
66
|
+
class Redshift(PostgreSQL):
|
|
67
|
+
DIALECT_CLASS: ClassVar[Type[BaseDialect]] = Dialect
|
|
68
|
+
CONNECT_URI_HELP = "redshift://<user>:<password>@<host>/<database>"
|
|
69
|
+
CONNECT_URI_PARAMS = ["database?"]
|
|
70
|
+
|
|
71
|
+
def select_table_schema(self, path: DbPath) -> str:
|
|
72
|
+
database, schema, table = self._normalize_table_path(path)
|
|
73
|
+
|
|
74
|
+
info_schema_path = ["information_schema", "columns"]
|
|
75
|
+
if database:
|
|
76
|
+
info_schema_path.insert(0, database)
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
f"SELECT column_name, data_type, datetime_precision, numeric_precision, numeric_scale FROM {'.'.join(info_schema_path)} "
|
|
80
|
+
f"WHERE table_name = '{table.lower()}' AND table_schema = '{schema.lower()}'"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def select_external_table_schema(self, path: DbPath) -> str:
|
|
84
|
+
database, schema, table = self._normalize_table_path(path)
|
|
85
|
+
|
|
86
|
+
db_clause = ""
|
|
87
|
+
if database:
|
|
88
|
+
db_clause = f" AND redshift_database_name = '{database.lower()}'"
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
f"""SELECT
|
|
92
|
+
columnname AS column_name
|
|
93
|
+
, CASE WHEN external_type = 'string' THEN 'varchar' ELSE external_type END AS data_type
|
|
94
|
+
, NULL AS datetime_precision
|
|
95
|
+
, NULL AS numeric_precision
|
|
96
|
+
, NULL AS numeric_scale
|
|
97
|
+
FROM svv_external_columns
|
|
98
|
+
WHERE tablename = '{table.lower()}' AND schemaname = '{schema.lower()}'
|
|
99
|
+
"""
|
|
100
|
+
+ db_clause
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def query_external_table_schema(self, path: DbPath) -> Dict[str, RawColumnInfo]:
|
|
104
|
+
rows = self.query(self.select_external_table_schema(path), list)
|
|
105
|
+
if not rows:
|
|
106
|
+
raise RuntimeError(f"{self.name}: Table '{'.'.join(path)}' does not exist, or has no columns")
|
|
107
|
+
|
|
108
|
+
schema_dict = self._normalize_schema_info(rows)
|
|
109
|
+
return schema_dict
|
|
110
|
+
|
|
111
|
+
def select_view_columns(self, path: DbPath) -> str:
|
|
112
|
+
_, schema, table = self._normalize_table_path(path)
|
|
113
|
+
|
|
114
|
+
return """select * from pg_get_cols('{}.{}')
|
|
115
|
+
cols(col_name name, col_type varchar)
|
|
116
|
+
""".format(schema, table)
|
|
117
|
+
|
|
118
|
+
def query_pg_get_cols(self, path: DbPath) -> Dict[str, RawColumnInfo]:
|
|
119
|
+
rows = self.query(self.select_view_columns(path), list)
|
|
120
|
+
if not rows:
|
|
121
|
+
raise RuntimeError(f"{self.name}: View '{'.'.join(path)}' does not exist, or has no columns")
|
|
122
|
+
|
|
123
|
+
schema_dict = self._normalize_schema_info(rows)
|
|
124
|
+
return schema_dict
|
|
125
|
+
|
|
126
|
+
def select_svv_columns_schema(self, path: DbPath) -> Dict[str, tuple]:
|
|
127
|
+
database, schema, table = self._normalize_table_path(path)
|
|
128
|
+
|
|
129
|
+
db_clause = ""
|
|
130
|
+
if database:
|
|
131
|
+
db_clause = f" AND table_catalog = '{database.lower()}'"
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
f"""
|
|
135
|
+
select
|
|
136
|
+
distinct
|
|
137
|
+
column_name,
|
|
138
|
+
data_type,
|
|
139
|
+
datetime_precision,
|
|
140
|
+
numeric_precision,
|
|
141
|
+
numeric_scale
|
|
142
|
+
from
|
|
143
|
+
svv_columns
|
|
144
|
+
where table_name = '{table.lower()}' and table_schema = '{schema.lower()}'
|
|
145
|
+
"""
|
|
146
|
+
+ db_clause
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def query_svv_columns(self, path: DbPath) -> Dict[str, RawColumnInfo]:
|
|
150
|
+
rows = self.query(self.select_svv_columns_schema(path), list)
|
|
151
|
+
if not rows:
|
|
152
|
+
raise RuntimeError(f"{self.name}: Table '{'.'.join(path)}' does not exist, or has no columns")
|
|
153
|
+
|
|
154
|
+
d = {
|
|
155
|
+
r[0]: RawColumnInfo(
|
|
156
|
+
column_name=r[0],
|
|
157
|
+
data_type=r[1],
|
|
158
|
+
datetime_precision=r[2],
|
|
159
|
+
numeric_precision=r[3],
|
|
160
|
+
numeric_scale=r[4],
|
|
161
|
+
collation_name=r[5] if len(r) > 5 else None,
|
|
162
|
+
)
|
|
163
|
+
for r in rows
|
|
164
|
+
}
|
|
165
|
+
assert len(d) == len(rows)
|
|
166
|
+
return d
|
|
167
|
+
|
|
168
|
+
# when using a non-information_schema source, strip (N) from type(N) etc. to match
|
|
169
|
+
# typical information_schema output
|
|
170
|
+
def _normalize_schema_info(self, rows: Iterable[Tuple[Any]]) -> Dict[str, RawColumnInfo]:
|
|
171
|
+
schema_dict: Dict[str, RawColumnInfo] = {}
|
|
172
|
+
for r in rows:
|
|
173
|
+
col_name = r[0]
|
|
174
|
+
type_info = r[1].split("(")
|
|
175
|
+
base_type = type_info[0]
|
|
176
|
+
precision = None
|
|
177
|
+
scale = None
|
|
178
|
+
|
|
179
|
+
if len(type_info) > 1:
|
|
180
|
+
if base_type == "numeric":
|
|
181
|
+
precision, scale = type_info[1][:-1].split(",")
|
|
182
|
+
precision = int(precision)
|
|
183
|
+
scale = int(scale)
|
|
184
|
+
|
|
185
|
+
schema_dict[col_name] = RawColumnInfo(
|
|
186
|
+
column_name=col_name,
|
|
187
|
+
data_type=base_type,
|
|
188
|
+
datetime_precision=None,
|
|
189
|
+
numeric_precision=precision,
|
|
190
|
+
numeric_scale=scale,
|
|
191
|
+
collation_name=None,
|
|
192
|
+
)
|
|
193
|
+
return schema_dict
|
|
194
|
+
|
|
195
|
+
def query_table_schema(self, path: DbPath) -> Dict[str, RawColumnInfo]:
|
|
196
|
+
try:
|
|
197
|
+
return super().query_table_schema(path)
|
|
198
|
+
except RuntimeError:
|
|
199
|
+
try:
|
|
200
|
+
return self.query_external_table_schema(path)
|
|
201
|
+
except RuntimeError:
|
|
202
|
+
try:
|
|
203
|
+
return self.query_pg_get_cols(path)
|
|
204
|
+
except Exception:
|
|
205
|
+
return self.query_svv_columns(path)
|
|
206
|
+
|
|
207
|
+
def _normalize_table_path(self, path: DbPath) -> DbPath:
|
|
208
|
+
if len(path) == 1:
|
|
209
|
+
return None, self.default_schema, path[0]
|
|
210
|
+
elif len(path) == 2:
|
|
211
|
+
return None, path[0], path[1]
|
|
212
|
+
elif len(path) == 3:
|
|
213
|
+
return path
|
|
214
|
+
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"{self.name}: Bad table path for {self}: '{'.'.join(path)}'. Expected format: table, schema.table, or database.schema.table"
|
|
217
|
+
)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from typing import Any, ClassVar, Union, List, Type, Optional
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import attrs
|
|
6
|
+
|
|
7
|
+
from data_diff.abcs.database_types import (
|
|
8
|
+
Timestamp,
|
|
9
|
+
TimestampTZ,
|
|
10
|
+
Decimal,
|
|
11
|
+
Float,
|
|
12
|
+
Text,
|
|
13
|
+
FractionalType,
|
|
14
|
+
TemporalType,
|
|
15
|
+
DbPath,
|
|
16
|
+
Boolean,
|
|
17
|
+
Date,
|
|
18
|
+
Time,
|
|
19
|
+
)
|
|
20
|
+
from data_diff.databases.base import (
|
|
21
|
+
BaseDialect,
|
|
22
|
+
ConnectError,
|
|
23
|
+
Database,
|
|
24
|
+
import_helper,
|
|
25
|
+
CHECKSUM_MASK,
|
|
26
|
+
ThreadLocalInterpreter,
|
|
27
|
+
CHECKSUM_OFFSET,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@import_helper("snowflake")
|
|
32
|
+
def import_snowflake():
|
|
33
|
+
import snowflake.connector
|
|
34
|
+
from cryptography.hazmat.primitives import serialization
|
|
35
|
+
from cryptography.hazmat.backends import default_backend
|
|
36
|
+
|
|
37
|
+
return snowflake, serialization, default_backend
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Dialect(BaseDialect):
|
|
41
|
+
name = "Snowflake"
|
|
42
|
+
ROUNDS_ON_PREC_LOSS = False
|
|
43
|
+
TYPE_CLASSES = {
|
|
44
|
+
# Timestamps
|
|
45
|
+
"TIMESTAMP_NTZ": Timestamp,
|
|
46
|
+
"TIMESTAMP_LTZ": Timestamp,
|
|
47
|
+
"TIMESTAMP_TZ": TimestampTZ,
|
|
48
|
+
"DATE": Date,
|
|
49
|
+
"TIME": Time,
|
|
50
|
+
# Numbers
|
|
51
|
+
"NUMBER": Decimal,
|
|
52
|
+
"FLOAT": Float,
|
|
53
|
+
# Text
|
|
54
|
+
"TEXT": Text,
|
|
55
|
+
# Boolean
|
|
56
|
+
"BOOLEAN": Boolean,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def explain_as_text(self, query: str) -> str:
|
|
60
|
+
return f"EXPLAIN USING TEXT {query}"
|
|
61
|
+
|
|
62
|
+
def quote(self, s: str):
|
|
63
|
+
return f'"{s}"'
|
|
64
|
+
|
|
65
|
+
def to_string(self, s: str):
|
|
66
|
+
return f"cast({s} as string)"
|
|
67
|
+
|
|
68
|
+
def set_timezone_to_utc(self) -> str:
|
|
69
|
+
return "ALTER SESSION SET TIMEZONE = 'UTC'"
|
|
70
|
+
|
|
71
|
+
def optimizer_hints(self, hints: str) -> str:
|
|
72
|
+
raise NotImplementedError("Optimizer hints not yet implemented in snowflake")
|
|
73
|
+
|
|
74
|
+
def type_repr(self, t) -> str:
|
|
75
|
+
if isinstance(t, TimestampTZ):
|
|
76
|
+
return f"timestamp_tz({t.precision})"
|
|
77
|
+
return super().type_repr(t)
|
|
78
|
+
|
|
79
|
+
def md5_as_int(self, s: str) -> str:
|
|
80
|
+
return f"BITAND(md5_number_lower64({s}), {CHECKSUM_MASK}) - {CHECKSUM_OFFSET}"
|
|
81
|
+
|
|
82
|
+
def md5_as_hex(self, s: str) -> str:
|
|
83
|
+
return f"md5({s})"
|
|
84
|
+
|
|
85
|
+
def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
|
|
86
|
+
try:
|
|
87
|
+
is_date = coltype.is_date
|
|
88
|
+
is_time = coltype.is_time
|
|
89
|
+
except:
|
|
90
|
+
is_date = False
|
|
91
|
+
is_time = False
|
|
92
|
+
if isinstance(coltype, Date) or is_date:
|
|
93
|
+
return f"({value}::varchar)"
|
|
94
|
+
elif isinstance(coltype, Time) or is_time:
|
|
95
|
+
microseconds = f"TIMEDIFF(microsecond, cast('00:00:00' as time), {value})"
|
|
96
|
+
rounded = f"round({microseconds}, -6 + {coltype.precision})"
|
|
97
|
+
time_value = f"TIMEADD(microsecond, {rounded}, cast('00:00:00' as time))"
|
|
98
|
+
converted = f"TO_VARCHAR({time_value}, 'HH24:MI:SS.FF6')"
|
|
99
|
+
return converted
|
|
100
|
+
|
|
101
|
+
if coltype.rounds:
|
|
102
|
+
timestamp = f"to_timestamp(round(date_part(epoch_nanosecond, convert_timezone('UTC', {value})::timestamp(9))/1000000000, {coltype.precision}))"
|
|
103
|
+
else:
|
|
104
|
+
timestamp = f"cast(convert_timezone('UTC', {value}) as timestamp({coltype.precision}))"
|
|
105
|
+
|
|
106
|
+
return f"to_char({timestamp}, 'YYYY-MM-DD HH24:MI:SS.FF6')"
|
|
107
|
+
|
|
108
|
+
def normalize_number(self, value: str, coltype: FractionalType) -> str:
|
|
109
|
+
return self.to_string(f"cast({value} as decimal(38, {coltype.precision}))")
|
|
110
|
+
|
|
111
|
+
def normalize_boolean(self, value: str, _coltype: Boolean) -> str:
|
|
112
|
+
return self.to_string(f"{value}::int")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@attrs.define(frozen=False, init=False, kw_only=True)
|
|
116
|
+
class Snowflake(Database):
|
|
117
|
+
DIALECT_CLASS: ClassVar[Type[BaseDialect]] = Dialect
|
|
118
|
+
CONNECT_URI_HELP = "snowflake://<user>:<password>@<account>/<database>/<SCHEMA>?warehouse=<WAREHOUSE>"
|
|
119
|
+
CONNECT_URI_PARAMS = ["database", "schema"]
|
|
120
|
+
CONNECT_URI_KWPARAMS = ["warehouse"]
|
|
121
|
+
|
|
122
|
+
_conn: Any
|
|
123
|
+
|
|
124
|
+
def __init__(self, *, schema: str, key: Optional[str] = None, key_content: Optional[str] = None, **kw) -> None:
|
|
125
|
+
super().__init__()
|
|
126
|
+
snowflake, serialization, default_backend = import_snowflake()
|
|
127
|
+
logging.getLogger("snowflake.connector").setLevel(logging.WARNING)
|
|
128
|
+
|
|
129
|
+
# Ignore the error: snowflake.connector.network.RetryRequest: could not find io module state
|
|
130
|
+
# It's a known issue: https://github.com/snowflakedb/snowflake-connector-python/issues/145
|
|
131
|
+
logging.getLogger("snowflake.connector.network").disabled = True
|
|
132
|
+
|
|
133
|
+
assert '"' not in schema, "Schema name should not contain quotes!"
|
|
134
|
+
if key_content and key:
|
|
135
|
+
raise ConnectError("Only key value or key file path can be specified, not both")
|
|
136
|
+
|
|
137
|
+
key_bytes = None
|
|
138
|
+
if key:
|
|
139
|
+
with open(key, "rb") as f:
|
|
140
|
+
key_bytes = f.read()
|
|
141
|
+
if key_content:
|
|
142
|
+
key_bytes = base64.b64decode(key_content)
|
|
143
|
+
|
|
144
|
+
# If a private key is used, read it from the specified path and pass it as "private_key" to the connector.
|
|
145
|
+
if key_bytes:
|
|
146
|
+
if "password" in kw:
|
|
147
|
+
raise ConnectError("Cannot use password and key at the same time")
|
|
148
|
+
if kw.get("private_key_passphrase"):
|
|
149
|
+
encoded_passphrase = kw.get("private_key_passphrase").encode()
|
|
150
|
+
else:
|
|
151
|
+
encoded_passphrase = None
|
|
152
|
+
p_key = serialization.load_pem_private_key(
|
|
153
|
+
key_bytes,
|
|
154
|
+
password=encoded_passphrase,
|
|
155
|
+
backend=default_backend(),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
kw["private_key"] = p_key.private_bytes(
|
|
159
|
+
encoding=serialization.Encoding.DER,
|
|
160
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
161
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
self._conn = snowflake.connector.connect(schema=f'"{schema}"', **kw)
|
|
165
|
+
|
|
166
|
+
self.default_schema = schema
|
|
167
|
+
|
|
168
|
+
def close(self):
|
|
169
|
+
super().close()
|
|
170
|
+
self._conn.close()
|
|
171
|
+
|
|
172
|
+
def _query(self, sql_code: Union[str, ThreadLocalInterpreter]):
|
|
173
|
+
"Uses the standard SQL cursor interface"
|
|
174
|
+
return self._query_conn(self._conn, sql_code)
|
|
175
|
+
|
|
176
|
+
def select_table_schema(self, path: DbPath) -> str:
|
|
177
|
+
"""Provide SQL for selecting the table schema as (name, type, date_prec, num_prec)"""
|
|
178
|
+
database, schema, name = self._normalize_table_path(path)
|
|
179
|
+
info_schema_path = ["information_schema", "columns"]
|
|
180
|
+
if database:
|
|
181
|
+
info_schema_path.insert(0, database)
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
"SELECT column_name, data_type, datetime_precision, numeric_precision, numeric_scale"
|
|
185
|
+
" , coalesce(collation_name, 'utf8') "
|
|
186
|
+
f"FROM {'.'.join(info_schema_path)} "
|
|
187
|
+
f"WHERE table_name = '{name}' AND table_schema = '{schema}'"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _normalize_table_path(self, path: DbPath) -> DbPath:
|
|
191
|
+
if len(path) == 1:
|
|
192
|
+
return None, self.default_schema, path[0]
|
|
193
|
+
elif len(path) == 2:
|
|
194
|
+
return None, path[0], path[1]
|
|
195
|
+
elif len(path) == 3:
|
|
196
|
+
return path
|
|
197
|
+
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"{self.name}: Bad table path for {self}: '{'.'.join(path)}'. Expected format: table, schema.table, or database.schema.table"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def is_autocommit(self) -> bool:
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
def query_table_unique_columns(self, path: DbPath) -> List[str]:
|
|
207
|
+
return []
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import Any, ClassVar, Type
|
|
2
|
+
|
|
3
|
+
import attrs
|
|
4
|
+
|
|
5
|
+
from data_diff.abcs.database_types import TemporalType, ColType_UUID
|
|
6
|
+
from data_diff.databases import presto
|
|
7
|
+
from data_diff.databases.base import import_helper
|
|
8
|
+
from data_diff.databases.base import TIMESTAMP_PRECISION_POS, BaseDialect
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@import_helper("trino")
|
|
12
|
+
def import_trino():
|
|
13
|
+
import trino
|
|
14
|
+
|
|
15
|
+
return trino
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Dialect(presto.Dialect):
|
|
19
|
+
name = "Trino"
|
|
20
|
+
|
|
21
|
+
def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
|
|
22
|
+
if coltype.rounds:
|
|
23
|
+
s = f"date_format(cast({value} as timestamp({coltype.precision})), '%Y-%m-%d %H:%i:%S.%f')"
|
|
24
|
+
else:
|
|
25
|
+
s = f"date_format(cast({value} as timestamp(6)), '%Y-%m-%d %H:%i:%S.%f')"
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
f"RPAD(RPAD({s}, {TIMESTAMP_PRECISION_POS + coltype.precision}, '.'), {TIMESTAMP_PRECISION_POS + 6}, '0')"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def normalize_uuid(self, value: str, coltype: ColType_UUID) -> str:
|
|
32
|
+
return f"TRIM({value})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@attrs.define(frozen=False, init=False, kw_only=True)
|
|
36
|
+
class Trino(presto.Presto):
|
|
37
|
+
DIALECT_CLASS: ClassVar[Type[BaseDialect]] = Dialect
|
|
38
|
+
CONNECT_URI_HELP = "trino://<user>@<host>/<catalog>/<schema>"
|
|
39
|
+
CONNECT_URI_PARAMS = ["catalog", "schema"]
|
|
40
|
+
|
|
41
|
+
_conn: Any
|
|
42
|
+
|
|
43
|
+
def __init__(self, **kw) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
trino = import_trino()
|
|
46
|
+
|
|
47
|
+
if kw.get("schema"):
|
|
48
|
+
self.default_schema = kw.get("schema")
|
|
49
|
+
|
|
50
|
+
self._conn = trino.dbapi.connect(**kw)
|