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