parity-diff 0.2.0__tar.gz → 0.2.1__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 (31) hide show
  1. {parity_diff-0.2.0/src/parity_diff.egg-info → parity_diff-0.2.1}/PKG-INFO +3 -1
  2. {parity_diff-0.2.0 → parity_diff-0.2.1}/pyproject.toml +4 -1
  3. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/__init__.py +1 -1
  4. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/dialects/base.py +19 -4
  5. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/engine.py +19 -3
  6. {parity_diff-0.2.0 → parity_diff-0.2.1/src/parity_diff.egg-info}/PKG-INFO +3 -1
  7. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity_diff.egg-info/SOURCES.txt +3 -1
  8. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity_diff.egg-info/requires.txt +3 -0
  9. {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/fakes.py +7 -1
  10. parity_diff-0.2.1/tests/test_fuzz_encoding.py +347 -0
  11. parity_diff-0.2.1/tests/test_properties.py +257 -0
  12. {parity_diff-0.2.0 → parity_diff-0.2.1}/CONTRIBUTING.md +0 -0
  13. {parity_diff-0.2.0 → parity_diff-0.2.1}/LICENSE +0 -0
  14. {parity_diff-0.2.0 → parity_diff-0.2.1}/MANIFEST.in +0 -0
  15. {parity_diff-0.2.0 → parity_diff-0.2.1}/README.md +0 -0
  16. {parity_diff-0.2.0 → parity_diff-0.2.1}/setup.cfg +0 -0
  17. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/cli.py +0 -0
  18. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/dialects/__init__.py +0 -0
  19. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/dialects/duckdb_dialect.py +0 -0
  20. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/dialects/mysql_dialect.py +0 -0
  21. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/dialects/postgres_dialect.py +0 -0
  22. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity/types.py +0 -0
  23. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity_diff.egg-info/dependency_links.txt +0 -0
  24. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity_diff.egg-info/entry_points.txt +0 -0
  25. {parity_diff-0.2.0 → parity_diff-0.2.1}/src/parity_diff.egg-info/top_level.txt +0 -0
  26. {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/conftest.py +0 -0
  27. {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/test_cli.py +0 -0
  28. {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/test_encoding.py +0 -0
  29. {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/test_engine.py +0 -0
  30. {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/test_integration.py +0 -0
  31. {parity_diff-0.2.0 → parity_diff-0.2.1}/tests/test_mysql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parity-diff
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Prove two tables in two different database engines hold the same data - without moving the data out of either engine.
5
5
  Author: Alessio Sorio
6
6
  License-Expression: MIT
@@ -34,6 +34,8 @@ Provides-Extra: all
34
34
  Requires-Dist: duckdb>=1.0; extra == "all"
35
35
  Requires-Dist: psycopg[binary]>=3.1; extra == "all"
36
36
  Requires-Dist: mysql-connector-python>=8.0; extra == "all"
37
+ Provides-Extra: test
38
+ Requires-Dist: hypothesis>=6.0; extra == "test"
37
39
  Dynamic: license-file
38
40
 
39
41
  # parity
@@ -3,7 +3,7 @@
3
3
  # PyPI by an empty project. The import name, the CLI command and the repo are
4
4
  # all still `parity`: `pip install parity-diff` gives you `parity ...`.
5
5
  name = "parity-diff"
6
- version = "0.2.0"
6
+ version = "0.2.1"
7
7
  description = "Prove two tables in two different database engines hold the same data - without moving the data out of either engine."
8
8
  readme = "README.md"
9
9
  license = "MIT"
@@ -38,6 +38,9 @@ duckdb = ["duckdb>=1.0"]
38
38
  postgres = ["psycopg[binary]>=3.1"]
39
39
  mysql = ["mysql-connector-python>=8.0"]
40
40
  all = ["duckdb>=1.0", "psycopg[binary]>=3.1", "mysql-connector-python>=8.0"]
41
+ # Test-only dependencies, kept out of every runtime extra. Hypothesis drives
42
+ # the generative suite (tests/test_properties.py); the core stays stdlib-only.
43
+ test = ["hypothesis>=6.0"]
41
44
 
42
45
  [project.urls]
43
46
  Homepage = "https://github.com/Aleixiou/parity-diff"
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import Any
15
15
 
16
- __version__ = "0.2.0"
16
+ __version__ = "0.2.1"
17
17
 
18
18
  __all__ = ["__version__", "diff", "get_dialect"]
19
19
 
@@ -275,9 +275,10 @@ class Dialect(ABC):
275
275
  # Two tables can legitimately share only their key - after
276
276
  # `--columns`/`--exclude`, or when the schemas have diverged
277
277
  # entirely. `concat_ws(chr(31), )` is a syntax error, so render a
278
- # constant instead. Row *contents* then always match, while
279
- # `count(*)` in the same checksum query still catches rows present
280
- # on one side only, which is the only difference left to find.
278
+ # constant instead. The checksum query never passes an empty column
279
+ # set here: `segment_checksums` always folds the key in, so in that
280
+ # mode the key itself is what gets hashed. This branch stays as a
281
+ # defensive fallback for any other caller.
281
282
  return "''"
282
283
  return self._concat([self.normalize(c) for c in columns])
283
284
 
@@ -459,9 +460,23 @@ class Dialect(ABC):
459
460
  # the one function whose off-by-one would make the walker skip rows.
460
461
  offset = f"({self.wide_int(k)} - ({lo}))"
461
462
  bucket = self.int_div(f"{offset} * {n_segments}", f"({hi - lo})")
463
+ # Fold the *key* into every row's hash, not just the comparable columns.
464
+ # A count plus a content-only sum cannot see a same-content insert and
465
+ # delete in one bucket: the counts balance (one in, one out) and equal
466
+ # content sums to the same value, so the bucket reads clean - a false
467
+ # "identical", on data as ordinary as two rows sharing a status or an
468
+ # empty string. Including the key makes an inserted key and a deleted
469
+ # key hash to different values, so the bucket sum changes and the walker
470
+ # recurses in. This only ever *adds* sensitivity: a checksum that
471
+ # differs is always re-checked by downloading and comparing the real
472
+ # rows, so an incidental mismatch costs a query, never a wrong verdict.
473
+ # It also subsumes the no-comparable-columns mode, where the key becomes
474
+ # the only thing hashed. `columns` never contains the key, so the key is
475
+ # hashed exactly once.
476
+ content = self.row_hash([*key.columns, *columns])
462
477
  return (
463
478
  f"select {bucket} as seg, count(*), "
464
- f"{self.sum_wide(self.row_hash(columns))} "
479
+ f"{self.sum_wide(content)} "
465
480
  f"from {self.qualify(table)} "
466
481
  f"where {k} >= {lo} and {k} <= {hi - 1} "
467
482
  f"group by 1"
@@ -411,10 +411,26 @@ def diff(
411
411
  stats.segments_checked += 1
412
412
 
413
413
  if span <= 1:
414
- _compare_rows(
415
- pool, a, b, a_table, b_table, key_a, key_b,
416
- a_cols, b_cols, s_lo, s_hi, diffs, stats,
414
+ # Only the *initial* range is ever this small: queued
415
+ # sub-ranges always have span > 1 (a single-key bucket is
416
+ # downloaded straight from the loop below, never re-queued).
417
+ # So this is a one-key table, and it must still be checksum-
418
+ # qualified rather than downloaded outright, or an identical
419
+ # one-row table moves rows and breaks the zero-download
420
+ # promise every other identical table keeps.
421
+ fa = pool.submit(
422
+ a.segment_checksums, a_table, key_a, a_cols, s_lo, s_hi, 1
417
423
  )
424
+ fb = pool.submit(
425
+ b.segment_checksums, b_table, key_b, b_cols, s_lo, s_hi, 1
426
+ )
427
+ cs_a, cs_b = _gather(fa, fb)
428
+ stats.queries += 2
429
+ if cs_a.get(0, EMPTY) != cs_b.get(0, EMPTY):
430
+ _compare_rows(
431
+ pool, a, b, a_table, b_table, key_a, key_b,
432
+ a_cols, b_cols, s_lo, s_hi, diffs, stats,
433
+ )
418
434
  continue
419
435
 
420
436
  n = min(bisection_factor, span)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parity-diff
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Prove two tables in two different database engines hold the same data - without moving the data out of either engine.
5
5
  Author: Alessio Sorio
6
6
  License-Expression: MIT
@@ -34,6 +34,8 @@ Provides-Extra: all
34
34
  Requires-Dist: duckdb>=1.0; extra == "all"
35
35
  Requires-Dist: psycopg[binary]>=3.1; extra == "all"
36
36
  Requires-Dist: mysql-connector-python>=8.0; extra == "all"
37
+ Provides-Extra: test
38
+ Requires-Dist: hypothesis>=6.0; extra == "test"
37
39
  Dynamic: license-file
38
40
 
39
41
  # parity
@@ -23,5 +23,7 @@ tests/fakes.py
23
23
  tests/test_cli.py
24
24
  tests/test_encoding.py
25
25
  tests/test_engine.py
26
+ tests/test_fuzz_encoding.py
26
27
  tests/test_integration.py
27
- tests/test_mysql.py
28
+ tests/test_mysql.py
29
+ tests/test_properties.py
@@ -12,3 +12,6 @@ mysql-connector-python>=8.0
12
12
 
13
13
  [postgres]
14
14
  psycopg[binary]>=3.1
15
+
16
+ [test]
17
+ hypothesis>=6.0
@@ -255,7 +255,13 @@ class FakeDialect(Dialect):
255
255
  bucket = ((k - lo) * n_segments) // span
256
256
  acc = out.setdefault(bucket, [0, 0])
257
257
  acc[0] += 1
258
- acc[1] += row_hash(self.table.text(k, columns))
258
+ # Mirror Dialect._segment_sql: fold the key into the hash so a
259
+ # same-content insert and delete in one bucket cannot cancel. The
260
+ # integer key's canonical text is str(k), and row_text prepends it
261
+ # to the column text with the field separator.
262
+ col_text = self.table.text(k, columns)
263
+ combined = str(k) if not columns else f"{k}\x1f{col_text}"
264
+ acc[1] += row_hash(combined)
259
265
  return {i: (c, s) for i, (c, s) in out.items()}
260
266
 
261
267
  def fetch_range(
@@ -0,0 +1,347 @@
1
+ """Differential fuzzing of the cross-engine encoding contract.
2
+
3
+ `test_encoding.py` pins the encoding with a fixed table of hand-chosen cases -
4
+ the seventeen values CLAUDE.md section 4 was verified against. This file attacks
5
+ the same contract from the other side: it *generates* hundreds of values, many
6
+ of them deliberately hostile (the field separator byte itself, the NULL
7
+ sentinel spelled as real data, emoji, combining marks, right-to-left text,
8
+ bigint extremes, microsecond timestamps), inserts the **same Python value** into
9
+ both engines, and asserts they render byte-identical canonical text and the same
10
+ 60-bit row hash.
11
+
12
+ Because the value inserted into each side is identical, any disagreement is a
13
+ pure *encoding* difference - exactly the class of bug that makes a migration
14
+ look clean when a NULL quietly became an empty string, or a timestamp lost its
15
+ microseconds on one engine only. A fixed case list can only catch the
16
+ differences someone already thought to write down; this catches the ones nobody
17
+ did.
18
+
19
+ Values are inserted through the drivers' parameter binding, never as SQL
20
+ literals, so an arbitrary string cannot escape into the statement - the fuzz
21
+ input is data, not code. The run is seeded, so a failure reproduces exactly.
22
+
23
+ Skips, never fails, when an engine is unreachable (see conftest).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import datetime as dt
29
+ import random
30
+ from decimal import Decimal
31
+
32
+ import pytest
33
+ from conftest import open_duckdb, open_pg
34
+
35
+ from parity.dialects.base import Dialect
36
+ from parity.engine import diff
37
+ from parity.types import Column
38
+
39
+ # A schema and a DuckDB file of this module's own, so nothing here collides with
40
+ # the fixtures in test_encoding.py (DuckDB permits a single writer per file).
41
+ FUZZ_SCHEMA = "parity_fuzz"
42
+ FUZZ_TABLE = "fuzz_values"
43
+ SEED = 20240906
44
+ N_RANDOM = 250
45
+
46
+ # id bigint, then one column per fuzzable logical type. The tuple is
47
+ # (name, postgres type, duckdb type).
48
+ COLUMNS: list[tuple[str, str, str]] = [
49
+ ("id", "bigint", "bigint"),
50
+ ("s", "text", "varchar"),
51
+ ("i", "bigint", "bigint"),
52
+ ("d", "decimal(20,6)", "decimal(20,6)"),
53
+ ("b", "boolean", "boolean"),
54
+ ("dd", "date", "date"),
55
+ ("ts", "timestamp", "timestamp"),
56
+ ]
57
+ VALUE_COLUMNS = [name for name, *_ in COLUMNS if name != "id"]
58
+
59
+ # Characters excluded from generated strings for reasons unrelated to parity:
60
+ # PostgreSQL's `text` type cannot store a NUL byte at all, and lone surrogates
61
+ # have no UTF-8 encoding. Everything else - including the 0x1f field separator
62
+ # and the two bytes of the `\N` sentinel - is fair game: a single column's
63
+ # canonical text never involves the separator, so both engines must still
64
+ # render these identically, and if they don't that is the bug.
65
+ _FORBIDDEN = {"\x00"}
66
+
67
+
68
+ def _rand_string(rng: random.Random) -> str:
69
+ """A random string drawn from a deliberately hostile alphabet."""
70
+ palette = (
71
+ "abcABC012 "
72
+ "héllo wörld" # accented latin
73
+ "日本語한국어" # CJK
74
+ "\U0001f389\U0001f600" # emoji (astral plane)
75
+ "éñ" # combining marks
76
+ "‏‮" # RTL / override controls
77
+ "\x1f" # the field separator, as real data
78
+ "\t\r\n" # whitespace controls
79
+ )
80
+ length = rng.randint(0, 24)
81
+ chars = [rng.choice(palette) for _ in range(length)]
82
+ s = "".join(c for c in chars if c not in _FORBIDDEN)
83
+ # Encoding round-trips as UTF-8 downstream; drop anything that cannot.
84
+ return s.encode("utf-8", "ignore").decode("utf-8")
85
+
86
+
87
+ def _rand_decimal(rng: random.Random) -> Decimal:
88
+ """A decimal with at most 6 places, so no rounding happens at insert time.
89
+
90
+ A value with more than 6 places would be rounded into decimal(20,6) by the
91
+ INSERT, and if the two engines rounded it differently the *stored* values
92
+ would differ - a value difference masquerading as an encoding difference.
93
+ Six places keeps the inserted value exact on both sides.
94
+ """
95
+ unscaled = rng.randint(-(10**12), 10**12)
96
+ scale = rng.randint(0, 6)
97
+ return (Decimal(unscaled) / (Decimal(10) ** scale)).quantize(Decimal("0.000001"))
98
+
99
+
100
+ def _rand_timestamp(rng: random.Random) -> dt.datetime:
101
+ """A timestamp somewhere in a wide range, to microsecond precision."""
102
+ base = dt.datetime(1000, 1, 1)
103
+ return base + dt.timedelta(
104
+ days=rng.randint(0, 3_000_000),
105
+ seconds=rng.randint(0, 86_399),
106
+ microseconds=rng.randint(0, 999_999),
107
+ )
108
+
109
+
110
+ def _adversarial_rows() -> list[tuple]:
111
+ """Hand-picked hostile rows, prepended so they are always exercised.
112
+
113
+ Each is a `(s, i, d, b, dd, ts)` tuple; `None` means SQL NULL, which must
114
+ render as the sentinel rather than poison the row.
115
+ """
116
+ return [
117
+ ("", 0, Decimal("0.000000"), False, dt.date(1970, 1, 1), dt.datetime(2024, 1, 1)),
118
+ ("\x1f", 1, Decimal("1.500000"), True, dt.date(2024, 2, 29),
119
+ dt.datetime(2024, 2, 29, 13, 4, 5, 123456)),
120
+ # The sentinel spelled as genuine string data - both engines must still
121
+ # agree on how they render it, whatever the diff logic later makes of it.
122
+ ("\\N", -1, Decimal("-0.125000"), None, None, None),
123
+ ("O'Brien \"x\"", 9223372036854775807, Decimal("123456789.987654"),
124
+ True, dt.date(9999, 12, 31), dt.datetime(9999, 12, 31, 23, 59, 59, 999999)),
125
+ (None, -9223372036854775808, None, False, dt.date(1, 1, 1),
126
+ dt.datetime(1, 1, 1, 0, 0, 0)),
127
+ ("héllo 日本語 \U0001f389", None, Decimal("-99999999999999.999999"),
128
+ None, dt.date(2000, 1, 1), None),
129
+ ]
130
+
131
+
132
+ def _rows() -> list[tuple]:
133
+ """The full fuzz corpus: the adversarial rows, then N random ones."""
134
+ rng = random.Random(SEED)
135
+ rows = list(_adversarial_rows())
136
+ for _ in range(N_RANDOM):
137
+ rows.append((
138
+ _rand_string(rng) if rng.random() > 0.1 else None,
139
+ rng.randint(-(2**63), 2**63 - 1) if rng.random() > 0.1 else None,
140
+ _rand_decimal(rng) if rng.random() > 0.1 else None,
141
+ rng.choice([True, False]) if rng.random() > 0.1 else None,
142
+ _rand_timestamp(rng).date() if rng.random() > 0.1 else None,
143
+ _rand_timestamp(rng) if rng.random() > 0.1 else None,
144
+ ))
145
+ return rows
146
+
147
+
148
+ ROWS = _rows()
149
+
150
+
151
+ # --------------------------------------------------------------------------
152
+ # Fixtures: build an identical fuzz table in each engine.
153
+ # --------------------------------------------------------------------------
154
+
155
+
156
+ @pytest.fixture(scope="module")
157
+ def duck_fuzz(tmp_path_factory: pytest.TempPathFactory) -> Dialect:
158
+ """Load the fuzz corpus into a private DuckDB file, then read it back."""
159
+ import duckdb
160
+ from conftest import _duckdb_available
161
+
162
+ ok, why = _duckdb_available()
163
+ if not ok:
164
+ pytest.skip(why)
165
+
166
+ path = str(tmp_path_factory.mktemp("fuzz") / "fuzz.duckdb")
167
+ con = duckdb.connect(path)
168
+ try:
169
+ cols = ", ".join(f"{n} {t[1]}" for n, *t in COLUMNS)
170
+ con.execute(f"create table {FUZZ_TABLE} ({cols})")
171
+ placeholders = ", ".join(["?"] * len(COLUMNS))
172
+ con.executemany(
173
+ f"insert into {FUZZ_TABLE} values ({placeholders})",
174
+ [(i, *row) for i, row in enumerate(ROWS)],
175
+ )
176
+ finally:
177
+ con.close()
178
+
179
+ dialect = open_duckdb(path, side="B")
180
+ yield dialect
181
+ dialect.close()
182
+
183
+
184
+ @pytest.fixture(scope="module")
185
+ def pg_fuzz(pg_url: str) -> Dialect:
186
+ """Load the fuzz corpus into a private PostgreSQL schema, then read it back."""
187
+ import psycopg
188
+
189
+ con = psycopg.connect(pg_url, autocommit=True)
190
+ try:
191
+ con.execute(f"drop schema if exists {FUZZ_SCHEMA} cascade")
192
+ con.execute(f"create schema {FUZZ_SCHEMA}")
193
+ cols = ", ".join(f"{n} {t[0]}" for n, *t in COLUMNS)
194
+ con.execute(f"create table {FUZZ_SCHEMA}.{FUZZ_TABLE} ({cols})")
195
+ placeholders = ", ".join(["%s"] * len(COLUMNS))
196
+ with con.cursor() as cur:
197
+ cur.executemany(
198
+ f"insert into {FUZZ_SCHEMA}.{FUZZ_TABLE} values ({placeholders})",
199
+ [(i, *row) for i, row in enumerate(ROWS)],
200
+ )
201
+ finally:
202
+ con.close()
203
+
204
+ dialect = open_pg(pg_url, side="A")
205
+ yield dialect
206
+ dialect.close()
207
+
208
+
209
+ def _qualified(dialect: Dialect) -> str:
210
+ """The fuzz table, qualified for whichever engine this is."""
211
+ schema = FUZZ_SCHEMA if dialect.name == "postgres" else "main"
212
+ return f"{schema}.{FUZZ_TABLE}"
213
+
214
+
215
+ def _column(dialect: Dialect, name: str) -> Column:
216
+ """The introspected Column of the given name on this engine's fuzz table."""
217
+ for col in dialect.columns(_qualified(dialect)):
218
+ if col.name == name:
219
+ return col
220
+ raise AssertionError(f"no column {name!r} on side {dialect.side}")
221
+
222
+
223
+ # --------------------------------------------------------------------------
224
+ # The differential assertions.
225
+ # --------------------------------------------------------------------------
226
+
227
+
228
+ @pytest.mark.postgres
229
+ @pytest.mark.parametrize("column", VALUE_COLUMNS)
230
+ def test_random_values_normalize_identically_across_engines(pg_fuzz, duck_fuzz, column):
231
+ """Every fuzzed value renders to byte-identical canonical text on both engines.
232
+
233
+ One column at a time, all rows at once. A single disagreeing row - a lost
234
+ microsecond, a NULL that rendered as SQL NULL instead of the sentinel, a
235
+ Unicode byte one engine folded - fails the test and names the row.
236
+ """
237
+ pg_col = _column(pg_fuzz, column)
238
+ duck_col = _column(duck_fuzz, column)
239
+ pg_rows = pg_fuzz.query(
240
+ f"select id, {pg_fuzz.normalize(pg_col)} "
241
+ f"from {pg_fuzz.qualify(_qualified(pg_fuzz))} order by id"
242
+ )
243
+ duck_rows = duck_fuzz.query(
244
+ f"select id, {duck_fuzz.normalize(duck_col)} "
245
+ f"from {duck_fuzz.qualify(_qualified(duck_fuzz))} order by id"
246
+ )
247
+ assert len(pg_rows) == len(duck_rows) == len(ROWS)
248
+ for (pid, pval), (did, dval) in zip(pg_rows, duck_rows, strict=True):
249
+ assert pid == did
250
+ assert pval == dval, (
251
+ f"column {column!r} row {pid}: postgres rendered {pval!r}, "
252
+ f"duckdb rendered {dval!r} for input {ROWS[pid][VALUE_COLUMNS.index(column)]!r}"
253
+ )
254
+
255
+
256
+ @pytest.mark.postgres
257
+ def test_random_rows_hash_identically_across_engines(pg_fuzz, duck_fuzz):
258
+ """The whole fuzzed row folds to the same 60-bit hash on both engines.
259
+
260
+ This is the row-level contract, not just per-column: the concatenation, the
261
+ separator, the coalesce, and the fold all have to agree at once, over every
262
+ generated row.
263
+ """
264
+ pg_cols = sorted(
265
+ (c for c in pg_fuzz.columns(_qualified(pg_fuzz)) if c.name != "id"),
266
+ key=lambda c: c.name,
267
+ )
268
+ duck_cols = sorted(
269
+ (c for c in duck_fuzz.columns(_qualified(duck_fuzz)) if c.name != "id"),
270
+ key=lambda c: c.name,
271
+ )
272
+ pg_rows = pg_fuzz.query(
273
+ f"select id, {pg_fuzz.row_hash(pg_cols)} "
274
+ f"from {pg_fuzz.qualify(_qualified(pg_fuzz))} order by id"
275
+ )
276
+ duck_rows = duck_fuzz.query(
277
+ f"select id, {duck_fuzz.row_hash(duck_cols)} "
278
+ f"from {duck_fuzz.qualify(_qualified(duck_fuzz))} order by id"
279
+ )
280
+ assert len(pg_rows) == len(duck_rows) == len(ROWS)
281
+ for (pid, phash), (did, dhash) in zip(pg_rows, duck_rows, strict=True):
282
+ assert pid == did
283
+ assert int(phash) == int(dhash), (
284
+ f"row {pid} hashed to {phash} on postgres, {dhash} on duckdb: "
285
+ f"input {ROWS[pid]!r}"
286
+ )
287
+ assert 0 <= int(phash) < 2**60, f"row {pid} hash out of 60-bit range"
288
+
289
+
290
+ @pytest.mark.postgres
291
+ def test_the_whole_engine_calls_two_fuzzed_tables_identical(pg_fuzz, duck_fuzz):
292
+ """End to end, over fuzzed data: the same rows in Postgres and DuckDB match.
293
+
294
+ Not just the encoding helpers - the real `diff()` walk, hashing pushed into
295
+ both engines and the key range bisected, over a table of hostile values.
296
+ Identical data must download zero rows and report identical; if any fuzzed
297
+ value hashed differently across engines, the walk would chase a phantom
298
+ difference and this would fail.
299
+ """
300
+ result = diff(pg_fuzz, duck_fuzz, _qualified(pg_fuzz), _qualified(duck_fuzz), "id")
301
+ assert result.identical, f"fuzzed tables reported different: {result.diffs[:5]}"
302
+ assert result.stats.rows_downloaded == 0
303
+
304
+
305
+ @pytest.mark.postgres
306
+ def test_a_single_planted_change_in_fuzzed_data_is_found(pg_fuzz, tmp_path):
307
+ """A parity tool that never plants a difference proves nothing (CLAUDE.md 8).
308
+
309
+ Build a fresh DuckDB copy of the fuzz corpus with exactly one row's string
310
+ column changed, diff the untouched Postgres table against it, and confirm
311
+ the real walk reports precisely that row and names the changed column - on
312
+ hostile fuzzed data, across two engines. Building a fresh copy (rather than
313
+ mutating a table a dialect already holds open) keeps the change visible: a
314
+ read-only dialect pins a snapshot at connect time and would not see a
315
+ mid-session write from another connection.
316
+ """
317
+ import duckdb
318
+
319
+ victim = 3 # an adversarial row, guaranteed present
320
+ path = str(tmp_path / "perturbed.duckdb")
321
+ con = duckdb.connect(path)
322
+ try:
323
+ cols = ", ".join(f"{n} {t[1]}" for n, *t in COLUMNS)
324
+ con.execute(f"create table {FUZZ_TABLE} ({cols})")
325
+ placeholders = ", ".join(["?"] * len(COLUMNS))
326
+ rows = []
327
+ for i, row in enumerate(ROWS):
328
+ r = list(row)
329
+ if i == victim:
330
+ r[VALUE_COLUMNS.index("s")] = "PERTURBED"
331
+ rows.append((i, *r))
332
+ con.executemany(f"insert into {FUZZ_TABLE} values ({placeholders})", rows)
333
+ finally:
334
+ con.close()
335
+
336
+ perturbed = open_duckdb(path, side="B")
337
+ try:
338
+ result = diff(
339
+ pg_fuzz, perturbed, _qualified(pg_fuzz), f"main.{FUZZ_TABLE}", "id"
340
+ )
341
+ assert [(d.key, d.kind) for d in result.diffs] == [(victim, "different")], (
342
+ f"expected exactly row {victim} to differ, got "
343
+ f"{[(d.key, d.kind) for d in result.diffs]}"
344
+ )
345
+ assert "s" in result.diffs[0].columns
346
+ finally:
347
+ perturbed.close()
@@ -0,0 +1,257 @@
1
+ """Property-based, oracle, and metamorphic tests for the bisection engine.
2
+
3
+ Everything here is generative: Hypothesis builds thousands of tables and the
4
+ tests assert properties that must hold for *all* of them, rather than for a few
5
+ hand-picked cases. The centrepiece is the **oracle** - parity's segmented,
6
+ network-frugal diff is checked against a trivial "download everything and
7
+ compare in Python" reference over random inputs. Any logic error in the walk,
8
+ the bucket arithmetic, or the row comparison shows up as a disagreement with
9
+ the oracle, on an input no human thought to write down.
10
+
11
+ All of it runs against the in-memory `FakeDialect`, so it needs no database and
12
+ runs everywhere - and `FakeDialect.query` raises, so these also keep proving the
13
+ engine never reaches past the dialect contract to build SQL itself.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import pytest
19
+
20
+ # Hypothesis is a test-only dependency (the `test` extra). If it is somehow
21
+ # absent, skip this whole module rather than fail collection - a missing test
22
+ # dependency must not look like a broken build.
23
+ pytest.importorskip("hypothesis")
24
+
25
+ from fakes import DictTable, FakeDialect
26
+ from hypothesis import given, settings
27
+ from hypothesis import strategies as st
28
+
29
+ from parity.engine import bucket_bounds, diff
30
+ from parity.types import Column, LogicalType
31
+
32
+ COLS = [
33
+ Column("a", LogicalType.STRING, "varchar"),
34
+ Column("b", LogicalType.STRING, "varchar"),
35
+ ]
36
+
37
+ # Text values that avoid the field separator (0x1f) and the NULL sentinel, so
38
+ # these tests isolate the *bisection* logic from the *encoding*. The separator
39
+ # and sentinel get their own adversarial tests in test_fuzz_encoding.py.
40
+ _safe_text = st.text(
41
+ alphabet=st.characters(blacklist_characters="\x1f", blacklist_categories=("Cs",)),
42
+ max_size=12,
43
+ )
44
+ _row = st.tuples(_safe_text, _safe_text)
45
+ # Keys span negatives and a wide range, so bucketing edge cases are exercised.
46
+ _key = st.integers(min_value=-(10**6), max_value=10**6)
47
+ _table = st.dictionaries(_key, _row, max_size=60)
48
+
49
+
50
+ def _oracle(rows_a: dict, rows_b: dict) -> list[tuple[int, str]]:
51
+ """The truth, computed the dumb way: compare every row directly.
52
+
53
+ parity must reproduce this exactly, however cleverly it gets there.
54
+ """
55
+ out = []
56
+ for k in set(rows_a) | set(rows_b):
57
+ a, b = rows_a.get(k), rows_b.get(k)
58
+ if a is None:
59
+ out.append((k, "only_in_b"))
60
+ elif b is None:
61
+ out.append((k, "only_in_a"))
62
+ elif a != b:
63
+ out.append((k, "different"))
64
+ return sorted(out)
65
+
66
+
67
+ def _run(rows_a: dict, rows_b: dict, **kwargs):
68
+ """Diff two literal row dicts through the engine, over the in-memory fake."""
69
+ a = FakeDialect(DictTable(COLS, rows_a), side="A")
70
+ b = FakeDialect(DictTable(COLS, rows_b), side="B")
71
+ return diff(a, b, "a.t", "b.t", "id", **kwargs)
72
+
73
+
74
+ def _kinds(result) -> list[tuple[int, str]]:
75
+ """The result as a sorted (key, kind) list, for comparison against the oracle."""
76
+ return sorted((d.key, d.kind) for d in result.diffs)
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # The oracle: the segmented walk must equal a brute-force comparison, always.
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ @settings(max_examples=400)
85
+ @given(a=_table, b=_table)
86
+ def test_diff_matches_a_brute_force_oracle(a, b):
87
+ """For any two tables, parity's result equals comparing every row directly.
88
+
89
+ This is the whole engine under test at once. A skipped range, an
90
+ off-by-one bucket, a misclassified row - any of them makes this fail on
91
+ some generated input.
92
+ """
93
+ assert _kinds(_run(a, b)) == _oracle(a, b)
94
+
95
+
96
+ @settings(max_examples=200)
97
+ @given(
98
+ a=_table,
99
+ b=_table,
100
+ bisection_factor=st.integers(min_value=2, max_value=64),
101
+ threshold=st.integers(min_value=1, max_value=50),
102
+ )
103
+ def test_result_is_invariant_to_the_tuning_knobs(a, b, bisection_factor, threshold):
104
+ """bisection_factor and threshold change cost, never the verdict.
105
+
106
+ Fan-out and download-threshold are performance dials. If either changes
107
+ *which* rows are reported, the walk is wrong.
108
+ """
109
+ assert _kinds(_run(a, b, bisection_factor=bisection_factor, threshold=threshold)) == _oracle(a, b)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Metamorphic properties: relations that must hold between related runs.
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ @settings(max_examples=200)
118
+ @given(t=_table)
119
+ def test_a_table_diffed_against_itself_is_identical(t):
120
+ """The most basic invariant, and a false match here is catastrophic."""
121
+ result = _run(t, t)
122
+ assert result.identical
123
+ assert result.diffs == []
124
+ assert result.stats.rows_downloaded == 0
125
+
126
+
127
+ @settings(max_examples=300)
128
+ @given(a=_table, b=_table)
129
+ def test_diff_is_symmetric_under_side_swap(a, b):
130
+ """diff(A,B) and diff(B,A) are mirror images: only_in_a <-> only_in_b,
131
+ different stays different, same keys throughout."""
132
+ forward = dict(_kinds(_run(a, b)))
133
+ backward = dict(_kinds(_run(b, a)))
134
+ flip = {"only_in_a": "only_in_b", "only_in_b": "only_in_a", "different": "different"}
135
+ assert backward == {k: flip[v] for k, v in forward.items()}
136
+
137
+
138
+ @settings(max_examples=200)
139
+ @given(t=_table, extra=_table)
140
+ def test_rows_downloaded_is_zero_exactly_when_identical(t, extra):
141
+ """The core efficiency promise, stated as an iff over random inputs."""
142
+ same = _run(t, t)
143
+ assert same.stats.rows_downloaded == 0 and same.identical
144
+
145
+ other = _run(t, extra)
146
+ if _oracle(t, extra): # they genuinely differ
147
+ assert other.stats.rows_downloaded > 0 and not other.identical
148
+ else:
149
+ assert other.stats.rows_downloaded == 0 and other.identical
150
+
151
+
152
+ @settings(max_examples=200)
153
+ @given(
154
+ base=st.dictionaries(_key, _row, min_size=1, max_size=40),
155
+ changed=st.integers(min_value=0, max_value=39),
156
+ )
157
+ def test_exactly_n_changed_rows_are_found(base, changed):
158
+ """Change a chosen number of rows; the walk must find exactly that many."""
159
+ keys = sorted(base)
160
+ n = min(changed, len(keys))
161
+ b = dict(base)
162
+ for k in keys[:n]:
163
+ b[k] = (base[k][0] + "X", base[k][1]) # guaranteed different
164
+ result = _run(base, b)
165
+ assert [d.kind for d in result.diffs] == ["different"] * n
166
+ assert {d.key for d in result.diffs} == set(keys[:n])
167
+
168
+
169
+ @settings(max_examples=150)
170
+ @given(a=_table, b=_table)
171
+ def test_excluding_every_differing_column_yields_identical(a, b):
172
+ """Metamorphic: a diff driven only by column values disappears when those
173
+ columns are excluded. Only key-presence differences can remain."""
174
+ result = _run(a, b, exclude=["a", "b"])
175
+ presence_only = sorted(
176
+ (k, kind) for k, kind in _oracle(a, b) if kind != "different"
177
+ )
178
+ assert _kinds(result) == presence_only
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # bucket_bounds: the inverse of the SQL bucket expression, over the whole space.
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ def _sql_bucket(key: int, lo: int, hi: int, n: int) -> int:
187
+ """The SQL bucket expression in Python: which segment a key falls in."""
188
+ return ((key - lo) * n) // (hi - lo)
189
+
190
+
191
+ @settings(max_examples=500)
192
+ @given(
193
+ lo=st.integers(min_value=-(10**9), max_value=10**9),
194
+ span=st.integers(min_value=2, max_value=10**7),
195
+ n=st.integers(min_value=2, max_value=1000),
196
+ )
197
+ def test_bucket_bounds_tiles_the_range_with_no_gap_or_overlap(lo, span, n):
198
+ """Every key lands in exactly one bucket, and the buckets cover [lo, hi).
199
+
200
+ A gap is a row the walker never looks at while still reporting a clean
201
+ match - the worst failure this tool can have. Tested over the full space
202
+ of (lo, span, n), not a handful of seeds.
203
+ """
204
+ hi = lo + span
205
+ n = min(n, span)
206
+ prev_hi = lo
207
+ for i in range(n):
208
+ b_lo, b_hi = bucket_bounds(i, lo, hi, n)
209
+ assert b_lo == prev_hi, f"gap or overlap before bucket {i}"
210
+ assert b_lo <= b_hi
211
+ prev_hi = b_hi
212
+ assert prev_hi == hi, "buckets do not reach the end of the range"
213
+
214
+
215
+ @settings(max_examples=300)
216
+ @given(
217
+ lo=st.integers(min_value=-1000, max_value=1000),
218
+ span=st.integers(min_value=2, max_value=3000),
219
+ n=st.integers(min_value=2, max_value=200),
220
+ offset=st.integers(min_value=0),
221
+ )
222
+ def test_bucket_bounds_agrees_with_the_sql_expression_for_a_key(lo, span, n, offset):
223
+ """For a key in range, Python's bucket assignment matches the SQL formula.
224
+
225
+ The two must agree exactly, or the walker recurses into the wrong range.
226
+ """
227
+ hi = lo + span
228
+ n = min(n, span)
229
+ key = lo + (offset % span)
230
+ sql = _sql_bucket(key, lo, hi, n)
231
+ b_lo, b_hi = bucket_bounds(sql, lo, hi, n)
232
+ assert b_lo <= key < b_hi
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # max_diffs: a capped run is a prefix of the truth, never a clean bill.
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ @settings(max_examples=150)
241
+ @given(
242
+ base=st.dictionaries(_key, _row, min_size=1, max_size=40),
243
+ limit=st.integers(min_value=1, max_value=20),
244
+ )
245
+ def test_max_diffs_never_reports_identical_when_differences_exist(base, limit):
246
+ """Change every row, cap the report, and check the cap is honest.
247
+
248
+ A truncated run must carry the flag, must never read as identical, and must
249
+ return no more than the limit.
250
+ """
251
+ b = {k: (v[0] + "Z", v[1]) for k, v in base.items()}
252
+ result = _run(base, b, max_diffs=limit)
253
+
254
+ assert len(result.diffs) <= limit
255
+ if len(base) > limit:
256
+ assert result.truncated
257
+ assert not result.identical
File without changes
File without changes
File without changes
File without changes
File without changes