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,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)