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
data_diff/format.py ADDED
@@ -0,0 +1,359 @@
1
+ import collections
2
+ from enum import Enum
3
+ from typing import Any, Optional, List, Dict, Tuple, Type
4
+
5
+ import attrs
6
+ from data_diff.diff_tables import DiffResultWrapper
7
+ from data_diff.abcs.database_types import (
8
+ JSON,
9
+ Boolean,
10
+ ColType,
11
+ Array,
12
+ ColType_UUID,
13
+ Date,
14
+ FractionalType,
15
+ NumericType,
16
+ Struct,
17
+ TemporalType,
18
+ ColType_Alphanum,
19
+ String_Alphanum,
20
+ )
21
+
22
+
23
+ def jsonify_error(table1: List[str], table2: List[str], dbt_model: str, error: str) -> "FailedDiff":
24
+ return attrs.asdict(
25
+ FailedDiff(
26
+ status="failed",
27
+ model=dbt_model,
28
+ dataset1=table1,
29
+ dataset2=table2,
30
+ error=error,
31
+ )
32
+ )
33
+
34
+
35
+ Columns = List[Tuple[str, str, ColType]]
36
+
37
+
38
+ def jsonify(
39
+ diff: DiffResultWrapper,
40
+ dbt_model: str,
41
+ dataset1_columns: Columns,
42
+ dataset2_columns: Columns,
43
+ columns_diff: Dict[str, List[str]],
44
+ with_summary: bool = False,
45
+ stats_only: bool = False,
46
+ ) -> "JsonDiff":
47
+ """
48
+ Converts the diff result into a JSON-serializable format.
49
+ Optionally add stats summary and schema diff.
50
+ """
51
+ diff_info = diff.info_tree.info
52
+ table1 = diff_info.tables[0]
53
+ table2 = diff_info.tables[1]
54
+ key_columns = table1.key_columns
55
+
56
+ t1_exclusive_rows = []
57
+ t2_exclusive_rows = []
58
+ diff_rows = []
59
+ rows = None
60
+ schema = [field for field, _ in diff_info.diff_schema]
61
+
62
+ t1_exclusive_rows, t2_exclusive_rows, diff_rows = _group_rows(diff_info, schema)
63
+
64
+ if not stats_only:
65
+ rows = _make_rows_diff(t1_exclusive_rows, t2_exclusive_rows, diff_rows, key_columns)
66
+
67
+ summary = None
68
+ if with_summary:
69
+ summary = _jsonify_diff_summary(diff.get_stats_dict(is_dbt=True))
70
+
71
+ columns = _jsonify_columns_diff(dataset1_columns, dataset2_columns, columns_diff, list(key_columns))
72
+
73
+ is_different = bool(
74
+ t1_exclusive_rows
75
+ or t2_exclusive_rows
76
+ or diff_rows
77
+ or (columns_diff["added"] or columns_diff["removed"] or columns_diff["changed"])
78
+ )
79
+ return attrs.asdict(
80
+ JsonDiff(
81
+ status="success",
82
+ result="different" if is_different else "identical",
83
+ model=dbt_model,
84
+ dataset1=list(table1.table_path),
85
+ dataset2=list(table2.table_path),
86
+ rows=rows,
87
+ summary=summary,
88
+ columns=columns,
89
+ )
90
+ )
91
+
92
+
93
+ @attrs.define(frozen=True)
94
+ class JsonExclusiveRowValue:
95
+ """
96
+ Value of a single column in a row
97
+ """
98
+
99
+ isPK: bool
100
+ value: Any
101
+
102
+
103
+ @attrs.define(frozen=True)
104
+ class JsonDiffRowValue:
105
+ """
106
+ Pair of diffed values for 2 rows with equal PKs
107
+ """
108
+
109
+ dataset1: Any
110
+ dataset2: Any
111
+ isDiff: bool
112
+ isPK: bool
113
+
114
+
115
+ @attrs.define(frozen=True)
116
+ class Total:
117
+ dataset1: int
118
+ dataset2: int
119
+
120
+
121
+ @attrs.define(frozen=True)
122
+ class ExclusiveRows:
123
+ dataset1: int
124
+ dataset2: int
125
+
126
+
127
+ @attrs.define(frozen=True)
128
+ class Rows:
129
+ total: Total
130
+ exclusive: ExclusiveRows
131
+ updated: int
132
+ unchanged: int
133
+
134
+
135
+ @attrs.define(frozen=True)
136
+ class Stats:
137
+ diffCounts: Dict[str, int]
138
+
139
+
140
+ @attrs.define(frozen=True)
141
+ class JsonDiffSummary:
142
+ rows: Rows
143
+ stats: Stats
144
+
145
+
146
+ @attrs.define(frozen=True)
147
+ class ExclusiveColumns:
148
+ dataset1: List[str]
149
+ dataset2: List[str]
150
+
151
+
152
+ class ColumnKind(Enum):
153
+ INTEGER = "integer"
154
+ FLOAT = "float"
155
+ STRING = "string"
156
+ DATE = "date"
157
+ TIME = "time"
158
+ DATETIME = "datetime"
159
+ BOOL = "boolean"
160
+ UNSUPPORTED = "unsupported"
161
+
162
+
163
+ KIND_MAPPING: List[Tuple[Type[ColType], ColumnKind]] = [
164
+ (Boolean, ColumnKind.BOOL),
165
+ (Date, ColumnKind.DATE),
166
+ (TemporalType, ColumnKind.DATETIME),
167
+ (FractionalType, ColumnKind.FLOAT),
168
+ (NumericType, ColumnKind.INTEGER),
169
+ (ColType_UUID, ColumnKind.STRING),
170
+ (ColType_Alphanum, ColumnKind.STRING),
171
+ (String_Alphanum, ColumnKind.STRING),
172
+ (JSON, ColumnKind.STRING),
173
+ (Array, ColumnKind.STRING),
174
+ (Struct, ColumnKind.STRING),
175
+ (ColType, ColumnKind.UNSUPPORTED),
176
+ ]
177
+
178
+
179
+ @attrs.define(frozen=True)
180
+ class Column:
181
+ name: str
182
+ type: str
183
+ kind: str
184
+
185
+
186
+ @attrs.define(frozen=True)
187
+ class JsonColumnsSummary:
188
+ dataset1: List[Column]
189
+ dataset2: List[Column]
190
+ primaryKey: List[str]
191
+ exclusive: ExclusiveColumns
192
+ typeChanged: List[str]
193
+
194
+
195
+ @attrs.define(frozen=True)
196
+ class ExclusiveDiff:
197
+ dataset1: List[Dict[str, JsonExclusiveRowValue]]
198
+ dataset2: List[Dict[str, JsonExclusiveRowValue]]
199
+
200
+
201
+ @attrs.define(frozen=True)
202
+ class RowsDiff:
203
+ exclusive: ExclusiveDiff
204
+ diff: List[Dict[str, JsonDiffRowValue]]
205
+
206
+
207
+ @attrs.define(frozen=True)
208
+ class FailedDiff:
209
+ status: str # Literal ["failed"]
210
+ model: str
211
+ dataset1: List[str]
212
+ dataset2: List[str]
213
+ error: str
214
+
215
+ version: str = "1.0.0"
216
+
217
+
218
+ @attrs.define(frozen=True)
219
+ class JsonDiff:
220
+ status: str # Literal ["success"]
221
+ result: str # Literal ["different", "identical"]
222
+ model: str
223
+ dataset1: List[str]
224
+ dataset2: List[str]
225
+ rows: Optional[RowsDiff]
226
+ summary: Optional[JsonDiffSummary]
227
+ columns: Optional[JsonColumnsSummary]
228
+
229
+ version: str = "1.1.0"
230
+
231
+
232
+ def _group_rows(
233
+ diff_info: DiffResultWrapper, schema: List[str]
234
+ ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
235
+ t1_exclusive_rows = []
236
+ t2_exclusive_rows = []
237
+ diff_rows = []
238
+
239
+ for row in diff_info.diff:
240
+ row_w_schema = dict(zip(schema, row))
241
+ is_t1_exclusive = row_w_schema["is_exclusive_a"]
242
+ is_t2_exclusive = row_w_schema["is_exclusive_b"]
243
+
244
+ if is_t1_exclusive:
245
+ t1_exclusive_rows.append(row_w_schema)
246
+
247
+ elif is_t2_exclusive:
248
+ t2_exclusive_rows.append(row_w_schema)
249
+
250
+ else:
251
+ diff_rows.append(row_w_schema)
252
+
253
+ return t1_exclusive_rows, t2_exclusive_rows, diff_rows
254
+
255
+
256
+ def _make_rows_diff(
257
+ t1_exclusive_rows: List[Dict[str, Any]],
258
+ t2_exclusive_rows: List[Dict[str, Any]],
259
+ diff_rows: List[Dict[str, Any]],
260
+ key_columns: List[str],
261
+ ) -> RowsDiff:
262
+ diff_rows_jsonified = []
263
+ for row in diff_rows:
264
+ diff_rows_jsonified.append(_jsonify_diff(row, key_columns))
265
+
266
+ t1_exclusive_rows_jsonified = []
267
+ for row in t1_exclusive_rows:
268
+ t1_exclusive_rows_jsonified.append(_jsonify_exclusive(row, key_columns))
269
+
270
+ t2_exclusive_rows_jsonified = []
271
+ for row in t2_exclusive_rows:
272
+ t2_exclusive_rows_jsonified.append(_jsonify_exclusive(row, key_columns))
273
+
274
+ return RowsDiff(
275
+ exclusive=ExclusiveDiff(dataset1=t1_exclusive_rows_jsonified, dataset2=t2_exclusive_rows_jsonified),
276
+ diff=diff_rows_jsonified,
277
+ )
278
+
279
+
280
+ def _jsonify_diff(row: Dict[str, Any], key_columns: List[str]) -> Dict[str, JsonDiffRowValue]:
281
+ columns = collections.defaultdict(dict)
282
+ for field, value in row.items():
283
+ if field in ("is_exclusive_a", "is_exclusive_b"):
284
+ continue
285
+
286
+ if field.startswith("is_diff_"):
287
+ column_name = field[len("is_diff_") :]
288
+ columns[column_name]["isDiff"] = bool(value)
289
+
290
+ elif field.endswith("_a"):
291
+ column_name = field[: -len("_a")]
292
+ columns[column_name]["dataset1"] = value
293
+ columns[column_name]["isPK"] = column_name in key_columns
294
+
295
+ elif field.endswith("_b"):
296
+ column_name = field[: -len("_b")]
297
+ columns[column_name]["dataset2"] = value
298
+ columns[column_name]["isPK"] = column_name in key_columns
299
+
300
+ return {column: JsonDiffRowValue(**data) for column, data in columns.items()}
301
+
302
+
303
+ def _jsonify_exclusive(row: Dict[str, Any], key_columns: List[str]) -> Dict[str, JsonExclusiveRowValue]:
304
+ columns = collections.defaultdict(dict)
305
+ for field, value in row.items():
306
+ if field in ("is_exclusive_a", "is_exclusive_b"):
307
+ continue
308
+ if field.startswith("is_diff_"):
309
+ continue
310
+ if field.endswith("_b") and row["is_exclusive_b"]:
311
+ column_name = field[: -len("_b")]
312
+ columns[column_name]["isPK"] = column_name in key_columns
313
+ columns[column_name]["value"] = value
314
+ elif field.endswith("_a") and row["is_exclusive_a"]:
315
+ column_name = field[: -len("_a")]
316
+ columns[column_name]["isPK"] = column_name in key_columns
317
+ columns[column_name]["value"] = value
318
+ return {column: JsonExclusiveRowValue(**data) for column, data in columns.items()}
319
+
320
+
321
+ def _jsonify_diff_summary(stats_dict: dict) -> JsonDiffSummary:
322
+ return JsonDiffSummary(
323
+ rows=Rows(
324
+ total=Total(dataset1=stats_dict["rows_A"], dataset2=stats_dict["rows_B"]),
325
+ exclusive=ExclusiveRows(
326
+ dataset1=stats_dict["exclusive_A"],
327
+ dataset2=stats_dict["exclusive_B"],
328
+ ),
329
+ updated=stats_dict["updated"],
330
+ unchanged=stats_dict["unchanged"],
331
+ ),
332
+ stats=Stats(diffCounts=stats_dict["values"]),
333
+ )
334
+
335
+
336
+ def _jsonify_columns_diff(
337
+ dataset1_columns: Columns, dataset2_columns: Columns, columns_diff: Dict[str, List[str]], key_columns: List[str]
338
+ ) -> JsonColumnsSummary:
339
+ return JsonColumnsSummary(
340
+ dataset1=[
341
+ Column(name=name, type=type_, kind=_map_kind(kind).value) for (name, type_, kind) in dataset1_columns
342
+ ],
343
+ dataset2=[
344
+ Column(name=name, type=type_, kind=_map_kind(kind).value) for (name, type_, kind) in dataset2_columns
345
+ ],
346
+ primaryKey=key_columns,
347
+ exclusive=ExclusiveColumns(
348
+ dataset2=list(columns_diff.get("added", [])),
349
+ dataset1=list(columns_diff.get("removed", [])),
350
+ ),
351
+ typeChanged=list(columns_diff.get("changed", [])),
352
+ )
353
+
354
+
355
+ def _map_kind(kind: ColType) -> ColumnKind:
356
+ for raw_kind, json_kind in KIND_MAPPING:
357
+ if isinstance(kind, raw_kind):
358
+ return json_kind
359
+ return ColumnKind.UNSUPPORTED
@@ -0,0 +1,264 @@
1
+ import os
2
+ from numbers import Number
3
+ import logging
4
+ from collections import defaultdict
5
+ from typing import Any, Collection, Dict, Iterator, List, Sequence, Set, Tuple
6
+
7
+ import attrs
8
+ from typing_extensions import Literal
9
+
10
+ from data_diff.abcs.database_types import ColType_UUID, NumericType, PrecisionType, StringType, Boolean, JSON
11
+ from data_diff.info_tree import InfoTree
12
+ from data_diff.utils import safezip, diffs_are_equiv_jsons
13
+ from data_diff.thread_utils import ThreadedYielder
14
+ from data_diff.table_segment import TableSegment
15
+ from data_diff.diff_tables import TableDiffer
16
+
17
+ BENCHMARK = os.environ.get("BENCHMARK", False)
18
+
19
+ DEFAULT_BISECTION_THRESHOLD = 1024 * 16
20
+ DEFAULT_BISECTION_FACTOR = 32
21
+
22
+ logger = logging.getLogger("hashdiff_tables")
23
+
24
+ # Just for local readability: TODO: later switch to real type declarations of these.
25
+ _Op = Literal["+", "-"]
26
+ _PK = Sequence[Any]
27
+ _Row = Tuple[Any]
28
+
29
+
30
+ def diff_sets(
31
+ a: Sequence[_Row],
32
+ b: Sequence[_Row],
33
+ *,
34
+ json_cols: dict = None,
35
+ columns1: Sequence[str],
36
+ columns2: Sequence[str],
37
+ key_columns1: Sequence[str],
38
+ key_columns2: Sequence[str],
39
+ ignored_columns1: Collection[str],
40
+ ignored_columns2: Collection[str],
41
+ ) -> Iterator:
42
+ # Group full rows by PKs on each side. The first items are the PK: TableSegment.relevant_columns
43
+ rows_by_pks1: Dict[_PK, List[_Row]] = defaultdict(list)
44
+ rows_by_pks2: Dict[_PK, List[_Row]] = defaultdict(list)
45
+ for row in a:
46
+ pk: _PK = tuple(val for col, val in zip(key_columns1, row))
47
+ rows_by_pks1[pk].append(row)
48
+ for row in b:
49
+ pk: _PK = tuple(val for col, val in zip(key_columns2, row))
50
+ rows_by_pks2[pk].append(row)
51
+
52
+ # Mind that the same pk MUST go in full with all the -/+ rows all at once, for grouping.
53
+ diffs_by_pks: Dict[_PK, List[Tuple[_Op, _Row]]] = defaultdict(list)
54
+ for pk in sorted(set(rows_by_pks1) | set(rows_by_pks2)):
55
+ cutrows1: List[_Row] = [
56
+ tuple(val for col, val in zip(columns1, row1) if col not in ignored_columns1) for row1 in rows_by_pks1[pk]
57
+ ]
58
+ cutrows2: List[_Row] = [
59
+ tuple(val for col, val in zip(columns2, row2) if col not in ignored_columns2) for row2 in rows_by_pks2[pk]
60
+ ]
61
+
62
+ # Either side has 0 rows: a clearly exclusive row.
63
+ # Either side has 2+ rows: duplicates on either side, yield it all regardless of values.
64
+ # Both sides == 1: non-duplicate, non-exclusive, so check for values of interest.
65
+ if len(cutrows1) != 1 or len(cutrows2) != 1 or cutrows1 != cutrows2:
66
+ for row1 in rows_by_pks1[pk]:
67
+ diffs_by_pks[pk].append(("-", row1))
68
+ for row2 in rows_by_pks2[pk]:
69
+ diffs_by_pks[pk].append(("+", row2))
70
+
71
+ warned_diff_cols = set()
72
+ for diffs in (diffs_by_pks[pk] for pk in sorted(diffs_by_pks)):
73
+ if json_cols:
74
+ parsed_match, overriden_diff_cols = diffs_are_equiv_jsons(diffs, json_cols)
75
+ if parsed_match:
76
+ to_warn = overriden_diff_cols - warned_diff_cols
77
+ for w in to_warn:
78
+ logger.warning(
79
+ f"Equivalent JSON objects with different string representations detected "
80
+ f"in column '{w}'. These cases are NOT reported as differences."
81
+ )
82
+ warned_diff_cols.add(w)
83
+ continue
84
+ yield from diffs
85
+
86
+
87
+ @attrs.define(frozen=False)
88
+ class HashDiffer(TableDiffer):
89
+ """Finds the diff between two SQL tables
90
+
91
+ The algorithm uses hashing to quickly check if the tables are different, and then applies a
92
+ bisection search recursively to find the differences efficiently.
93
+
94
+ Works best for comparing tables that are mostly the same, with minor discrepancies.
95
+
96
+ Parameters:
97
+ bisection_factor (int): Into how many segments to bisect per iteration.
98
+ bisection_threshold (Number): When should we stop bisecting and compare locally (in row count).
99
+ threaded (bool): Enable/disable threaded diffing. Needed to take advantage of database threads.
100
+ max_threadpool_size (int): Maximum size of each threadpool. ``None`` means auto.
101
+ Only relevant when `threaded` is ``True``.
102
+ There may be many pools, so number of actual threads can be a lot higher.
103
+ """
104
+
105
+ bisection_factor: int = DEFAULT_BISECTION_FACTOR
106
+ bisection_threshold: int = DEFAULT_BISECTION_THRESHOLD
107
+ bisection_disabled: bool = False # i.e. always download the rows (used in tests)
108
+
109
+ stats: dict = attrs.field(factory=dict)
110
+
111
+ def __attrs_post_init__(self) -> None:
112
+ # Validate options
113
+ if self.bisection_factor >= self.bisection_threshold:
114
+ raise ValueError("Incorrect param values (bisection factor must be lower than threshold)")
115
+ if self.bisection_factor < 2:
116
+ raise ValueError("Must have at least two segments per iteration (i.e. bisection_factor >= 2)")
117
+
118
+ def _validate_and_adjust_columns(self, table1: TableSegment, table2: TableSegment, *, strict: bool = True) -> None:
119
+ for c1, c2 in safezip(table1.relevant_columns, table2.relevant_columns):
120
+ if c1 not in table1._schema:
121
+ raise ValueError(f"Column '{c1}' not found in schema for table {table1}")
122
+ if c2 not in table2._schema:
123
+ raise ValueError(f"Column '{c2}' not found in schema for table {table2}")
124
+
125
+ # Update schemas to minimal mutual precision
126
+ col1 = table1._schema[c1]
127
+ col2 = table2._schema[c2]
128
+ if isinstance(col1, PrecisionType):
129
+ if not isinstance(col2, PrecisionType):
130
+ if strict:
131
+ raise TypeError(f"Incompatible types for column '{c1}': {col1} <-> {col2}")
132
+ else:
133
+ continue
134
+
135
+ lowest = min(col1, col2, key=lambda col: col.precision)
136
+
137
+ if col1.precision != col2.precision:
138
+ logger.warning(f"Using reduced precision {lowest} for column '{c1}'. Types={col1}, {col2}")
139
+
140
+ table1._schema[c1] = attrs.evolve(col1, precision=lowest.precision, rounds=lowest.rounds)
141
+ table2._schema[c2] = attrs.evolve(col2, precision=lowest.precision, rounds=lowest.rounds)
142
+
143
+ elif isinstance(col1, (NumericType, Boolean)):
144
+ if not isinstance(col2, (NumericType, Boolean)):
145
+ if strict:
146
+ raise TypeError(f"Incompatible types for column '{c1}': {col1} <-> {col2}")
147
+ else:
148
+ continue
149
+
150
+ lowest = min(col1, col2, key=lambda col: col.precision)
151
+
152
+ if col1.precision != col2.precision:
153
+ logger.warning(f"Using reduced precision {lowest} for column '{c1}'. Types={col1}, {col2}")
154
+
155
+ if lowest.precision != col1.precision:
156
+ table1._schema[c1] = attrs.evolve(col1, precision=lowest.precision)
157
+ if lowest.precision != col2.precision:
158
+ table2._schema[c2] = attrs.evolve(col2, precision=lowest.precision)
159
+
160
+ for t in [table1, table2]:
161
+ for c in t.relevant_columns:
162
+ ctype = t._schema[c]
163
+ if not ctype.supported:
164
+ logger.warning(
165
+ f"[{t.database.name}] Column '{c}' of type '{ctype}' has no compatibility handling. "
166
+ "If encoding/formatting differs between databases, it may result in false positives."
167
+ )
168
+
169
+ def _diff_segments(
170
+ self,
171
+ ti: ThreadedYielder,
172
+ table1: TableSegment,
173
+ table2: TableSegment,
174
+ info_tree: InfoTree,
175
+ max_rows: int,
176
+ level=0,
177
+ segment_index=None,
178
+ segment_count=None,
179
+ ):
180
+ logger.info(
181
+ ". " * level + f"Diffing segment {segment_index}/{segment_count}, "
182
+ f"key-range: {table1.min_key}..{table2.max_key}, "
183
+ f"size <= {max_rows}"
184
+ )
185
+
186
+ # When benchmarking, we want the ability to skip checksumming. This
187
+ # allows us to download all rows for comparison in performance. By
188
+ # default, data-diff will checksum the section first (when it's below
189
+ # the threshold) and _then_ download it.
190
+ if BENCHMARK:
191
+ if self.bisection_disabled or max_rows < self.bisection_threshold:
192
+ return self._bisect_and_diff_segments(ti, table1, table2, info_tree, level=level, max_rows=max_rows)
193
+
194
+ (count1, checksum1), (count2, checksum2) = self._threaded_call("count_and_checksum", [table1, table2])
195
+
196
+ assert not info_tree.info.rowcounts
197
+ info_tree.info.rowcounts = {1: count1, 2: count2}
198
+
199
+ if count1 == 0 and count2 == 0:
200
+ logger.debug(
201
+ "Uneven distribution of keys detected in segment %s..%s (big gaps in the key column). "
202
+ "For better performance, we recommend to increase the bisection-threshold.",
203
+ table1.min_key,
204
+ table1.max_key,
205
+ )
206
+ assert checksum1 is None and checksum2 is None
207
+ info_tree.info.is_diff = False
208
+ return
209
+
210
+ if checksum1 == checksum2:
211
+ info_tree.info.is_diff = False
212
+ return
213
+
214
+ info_tree.info.is_diff = True
215
+ return self._bisect_and_diff_segments(ti, table1, table2, info_tree, level=level, max_rows=max(count1, count2))
216
+
217
+ def _bisect_and_diff_segments(
218
+ self,
219
+ ti: ThreadedYielder,
220
+ table1: TableSegment,
221
+ table2: TableSegment,
222
+ info_tree: InfoTree,
223
+ level=0,
224
+ max_rows=None,
225
+ ):
226
+ assert table1.is_bounded and table2.is_bounded
227
+
228
+ max_space_size = max(table1.approximate_size(), table2.approximate_size())
229
+ if max_rows is None:
230
+ # We can be sure that row_count <= max_rows iff the table key is unique
231
+ max_rows = max_space_size
232
+ info_tree.info.max_rows = max_rows
233
+
234
+ # If count is below the threshold, just download and compare the columns locally
235
+ # This saves time, as bisection speed is limited by ping and query performance.
236
+ if self.bisection_disabled or max_rows < self.bisection_threshold or max_space_size < self.bisection_factor * 2:
237
+ rows1, rows2 = self._threaded_call("get_values", [table1, table2])
238
+ json_cols = {
239
+ i: colname
240
+ for i, colname in enumerate(table1.extra_columns)
241
+ if isinstance(table1._schema[colname], JSON)
242
+ }
243
+ diff = list(
244
+ diff_sets(
245
+ rows1,
246
+ rows2,
247
+ json_cols=json_cols,
248
+ columns1=table1.relevant_columns,
249
+ columns2=table2.relevant_columns,
250
+ key_columns1=table1.key_columns,
251
+ key_columns2=table2.key_columns,
252
+ ignored_columns1=self.ignored_columns1,
253
+ ignored_columns2=self.ignored_columns2,
254
+ )
255
+ )
256
+
257
+ info_tree.info.set_diff(diff)
258
+ info_tree.info.rowcounts = {1: len(rows1), 2: len(rows2)}
259
+
260
+ logger.info(". " * level + f"Diff found {len(diff)} different rows.")
261
+ self.stats["rows_downloaded"] = self.stats.get("rows_downloaded", 0) + max(len(rows1), len(rows2))
262
+ return diff
263
+
264
+ return super()._bisect_and_diff_segments(ti, table1, table2, info_tree, level, max_rows)
data_diff/info_tree.py ADDED
@@ -0,0 +1,62 @@
1
+ from typing import List, Dict, Optional, Any, Tuple, Union
2
+
3
+ import attrs
4
+ from typing_extensions import Self
5
+
6
+ from data_diff.table_segment import TableSegment
7
+
8
+
9
+ @attrs.define(frozen=False)
10
+ class SegmentInfo:
11
+ tables: List[TableSegment]
12
+
13
+ diff: Optional[List[Union[Tuple[Any, ...], List[Any]]]] = None
14
+ diff_schema: Optional[Tuple[Tuple[str, type], ...]] = None
15
+ is_diff: Optional[bool] = None
16
+ diff_count: Optional[int] = None
17
+
18
+ rowcounts: Dict[int, int] = attrs.field(factory=dict)
19
+ max_rows: Optional[int] = None
20
+
21
+ def set_diff(
22
+ self, diff: List[Union[Tuple[Any, ...], List[Any]]], schema: Optional[Tuple[Tuple[str, type]]] = None
23
+ ) -> None:
24
+ self.diff_schema = schema
25
+ self.diff = diff
26
+ self.diff_count = len(diff)
27
+ self.is_diff = self.diff_count > 0
28
+
29
+ def update_from_children(self, child_infos) -> None:
30
+ child_infos = list(child_infos)
31
+ assert child_infos
32
+
33
+ # self.diff = list(chain(*[c.diff for c in child_infos]))
34
+ self.diff_count = sum(c.diff_count for c in child_infos if c.diff_count is not None)
35
+ self.is_diff = any(c.is_diff for c in child_infos)
36
+ self.diff_schema = next((child.diff_schema for child in child_infos if child.diff_schema is not None), None)
37
+ self.diff = sum((c.diff for c in child_infos if c.diff is not None), [])
38
+
39
+ self.rowcounts = {
40
+ 1: sum(c.rowcounts[1] for c in child_infos if c.rowcounts),
41
+ 2: sum(c.rowcounts[2] for c in child_infos if c.rowcounts),
42
+ }
43
+
44
+
45
+ @attrs.define(frozen=True)
46
+ class InfoTree:
47
+ SEGMENT_INFO_CLASS = SegmentInfo
48
+
49
+ info: SegmentInfo
50
+ children: List["InfoTree"] = attrs.field(factory=list)
51
+
52
+ def add_node(self, table1: TableSegment, table2: TableSegment, max_rows: Optional[int] = None) -> Self:
53
+ cls = self.__class__
54
+ node = cls(cls.SEGMENT_INFO_CLASS([table1, table2], max_rows=max_rows))
55
+ self.children.append(node)
56
+ return node
57
+
58
+ def aggregate_info(self) -> None:
59
+ if self.children:
60
+ for c in self.children:
61
+ c.aggregate_info()
62
+ self.info.update_from_children(c.info for c in self.children)