collate-data-diff 0.11.9__tar.gz → 0.11.11__tar.gz

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.9 → collate_data_diff-0.11.11}/PKG-INFO +6 -6
  2. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/__init__.py +1 -0
  3. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/__main__.py +1 -3
  4. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/cloud/datafold_api.py +9 -9
  5. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/base.py +2 -2
  6. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/bigquery.py +2 -4
  7. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/databricks.py +1 -1
  8. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/duckdb.py +15 -11
  9. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/mysql.py +4 -2
  10. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/oracle.py +2 -2
  11. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/postgresql.py +4 -4
  12. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/presto.py +21 -10
  13. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/redshift.py +1 -1
  14. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/trino.py +26 -14
  15. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/vertica.py +12 -3
  16. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/dbt.py +4 -3
  17. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/dbt_config_validators.py +10 -10
  18. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/dbt_parser.py +2 -2
  19. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/diff_tables.py +1 -1
  20. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/parse_time.py +1 -1
  21. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/schema.py +1 -1
  22. collate_data_diff-0.11.11/data_diff/version.py +1 -0
  23. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/pyproject.toml +8 -8
  24. collate_data_diff-0.11.9/data_diff/version.py +0 -1
  25. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/LICENSE +0 -0
  26. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/README.md +0 -0
  27. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/abcs/__init__.py +0 -0
  28. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/abcs/compiler.py +0 -0
  29. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/abcs/database_types.py +0 -0
  30. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/cloud/__init__.py +0 -0
  31. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/cloud/data_source.py +0 -0
  32. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/config.py +0 -0
  33. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/__init__.py +0 -0
  34. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/_connect.py +0 -0
  35. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/clickhouse.py +0 -0
  36. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/mssql.py +0 -0
  37. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/databases/snowflake.py +0 -0
  38. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/errors.py +0 -0
  39. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/format.py +0 -0
  40. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/hashdiff_tables.py +0 -0
  41. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/info_tree.py +0 -0
  42. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/joindiff_tables.py +0 -0
  43. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/lexicographic_space.py +0 -0
  44. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/py.typed +0 -0
  45. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/queries/__init__.py +0 -0
  46. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/queries/api.py +0 -0
  47. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/queries/ast_classes.py +0 -0
  48. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/queries/base.py +0 -0
  49. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/queries/extras.py +0 -0
  50. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/query_utils.py +0 -0
  51. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/table_segment.py +0 -0
  52. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/thread_utils.py +0 -0
  53. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/tracking.py +0 -0
  54. {collate_data_diff-0.11.9 → collate_data_diff-0.11.11}/data_diff/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collate-data-diff
3
- Version: 0.11.9
3
+ Version: 0.11.11
4
4
  Summary: Command-line tool and Python library to efficiently diff rows across two different databases.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -37,9 +37,9 @@ Provides-Extra: snowflake
37
37
  Provides-Extra: trino
38
38
  Provides-Extra: vertica
39
39
  Requires-Dist: attrs (>=23.1.0)
40
- Requires-Dist: click (>=8.1)
40
+ Requires-Dist: click (>=8.3.3)
41
41
  Requires-Dist: clickhouse-driver ; extra == "clickhouse" or extra == "all-dbs"
42
- Requires-Dist: cryptography ; extra == "snowflake" or extra == "all-dbs"
42
+ Requires-Dist: cryptography (>=46.0.5) ; extra == "snowflake" or extra == "all-dbs"
43
43
  Requires-Dist: dbt-core (>=1.0.0,<2.0.0)
44
44
  Requires-Dist: deepdiff (>=8.6.1,<9.0)
45
45
  Requires-Dist: dsnparse (<0.2.0)
@@ -52,15 +52,15 @@ Requires-Dist: oracledb ; extra == "oracle" or extra == "all-dbs"
52
52
  Requires-Dist: preql (>=0.2.19) ; extra == "preql" or extra == "all-dbs"
53
53
  Requires-Dist: presto-python-client ; extra == "presto" or extra == "all-dbs"
54
54
  Requires-Dist: psycopg2 ; extra == "postgresql" or extra == "redshift" or extra == "all-dbs"
55
- Requires-Dist: pydantic (>=1.10.12)
55
+ Requires-Dist: pydantic (>=2.0,<3)
56
56
  Requires-Dist: pyodbc (>=4.0.39) ; extra == "mssql" or extra == "all-dbs"
57
57
  Requires-Dist: rich
58
- Requires-Dist: snowflake-connector-python (>=3.15.0,<4.0.0) ; (python_version >= "3.9") and (extra == "snowflake" or extra == "all-dbs")
58
+ Requires-Dist: snowflake-connector-python (>=4.4.0,<5.0.0) ; (python_version >= "3.10") and (extra == "snowflake" or extra == "all-dbs")
59
59
  Requires-Dist: tabulate (>=0.9.0)
60
60
  Requires-Dist: toml (>=0.10.2)
61
61
  Requires-Dist: trino (>=0.314.0) ; extra == "trino" or extra == "all-dbs"
