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