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,159 @@
|
|
|
1
|
+
from typing import Any, ClassVar, Dict, Type, Union
|
|
2
|
+
|
|
3
|
+
import attrs
|
|
4
|
+
|
|
5
|
+
from data_diff.abcs.database_types import (
|
|
6
|
+
Datetime,
|
|
7
|
+
Timestamp,
|
|
8
|
+
Float,
|
|
9
|
+
Decimal,
|
|
10
|
+
Integer,
|
|
11
|
+
Text,
|
|
12
|
+
TemporalType,
|
|
13
|
+
FractionalType,
|
|
14
|
+
ColType_UUID,
|
|
15
|
+
Boolean,
|
|
16
|
+
Date,
|
|
17
|
+
)
|
|
18
|
+
from data_diff.databases.base import (
|
|
19
|
+
ThreadedDatabase,
|
|
20
|
+
import_helper,
|
|
21
|
+
ConnectError,
|
|
22
|
+
BaseDialect,
|
|
23
|
+
ThreadLocalInterpreter,
|
|
24
|
+
)
|
|
25
|
+
from data_diff.databases.base import (
|
|
26
|
+
MD5_HEXDIGITS,
|
|
27
|
+
CHECKSUM_HEXDIGITS,
|
|
28
|
+
TIMESTAMP_PRECISION_POS,
|
|
29
|
+
CHECKSUM_OFFSET,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@import_helper("mysql")
|
|
34
|
+
def import_mysql():
|
|
35
|
+
import mysql.connector
|
|
36
|
+
|
|
37
|
+
return mysql.connector
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@attrs.define(frozen=False)
|
|
41
|
+
class Dialect(BaseDialect):
|
|
42
|
+
name = "MySQL"
|
|
43
|
+
ROUNDS_ON_PREC_LOSS = True
|
|
44
|
+
SUPPORTS_PRIMARY_KEY: ClassVar[bool] = True
|
|
45
|
+
SUPPORTS_INDEXES = True
|
|
46
|
+
TYPE_CLASSES = {
|
|
47
|
+
# Dates
|
|
48
|
+
"datetime": Datetime,
|
|
49
|
+
"timestamp": Timestamp,
|
|
50
|
+
"date": Date,
|
|
51
|
+
# Numbers
|
|
52
|
+
"double": Float,
|
|
53
|
+
"float": Float,
|
|
54
|
+
"decimal": Decimal,
|
|
55
|
+
"int": Integer,
|
|
56
|
+
"bigint": Integer,
|
|
57
|
+
"mediumint": Integer,
|
|
58
|
+
"smallint": Integer,
|
|
59
|
+
"tinyint": Integer,
|
|
60
|
+
# Text
|
|
61
|
+
"varchar": Text,
|
|
62
|
+
"char": Text,
|
|
63
|
+
"varbinary": Text,
|
|
64
|
+
"binary": Text,
|
|
65
|
+
"text": Text,
|
|
66
|
+
"mediumtext": Text,
|
|
67
|
+
"longtext": Text,
|
|
68
|
+
"tinytext": Text,
|
|
69
|
+
# Boolean
|
|
70
|
+
"boolean": Boolean,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def quote(self, s: str) -> str:
|
|
74
|
+
return f"`{s}`"
|
|
75
|
+
|
|
76
|
+
def to_string(self, s: str) -> str:
|
|
77
|
+
return f"cast({s} as char)"
|
|
78
|
+
|
|
79
|
+
def is_distinct_from(self, a: str, b: str) -> str:
|
|
80
|
+
return f"not ({a} <=> {b})"
|
|
81
|
+
|
|
82
|
+
def random(self) -> str:
|
|
83
|
+
return "RAND()"
|
|
84
|
+
|
|
85
|
+
def type_repr(self, t) -> str:
|
|
86
|
+
try:
|
|
87
|
+
return {
|
|
88
|
+
str: "VARCHAR(1024)",
|
|
89
|
+
}[t]
|
|
90
|
+
except KeyError:
|
|
91
|
+
return super().type_repr(t)
|
|
92
|
+
|
|
93
|
+
def explain_as_text(self, query: str) -> str:
|
|
94
|
+
return f"EXPLAIN FORMAT=TREE {query}"
|
|
95
|
+
|
|
96
|
+
def optimizer_hints(self, s: str):
|
|
97
|
+
return f"/*+ {s} */ "
|
|
98
|
+
|
|
99
|
+
def set_timezone_to_utc(self) -> str:
|
|
100
|
+
return "SET @@session.time_zone='+00:00'"
|
|
101
|
+
|
|
102
|
+
def md5_as_int(self, s: str) -> str:
|
|
103
|
+
return f"conv(substring(md5({s}), {1+MD5_HEXDIGITS-CHECKSUM_HEXDIGITS}), 16, 10) - {CHECKSUM_OFFSET}"
|
|
104
|
+
|
|
105
|
+
def md5_as_hex(self, s: str) -> str:
|
|
106
|
+
return f"md5({s})"
|
|
107
|
+
|
|
108
|
+
def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
|
|
109
|
+
if coltype.rounds:
|
|
110
|
+
return self.to_string(f"cast( cast({value} as datetime({coltype.precision})) as datetime(6))")
|
|
111
|
+
|
|
112
|
+
s = self.to_string(f"cast({value} as datetime(6))")
|
|
113
|
+
return f"RPAD(RPAD({s}, {TIMESTAMP_PRECISION_POS+coltype.precision}, '.'), {TIMESTAMP_PRECISION_POS+6}, '0')"
|
|
114
|
+
|
|
115
|
+
def normalize_number(self, value: str, coltype: FractionalType) -> str:
|
|
116
|
+
return self.to_string(f"cast({value} as decimal(38, {coltype.precision}))")
|
|
117
|
+
|
|
118
|
+
def normalize_uuid(self, value: str, coltype: ColType_UUID) -> str:
|
|
119
|
+
return f"TRIM(CAST({value} AS char))"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@attrs.define(frozen=False, init=False, kw_only=True)
|
|
123
|
+
class MySQL(ThreadedDatabase):
|
|
124
|
+
DIALECT_CLASS: ClassVar[Type[BaseDialect]] = Dialect
|
|
125
|
+
SUPPORTS_ALPHANUMS = False
|
|
126
|
+
SUPPORTS_UNIQUE_CONSTAINT = True
|
|
127
|
+
CONNECT_URI_HELP = "mysql://<user>:<password>@<host>/<database>"
|
|
128
|
+
CONNECT_URI_PARAMS = ["database?"]
|
|
129
|
+
|
|
130
|
+
_args: Dict[str, Any]
|
|
131
|
+
|
|
132
|
+
def __init__(self, *, thread_count, **kw) -> None:
|
|
133
|
+
super().__init__(thread_count=thread_count)
|
|
134
|
+
self._args = kw
|
|
135
|
+
|
|
136
|
+
# In MySQL schema and database are synonymous
|
|
137
|
+
try:
|
|
138
|
+
self.default_schema = kw["database"]
|
|
139
|
+
except KeyError:
|
|
140
|
+
raise ValueError("MySQL URL must specify a database")
|
|
141
|
+
|
|
142
|
+
def create_connection(self):
|
|
143
|
+
mysql = import_mysql()
|
|
144
|
+
try:
|
|
145
|
+
return mysql.connect(charset="utf8", use_unicode=True, **self._args)
|
|
146
|
+
except mysql.Error as e:
|
|
147
|
+
if e.errno == mysql.errorcode.ER_ACCESS_DENIED_ERROR:
|
|
148
|
+
raise ConnectError("Bad user name or password") from e
|
|
149
|
+
elif e.errno == mysql.errorcode.ER_BAD_DB_ERROR:
|
|
150
|
+
raise ConnectError("Database does not exist") from e
|
|
151
|
+
raise ConnectError(*e.args) from e
|
|
152
|
+
|
|
153
|
+
def _query_in_worker(self, sql_code: Union[str, ThreadLocalInterpreter]):
|
|
154
|
+
"This method runs in a worker thread"
|
|
155
|
+
if self._init_error:
|
|
156
|
+
raise self._init_error
|
|
157
|
+
if not self.thread_local.conn.is_connected():
|
|
158
|
+
self.thread_local.conn.ping(reconnect=True, attempts=3, delay=5)
|
|
159
|
+
return self._query_conn(self.thread_local.conn, sql_code)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from typing import Any, ClassVar, Dict, List, Optional, Type
|
|
2
|
+
|
|
3
|
+
import attrs
|
|
4
|
+
|
|
5
|
+
from data_diff.schema import RawColumnInfo
|
|
6
|
+
from data_diff.utils import match_regexps
|
|
7
|
+
from data_diff.abcs.database_types import (
|
|
8
|
+
Decimal,
|
|
9
|
+
Float,
|
|
10
|
+
Text,
|
|
11
|
+
DbPath,
|
|
12
|
+
TemporalType,
|
|
13
|
+
ColType,
|
|
14
|
+
DbTime,
|
|
15
|
+
ColType_UUID,
|
|
16
|
+
Timestamp,
|
|
17
|
+
TimestampTZ,
|
|
18
|
+
FractionalType,
|
|
19
|
+
)
|
|
20
|
+
from data_diff.databases.base import (
|
|
21
|
+
BaseDialect,
|
|
22
|
+
ThreadedDatabase,
|
|
23
|
+
import_helper,
|
|
24
|
+
ConnectError,
|
|
25
|
+
QueryError,
|
|
26
|
+
CHECKSUM_OFFSET,
|
|
27
|
+
CHECKSUM_HEXDIGITS,
|
|
28
|
+
MD5_HEXDIGITS,
|
|
29
|
+
)
|
|
30
|
+
from data_diff.databases.base import TIMESTAMP_PRECISION_POS
|
|
31
|
+
|
|
32
|
+
SESSION_TIME_ZONE = None # Changed by the tests
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@import_helper("oracle")
|
|
36
|
+
def import_oracle():
|
|
37
|
+
import oracledb
|
|
38
|
+
|
|
39
|
+
return oracledb
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@attrs.define(frozen=False)
|
|
43
|
+
class Dialect(
|
|
44
|
+
BaseDialect,
|
|
45
|
+
):
|
|
46
|
+
name = "Oracle"
|
|
47
|
+
SUPPORTS_PRIMARY_KEY: ClassVar[bool] = True
|
|
48
|
+
SUPPORTS_INDEXES = True
|
|
49
|
+
TYPE_CLASSES: Dict[str, type] = {
|
|
50
|
+
"NUMBER": Decimal,
|
|
51
|
+
"FLOAT": Float,
|
|
52
|
+
# Text
|
|
53
|
+
"CHAR": Text,
|
|
54
|
+
"NCHAR": Text,
|
|
55
|
+
"NVARCHAR2": Text,
|
|
56
|
+
"VARCHAR2": Text,
|
|
57
|
+
"DATE": Timestamp,
|
|
58
|
+
}
|
|
59
|
+
ROUNDS_ON_PREC_LOSS = True
|
|
60
|
+
PLACEHOLDER_TABLE = "DUAL"
|
|
61
|
+
|
|
62
|
+
def quote(self, s: str) -> str:
|
|
63
|
+
return f'"{s}"'
|
|
64
|
+
|
|
65
|
+
def to_string(self, s: str) -> str:
|
|
66
|
+
return f"cast({s} as varchar(1024))"
|
|
67
|
+
|
|
68
|
+
def limit_select(
|
|
69
|
+
self,
|
|
70
|
+
select_query: str,
|
|
71
|
+
offset: Optional[int] = None,
|
|
72
|
+
limit: Optional[int] = None,
|
|
73
|
+
has_order_by: Optional[bool] = None,
|
|
74
|
+
) -> str:
|
|
75
|
+
if offset:
|
|
76
|
+
raise NotImplementedError("No support for OFFSET in query")
|
|
77
|
+
|
|
78
|
+
return f"SELECT * FROM ({select_query}) FETCH NEXT {limit} ROWS ONLY"
|
|
79
|
+
|
|
80
|
+
def concat(self, items: List[str]) -> str:
|
|
81
|
+
joined_exprs = " || ".join(items)
|
|
82
|
+
return f"({joined_exprs})"
|
|
83
|
+
|
|
84
|
+
def timestamp_value(self, t: DbTime) -> str:
|
|
85
|
+
return "timestamp '%s'" % t.isoformat(" ")
|
|
86
|
+
|
|
87
|
+
def random(self) -> str:
|
|
88
|
+
return "dbms_random.value"
|
|
89
|
+
|
|
90
|
+
def is_distinct_from(self, a: str, b: str) -> str:
|
|
91
|
+
return f"DECODE({a}, {b}, 1, 0) = 0"
|
|
92
|
+
|
|
93
|
+
def type_repr(self, t) -> str:
|
|
94
|
+
try:
|
|
95
|
+
return {
|
|
96
|
+
str: "VARCHAR(1024)",
|
|
97
|
+
}[t]
|
|
98
|
+
except KeyError:
|
|
99
|
+
return super().type_repr(t)
|
|
100
|
+
|
|
101
|
+
def constant_values(self, rows) -> str:
|
|
102
|
+
return " UNION ALL ".join(
|
|
103
|
+
"SELECT %s FROM DUAL" % ", ".join(self._constant_value(v) for v in row) for row in rows
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def explain_as_text(self, query: str) -> str:
|
|
107
|
+
raise NotImplementedError("Explain not yet implemented in Oracle")
|
|
108
|
+
|
|
109
|
+
def parse_type(self, table_path: DbPath, info: RawColumnInfo) -> ColType:
|
|
110
|
+
regexps = {
|
|
111
|
+
r"TIMESTAMP\((\d)\) WITH LOCAL TIME ZONE": Timestamp,
|
|
112
|
+
r"TIMESTAMP\((\d)\) WITH TIME ZONE": TimestampTZ,
|
|
113
|
+
r"TIMESTAMP\((\d)\)": Timestamp,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for m, t_cls in match_regexps(regexps, info.data_type):
|
|
117
|
+
precision = int(m.group(1))
|
|
118
|
+
return t_cls(precision=precision, rounds=self.ROUNDS_ON_PREC_LOSS)
|
|
119
|
+
|
|
120
|
+
return super().parse_type(table_path, info)
|
|
121
|
+
|
|
122
|
+
def set_timezone_to_utc(self) -> str:
|
|
123
|
+
return "ALTER SESSION SET TIME_ZONE = 'UTC'"
|
|
124
|
+
|
|
125
|
+
def current_timestamp(self) -> str:
|
|
126
|
+
return "LOCALTIMESTAMP"
|
|
127
|
+
|
|
128
|
+
def md5_as_int(self, s: str) -> str:
|
|
129
|
+
# standard_hash is faster than DBMS_CRYPTO.Hash
|
|
130
|
+
# TODO: Find a way to use UTL_RAW.CAST_TO_BINARY_INTEGER ?
|
|
131
|
+
return f"to_number(substr(standard_hash({s}, 'MD5'), {1+MD5_HEXDIGITS-CHECKSUM_HEXDIGITS}), 'xxxxxxxxxxxxxxx') - {CHECKSUM_OFFSET}"
|
|
132
|
+
|
|
133
|
+
def md5_as_hex(self, s: str) -> str:
|
|
134
|
+
return f"standard_hash({s}, 'MD5')"
|
|
135
|
+
|
|
136
|
+
def normalize_uuid(self, value: str, coltype: ColType_UUID) -> str:
|
|
137
|
+
# Cast is necessary for correct MD5 (trimming not enough)
|
|
138
|
+
return f"CAST(TRIM({value}) AS VARCHAR(36))"
|
|
139
|
+
|
|
140
|
+
def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
|
|
141
|
+
if coltype.rounds:
|
|
142
|
+
return f"to_char(cast({value} as timestamp({coltype.precision})), 'YYYY-MM-DD HH24:MI:SS.FF6')"
|
|
143
|
+
|
|
144
|
+
if coltype.precision > 0:
|
|
145
|
+
truncated = f"to_char({value}, 'YYYY-MM-DD HH24:MI:SS.FF{coltype.precision}')"
|
|
146
|
+
else:
|
|
147
|
+
truncated = f"to_char({value}, 'YYYY-MM-DD HH24:MI:SS.')"
|
|
148
|
+
return f"RPAD({truncated}, {TIMESTAMP_PRECISION_POS+6}, '0')"
|
|
149
|
+
|
|
150
|
+
def normalize_number(self, value: str, coltype: FractionalType) -> str:
|
|
151
|
+
# FM999.9990
|
|
152
|
+
format_str = "FM" + "9" * (38 - coltype.precision)
|
|
153
|
+
if coltype.precision:
|
|
154
|
+
format_str += "0." + "9" * (coltype.precision - 1) + "0"
|
|
155
|
+
return f"to_char({value}, '{format_str}')"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@attrs.define(frozen=False, init=False, kw_only=True)
|
|
159
|
+
class Oracle(ThreadedDatabase):
|
|
160
|
+
DIALECT_CLASS: ClassVar[Type[BaseDialect]] = Dialect
|
|
161
|
+
CONNECT_URI_HELP = "oracle://<user>:<password>@<host>/<database>"
|
|
162
|
+
CONNECT_URI_PARAMS = ["database?"]
|
|
163
|
+
|
|
164
|
+
kwargs: Dict[str, Any]
|
|
165
|
+
_oracle: Any
|
|
166
|
+
|
|
167
|
+
def __init__(self, *, host, database, thread_count, **kw) -> None:
|
|
168
|
+
super().__init__(thread_count=thread_count)
|
|
169
|
+
self.kwargs = dict(dsn=f"{host}/{database}" if database else host, **kw)
|
|
170
|
+
self.default_schema = kw.get("user").upper()
|
|
171
|
+
self._oracle = None
|
|
172
|
+
|
|
173
|
+
def create_connection(self):
|
|
174
|
+
self._oracle = import_oracle()
|
|
175
|
+
try:
|
|
176
|
+
c = self._oracle.connect(**self.kwargs)
|
|
177
|
+
if SESSION_TIME_ZONE:
|
|
178
|
+
c.cursor().execute(f"ALTER SESSION SET TIME_ZONE = '{SESSION_TIME_ZONE}'")
|
|
179
|
+
return c
|
|
180
|
+
except Exception as e:
|
|
181
|
+
raise ConnectError(*e.args) from e
|
|
182
|
+
|
|
183
|
+
def _query_cursor(self, c, sql_code: str):
|
|
184
|
+
try:
|
|
185
|
+
return super()._query_cursor(c, sql_code)
|
|
186
|
+
except self._oracle.DatabaseError as e:
|
|
187
|
+
raise QueryError(e)
|
|
188
|
+
|
|
189
|
+
def select_table_schema(self, path: DbPath) -> str:
|
|
190
|
+
schema, name = self._normalize_table_path(path)
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
f"SELECT column_name, data_type, 6 as datetime_precision, data_precision as numeric_precision, data_scale as numeric_scale"
|
|
194
|
+
f" FROM ALL_TAB_COLUMNS WHERE table_name = '{name}' AND owner = '{schema}'"
|
|
195
|
+
)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from typing import Any, ClassVar, Dict, List, Type
|
|
2
|
+
from urllib.parse import unquote
|
|
3
|
+
import attrs
|
|
4
|
+
|
|
5
|
+
from data_diff.abcs.database_types import (
|
|
6
|
+
ColType,
|
|
7
|
+
DbPath,
|
|
8
|
+
JSON,
|
|
9
|
+
Timestamp,
|
|
10
|
+
TimestampTZ,
|
|
11
|
+
Float,
|
|
12
|
+
Decimal,
|
|
13
|
+
Integer,
|
|
14
|
+
TemporalType,
|
|
15
|
+
Native_UUID,
|
|
16
|
+
Text,
|
|
17
|
+
FractionalType,
|
|
18
|
+
Boolean,
|
|
19
|
+
Date,
|
|
20
|
+
Time,
|
|
21
|
+
)
|
|
22
|
+
from data_diff.databases.base import BaseDialect, ThreadedDatabase, import_helper, ConnectError
|
|
23
|
+
from data_diff.databases.base import (
|
|
24
|
+
MD5_HEXDIGITS,
|
|
25
|
+
CHECKSUM_HEXDIGITS,
|
|
26
|
+
_CHECKSUM_BITSIZE,
|
|
27
|
+
TIMESTAMP_PRECISION_POS,
|
|
28
|
+
CHECKSUM_OFFSET,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
SESSION_TIME_ZONE = None # Changed by the tests
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@import_helper("postgresql")
|
|
35
|
+
def import_postgresql():
|
|
36
|
+
import psycopg2.extras
|
|
37
|
+
|
|
38
|
+
psycopg2.extensions.set_wait_callback(psycopg2.extras.wait_select)
|
|
39
|
+
return psycopg2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@attrs.define(frozen=False)
|
|
43
|
+
class PostgresqlDialect(BaseDialect):
|
|
44
|
+
name = "PostgreSQL"
|
|
45
|
+
ROUNDS_ON_PREC_LOSS = True
|
|
46
|
+
SUPPORTS_PRIMARY_KEY: ClassVar[bool] = True
|
|
47
|
+
SUPPORTS_INDEXES = True
|
|
48
|
+
|
|
49
|
+
# https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-NUMERIC-DECIMAL
|
|
50
|
+
# without any precision or scale creates an “unconstrained numeric” column
|
|
51
|
+
# in which numeric values of any length can be stored, up to the implementation limits.
|
|
52
|
+
# https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-NUMERIC-TABLE
|
|
53
|
+
DEFAULT_NUMERIC_PRECISION = 16383
|
|
54
|
+
|
|
55
|
+
TYPE_CLASSES: ClassVar[Dict[str, Type[ColType]]] = {
|
|
56
|
+
# Timestamps
|
|
57
|
+
"timestamp with time zone": TimestampTZ,
|
|
58
|
+
"timestamp without time zone": Timestamp,
|
|
59
|
+
"timestamp": Timestamp,
|
|
60
|
+
"date": Date,
|
|
61
|
+
"time with time zone": Time,
|
|
62
|
+
"time without time zone": Time,
|
|
63
|
+
# Numbers
|
|
64
|
+
"double precision": Float,
|
|
65
|
+
"real": Float,
|
|
66
|
+
"decimal": Decimal,
|
|
67
|
+
"smallint": Integer,
|
|
68
|
+
"integer": Integer,
|
|
69
|
+
"numeric": Decimal,
|
|
70
|
+
"bigint": Integer,
|
|
71
|
+
# Text
|
|
72
|
+
"character": Text,
|
|
73
|
+
"character varying": Text,
|
|
74
|
+
"varchar": Text,
|
|
75
|
+
"text": Text,
|
|
76
|
+
"json": JSON,
|
|
77
|
+
"jsonb": JSON,
|
|
78
|
+
"uuid": Native_UUID,
|
|
79
|
+
"boolean": Boolean,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def quote(self, s: str):
|
|
83
|
+
return f'"{s}"'
|
|
84
|
+
|
|
85
|
+
def to_string(self, s: str):
|
|
86
|
+
return f"{s}::varchar"
|
|
87
|
+
|
|
88
|
+
def concat(self, items: List[str]) -> str:
|
|
89
|
+
joined_exprs = " || ".join(items)
|
|
90
|
+
return f"({joined_exprs})"
|
|
91
|
+
|
|
92
|
+
def _convert_db_precision_to_digits(self, p: int) -> int:
|
|
93
|
+
# Subtracting 2 due to wierd precision issues in PostgreSQL
|
|
94
|
+
return super()._convert_db_precision_to_digits(p) - 2
|
|
95
|
+
|
|
96
|
+
def set_timezone_to_utc(self) -> str:
|
|
97
|
+
return "SET TIME ZONE 'UTC'"
|
|
98
|
+
|
|
99
|
+
def current_timestamp(self) -> str:
|
|
100
|
+
return "current_timestamp"
|
|
101
|
+
|
|
102
|
+
def type_repr(self, t) -> str:
|
|
103
|
+
if isinstance(t, TimestampTZ):
|
|
104
|
+
return f"timestamp ({t.precision}) with time zone"
|
|
105
|
+
return super().type_repr(t)
|
|
106
|
+
|
|
107
|
+
def md5_as_int(self, s: str) -> str:
|
|
108
|
+
return f"('x' || substring(md5({s}), {1+MD5_HEXDIGITS-CHECKSUM_HEXDIGITS}))::bit({_CHECKSUM_BITSIZE})::bigint - {CHECKSUM_OFFSET}"
|
|
109
|
+
|
|
110
|
+
def md5_as_hex(self, s: str) -> str:
|
|
111
|
+
return f"md5({s})"
|
|
112
|
+
|
|
113
|
+
def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
|
|
114
|
+
def _add_padding(coltype: TemporalType, timestamp6: str):
|
|
115
|
+
return f"RPAD(LEFT({timestamp6}, {TIMESTAMP_PRECISION_POS+coltype.precision}), {TIMESTAMP_PRECISION_POS+6}, '0')"
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
is_date = coltype.is_date
|
|
119
|
+
is_time = coltype.is_time
|
|
120
|
+
except:
|
|
121
|
+
is_date = False
|
|
122
|
+
is_time = False
|
|
123
|
+
|
|
124
|
+
if isinstance(coltype, Date) or is_date:
|
|
125
|
+
return f"cast({value} as varchar)"
|
|
126
|
+
|
|
127
|
+
if isinstance(coltype, Time) or is_time:
|
|
128
|
+
seconds = f"EXTRACT( epoch from {value})"
|
|
129
|
+
rounded = f"ROUND({seconds}, {coltype.precision})"
|
|
130
|
+
time_value = f"CAST('00:00:00' as time) + make_interval(0, 0, 0, 0, 0, 0, {rounded})" # 6th arg = seconds
|
|
131
|
+
converted = f"to_char({time_value}, 'hh24:mi:ss.ff6')"
|
|
132
|
+
return converted
|
|
133
|
+
|
|
134
|
+
if coltype.rounds:
|
|
135
|
+
# NULL value expected to return NULL after normalization
|
|
136
|
+
null_case_begin = f"CASE WHEN {value} IS NULL THEN NULL ELSE "
|
|
137
|
+
null_case_end = "END"
|
|
138
|
+
|
|
139
|
+
# 294277 or 4714 BC would be out of range, make sure we can't round to that
|
|
140
|
+
# TODO test timezones for overflow?
|
|
141
|
+
max_timestamp = "294276-12-31 23:59:59.0000"
|
|
142
|
+
min_timestamp = "4713-01-01 00:00:00.00 BC"
|
|
143
|
+
timestamp = f"least('{max_timestamp}'::timestamp(6), {value}::timestamp(6))"
|
|
144
|
+
timestamp = f"greatest('{min_timestamp}'::timestamp(6), {timestamp})"
|
|
145
|
+
|
|
146
|
+
interval = format((0.5 * (10 ** (-coltype.precision))), f".{coltype.precision+1}f")
|
|
147
|
+
|
|
148
|
+
rounded_timestamp = (
|
|
149
|
+
f"left(to_char(least('{max_timestamp}'::timestamp, {timestamp})"
|
|
150
|
+
f"+ interval '{interval}', 'YYYY-mm-dd HH24:MI:SS.US'),"
|
|
151
|
+
f"length(to_char(least('{max_timestamp}'::timestamp, {timestamp})"
|
|
152
|
+
f"+ interval '{interval}', 'YYYY-mm-dd HH24:MI:SS.US')) - (6-{coltype.precision}))"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
padded = _add_padding(coltype, rounded_timestamp)
|
|
156
|
+
return f"{null_case_begin} {padded} {null_case_end}"
|
|
157
|
+
|
|
158
|
+
# TODO years with > 4 digits not padded correctly
|
|
159
|
+
# current w/ precision 6: 294276-12-31 23:59:59.0000
|
|
160
|
+
# should be 294276-12-31 23:59:59.000000
|
|
161
|
+
else:
|
|
162
|
+
rounded_timestamp = f"to_char({value}::timestamp(6), 'YYYY-mm-dd HH24:MI:SS.US')"
|
|
163
|
+
padded = _add_padding(coltype, rounded_timestamp)
|
|
164
|
+
return padded
|
|
165
|
+
|
|
166
|
+
def normalize_number(self, value: str, coltype: FractionalType) -> str:
|
|
167
|
+
return self.to_string(f"{value}::decimal(38, {coltype.precision})")
|
|
168
|
+
|
|
169
|
+
def normalize_boolean(self, value: str, _coltype: Boolean) -> str:
|
|
170
|
+
return self.to_string(f"{value}::int")
|
|
171
|
+
|
|
172
|
+
def normalize_json(self, value: str, _coltype: JSON) -> str:
|
|
173
|
+
return f"{value}::text"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@attrs.define(frozen=False, init=False, kw_only=True)
|
|
177
|
+
class PostgreSQL(ThreadedDatabase):
|
|
178
|
+
DIALECT_CLASS: ClassVar[Type[BaseDialect]] = PostgresqlDialect
|
|
179
|
+
SUPPORTS_UNIQUE_CONSTAINT = True
|
|
180
|
+
CONNECT_URI_HELP = "postgresql://<user>:<password>@<host>/<database>"
|
|
181
|
+
CONNECT_URI_PARAMS = ["database?"]
|
|
182
|
+
|
|
183
|
+
_args: Dict[str, Any]
|
|
184
|
+
_conn: Any
|
|
185
|
+
|
|
186
|
+
def __init__(self, *, thread_count, **kw) -> None:
|
|
187
|
+
super().__init__(thread_count=thread_count)
|
|
188
|
+
self._args = kw
|
|
189
|
+
self.default_schema = "public"
|
|
190
|
+
|
|
191
|
+
def create_connection(self):
|
|
192
|
+
if not self._args:
|
|
193
|
+
self._args["host"] = None # psycopg2 requires 1+ arguments
|
|
194
|
+
|
|
195
|
+
pg = import_postgresql()
|
|
196
|
+
try:
|
|
197
|
+
self._args["password"] = unquote(self._args["password"])
|
|
198
|
+
self._conn = pg.connect(
|
|
199
|
+
**self._args, keepalives=1, keepalives_idle=5, keepalives_interval=2, keepalives_count=2
|
|
200
|
+
)
|
|
201
|
+
if SESSION_TIME_ZONE:
|
|
202
|
+
self._conn.cursor().execute(f"SET TIME ZONE '{SESSION_TIME_ZONE}'")
|
|
203
|
+
return self._conn
|
|
204
|
+
except pg.OperationalError as e:
|
|
205
|
+
raise ConnectError(*e.args) from e
|
|
206
|
+
|
|
207
|
+
def select_table_schema(self, path: DbPath) -> str:
|
|
208
|
+
database, schema, table = self._normalize_table_path(path)
|
|
209
|
+
|
|
210
|
+
info_schema_path = ["information_schema", "columns"]
|
|
211
|
+
if database:
|
|
212
|
+
info_schema_path.insert(0, database)
|
|
213
|
+
|
|
214
|
+
return f"""SELECT column_name, data_type, datetime_precision,
|
|
215
|
+
-- see comment for DEFAULT_NUMERIC_PRECISION
|
|
216
|
+
CASE
|
|
217
|
+
WHEN data_type = 'numeric'
|
|
218
|
+
THEN coalesce(numeric_precision, 131072 + {self.dialect.DEFAULT_NUMERIC_PRECISION})
|
|
219
|
+
ELSE numeric_precision
|
|
220
|
+
END AS numeric_precision,
|
|
221
|
+
CASE
|
|
222
|
+
WHEN data_type = 'numeric'
|
|
223
|
+
THEN coalesce(numeric_scale, {self.dialect.DEFAULT_NUMERIC_PRECISION})
|
|
224
|
+
ELSE numeric_scale
|
|
225
|
+
END AS numeric_scale
|
|
226
|
+
FROM {'.'.join(info_schema_path)}
|
|
227
|
+
WHERE table_name = '{table}' AND table_schema = '{schema}'
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
def select_table_unique_columns(self, path: DbPath) -> str:
|
|
231
|
+
database, schema, table = self._normalize_table_path(path)
|
|
232
|
+
|
|
233
|
+
info_schema_path = ["information_schema", "key_column_usage"]
|
|
234
|
+
if database:
|
|
235
|
+
info_schema_path.insert(0, database)
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
"SELECT column_name "
|
|
239
|
+
f"FROM {'.'.join(info_schema_path)} "
|
|
240
|
+
f"WHERE table_name = '{table}' AND table_schema = '{schema}'"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def _normalize_table_path(self, path: DbPath) -> DbPath:
|
|
244
|
+
if len(path) == 1:
|
|
245
|
+
return None, self.default_schema, path[0]
|
|
246
|
+
elif len(path) == 2:
|
|
247
|
+
return None, path[0], path[1]
|
|
248
|
+
elif len(path) == 3:
|
|
249
|
+
return path
|
|
250
|
+
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"{self.name}: Bad table path for {self}: '{'.'.join(path)}'. Expected format: table, schema.table, or database.schema.table"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def close(self):
|
|
256
|
+
super().close()
|
|
257
|
+
if self._conn is not None:
|
|
258
|
+
self._conn.close()
|