62
62
  Requires-Dist: typing-extensions (>=4.0.1)
63
- Requires-Dist: urllib3 (<2)
63
+ Requires-Dist: urllib3 (>=2.7.0,<3)
64
64
  Requires-Dist: vertica-python ; extra == "vertica" or extra == "all-dbs"
65
65
  Project-URL: Repository, https://github.com/datafold/data-diff
66
66
  Description-Content-Type: text/markdown
@@ -41,6 +41,7 @@ def connect_to_table(
41
41
 
42
42
  return TableSegment(db, table_name, key_columns, **kw)
43
43
 
44
+
44
45
  def _get_table_segment_args(**kwargs):
45
46
  accepted_kwargs = {
46
47
  "update_column",
@@ -253,14 +253,12 @@ click.Context.formatter_class = MyHelpFormatter
253
253
  )
254
254
  @click.option(
255
255
  "--select",
256
- "-s",
257
256
  default=None,
258
257
  metavar="SELECTION or MODEL_NAME",
259
258
  help="--select dbt resources to compare using dbt selection syntax in dbt versions >= 1.5.\nIn versions < 1.5, it will naively search for a model with MODEL_NAME as the name.",
260
259
  )
261
260
  @click.option(
262
261
  "--state",
263
- "-s",
264
262
  default=None,
265
263
  metavar="PATH",
266
264
  help="Specify manifest to utilize for 'prod' comparison paths instead of using configuration.",
@@ -611,7 +609,7 @@ def _data_diff(
611
609
  _print_result(stats, json_output, diff_iter)
612
610
 
613
611
  end = time.monotonic()
614
- logging.info(f"Duration: {end-start:.2f} seconds.")
612
+ logging.info(f"Duration: {end - start:.2f} seconds.")
615
613
 
616
614
 
617
615
  if __name__ == "__main__":
@@ -103,8 +103,8 @@ class TCloudApiDataDiff(pydantic.BaseModel):
103
103
  pk_columns: List[str]
104
104
  filter1: Optional[str] = None
105
105
  filter2: Optional[str] = None
106
- include_columns: Optional[List[str]]
107
- exclude_columns: Optional[List[str]]
106
+ include_columns: Optional[List[str]] = None
107
+ exclude_columns: Optional[List[str]] = None
108
108
 
109
109
 
110
110
  class TCloudApiOrgMeta(pydantic.BaseModel):
@@ -150,10 +150,10 @@ class TSummaryResultDependencyDetails(pydantic.BaseModel):
150
150
 
151
151
  class TCloudApiDataDiffSummaryResult(pydantic.BaseModel):
152
152
  status: str
153
- pks: Optional[TSummaryResultPrimaryKeyStats]
154
- values: Optional[TSummaryResultValueStats]
155
- schema_: Optional[TSummaryResultSchemaStats]
156
- deps: Optional[TSummaryResultDependencyDetails]
153
+ pks: Optional[TSummaryResultPrimaryKeyStats] = None
154
+ values: Optional[TSummaryResultValueStats] = None
155
+ schema_: Optional[TSummaryResultSchemaStats] = None
156
+ deps: Optional[TSummaryResultDependencyDetails] = None
157
157
 
158
158
  @classmethod
159
159
  def from_orm(cls, obj: Any) -> Self:
@@ -179,7 +179,7 @@ class TCloudDataSourceTestResult(pydantic.BaseModel):
179
179
  class TCloudApiDataSourceTestResult(pydantic.BaseModel):
180
180
  name: str
181
181
  status: str
182
- result: Optional[TCloudDataSourceTestResult]
182
+ result: Optional[TCloudDataSourceTestResult] = None
183
183
 
184
184
 
185
185
  @attrs.define(frozen=False)
@@ -223,7 +223,7 @@ class DatafoldAPI:
223
223
  return TCloudApiDataSource(**datasource)
224
224
 
225
225
  def create_data_source(self, config: TDsConfig) -> TCloudApiDataSource:
226
- payload = config.dict()
226
+ payload = config.model_dump()
227
227
  if config.type == "bigquery":
228
228
  json_string = payload["options"]["jsonKeyFile"].encode("utf-8")
229
229
  payload["options"]["jsonKeyFile"] = base64.b64encode(json_string).decode("utf-8")
@@ -245,7 +245,7 @@ class DatafoldAPI:
245
245
  ]
246
246
 
247
247
  def create_data_diff(self, payload: TCloudApiDataDiff) -> int:
248
- rv = self.make_post_request(url="api/v1/datadiffs", payload=payload.dict())
248
+ rv = self.make_post_request(url="api/v1/datadiffs", payload=payload.model_dump())
249
249
  return rv.json()["id"]
250
250
 
251
251
  def poll_data_diff_results(self, diff_id: int) -> TCloudApiDataDiffSummaryResult:
@@ -137,7 +137,7 @@ class Compiler(AbstractCompiler):
137
137
 
138
138
  def new_unique_table_name(self, prefix="tmp") -> DbPath:
139
139
  self._counter[0] += 1
140
- table_name = f"{prefix}{self._counter[0]}_{'%x'%random.randrange(2**32)}"
140
+ table_name = f"{prefix}{self._counter[0]}_{'%x' % random.randrange(2**32)}"
141
141
  return self.database.dialect.parse_table_name(table_name)
142
142
 
143
143
  def add_table_context(self, *tables: Sequence, **kw) -> Self:
@@ -706,7 +706,7 @@ class BaseDialect(abc.ABC):
706
706
  return f"'{v.uuid}'"
707
707
  elif isinstance(v, decimal.Decimal):
708
708
  return str(v)
709
- elif isinstance(v, bytearray):
709
+ elif isinstance(v, (bytes, bytearray)):
710
710
  return f"'{v.decode()}'"
711
711
  elif isinstance(v, Code):
712
712
  return v.code
@@ -158,7 +158,7 @@ class Dialect(BaseDialect):
158
158
  return tuple(i for i in path if i is not None)
159
159
 
160
160
  def md5_as_int(self, s: str) -> str:
161
- return f"cast(cast( ('0x' || substr(TO_HEX(md5({s})), {1+MD5_HEXDIGITS-CHECKSUM_HEXDIGITS})) as int64) as numeric) - {CHECKSUM_OFFSET}"
161
+ return f"cast(cast( ('0x' || substr(TO_HEX(md5({s})), {1 + MD5_HEXDIGITS - CHECKSUM_HEXDIGITS})) as int64) as numeric) - {CHECKSUM_OFFSET}"
162
162
 
163
163
  def md5_as_hex(self, s: str) -> str:
164
164
  return f"md5({s})"
@@ -189,9 +189,7 @@ class Dialect(BaseDialect):
189
189
  return f"FORMAT_TIMESTAMP('%F %H:%M:%E6S', {value})"
190
190
 
191
191
  timestamp6 = f"FORMAT_TIMESTAMP('%F %H:%M:%E6S', {value})"
192
- return (
193
- f"RPAD(LEFT({timestamp6}, {TIMESTAMP_PRECISION_POS+coltype.precision}), {TIMESTAMP_PRECISION_POS+6}, '0')"
194
- )
192
+ return f"RPAD(LEFT({timestamp6}, {TIMESTAMP_PRECISION_POS + coltype.precision}), {TIMESTAMP_PRECISION_POS + 6}, '0')"
195
193
 
196
194
  def normalize_number(self, value: str, coltype: FractionalType) -> str:
197
195
  return f"format('%.{coltype.precision}f', {value})"
@@ -85,7 +85,7 @@ class Dialect(BaseDialect):
85
85
  return tuple(i for i in path if i is not None)
86
86
 
87
87
  def md5_as_int(self, s: str) -> str:
88
- return f"cast(conv(substr(md5({s}), {1+MD5_HEXDIGITS-CHECKSUM_HEXDIGITS}), 16, 10) as decimal(38, 0)) - {CHECKSUM_OFFSET}"
88
+ return f"cast(conv(substr(md5({s}), {1 + MD5_HEXDIGITS - CHECKSUM_HEXDIGITS}), 16, 10) as decimal(38, 0)) - {CHECKSUM_OFFSET}"
89
89
 
90
90
  def md5_as_hex(self, s: str) -> str:
91
91
  return f"md5({s})"
@@ -97,7 +97,7 @@ class Dialect(BaseDialect):
97
97
  return "current_timestamp"
98
98
 
99
99
  def md5_as_int(self, s: str) -> str:
100
- return f"('0x' || SUBSTRING(md5({s}), {1+MD5_HEXDIGITS-CHECKSUM_HEXDIGITS},{CHECKSUM_HEXDIGITS}))::BIGINT - {CHECKSUM_OFFSET}"
100
+ return f"('0x' || SUBSTRING(md5({s}), {1 + MD5_HEXDIGITS - CHECKSUM_HEXDIGITS},{CHECKSUM_HEXDIGITS}))::BIGINT - {CHECKSUM_OFFSET}"
101
101
 
102
102
  def md5_as_hex(self, s: str) -> str:
103
103
  return f"md5({s})"
@@ -105,9 +105,9 @@ class Dialect(BaseDialect):
105
105
  def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
106
106
  # It's precision 6 by default. If precision is less than 6 -> we remove the trailing numbers.
107
107
  if coltype.rounds and coltype.precision > 0:
108
- return f"CONCAT(SUBSTRING(STRFTIME({value}::TIMESTAMP, '%Y-%m-%d %H:%M:%S.'),1,23), LPAD(((ROUND(strftime({value}::timestamp, '%f')::DECIMAL(15,7)/100000,{coltype.precision-1})*100000)::INT)::VARCHAR,6,'0'))"
108
+ return f"CONCAT(SUBSTRING(STRFTIME({value}::TIMESTAMP, '%Y-%m-%d %H:%M:%S.'),1,23), LPAD(((ROUND(strftime({value}::timestamp, '%f')::DECIMAL(15,7)/100000,{coltype.precision - 1})*100000)::INT)::VARCHAR,6,'0'))"
109
109
 
110
- return f"rpad(substring(strftime({value}::timestamp, '%Y-%m-%d %H:%M:%S.%f'),1,{TIMESTAMP_PRECISION_POS+coltype.precision}),26,'0')"
110
+ return f"rpad(substring(strftime({value}::timestamp, '%Y-%m-%d %H:%M:%S.%f'),1,{TIMESTAMP_PRECISION_POS + coltype.precision}),26,'0')"
111
111
 
112
112
  def normalize_number(self, value: str, coltype: FractionalType) -> str:
113
113
  return self.to_string(f"{value}::DECIMAL(38, {coltype.precision})")
@@ -115,6 +115,11 @@ class Dialect(BaseDialect):
115
115
  def normalize_boolean(self, value: str, _coltype: Boolean) -> str:
116
116
  return self.to_string(f"{value}::INTEGER")
117
117
 
118
+ def type_repr(self, t) -> str:
119
+ if isinstance(t, TimestampTZ):
120
+ return "TIMESTAMP WITH TIME ZONE"
121
+ return super().type_repr(t)
122
+
118
123
 
119
124
  @attrs.define(frozen=False, init=False, kw_only=True)
120
125
  class DuckDB(Database):
@@ -166,16 +171,15 @@ class DuckDB(Database):
166
171
  def select_table_schema(self, path: DbPath) -> str:
167
172
  database, schema, table = self._normalize_table_path(path)
168
173
 
169
- info_schema_path = ["information_schema", "columns"]
170
-
171
- if database:
172
- info_schema_path.insert(0, database)
173
- dynamic_database_clause = f"'{database}'"
174
- else:
175
- dynamic_database_clause = "current_catalog()"
174
+ # Newer DuckDB rejects the <catalog>.information_schema.columns three
175
+ # part form that older versions accepted. Query the unqualified
176
+ # information_schema.columns view which already returns rows for every
177
+ # attached catalog and scope it with table_catalog.
178
+ dynamic_database_clause = f"'{database}'" if database else "current_catalog()"
176
179
 
177
180
  return (
178
- f"SELECT column_name, data_type, datetime_precision, numeric_precision, numeric_scale FROM {'.'.join(info_schema_path)} "
181
+ "SELECT column_name, data_type, datetime_precision, numeric_precision, numeric_scale "
182
+ "FROM information_schema.columns "
179
183
  f"WHERE table_name = '{table}' AND table_schema = '{schema}' and table_catalog = {dynamic_database_clause}"
180
184
  )
181
185
 
@@ -100,7 +100,7 @@ class Dialect(BaseDialect):
100
100
  return "SET @@session.time_zone='+00:00'"
101
101
 
102
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}"
103
+ return f"conv(substring(md5({s}), {1 + MD5_HEXDIGITS - CHECKSUM_HEXDIGITS}), 16, 10) - {CHECKSUM_OFFSET}"
104
104
 
105
105
  def md5_as_hex(self, s: str) -> str:
106
106
  return f"md5({s})"
@@ -110,7 +110,9 @@ class Dialect(BaseDialect):
110
110
  return self.to_string(f"cast( cast({value} as datetime({coltype.precision})) as datetime(6))")
111
111
 
112
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')"
113
+ return (
114
+ f"RPAD(RPAD({s}, {TIMESTAMP_PRECISION_POS + coltype.precision}, '.'), {TIMESTAMP_PRECISION_POS + 6}, '0')"
115
+ )
114
116
 
115
117
  def normalize_number(self, value: str, coltype: FractionalType) -> str:
116
118
  return self.to_string(f"cast({value} as decimal(38, {coltype.precision}))")
@@ -128,7 +128,7 @@ class Dialect(
128
128
  def md5_as_int(self, s: str) -> str:
129
129
  # standard_hash is faster than DBMS_CRYPTO.Hash
130
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}"
131
+ return f"to_number(substr(standard_hash({s}, 'MD5'), {1 + MD5_HEXDIGITS - CHECKSUM_HEXDIGITS}), 'xxxxxxxxxxxxxxx') - {CHECKSUM_OFFSET}"
132
132
 
133
133
  def md5_as_hex(self, s: str) -> str:
134
134
  return f"standard_hash({s}, 'MD5')"
@@ -145,7 +145,7 @@ class Dialect(
145
145
  truncated = f"to_char({value}, 'YYYY-MM-DD HH24:MI:SS.FF{coltype.precision}')"
146
146
  else:
147
147
  truncated = f"to_char({value}, 'YYYY-MM-DD HH24:MI:SS.')"
148
- return f"RPAD({truncated}, {TIMESTAMP_PRECISION_POS+6}, '0')"
148
+ return f"RPAD({truncated}, {TIMESTAMP_PRECISION_POS + 6}, '0')"
149
149
 
150
150
  def normalize_number(self, value: str, coltype: FractionalType) -> str:
151
151
  # FM999.9990
@@ -105,14 +105,14 @@ class PostgresqlDialect(BaseDialect):
105
105
  return super().type_repr(t)
106
106
 
107
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}"
108
+ return f"('x' || substring(md5({s}), {1 + MD5_HEXDIGITS - CHECKSUM_HEXDIGITS}))::bit({_CHECKSUM_BITSIZE})::bigint - {CHECKSUM_OFFSET}"
109
109
 
110
110
  def md5_as_hex(self, s: str) -> str:
111
111
  return f"md5({s})"
112
112
 
113
113
  def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
114
114
  def _add_padding(coltype: TemporalType, timestamp6: str):
115
- return f"RPAD(LEFT({timestamp6}, {TIMESTAMP_PRECISION_POS+coltype.precision}), {TIMESTAMP_PRECISION_POS+6}, '0')"
115
+ return f"RPAD(LEFT({timestamp6}, {TIMESTAMP_PRECISION_POS + coltype.precision}), {TIMESTAMP_PRECISION_POS + 6}, '0')"
116
116
 
117
117
  try:
118
118
  is_date = coltype.is_date
@@ -143,7 +143,7 @@ class PostgresqlDialect(BaseDialect):
143
143
  timestamp = f"least('{max_timestamp}'::timestamp(6), {value}::timestamp(6))"
144
144
  timestamp = f"greatest('{min_timestamp}'::timestamp(6), {timestamp})"
145
145
 
146
- interval = format((0.5 * (10 ** (-coltype.precision))), f".{coltype.precision+1}f")
146
+ interval = format((0.5 * (10 ** (-coltype.precision))), f".{coltype.precision + 1}f")
147
147
 
148
148
  rounded_timestamp = (
149
149
  f"left(to_char(least('{max_timestamp}'::timestamp, {timestamp})"
@@ -223,7 +223,7 @@ class PostgreSQL(ThreadedDatabase):
223
223
  THEN coalesce(numeric_scale, {self.dialect.DEFAULT_NUMERIC_PRECISION})
224
224
  ELSE numeric_scale
225
225
  END AS numeric_scale
226
- FROM {'.'.join(info_schema_path)}
226
+ FROM {".".join(info_schema_path)}
227
227
  WHERE table_name = '{table}' AND table_schema = '{schema}'
228
228
  """
229
229
 
@@ -113,13 +113,18 @@ class Dialect(BaseDialect):
113
113
  return super().parse_type(table_path, info)
114
114
 
115
115
  def set_timezone_to_utc(self) -> str:
116
- return "SET TIME ZONE '+00:00'"
116
+ # PrestoDB has no SQL command to change the session timezone and no
117
+ # timezone session property. The session timezone is fixed at connect
118
+ # time. Raise so the connection setup logs and moves on, matching the
119
+ # base class pattern for databases without session timezone support.
120
+ # The Trino dialect overrides this since Trino does have SET TIME ZONE.
121
+ raise NotImplementedError("No support for session tz.")
117
122
 
118
123
  def current_timestamp(self) -> str:
119
124
  return "current_timestamp"
120
125
 
121
126
  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}"
127
+ 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
128
 
124
129
  def md5_as_hex(self, s: str) -> str:
125
130
  return f"to_hex(md5(to_utf8({s})))"
@@ -129,13 +134,15 @@ class Dialect(BaseDialect):
129
134
  return f"TRIM(CAST({value} AS VARCHAR))"
130
135
 
131
136
  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')"
137
+ # PrestoDB has no parameterized TIMESTAMP(p) type, only plain TIMESTAMP
138
+ # which is fixed at millisecond precision. Casting to TIMESTAMP(6) here
139
+ # fails with "Unknown type: timestamp(6)" on real PrestoDB even though
140
+ # Trino accepts it. The Trino dialect provides its own precision aware
141
+ # override so this only affects PrestoDB.
142
+ s = f"date_format(cast({value} as timestamp), '%Y-%m-%d %H:%i:%S.%f')"
143
+ return (
144
+ f"RPAD(RPAD({s}, {TIMESTAMP_PRECISION_POS + coltype.precision}, '.'), {TIMESTAMP_PRECISION_POS + 6}, '0')"
145
+ )
139
146
 
140
147
  def normalize_number(self, value: str, coltype: FractionalType) -> str:
141
148
  return self.to_string(f"cast({value} as decimal(38,{coltype.precision}))")
@@ -197,4 +204,8 @@ class Presto(Database):
197
204
 
198
205
  @property
199
206
  def is_autocommit(self) -> bool:
200
- return False
207
+ # PrestoDB and Trino both run every statement in autocommit mode by
208
+ # default, with no implicit transaction. Emitting a bare COMMIT then
209
+ # fails with NOT_IN_TRANSACTION. Telling the base dialect that we are
210
+ # autocommit makes render_commit a no op so the COMMIT is never sent.
211
+ return True
@@ -50,7 +50,7 @@ class Dialect(PostgresqlDialect):
50
50
  return super().type_repr(t)
51
51
 
52
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}"
53
+ return f"strtol(substring(md5({s}), {1 + MD5_HEXDIGITS - CHECKSUM_HEXDIGITS}), 16)::decimal(38) - {CHECKSUM_OFFSET}"
54
54
 
55
55
  def md5_as_hex(self, s: str) -> str:
56
56
  return f"md5({s})"
@@ -3,7 +3,7 @@ from typing import Any, ClassVar, Type
3
3
  import attrs
4
4
  from requests import Session
5
5
 
6
- from data_diff.abcs.database_types import TemporalType, ColType_UUID
6
+ from data_diff.abcs.database_types import TemporalType, TimestampTZ, ColType_UUID
7
7
  from data_diff.databases import presto
8
8
  from data_diff.databases.base import import_helper
9
9
  from data_diff.databases.base import TIMESTAMP_PRECISION_POS, BaseDialect
@@ -19,12 +19,29 @@ def import_trino():
19
19
  class Dialect(presto.Dialect):
20
20
  name = "Trino"
21
21
 
22
- def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
23
- if coltype.rounds:
24
- s = f"date_format(cast({value} as timestamp({coltype.precision})), '%Y-%m-%d %H:%i:%S.%f')"
25
- else:
26
- s = f"date_format(cast({value} as timestamp(6)), '%Y-%m-%d %H:%i:%S.%f')"
22
+ def set_timezone_to_utc(self) -> str:
23
+ # Trino has its own statement form that PrestoDB does not accept.
24
+ return "SET TIME ZONE 'UTC'"
25
+
26
+ def type_repr(self, t) -> str:
27
+ # The base dialect emits TIMESTAMP(p) for TimestampTZ, which Trino
28
+ # interprets as TIMESTAMP WITHOUT TIME ZONE. That silently drops the
29
+ # offset on insert, so be explicit and emit TIMESTAMP(p) WITH TIME ZONE.
30
+ if isinstance(t, TimestampTZ):
31
+ return f"TIMESTAMP({t.precision}) WITH TIME ZONE"
32
+ return super().type_repr(t)
27
33
 
34
+ def normalize_timestamp(self, value: str, coltype: TemporalType) -> str:
35
+ # Trino's date_format truncates fractional seconds to millisecond
36
+ # precision and loses any trailing microseconds. Casting the value to
37
+ # TIMESTAMP(p) and then to VARCHAR keeps the full precision. For
38
+ # TIMESTAMP WITH TIME ZONE columns the cast would preserve the source
39
+ # offset instead of using the session timezone, so apply
40
+ # AT TIME ZONE 'UTC' first to normalize.
41
+ inner_prec = coltype.precision if coltype.rounds else 6
42
+ if isinstance(coltype, TimestampTZ):
43
+ value = f"({value}) AT TIME ZONE 'UTC'"
44
+ s = f"CAST(CAST({value} AS TIMESTAMP({inner_prec})) AS VARCHAR)"
28
45
  return (
29
46
  f"RPAD(RPAD({s}, {TIMESTAMP_PRECISION_POS + coltype.precision}, '.'), {TIMESTAMP_PRECISION_POS + 6}, '0')"
30
47
  )
@@ -58,20 +75,15 @@ class Trino(presto.Presto):
58
75
 
59
76
  if auth:
60
77
  if auth.get("authType") == "basic":
61
- kw["auth"] = trino.auth.BasicAuthentication(
62
- auth.get("username"),
63
- auth.get("password")
64
- )
78
+ kw["auth"] = trino.auth.BasicAuthentication(auth.get("username"), auth.get("password"))
65
79
  kw["http_scheme"] = "https"
66
80
 
67
81
  elif auth.get("authType") == "jwt":
68
- kw["auth"] = trino.auth.JWTAuthentication(
69
- auth.get("jwt")
70
- )
82
+ kw["auth"] = trino.auth.JWTAuthentication(auth.get("jwt"))
71
83
  kw["http_scheme"] = "https"
72
84
 
73
85
  elif auth.get("authType") == "oauth2":
74
86
  kw["auth"] = trino.auth.OAuth2Authentication()
75
87
  kw["http_scheme"] = "https"
76
-
88
+
77
89
  self._conn = trino.dbapi.connect(**kw)
@@ -114,9 +114,7 @@ class Dialect(BaseDialect):
114
114
  return f"TO_CHAR({value}::TIMESTAMP({coltype.precision}), 'YYYY-MM-DD HH24:MI:SS.US')"
115
115
 
116
116
  timestamp6 = f"TO_CHAR({value}::TIMESTAMP(6), 'YYYY-MM-DD HH24:MI:SS.US')"
117
- return (
118
- f"RPAD(LEFT({timestamp6}, {TIMESTAMP_PRECISION_POS+coltype.precision}), {TIMESTAMP_PRECISION_POS+6}, '0')"
119
- )
117
+ return f"RPAD(LEFT({timestamp6}, {TIMESTAMP_PRECISION_POS + coltype.precision}), {TIMESTAMP_PRECISION_POS + 6}, '0')"
120
118
 
121
119
  def normalize_number(self, value: str, coltype: FractionalType) -> str:
122
120
  return self.to_string(f"CAST({value} AS NUMERIC(38, {coltype.precision}))")
@@ -128,6 +126,17 @@ class Dialect(BaseDialect):
128
126
  def normalize_boolean(self, value: str, _coltype: Boolean) -> str:
129
127
  return self.to_string(f"cast ({value} as int)")
130
128
 
129
+ def timestamp_value(self, t) -> str:
130
+ # In the UNION ALL SELECT path emitted by constant_values, Vertica
131
+ # refuses to implicitly cast a varchar to TIMESTAMP. Emit a typed
132
+ # literal so the value is unambiguously a TIMESTAMP everywhere.
133
+ return f"TIMESTAMP '{t.isoformat()}'"
134
+
135
+ def constant_values(self, rows) -> str:
136
+ # Vertica before 11.0 does not accept multi-row INSERT ... VALUES (a), (b), (c).
137
+ # Emit UNION ALL SELECT instead, which is accepted by every Vertica version.
138
+ return " UNION ALL ".join("SELECT " + ", ".join(self._constant_value(v) for v in row) for row in rows)
139
+
131
140
 
132
141
  @attrs.define(frozen=False, init=False, kw_only=True)
133
142
  class Vertica(ThreadedDatabase):
@@ -116,9 +116,10 @@ def dbt_diff(
116
116
 
117
117
  futures = {}
118
118
 
119
- with log_status_handler.status if log_status_handler else nullcontext(), ThreadPoolExecutor(
120
- max_workers=dbt_parser.threads
121
- ) as executor:
119
+ with (
120
+ log_status_handler.status if log_status_handler else nullcontext(),
121
+ ThreadPoolExecutor(max_workers=dbt_parser.threads) as executor,
122
+ ):
122
123
  for model in models:
123
124
  if log_status_handler:
124
125
  log_status_handler.set_prefix(f"Diffing {model.alias} \n")
@@ -5,13 +5,13 @@ from pydantic import BaseModel, Field
5
5
 
6
6
  class ManifestJsonConfig(BaseModel):
7
7
  class Metadata(BaseModel):
8
- dbt_version: str = Field(..., regex=r"^\d+\.\d+\.\d+([a-zA-Z0-9]+)?$")
9
- project_id: Optional[str]
10
- user_id: Optional[str]
8
+ dbt_version: str = Field(..., pattern=r"^\d+\.\d+\.\d+([a-zA-Z0-9]+)?$")
9
+ project_id: Optional[str] = None
10
+ user_id: Optional[str] = None
11
11
 
12
12
  class Nodes(BaseModel):
13
- class Config(BaseModel):
14
- database: Optional[str]
13
+ class NodeConfig(BaseModel):
14
+ database: Optional[str] = None
15
15
  schema_: Optional[str] = Field(..., alias="schema")
16
16
  tags: List[str]
17
17
 
@@ -31,13 +31,13 @@ class ManifestJsonConfig(BaseModel):
31
31
  resource_type: str
32
32
  name: str
33
33
  alias: str
34
- database: Optional[str]
34
+ database: Optional[str] = None
35
35
  schema_: str = Field(..., alias="schema")
36
- columns: Optional[Dict[str, Column]]
36
+ columns: Optional[Dict[str, Column]] = None
37
37
  meta: Dict[str, Any]
38
- config: Config
38
+ config: NodeConfig
39
39
  tags: List[str]
40
- test_metadata: Optional[TestMetadata]
40
+ test_metadata: Optional[TestMetadata] = None
41
41
  depends_on: DependsOn
42
42
 
43
43
  metadata: Metadata
@@ -46,7 +46,7 @@ class ManifestJsonConfig(BaseModel):
46
46
 
47
47
  class RunResultsJsonConfig(BaseModel):
48
48
  class Metadata(BaseModel):
49
- dbt_version: str = Field(..., regex=r"^\d+\.\d+\.\d+([a-zA-Z0-9]+)?$")
49
+ dbt_version: str = Field(..., pattern=r"^\d+\.\d+\.\d+([a-zA-Z0-9]+)?$")
50
50
 
51
51
  class Results(BaseModel):
52
52
  class Status(Enum):
@@ -252,7 +252,7 @@ class DbtParser:
252
252
  with open(self.project_dir / RUN_RESULTS_PATH) as run_results:
253
253
  logger.info(f"Parsing file {RUN_RESULTS_PATH}")
254
254
  run_results_dict = json.load(run_results)
255
- run_results_validated = RunResultsJsonConfig.parse_obj(run_results_dict)
255
+ run_results_validated = RunResultsJsonConfig.model_validate(run_results_dict)
256
256
 
257
257
  dbt_version = parse_version(run_results_validated.metadata.dbt_version)
258
258
 
@@ -279,7 +279,7 @@ class DbtParser:
279
279
  with open(path) as manifest:
280
280
  logger.info(f"Parsing file {path}")
281
281
  manifest_dict = json.load(manifest)
282
- manifest_obj = ManifestJsonConfig.parse_obj(manifest_dict)
282
+ manifest_obj = ManifestJsonConfig.model_validate(manifest_dict)
283
283
  return manifest_obj
284
284
 
285
285
  def get_project_dict(self):
@@ -161,7 +161,7 @@ class DiffResultWrapper:
161
161
  string_output += f"{diff_stats.diff_by_sign['+']} rows exclusive to table B (not present in A)\n"
162
162
  string_output += f"{diff_stats.diff_by_sign['!']} rows updated\n"
163
163
  string_output += f"{diff_stats.unchanged} rows unchanged\n"
164
- string_output += f"{100*diff_stats.diff_percent:.2f}% difference score\n"
164
+ string_output += f"{100 * diff_stats.diff_percent:.2f}% difference score\n"
165
165
 
166
166
  if self.stats:
167
167
  string_output += "\nExtra-Info:\n"
@@ -44,7 +44,7 @@ def parse_time_atom(count, unit):
44
44
  except KeyError:
45
45
  most_similar = max(TIME_UNITS, key=lambda k: string_similarity(k, unit))
46
46
  raise ParseError(
47
- f"'{unit}' is not a recognized time unit. Did you mean '{most_similar}'?" f"\nSupported units: {UNITS_STR}"
47
+ f"'{unit}' is not a recognized time unit. Did you mean '{most_similar}'?\nSupported units: {UNITS_STR}"
48
48
  )
49
49
 
50
50
  if unit in EXTRAPOLATED:
@@ -47,6 +47,6 @@ def create_schema(db_name: str, table_path: DbPath, schema: dict, case_sensitive
47
47
  return CaseSensitiveDict(schema)
48
48
 
49
49
  if len({k.lower() for k in schema}) < len(schema):
50
- logger.warning(f'Ambiguous schema for {db_name}:{".".join(table_path)} | Columns = {", ".join(list(schema))}')
50
+ logger.warning(f"Ambiguous schema for {db_name}:{'.'.join(table_path)} | Columns = {', '.join(list(schema))}")
51
51
  logger.warning("We recommend to disable case-insensitivity (set --case-sensitive).")
52
52
  return CaseInsensitiveDict(schema)
@@ -0,0 +1 @@
1
+ __version__ = "0.11.11"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "collate-data-diff"
3
- version = "0.11.9"
3
+ version = "0.11.11"
4
4
  description = "Command-line tool and Python library to efficiently diff rows across two different databases."
5
5
  authors = ["Collate <info@getcollate.io>", "Datafold <data-diff@datafold.com>"]
6
6
  license = "MIT"
@@ -21,10 +21,10 @@ classifiers = [
21
21
  ]
22
22
  packages = [{ include = "data_diff" }]
23
23
  [tool.poetry.dependencies]
24
- pydantic = ">=1.10.12"
24
+ pydantic = ">=2.0,<3"
25
25
  python = ">=3.10,<4.0"
26
26
  dsnparse = "<0.2.0"
27
- click = ">=8.1"
27
+ click = ">=8.3.3" # Security fix for CVE-2026-7246
28
28
  rich = "*"
29
29
  toml = ">=0.10.2"
30
30
  mysql-connector-python = [
@@ -33,9 +33,9 @@ mysql-connector-python = [
33
33
  ]
34
34
  psycopg2 = {version="*", optional=true}
35
35
  snowflake-connector-python = [
36
- {version = ">=3.15.0,<4.0.0", python = ">=3.9", optional=true}
36
+ {version = ">=4.4.0,<5.0.0", python = ">=3.10", optional=true}
37
37
  ]
38
- cryptography = {version="*", optional=true}
38
+ cryptography = {version=">=46.0.5", optional=true} # Security fix for CVE-2026-26007
39
39
  trino = {version=">=0.314.0", optional=true}
40
40
  presto-python-client = {version="*", optional=true}
41
41
  clickhouse-driver = {version="*", optional=true}
@@ -46,7 +46,7 @@ keyring = "*"
46
46
  tabulate = ">=0.9.0"
47
47
  preql = {version=">=0.2.19", optional=true}
48
48
  vertica-python = {version="*", optional=true}
49
- urllib3 = "<2"
49
+ urllib3 = ">=2.7.0,<3" # Security fix for CVE-2026-44431, CVE-2026-21441, CVE-2025-66418, CVE-2025-66471
50
50
  oracledb = {version = "*", optional=true}
51
51
  pyodbc = {version=">=4.0.39", optional=true}
52
52
  typing-extensions = ">=4.0.1"
@@ -84,9 +84,9 @@ mysql-connector-python = [
84
84
  ]
85
85
  psycopg2 = "*"
86
86
  snowflake-connector-python = [
87
- {version=">=3.13.1,<4.0.0", python = ">=3.9"}
87
+ {version=">=4.4.0,<5.0.0", python = ">=3.10"}
88
88
  ]
89
- cryptography = "*"
89
+ cryptography = ">=46.0.5" # Security fix for CVE-2026-26007
90
90
  trino = ">=0.314.0"
91
91
  presto-python-client = ">=0.8.3"
92
92
  clickhouse-driver = "*"
@@ -1 +0,0 @@
1
- __version__ = "0.11.1"