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/diff_tables.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Provides classes for performing a table diff"""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from operator import methodcaller
|
|
9
|
+
from typing import Any, Dict, Set, List, Tuple, Iterator, Optional, Union
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
|
|
12
|
+
import attrs
|
|
13
|
+
|
|
14
|
+
from data_diff.errors import DataDiffMismatchingKeyTypesError
|
|
15
|
+
from data_diff.info_tree import InfoTree, SegmentInfo
|
|
16
|
+
from data_diff.utils import dbt_diff_string_template, run_as_daemon, safezip, getLogger, truncate_error, Vector
|
|
17
|
+
from data_diff.thread_utils import ThreadedYielder
|
|
18
|
+
from data_diff.table_segment import TableSegment, create_mesh_from_points
|
|
19
|
+
from data_diff.tracking import create_end_event_json, create_start_event_json, send_event_json, is_tracking_enabled
|
|
20
|
+
from data_diff.abcs.database_types import IKey
|
|
21
|
+
|
|
22
|
+
logger = getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Algorithm(Enum):
|
|
26
|
+
AUTO = "auto"
|
|
27
|
+
JOINDIFF = "joindiff"
|
|
28
|
+
HASHDIFF = "hashdiff"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DiffResult = Iterator[Tuple[str, tuple]] # Iterator[Tuple[Literal["+", "-"], tuple]]
|
|
32
|
+
DiffResultList = Iterator[List[Tuple[str, tuple]]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@attrs.define(frozen=False)
|
|
36
|
+
class ThreadBase:
|
|
37
|
+
"Provides utility methods for optional threading"
|
|
38
|
+
|
|
39
|
+
threaded: bool = True
|
|
40
|
+
max_threadpool_size: Optional[int] = 1
|
|
41
|
+
|
|
42
|
+
def _thread_map(self, func, iterable):
|
|
43
|
+
if not self.threaded:
|
|
44
|
+
return map(func, iterable)
|
|
45
|
+
|
|
46
|
+
with ThreadPoolExecutor(max_workers=self.max_threadpool_size) as task_pool:
|
|
47
|
+
return task_pool.map(func, iterable)
|
|
48
|
+
|
|
49
|
+
def _threaded_call(self, func, iterable):
|
|
50
|
+
"Calls a method for each object in iterable."
|
|
51
|
+
return list(self._thread_map(methodcaller(func), iterable))
|
|
52
|
+
|
|
53
|
+
def _thread_as_completed(self, func, iterable):
|
|
54
|
+
if not self.threaded:
|
|
55
|
+
yield from map(func, iterable)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
with ThreadPoolExecutor(max_workers=self.max_threadpool_size) as task_pool:
|
|
59
|
+
futures = [task_pool.submit(func, item) for item in iterable]
|
|
60
|
+
for future in as_completed(futures):
|
|
61
|
+
yield future.result()
|
|
62
|
+
|
|
63
|
+
def _threaded_call_as_completed(self, func, iterable):
|
|
64
|
+
"Calls a method for each object in iterable. Returned in order of completion."
|
|
65
|
+
return self._thread_as_completed(methodcaller(func), iterable)
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def _run_in_background(self, *funcs):
|
|
69
|
+
with ThreadPoolExecutor(max_workers=self.max_threadpool_size) as task_pool:
|
|
70
|
+
futures = [task_pool.submit(f) for f in funcs if f is not None]
|
|
71
|
+
yield futures
|
|
72
|
+
for f in futures:
|
|
73
|
+
f.result()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@attrs.define(frozen=True)
|
|
77
|
+
class DiffStats:
|
|
78
|
+
diff_by_sign: Dict[str, int]
|
|
79
|
+
table1_count: int
|
|
80
|
+
table2_count: int
|
|
81
|
+
unchanged: int
|
|
82
|
+
diff_percent: float
|
|
83
|
+
extra_column_diffs: Optional[Dict[str, int]]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@attrs.define(frozen=True)
|
|
87
|
+
class DiffResultWrapper:
|
|
88
|
+
diff: iter # DiffResult
|
|
89
|
+
info_tree: InfoTree
|
|
90
|
+
stats: dict
|
|
91
|
+
result_list: list = attrs.field(factory=list)
|
|
92
|
+
|
|
93
|
+
def __iter__(self) -> Iterator[Any]:
|
|
94
|
+
yield from self.result_list
|
|
95
|
+
for i in self.diff:
|
|
96
|
+
self.result_list.append(i)
|
|
97
|
+
yield i
|
|
98
|
+
|
|
99
|
+
def _get_stats(self, is_dbt: bool = False) -> DiffStats:
|
|
100
|
+
list(self) # Consume the iterator into result_list, if we haven't already
|
|
101
|
+
|
|
102
|
+
key_columns = self.info_tree.info.tables[0].key_columns
|
|
103
|
+
len_key_columns = len(key_columns)
|
|
104
|
+
diff_by_key = {}
|
|
105
|
+
extra_column_diffs = None
|
|
106
|
+
if is_dbt:
|
|
107
|
+
extra_column_values_store = {}
|
|
108
|
+
extra_columns = self.info_tree.info.tables[0].extra_columns
|
|
109
|
+
extra_column_diffs = {k: 0 for k in extra_columns}
|
|
110
|
+
|
|
111
|
+
for sign, values in self.result_list:
|
|
112
|
+
k = values[:len_key_columns]
|
|
113
|
+
if is_dbt:
|
|
114
|
+
extra_column_values = values[len_key_columns:]
|
|
115
|
+
if k in diff_by_key:
|
|
116
|
+
assert sign != diff_by_key[k]
|
|
117
|
+
diff_by_key[k] = "!"
|
|
118
|
+
if is_dbt:
|
|
119
|
+
for i in range(0, len(extra_columns)):
|
|
120
|
+
if extra_column_values[i] != extra_column_values_store[k][i]:
|
|
121
|
+
extra_column_diffs[extra_columns[i]] += 1
|
|
122
|
+
else:
|
|
123
|
+
diff_by_key[k] = sign
|
|
124
|
+
if is_dbt:
|
|
125
|
+
extra_column_values_store[k] = extra_column_values
|
|
126
|
+
|
|
127
|
+
diff_by_sign = {k: 0 for k in "+-!"}
|
|
128
|
+
for sign in diff_by_key.values():
|
|
129
|
+
diff_by_sign[sign] += 1
|
|
130
|
+
|
|
131
|
+
table1_count = self.info_tree.info.rowcounts[1]
|
|
132
|
+
table2_count = self.info_tree.info.rowcounts[2]
|
|
133
|
+
unchanged = table1_count - diff_by_sign["-"] - diff_by_sign["!"]
|
|
134
|
+
diff_percent = 1 - unchanged / max(table1_count, table2_count)
|
|
135
|
+
|
|
136
|
+
return DiffStats(diff_by_sign, table1_count, table2_count, unchanged, diff_percent, extra_column_diffs)
|
|
137
|
+
|
|
138
|
+
def get_stats_string(self, is_dbt: bool = False):
|
|
139
|
+
diff_stats = self._get_stats(is_dbt)
|
|
140
|
+
|
|
141
|
+
total_rows_diff = diff_stats.table2_count - diff_stats.table1_count
|
|
142
|
+
|
|
143
|
+
if is_dbt:
|
|
144
|
+
string_output = dbt_diff_string_template(
|
|
145
|
+
total_rows_table1=diff_stats.table1_count,
|
|
146
|
+
total_rows_table2=diff_stats.table2_count,
|
|
147
|
+
total_rows_diff=total_rows_diff,
|
|
148
|
+
rows_added=diff_stats.diff_by_sign["+"],
|
|
149
|
+
rows_removed=diff_stats.diff_by_sign["-"],
|
|
150
|
+
rows_updated=diff_stats.diff_by_sign["!"],
|
|
151
|
+
rows_unchanged=diff_stats.unchanged,
|
|
152
|
+
extra_info_dict=diff_stats.extra_column_diffs,
|
|
153
|
+
extra_info_str="[u]Values Changed[/u]",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
else:
|
|
157
|
+
string_output = ""
|
|
158
|
+
string_output += f"{diff_stats.table1_count} rows in table A\n"
|
|
159
|
+
string_output += f"{diff_stats.table2_count} rows in table B\n"
|
|
160
|
+
string_output += f"{diff_stats.diff_by_sign['-']} rows exclusive to table A (not present in B)\n"
|
|
161
|
+
string_output += f"{diff_stats.diff_by_sign['+']} rows exclusive to table B (not present in A)\n"
|
|
162
|
+
string_output += f"{diff_stats.diff_by_sign['!']} rows updated\n"
|
|
163
|
+
string_output += f"{diff_stats.unchanged} rows unchanged\n"
|
|
164
|
+
string_output += f"{100*diff_stats.diff_percent:.2f}% difference score\n"
|
|
165
|
+
|
|
166
|
+
if self.stats:
|
|
167
|
+
string_output += "\nExtra-Info:\n"
|
|
168
|
+
for k, v in sorted(self.stats.items()):
|
|
169
|
+
string_output += f" {k} = {v}\n"
|
|
170
|
+
|
|
171
|
+
return string_output
|
|
172
|
+
|
|
173
|
+
def get_stats_dict(self, is_dbt: bool = False):
|
|
174
|
+
diff_stats = self._get_stats(is_dbt)
|
|
175
|
+
json_output = {
|
|
176
|
+
"rows_A": diff_stats.table1_count,
|
|
177
|
+
"rows_B": diff_stats.table2_count,
|
|
178
|
+
"exclusive_A": diff_stats.diff_by_sign["-"],
|
|
179
|
+
"exclusive_B": diff_stats.diff_by_sign["+"],
|
|
180
|
+
"updated": diff_stats.diff_by_sign["!"],
|
|
181
|
+
"unchanged": diff_stats.unchanged,
|
|
182
|
+
"total": sum(diff_stats.diff_by_sign.values()),
|
|
183
|
+
"stats": self.stats,
|
|
184
|
+
}
|
|
185
|
+
json_output["values"] = diff_stats.extra_column_diffs or {}
|
|
186
|
+
return json_output
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@attrs.define(frozen=False)
|
|
190
|
+
class TableDiffer(ThreadBase, ABC):
|
|
191
|
+
INFO_TREE_CLASS = InfoTree
|
|
192
|
+
|
|
193
|
+
bisection_factor = 32
|
|
194
|
+
stats: dict = {}
|
|
195
|
+
|
|
196
|
+
ignored_columns1: Set[str] = attrs.field(factory=set)
|
|
197
|
+
ignored_columns2: Set[str] = attrs.field(factory=set)
|
|
198
|
+
_ignored_columns_lock: threading.Lock = attrs.field(factory=threading.Lock, init=False)
|
|
199
|
+
yield_list: bool = False
|
|
200
|
+
|
|
201
|
+
def diff_tables(self, table1: TableSegment, table2: TableSegment, info_tree: InfoTree = None) -> DiffResultWrapper:
|
|
202
|
+
"""Diff the given tables.
|
|
203
|
+
|
|
204
|
+
Parameters:
|
|
205
|
+
table1 (TableSegment): The "before" table to compare. Or: source table
|
|
206
|
+
table2 (TableSegment): The "after" table to compare. Or: target table
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
An iterator that yield pair-tuples, representing the diff. Items can be either -
|
|
210
|
+
('-', row) for items in table1 but not in table2.
|
|
211
|
+
('+', row) for items in table2 but not in table1.
|
|
212
|
+
Where `row` is a tuple of values, corresponding to the diffed columns.
|
|
213
|
+
"""
|
|
214
|
+
if info_tree is None:
|
|
215
|
+
segment_info = self.INFO_TREE_CLASS.SEGMENT_INFO_CLASS([table1, table2])
|
|
216
|
+
info_tree = self.INFO_TREE_CLASS(segment_info)
|
|
217
|
+
return DiffResultWrapper(self._diff_tables_wrapper(table1, table2, info_tree), info_tree, self.stats)
|
|
218
|
+
|
|
219
|
+
def _diff_tables_wrapper(self, table1: TableSegment, table2: TableSegment, info_tree: InfoTree) -> DiffResult:
|
|
220
|
+
if is_tracking_enabled():
|
|
221
|
+
options = attrs.asdict(self, recurse=False)
|
|
222
|
+
# not a useful event attribute
|
|
223
|
+
options.pop("_ignored_columns_lock")
|
|
224
|
+
options["differ_name"] = type(self).__name__
|
|
225
|
+
event_json = create_start_event_json(options)
|
|
226
|
+
run_as_daemon(send_event_json, event_json)
|
|
227
|
+
|
|
228
|
+
if table1.database.dialect.PREVENT_OVERFLOW_WHEN_CONCAT or table2.database.dialect.PREVENT_OVERFLOW_WHEN_CONCAT:
|
|
229
|
+
table1.database.dialect.enable_preventing_type_overflow()
|
|
230
|
+
table2.database.dialect.enable_preventing_type_overflow()
|
|
231
|
+
|
|
232
|
+
start = time.monotonic()
|
|
233
|
+
error = None
|
|
234
|
+
try:
|
|
235
|
+
# Query and validate schema
|
|
236
|
+
table1, table2 = self._threaded_call("with_schema", [table1, table2])
|
|
237
|
+
self._validate_and_adjust_columns(table1, table2)
|
|
238
|
+
|
|
239
|
+
yield from self._diff_tables_root(table1, table2, info_tree)
|
|
240
|
+
|
|
241
|
+
except BaseException as e: # Catch KeyboardInterrupt too
|
|
242
|
+
error = e
|
|
243
|
+
finally:
|
|
244
|
+
info_tree.aggregate_info()
|
|
245
|
+
|
|
246
|
+
if is_tracking_enabled():
|
|
247
|
+
runtime = time.monotonic() - start
|
|
248
|
+
rowcounts = info_tree.info.rowcounts
|
|
249
|
+
table1_count = rowcounts[1] if rowcounts else None
|
|
250
|
+
table2_count = rowcounts[2] if rowcounts else None
|
|
251
|
+
diff_count = info_tree.info.diff_count
|
|
252
|
+
err_message = truncate_error(repr(error))
|
|
253
|
+
event_json = create_end_event_json(
|
|
254
|
+
error is None,
|
|
255
|
+
runtime,
|
|
256
|
+
table1.database.name,
|
|
257
|
+
table2.database.name,
|
|
258
|
+
table1_count,
|
|
259
|
+
table2_count,
|
|
260
|
+
diff_count,
|
|
261
|
+
err_message,
|
|
262
|
+
)
|
|
263
|
+
send_event_json(event_json)
|
|
264
|
+
|
|
265
|
+
if error:
|
|
266
|
+
raise error
|
|
267
|
+
|
|
268
|
+
def _validate_and_adjust_columns(self, table1: TableSegment, table2: TableSegment) -> None:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
def _diff_tables_root(
|
|
272
|
+
self, table1: TableSegment, table2: TableSegment, info_tree: InfoTree
|
|
273
|
+
) -> Union[DiffResult, DiffResultList]:
|
|
274
|
+
return self._bisect_and_diff_tables(table1, table2, info_tree)
|
|
275
|
+
|
|
276
|
+
@abstractmethod
|
|
277
|
+
def _diff_segments(
|
|
278
|
+
self,
|
|
279
|
+
ti: ThreadedYielder,
|
|
280
|
+
table1: TableSegment,
|
|
281
|
+
table2: TableSegment,
|
|
282
|
+
info_tree: InfoTree,
|
|
283
|
+
max_rows: int,
|
|
284
|
+
level=0,
|
|
285
|
+
segment_index=None,
|
|
286
|
+
segment_count=None,
|
|
287
|
+
): ...
|
|
288
|
+
|
|
289
|
+
def _bisect_and_diff_tables(self, table1: TableSegment, table2: TableSegment, info_tree):
|
|
290
|
+
if len(table1.key_columns) != len(table2.key_columns):
|
|
291
|
+
raise ValueError("Tables should have an equivalent number of key columns!")
|
|
292
|
+
|
|
293
|
+
key_types1 = [table1._schema[i] for i in table1.key_columns]
|
|
294
|
+
key_types2 = [table2._schema[i] for i in table2.key_columns]
|
|
295
|
+
|
|
296
|
+
for kt in key_types1 + key_types2:
|
|
297
|
+
if not isinstance(kt, IKey):
|
|
298
|
+
raise NotImplementedError(f"Cannot use a column of type {kt} as a key")
|
|
299
|
+
|
|
300
|
+
for i, (kt1, kt2) in enumerate(safezip(key_types1, key_types2)):
|
|
301
|
+
if kt1.python_type is not kt2.python_type:
|
|
302
|
+
k1 = table1.key_columns[i]
|
|
303
|
+
k2 = table2.key_columns[i]
|
|
304
|
+
raise DataDiffMismatchingKeyTypesError(
|
|
305
|
+
f"Key columns {k1} and {k2} can't be compared due to different types."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Query min/max values
|
|
309
|
+
key_ranges = self._threaded_call_as_completed("query_key_range", [table1, table2])
|
|
310
|
+
|
|
311
|
+
# Start with the first completed value, so we don't waste time waiting
|
|
312
|
+
min_key1, max_key1 = self._parse_key_range_result(key_types1, next(key_ranges))
|
|
313
|
+
|
|
314
|
+
btable1 = table1.new_key_bounds(min_key=min_key1, max_key=max_key1, key_types=key_types1)
|
|
315
|
+
btable2 = table2.new_key_bounds(min_key=min_key1, max_key=max_key1, key_types=key_types2)
|
|
316
|
+
|
|
317
|
+
logger.info(
|
|
318
|
+
f"Diffing segments at key-range: {btable1.min_key}..{btable2.max_key}. "
|
|
319
|
+
f"size: table1 <= {btable1.approximate_size()}, table2 <= {btable2.approximate_size()}"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
ti = ThreadedYielder(self.max_threadpool_size, self.yield_list)
|
|
323
|
+
# Bisect (split) the table into segments, and diff them recursively.
|
|
324
|
+
ti.submit(self._bisect_and_diff_segments, ti, btable1, btable2, info_tree, priority=999)
|
|
325
|
+
|
|
326
|
+
# Now we check for the second min-max, to diff the portions we "missed".
|
|
327
|
+
# This is achieved by subtracting the table ranges, and dividing the resulting space into aligned boxes.
|
|
328
|
+
# For example, given tables A & B, and a 2D compound key, where A was queried first for key-range,
|
|
329
|
+
# the regions of B we need to diff in this second pass are marked by B1..8:
|
|
330
|
+
# ┌──┬──────┬──┐
|
|
331
|
+
# │B1│ B2 │B3│
|
|
332
|
+
# ├──┼──────┼──┤
|
|
333
|
+
# │B4│ A │B5│
|
|
334
|
+
# ├──┼──────┼──┤
|
|
335
|
+
# │B6│ B7 │B8│
|
|
336
|
+
# └──┴──────┴──┘
|
|
337
|
+
# Overall, the max number of new regions in this 2nd pass is 3^|k| - 1
|
|
338
|
+
|
|
339
|
+
# Note: python types can be the same, but the rendering parameters (e.g. casing) can differ.
|
|
340
|
+
min_key2, max_key2 = self._parse_key_range_result(key_types2, next(key_ranges))
|
|
341
|
+
|
|
342
|
+
points = [list(sorted(p)) for p in safezip(min_key1, min_key2, max_key1, max_key2)]
|
|
343
|
+
box_mesh = create_mesh_from_points(*points)
|
|
344
|
+
|
|
345
|
+
new_regions = [(p1, p2) for p1, p2 in box_mesh if p1 < p2 and not (p1 >= min_key1 and p2 <= max_key1)]
|
|
346
|
+
|
|
347
|
+
for p1, p2 in new_regions:
|
|
348
|
+
extra_table1 = table1.new_key_bounds(min_key=p1, max_key=p2, key_types=key_types1)
|
|
349
|
+
extra_table2 = table2.new_key_bounds(min_key=p1, max_key=p2, key_types=key_types2)
|
|
350
|
+
ti.submit(self._bisect_and_diff_segments, ti, extra_table1, extra_table2, info_tree, priority=999)
|
|
351
|
+
|
|
352
|
+
return ti
|
|
353
|
+
|
|
354
|
+
def _parse_key_range_result(self, key_types, key_range) -> Tuple[Vector, Vector]:
|
|
355
|
+
min_key_values, max_key_values = key_range
|
|
356
|
+
|
|
357
|
+
# We add 1 because our ranges are exclusive of the end (like in Python)
|
|
358
|
+
try:
|
|
359
|
+
min_key = Vector(key_type.make_value(mn) for key_type, mn in safezip(key_types, min_key_values))
|
|
360
|
+
max_key = Vector(key_type.make_value(mx) + 1 for key_type, mx in safezip(key_types, max_key_values))
|
|
361
|
+
except (TypeError, ValueError) as e:
|
|
362
|
+
raise type(e)(f"Cannot apply {key_types} to '{min_key_values}', '{max_key_values}'.") from e
|
|
363
|
+
|
|
364
|
+
return min_key, max_key
|
|
365
|
+
|
|
366
|
+
def _bisect_and_diff_segments(
|
|
367
|
+
self,
|
|
368
|
+
ti: ThreadedYielder,
|
|
369
|
+
table1: TableSegment,
|
|
370
|
+
table2: TableSegment,
|
|
371
|
+
info_tree: InfoTree,
|
|
372
|
+
level=0,
|
|
373
|
+
max_rows=None,
|
|
374
|
+
):
|
|
375
|
+
assert table1.is_bounded and table2.is_bounded
|
|
376
|
+
|
|
377
|
+
# Choose evenly spaced checkpoints (according to min_key and max_key)
|
|
378
|
+
biggest_table = max(table1, table2, key=methodcaller("approximate_size"))
|
|
379
|
+
checkpoints = biggest_table.choose_checkpoints(self.bisection_factor - 1)
|
|
380
|
+
|
|
381
|
+
# Get it thread-safe, to avoid segment misalignment because of bad timing.
|
|
382
|
+
with self._ignored_columns_lock:
|
|
383
|
+
table1 = attrs.evolve(table1, ignored_columns=frozenset(self.ignored_columns1))
|
|
384
|
+
table2 = attrs.evolve(table2, ignored_columns=frozenset(self.ignored_columns2))
|
|
385
|
+
|
|
386
|
+
# Create new instances of TableSegment between each checkpoint
|
|
387
|
+
segmented1 = table1.segment_by_checkpoints(checkpoints)
|
|
388
|
+
segmented2 = table2.segment_by_checkpoints(checkpoints)
|
|
389
|
+
|
|
390
|
+
# Recursively compare each pair of corresponding segments between table1 and table2
|
|
391
|
+
for i, (t1, t2) in enumerate(safezip(segmented1, segmented2)):
|
|
392
|
+
info_node = info_tree.add_node(t1, t2, max_rows=max_rows)
|
|
393
|
+
ti.submit(
|
|
394
|
+
self._diff_segments, ti, t1, t2, info_node, max_rows, level + 1, i + 1, len(segmented1), priority=level
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def ignore_column(self, column_name1: str, column_name2: str) -> None:
|
|
398
|
+
"""
|
|
399
|
+
Ignore the column (by name on sides A & B) in md5s & diffs from now on.
|
|
400
|
+
|
|
401
|
+
This affects 2 places:
|
|
402
|
+
|
|
403
|
+
- The columns are not checksumed for new(!) segments.
|
|
404
|
+
- The columns are ignored in in-memory diffing for running segments.
|
|
405
|
+
|
|
406
|
+
The columns are never ignored in the fetched values, whether they are
|
|
407
|
+
the same or different — for data consistency.
|
|
408
|
+
|
|
409
|
+
Use this feature to collect relatively well-represented differences
|
|
410
|
+
across all columns if one of them is highly different in the beginning
|
|
411
|
+
of a table (as per the order of segmentation/bisection). Otherwise,
|
|
412
|
+
that one column might easily hit the limit and stop the whole diff.
|
|
413
|
+
"""
|
|
414
|
+
with self._ignored_columns_lock:
|
|
415
|
+
self.ignored_columns1.add(column_name1)
|
|
416
|
+
self.ignored_columns2.add(column_name2)
|
data_diff/errors.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
class DataDiffDbtProjectVarsNotFoundError(Exception):
|
|
2
|
+
"Raised when an expected dbt_project.yml section is missing."
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DataDiffDbtProfileNotFoundError(Exception):
|
|
6
|
+
"Raised when an expected profiles.yml section is missing."
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DataDiffDbtNoSuccessfulModelsInRunError(Exception):
|
|
10
|
+
"Raised when there are no successful model runs in the run_results.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DataDiffDbtRunResultsVersionError(Exception):
|
|
14
|
+
"Raised when the dbt version in run_results.json is lower than the minimum version."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DataDiffDbtSelectNoMatchingModelsError(Exception):
|
|
18
|
+
"Raised when the `--select` flag returns no models."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DataDiffDbtSelectUnexpectedError(Exception):
|
|
22
|
+
"Catch all for unexpected dbt list --select results."
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DataDiffDbtSnowflakeSetConnectionError(Exception):
|
|
26
|
+
"Raised when a dbt snowflake profile has unexpected values."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DataDiffDbtBigQueryUnsupportedMethodError(Exception):
|
|
30
|
+
"Raised when trying to use an unsupported connection with BigQuery."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DataDiffDbtRedshiftPasswordOnlyError(Exception):
|
|
34
|
+
"Raised when using a non-password connection method with Redshift."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DataDiffDbtConnectionNotImplementedError(Exception):
|
|
38
|
+
"Raised when trying to use an unsupported dbt connection method."
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DataDiffDbtCoreNoRunnerError(Exception):
|
|
42
|
+
"Raised when the manifest version >= 1.5, but the dbt-core package is < 1.5. This is an edge case most likely to occur in development."
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DataDiffCustomSchemaNoConfigError(Exception):
|
|
46
|
+
"Raised when a model has a custom schema, but there is no prod_custom_schema config. (And not using --state)."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DataDiffNoAPIKeyError(Exception):
|
|
50
|
+
"Raised when using --cloud but no API key is present in the DATAFOLD_API_KEY env var or keyring"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DataDiffNoDatasourceIdError(Exception):
|
|
54
|
+
"Raised when using --cloud but no datasource_id was found in dbt_project.yml"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DataDiffDatasourceIdNotFoundError(Exception):
|
|
58
|
+
"Raised when using --cloud but the datasource_id is not found for a particular org."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DataDiffCloudDiffFailed(Exception):
|
|
62
|
+
"Raised when using --cloud and the remote diff fails."
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DataDiffCloudDiffTimedOut(Exception):
|
|
66
|
+
"Raised when using --cloud and the diff did not return finish before the timeout value."
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DataDiffSimpleSelectNotFound(Exception):
|
|
70
|
+
"Raised when using --select on dbt < 1.5 and a model node is not found in the manifest."
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DataDiffMismatchingKeyTypesError(Exception):
|
|
74
|
+
"Raised when the key types of two tables do not match, like VARCHAR and INT."
|