parity-diff 0.1.0__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.
- parity/__init__.py +31 -0
- parity/cli.py +361 -0
- parity/dialects/__init__.py +0 -0
- parity/dialects/base.py +487 -0
- parity/dialects/duckdb_dialect.py +132 -0
- parity/dialects/postgres_dialect.py +139 -0
- parity/engine.py +449 -0
- parity/types.py +125 -0
- parity_diff-0.1.0.dist-info/METADATA +268 -0
- parity_diff-0.1.0.dist-info/RECORD +14 -0
- parity_diff-0.1.0.dist-info/WHEEL +5 -0
- parity_diff-0.1.0.dist-info/entry_points.txt +2 -0
- parity_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
- parity_diff-0.1.0.dist-info/top_level.txt +1 -0
parity/dialects/base.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Dialect contract.
|
|
2
|
+
|
|
3
|
+
A dialect knows three things about one database engine:
|
|
4
|
+
|
|
5
|
+
1. how to read a column's type and map it onto a :class:`LogicalType`
|
|
6
|
+
2. how to render a value as *canonical text* - the same bytes any other
|
|
7
|
+
engine would produce for the same logical value
|
|
8
|
+
3. how to fold canonical text into a 60-bit integer and aggregate it
|
|
9
|
+
|
|
10
|
+
Everything else (segmentation, recursion, reporting) is engine-independent
|
|
11
|
+
and lives in :mod:`parity.engine`.
|
|
12
|
+
|
|
13
|
+
The 60-bit width is deliberate: it is the widest prefix of an MD5 hex digest
|
|
14
|
+
that both PostgreSQL's ``bit(n)::bigint`` cast and DuckDB's hex-string cast
|
|
15
|
+
render as the same *positive* signed 64-bit integer. At 64 bits PostgreSQL
|
|
16
|
+
wraps to negative and the two engines disagree.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from parity.types import Column, KeyStats, LogicalType
|
|
26
|
+
|
|
27
|
+
# Field separator inside a row's canonical text. Unit Separator (0x1f) is
|
|
28
|
+
# chosen because it effectively never appears in warehouse string data;
|
|
29
|
+
# `chr(31)` is spelled the same way in every engine we support.
|
|
30
|
+
SEPARATOR_SQL = "chr(31)"
|
|
31
|
+
NULL_SENTINEL = "\\N"
|
|
32
|
+
|
|
33
|
+
# Number of MD5 hex characters folded into the row hash. 15 nibbles = 60 bits.
|
|
34
|
+
HASH_HEX_CHARS = 15
|
|
35
|
+
|
|
36
|
+
#: Most values a single `concat_ws` call may take. PostgreSQL's
|
|
37
|
+
#: `max_function_args` is 100 and fixed at compile time; DuckDB allows more.
|
|
38
|
+
#: 64 leaves clear headroom on the strictest engine and keeps both sides
|
|
39
|
+
#: nesting identically, which is what guarantees identical canonical text.
|
|
40
|
+
MAX_CONCAT_ARGS = 64
|
|
41
|
+
|
|
42
|
+
#: Decimal places at which DECIMAL/FLOAT columns are compared. A deliberate,
|
|
43
|
+
#: documented limitation - see CLAUDE.md section 4.2.
|
|
44
|
+
DEFAULT_FLOAT_SCALE = 6
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sql_literal(value: str) -> str:
|
|
48
|
+
"""Quote a string literal. Only ever used for schema and table names read
|
|
49
|
+
back from `information_schema`, never for user data."""
|
|
50
|
+
return "'" + value.replace("'", "''") + "'"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Dialect(ABC):
|
|
54
|
+
"""One database engine's half of a comparison."""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
float_scale: int = DEFAULT_FLOAT_SCALE,
|
|
61
|
+
side: str = "?",
|
|
62
|
+
) -> None:
|
|
63
|
+
if float_scale < 0:
|
|
64
|
+
raise ValueError(f"float_scale must be >= 0, got {float_scale}")
|
|
65
|
+
#: Decimal places at which DECIMAL/FLOAT columns are compared. This is
|
|
66
|
+
#: per-instance, not per-class: as a class attribute a change on one
|
|
67
|
+
#: side would leak to the other, or worse, not leak - and two sides
|
|
68
|
+
#: rounding differently reports *every* float row as different. Use
|
|
69
|
+
#: `require_matching_scales` before comparing.
|
|
70
|
+
self.float_scale = float_scale
|
|
71
|
+
#: "A" or "B". Carried only so errors can name which side failed;
|
|
72
|
+
#: "table not found" is useless when two databases are in play.
|
|
73
|
+
self.side = side
|
|
74
|
+
|
|
75
|
+
def _err(self, message: str) -> ValueError:
|
|
76
|
+
return ValueError(f"[side {self.side}: {self.name}] {message}")
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------- setup
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def connect(self, connection_string: str) -> None: ...
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def close(self) -> None: ...
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def query(self, sql: str) -> list[tuple[Any, ...]]: ...
|
|
88
|
+
|
|
89
|
+
def cancel(self) -> None: # noqa: B027 - optional by design, see below
|
|
90
|
+
"""Abort whatever query is in flight, from another thread.
|
|
91
|
+
|
|
92
|
+
Both sides are queried on worker threads, so a Ctrl-C reaches the main
|
|
93
|
+
thread while the workers sit blocked on the database. Without this the
|
|
94
|
+
interrupt is not acted on until the queries finish on their own - which
|
|
95
|
+
on the ten-minute diff someone actually wants to abort is the whole
|
|
96
|
+
problem. Optional: a dialect that cannot do it inherits a no-op and
|
|
97
|
+
simply behaves as before.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------ metadata
|
|
101
|
+
|
|
102
|
+
#: Where an unqualified table name is looked up. `public` on PostgreSQL,
|
|
103
|
+
#: `main` on DuckDB.
|
|
104
|
+
default_schema: str = "public"
|
|
105
|
+
|
|
106
|
+
def columns(self, table: str) -> list[Column]:
|
|
107
|
+
"""Introspect a table's columns.
|
|
108
|
+
|
|
109
|
+
Both supported engines expose `information_schema.columns`, so this is
|
|
110
|
+
shared. Override it in a dialect whose engine does not.
|
|
111
|
+
"""
|
|
112
|
+
schema, name = self.split_table(table, self.default_schema)
|
|
113
|
+
rows = self.query(
|
|
114
|
+
"select column_name, data_type from information_schema.columns "
|
|
115
|
+
f"where table_schema = {sql_literal(schema)} "
|
|
116
|
+
f"and table_name = {sql_literal(name)} "
|
|
117
|
+
"order by ordinal_position"
|
|
118
|
+
)
|
|
119
|
+
if not rows:
|
|
120
|
+
raise self._err(self._not_found(table, schema, name))
|
|
121
|
+
return [Column(r[0], map_type(r[1]), r[1]) for r in rows]
|
|
122
|
+
|
|
123
|
+
def _not_found(self, table: str, schema: str, name: str) -> str:
|
|
124
|
+
"""Explain a missing table, and point at a case mismatch if that is it.
|
|
125
|
+
|
|
126
|
+
Identifiers are always quoted, so lookup is exact and case-sensitive.
|
|
127
|
+
That is correct, but unquoted SQL gets folded to lower case by the
|
|
128
|
+
server, so `--a-table Orders` against a table the server stored as
|
|
129
|
+
`orders` is a very easy mistake with a very unhelpful default message.
|
|
130
|
+
"""
|
|
131
|
+
if self._exists_but_unreadable(schema, name):
|
|
132
|
+
return (
|
|
133
|
+
f"table {table} exists but this role cannot read it. "
|
|
134
|
+
f"`information_schema` only lists tables you hold privileges "
|
|
135
|
+
f"on, so a missing GRANT looks exactly like a missing table. "
|
|
136
|
+
f"Ask for SELECT on {schema}.{name}."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
message = f"table not found: {table} (looked in schema {schema!r})"
|
|
140
|
+
try:
|
|
141
|
+
near = self.query(
|
|
142
|
+
"select distinct table_schema, table_name "
|
|
143
|
+
"from information_schema.columns "
|
|
144
|
+
f"where lower(table_name) = lower({sql_literal(name)})"
|
|
145
|
+
)
|
|
146
|
+
except Exception: # noqa: BLE001 - diagnosing an error must never replace it
|
|
147
|
+
return message
|
|
148
|
+
others = [f"{s}.{t}" for s, t in near if (s, t) != (schema, name)]
|
|
149
|
+
if others:
|
|
150
|
+
message += (
|
|
151
|
+
f". Names are matched exactly, including case - did you mean "
|
|
152
|
+
f"{' or '.join(sorted(others))}?"
|
|
153
|
+
)
|
|
154
|
+
return message
|
|
155
|
+
|
|
156
|
+
def _exists_but_unreadable(self, schema: str, name: str) -> bool:
|
|
157
|
+
"""Whether the table is really there and this role simply cannot see it.
|
|
158
|
+
|
|
159
|
+
Engines with a privilege model filter `information_schema` by what the
|
|
160
|
+
current role may access, so "not found" and "not granted" arrive as the
|
|
161
|
+
same empty result - and telling someone their table does not exist when
|
|
162
|
+
it does sends them hunting for a typo instead of asking for a GRANT.
|
|
163
|
+
Default False for engines with no privilege model.
|
|
164
|
+
"""
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
@abstractmethod
|
|
168
|
+
def quote(self, identifier: str) -> str: ...
|
|
169
|
+
|
|
170
|
+
def qualify(self, table: str) -> str:
|
|
171
|
+
"""Quote a possibly schema-qualified table name."""
|
|
172
|
+
return ".".join(self.quote(part) for part in table.split("."))
|
|
173
|
+
|
|
174
|
+
def split_table(self, table: str, default_schema: str) -> tuple[str, str]:
|
|
175
|
+
"""Split ``schema.table``, refusing anything it cannot honour.
|
|
176
|
+
|
|
177
|
+
Without the length check a three-part name like ``db.schema.table``
|
|
178
|
+
silently fell through to the unqualified branch and was looked up as a
|
|
179
|
+
*table* called ``db`` in the default schema - so the eventual "table
|
|
180
|
+
not found" named a schema the user never mentioned.
|
|
181
|
+
"""
|
|
182
|
+
parts = table.split(".")
|
|
183
|
+
if len(parts) == 1:
|
|
184
|
+
return default_schema, parts[0]
|
|
185
|
+
if len(parts) == 2:
|
|
186
|
+
return parts[0], parts[1]
|
|
187
|
+
raise self._err(
|
|
188
|
+
f"table name {table!r} has {len(parts)} dot-separated parts; "
|
|
189
|
+
f"expected 'table' or 'schema.table'"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# ----------------------------------------------------------- rendering
|
|
193
|
+
|
|
194
|
+
@abstractmethod
|
|
195
|
+
def normalize(self, column: Column) -> str:
|
|
196
|
+
"""SQL expression rendering ``column`` as canonical text.
|
|
197
|
+
|
|
198
|
+
Implementations MUST be null-safe: a NULL value renders as
|
|
199
|
+
:data:`NULL_SENTINEL`, never as SQL NULL.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
@abstractmethod
|
|
203
|
+
def hash_expr(self, text_expr: str) -> str:
|
|
204
|
+
"""SQL expression folding canonical text into a 60-bit integer."""
|
|
205
|
+
|
|
206
|
+
@abstractmethod
|
|
207
|
+
def int_div(self, numerator: str, denominator: str) -> str:
|
|
208
|
+
"""Truncating integer division. ``/`` is *not* portable: PostgreSQL
|
|
209
|
+
truncates on integers, DuckDB promotes to double."""
|
|
210
|
+
|
|
211
|
+
@abstractmethod
|
|
212
|
+
def sum_wide(self, expr: str) -> str:
|
|
213
|
+
"""Sum ``expr`` in a type wide enough not to overflow.
|
|
214
|
+
|
|
215
|
+
Row hashes are up to 2^60; summing millions of them overflows a
|
|
216
|
+
64-bit accumulator, so both sides must aggregate in a 128-bit or
|
|
217
|
+
arbitrary-precision type.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
@abstractmethod
|
|
221
|
+
def wide_int(self, expr: str) -> str:
|
|
222
|
+
"""Widen an integer expression beyond 64 bits before arithmetic.
|
|
223
|
+
|
|
224
|
+
The bucket expression multiplies the key offset by the bucket count,
|
|
225
|
+
and ``span * n_segments`` exceeds a signed 64-bit integer as soon as
|
|
226
|
+
the key range is wider than about 2.9e17 - which is ordinary for
|
|
227
|
+
sparse bigint keys, and guaranteed once keys are hashed into the full
|
|
228
|
+
bigint range. Both engines raise rather than wrap, so this shows up as
|
|
229
|
+
a crash rather than a wrong answer, but it is still a hole.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
# ------------------------------------------------------------ building
|
|
233
|
+
|
|
234
|
+
def row_text(self, columns: Sequence[Column]) -> str:
|
|
235
|
+
if not columns:
|
|
236
|
+
# Two tables can legitimately share only their key - after
|
|
237
|
+
# `--columns`/`--exclude`, or when the schemas have diverged
|
|
238
|
+
# entirely. `concat_ws(chr(31), )` is a syntax error, so render a
|
|
239
|
+
# constant instead. Row *contents* then always match, while
|
|
240
|
+
# `count(*)` in the same checksum query still catches rows present
|
|
241
|
+
# on one side only, which is the only difference left to find.
|
|
242
|
+
return "''"
|
|
243
|
+
return self._concat([self.normalize(c) for c in columns])
|
|
244
|
+
|
|
245
|
+
def _concat(self, parts: list[str]) -> str:
|
|
246
|
+
"""Join rendered columns, nesting to stay under the argument limit.
|
|
247
|
+
|
|
248
|
+
PostgreSQL's `max_function_args` is 100 and is fixed at compile time,
|
|
249
|
+
so a flat `concat_ws(sep, c1, ..., cN)` raises "cannot pass more than
|
|
250
|
+
100 arguments to a function" the moment a table has ~99 comparable
|
|
251
|
+
columns - which a denormalised warehouse fact table routinely does.
|
|
252
|
+
DuckDB happily accepts 150, so the failure was asymmetric: the same
|
|
253
|
+
table worked on one side and not the other.
|
|
254
|
+
|
|
255
|
+
Nesting is **exact, not an approximation**. `concat_ws` joins its
|
|
256
|
+
arguments with the separator and skips only NULLs, and every argument
|
|
257
|
+
here has already been through `coalesce`, so none is ever NULL.
|
|
258
|
+
Therefore `concat_ws(s, concat_ws(s, a, b), c)` is byte-identical to
|
|
259
|
+
`concat_ws(s, a, b, c)`, and a table narrow enough to fit in one call
|
|
260
|
+
renders exactly the SQL it always did - no checksum moves.
|
|
261
|
+
"""
|
|
262
|
+
if len(parts) <= MAX_CONCAT_ARGS:
|
|
263
|
+
return f"concat_ws({SEPARATOR_SQL}, {', '.join(parts)})"
|
|
264
|
+
groups = [
|
|
265
|
+
self._concat(parts[i : i + MAX_CONCAT_ARGS])
|
|
266
|
+
for i in range(0, len(parts), MAX_CONCAT_ARGS)
|
|
267
|
+
]
|
|
268
|
+
return self._concat(groups)
|
|
269
|
+
|
|
270
|
+
def row_hash(self, columns: Sequence[Column]) -> str:
|
|
271
|
+
return self.hash_expr(self.row_text(columns))
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------- queries
|
|
274
|
+
|
|
275
|
+
def key_stats(self, table: str, key: str) -> KeyStats:
|
|
276
|
+
"""Key range plus row and distinct-key counts, in one scan.
|
|
277
|
+
|
|
278
|
+
``count(distinct key)`` rides along with min/max deliberately. The
|
|
279
|
+
query already has to visit the key column, and without the distinct
|
|
280
|
+
count a non-unique key goes undetected: `fetch_range` returns a dict
|
|
281
|
+
keyed by the key column, so duplicate rows collapse and their
|
|
282
|
+
differences disappear. Paying for it here costs one aggregation, not
|
|
283
|
+
an extra round trip.
|
|
284
|
+
"""
|
|
285
|
+
k = self.quote(key)
|
|
286
|
+
# `count({k})` counts non-NULL keys only, while `count(*)` counts every
|
|
287
|
+
# row. Carrying both is what lets a NULL key be diagnosed as a NULL key
|
|
288
|
+
# rather than misreported as a duplicate - `count(distinct)` also
|
|
289
|
+
# ignores NULLs, so without this a single NULL key looks exactly like a
|
|
290
|
+
# duplicated one.
|
|
291
|
+
sql = (
|
|
292
|
+
f"select min({k}), max({k}), count(*), count({k}), count(distinct {k}) "
|
|
293
|
+
f"from {self.qualify(table)}"
|
|
294
|
+
)
|
|
295
|
+
lo, hi, rows, non_null, distinct = self.query(sql)[0]
|
|
296
|
+
if lo is None:
|
|
297
|
+
return KeyStats(None, None, int(rows), 0, int(non_null))
|
|
298
|
+
try:
|
|
299
|
+
return KeyStats(
|
|
300
|
+
int(lo), int(hi), int(rows), int(distinct), int(non_null)
|
|
301
|
+
)
|
|
302
|
+
except (TypeError, ValueError) as exc:
|
|
303
|
+
# A varchar or uuid key lands here. The bisection arithmetic is
|
|
304
|
+
# integer-only, so say that plainly instead of leaking a cast error.
|
|
305
|
+
raise self._err(
|
|
306
|
+
f"key column {key!r} in {table} is not an integer "
|
|
307
|
+
f"(min value {lo!r}); only integer keys are supported"
|
|
308
|
+
) from exc
|
|
309
|
+
|
|
310
|
+
def segment_checksums(
|
|
311
|
+
self,
|
|
312
|
+
table: str,
|
|
313
|
+
key: str,
|
|
314
|
+
columns: Sequence[Column],
|
|
315
|
+
lo: int,
|
|
316
|
+
hi: int,
|
|
317
|
+
n_segments: int,
|
|
318
|
+
) -> dict[int, tuple[int, int]]:
|
|
319
|
+
"""Return ``{segment_index: (row_count, checksum)}`` for ``[lo, hi)``.
|
|
320
|
+
|
|
321
|
+
This is the whole point of the tool: one query per side per level,
|
|
322
|
+
with the hashing pushed into the engine. Nothing but a handful of
|
|
323
|
+
integers crosses the network.
|
|
324
|
+
"""
|
|
325
|
+
return {
|
|
326
|
+
int(seg): (int(count), int(checksum or 0))
|
|
327
|
+
for seg, count, checksum in self.query(
|
|
328
|
+
self._segment_sql(table, key, columns, lo, hi, n_segments)
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
def _segment_sql(
|
|
333
|
+
self,
|
|
334
|
+
table: str,
|
|
335
|
+
key: str,
|
|
336
|
+
columns: Sequence[Column],
|
|
337
|
+
lo: int,
|
|
338
|
+
hi: int,
|
|
339
|
+
n_segments: int,
|
|
340
|
+
) -> str:
|
|
341
|
+
"""The checksum query. Split out so its shape can be tested directly."""
|
|
342
|
+
k = self.quote(key)
|
|
343
|
+
# Every part of the bucket expression has to survive a key range as wide
|
|
344
|
+
# as bigint itself, which is what a hashed key produces.
|
|
345
|
+
#
|
|
346
|
+
# 1. Widen the key *before* subtracting, not after. `wide_int(k - lo)`
|
|
347
|
+
# still computes `k - lo` in the column's own type first, and that
|
|
348
|
+
# overflows outright when lo is near the bottom of the range and the
|
|
349
|
+
# key is near the top.
|
|
350
|
+
# 2. Emit the span as one precomputed literal. Letting SQL evaluate
|
|
351
|
+
# `hi - lo` puts the same overflow back.
|
|
352
|
+
# 3. Bound the range inclusively. `hi` is `max_key + 1`, so for a table
|
|
353
|
+
# holding the largest bigint it is one past what the type can hold;
|
|
354
|
+
# `<= hi - 1` keeps every literal inside the column's own range.
|
|
355
|
+
#
|
|
356
|
+
# Widening is unconditional rather than only for wide ranges. The
|
|
357
|
+
# conditional version was measured at 10M rows and saved nothing - 39.3s
|
|
358
|
+
# against 38.4s, inside run-to-run noise, because the cost here is
|
|
359
|
+
# dominated by MD5 over every row, not by integer arithmetic. Paying a
|
|
360
|
+
# couple of percent to delete a second code path is the right trade in
|
|
361
|
+
# the one function whose off-by-one would make the walker skip rows.
|
|
362
|
+
offset = f"({self.wide_int(k)} - ({lo}))"
|
|
363
|
+
bucket = self.int_div(f"{offset} * {n_segments}", f"({hi - lo})")
|
|
364
|
+
return (
|
|
365
|
+
f"select {bucket} as seg, count(*), "
|
|
366
|
+
f"{self.sum_wide(self.row_hash(columns))} "
|
|
367
|
+
f"from {self.qualify(table)} "
|
|
368
|
+
f"where {k} >= {lo} and {k} <= {hi - 1} "
|
|
369
|
+
f"group by 1"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def fetch_range(
|
|
373
|
+
self,
|
|
374
|
+
table: str,
|
|
375
|
+
key: str,
|
|
376
|
+
columns: Sequence[Column],
|
|
377
|
+
lo: int,
|
|
378
|
+
hi: int,
|
|
379
|
+
) -> dict[int, tuple[str, ...]]:
|
|
380
|
+
"""Download canonical text for every row in ``[lo, hi)``.
|
|
381
|
+
|
|
382
|
+
Only ever called on ranges the checksums already proved to differ,
|
|
383
|
+
and only once they are small enough to be cheap.
|
|
384
|
+
"""
|
|
385
|
+
k = self.quote(key)
|
|
386
|
+
# With no comparable columns the row tuple is empty and only key
|
|
387
|
+
# presence distinguishes the sides - see `row_text`.
|
|
388
|
+
exprs = "".join(", " + self.normalize(c) for c in columns)
|
|
389
|
+
# Inclusive upper bound, for the same reason as the checksum query:
|
|
390
|
+
# `hi` is `max_key + 1`, which for a table holding the largest bigint is
|
|
391
|
+
# one past what the column type can represent.
|
|
392
|
+
sql = (
|
|
393
|
+
f"select {k}{exprs} from {self.qualify(table)} "
|
|
394
|
+
f"where {k} >= {lo} and {k} <= {hi - 1} order by {k}"
|
|
395
|
+
)
|
|
396
|
+
out: dict[int, tuple[str, ...]] = {}
|
|
397
|
+
for row in self.query(sql):
|
|
398
|
+
rk = int(row[0])
|
|
399
|
+
if rk in out:
|
|
400
|
+
# Second line of defence behind the up-front uniqueness check
|
|
401
|
+
# in `key_stats`: a key that duplicates only inside a fetched
|
|
402
|
+
# range would otherwise overwrite the earlier row and silently
|
|
403
|
+
# drop a real difference.
|
|
404
|
+
raise self._err(
|
|
405
|
+
f"duplicate key {rk} in {table}: key column {key!r} is not "
|
|
406
|
+
f"unique, so rows cannot be compared one-to-one"
|
|
407
|
+
)
|
|
408
|
+
out[rk] = tuple(row[1:])
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def get_dialect(
|
|
413
|
+
connection_string: str,
|
|
414
|
+
side: str = "?",
|
|
415
|
+
float_scale: int = DEFAULT_FLOAT_SCALE,
|
|
416
|
+
) -> Dialect:
|
|
417
|
+
"""Open a connection and return the dialect for it.
|
|
418
|
+
|
|
419
|
+
Drivers are imported lazily so a DuckDB-only user never needs a PostgreSQL
|
|
420
|
+
driver installed, and vice versa.
|
|
421
|
+
"""
|
|
422
|
+
scheme = connection_string.split(":", 1)[0].lower()
|
|
423
|
+
if scheme in ("duckdb",):
|
|
424
|
+
from parity.dialects.duckdb_dialect import DuckDBDialect
|
|
425
|
+
|
|
426
|
+
dialect: Dialect = DuckDBDialect(float_scale=float_scale, side=side)
|
|
427
|
+
elif scheme in ("postgres", "postgresql"):
|
|
428
|
+
from parity.dialects.postgres_dialect import PostgresDialect
|
|
429
|
+
|
|
430
|
+
dialect = PostgresDialect(float_scale=float_scale, side=side)
|
|
431
|
+
else:
|
|
432
|
+
raise ValueError(
|
|
433
|
+
f"[side {side}] no dialect for scheme {scheme!r}. "
|
|
434
|
+
f"Supported: duckdb, postgres."
|
|
435
|
+
)
|
|
436
|
+
try:
|
|
437
|
+
dialect.connect(connection_string)
|
|
438
|
+
except Exception as exc:
|
|
439
|
+
# A bare driver error says nothing about which of the two endpoints
|
|
440
|
+
# failed, which is the first thing anyone needs to know.
|
|
441
|
+
raise ValueError(
|
|
442
|
+
f"[side {side}: {dialect.name}] could not connect: {exc}"
|
|
443
|
+
) from exc
|
|
444
|
+
return dialect
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def require_matching_scales(a: Dialect, b: Dialect) -> None:
|
|
448
|
+
"""Refuse to compare two sides that round floats differently.
|
|
449
|
+
|
|
450
|
+
If the scales disagree, every DECIMAL and FLOAT row renders to different
|
|
451
|
+
canonical text and the tool reports the whole table as changed. That looks
|
|
452
|
+
like a catastrophic migration bug rather than a configuration mistake, so
|
|
453
|
+
fail before any query runs.
|
|
454
|
+
"""
|
|
455
|
+
if a.float_scale != b.float_scale:
|
|
456
|
+
raise ValueError(
|
|
457
|
+
f"float_scale differs between sides: A={a.float_scale} "
|
|
458
|
+
f"B={b.float_scale}. Both sides must round identically or every "
|
|
459
|
+
f"float and decimal row reports as different."
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def map_type(raw: str) -> LogicalType:
|
|
464
|
+
"""Map an engine's type name onto a logical category."""
|
|
465
|
+
t = raw.lower().split("(")[0].strip()
|
|
466
|
+
if t in {
|
|
467
|
+
"int", "int2", "int4", "int8", "integer", "bigint", "smallint",
|
|
468
|
+
"tinyint", "hugeint", "utinyint", "usmallint", "uinteger", "ubigint",
|
|
469
|
+
"serial", "bigserial",
|
|
470
|
+
}:
|
|
471
|
+
return LogicalType.INTEGER
|
|
472
|
+
if t in {"decimal", "numeric"}:
|
|
473
|
+
return LogicalType.DECIMAL
|
|
474
|
+
if t in {"float", "float4", "float8", "double", "real", "double precision"}:
|
|
475
|
+
return LogicalType.FLOAT
|
|
476
|
+
if t in {"bool", "boolean"}:
|
|
477
|
+
return LogicalType.BOOLEAN
|
|
478
|
+
if t in {"date"}:
|
|
479
|
+
return LogicalType.DATE
|
|
480
|
+
if t.startswith(("timestamp", "datetime")):
|
|
481
|
+
return LogicalType.TIMESTAMP
|
|
482
|
+
if t in {
|
|
483
|
+
"text", "varchar", "char", "bpchar", "character varying",
|
|
484
|
+
"character", "string", "uuid",
|
|
485
|
+
}:
|
|
486
|
+
return LogicalType.STRING
|
|
487
|
+
return LogicalType.UNKNOWN
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""DuckDB dialect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from parity.dialects.base import HASH_HEX_CHARS, NULL_SENTINEL, Dialect
|
|
9
|
+
from parity.types import Column, LogicalType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def duckdb_path(connection_string: str) -> str:
|
|
13
|
+
"""Extract the database path from a ``duckdb://`` connection string.
|
|
14
|
+
|
|
15
|
+
Following the sqlite/SQLAlchemy convention, the slashes carry meaning:
|
|
16
|
+
|
|
17
|
+
duckdb:///relative/path.db -> relative/path.db
|
|
18
|
+
duckdb:////var/lib/w.db -> /var/lib/w.db (absolute, POSIX)
|
|
19
|
+
duckdb:///C:/data/w.db -> C:/data/w.db (absolute, Windows)
|
|
20
|
+
duckdb:///:memory: -> :memory:
|
|
21
|
+
|
|
22
|
+
So exactly *one* leading slash comes off - the one separating the empty
|
|
23
|
+
authority from the path. Stripping them all (``lstrip("/")``) quietly turned
|
|
24
|
+
every absolute POSIX path into a relative one, and the tool then reported
|
|
25
|
+
"database file not found" for a file that was plainly there. Windows hid it
|
|
26
|
+
completely, because its paths start with a drive letter and so carry only
|
|
27
|
+
one leading slash to begin with; it took a Linux CI run to surface.
|
|
28
|
+
"""
|
|
29
|
+
rest = connection_string.split("://", 1)[1]
|
|
30
|
+
return rest.removeprefix("/")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DuckDBDialect(Dialect):
|
|
34
|
+
name = "duckdb"
|
|
35
|
+
default_schema = "main"
|
|
36
|
+
|
|
37
|
+
def connect(self, connection_string: str) -> None:
|
|
38
|
+
import duckdb
|
|
39
|
+
|
|
40
|
+
path = duckdb_path(connection_string)
|
|
41
|
+
if not path or path == ":memory:":
|
|
42
|
+
# An in-memory database holds no user data to protect, and a
|
|
43
|
+
# read-only in-memory database is empty by definition.
|
|
44
|
+
self._conn = duckdb.connect(":memory:")
|
|
45
|
+
self._pin_utc()
|
|
46
|
+
return
|
|
47
|
+
if not os.path.exists(path):
|
|
48
|
+
# read_only=True on a missing path fails with a driver-level error
|
|
49
|
+
# that does not say which side or which file. Say it ourselves.
|
|
50
|
+
raise self._err(f"database file not found: {path}")
|
|
51
|
+
# CLAUDE.md section 6: read-only by construction. Enforcing it at the
|
|
52
|
+
# connection means no bug in query building can ever write to a user's
|
|
53
|
+
# database. It also lets several parity runs share one file.
|
|
54
|
+
self._conn = duckdb.connect(path, read_only=True)
|
|
55
|
+
self._pin_utc()
|
|
56
|
+
|
|
57
|
+
def _pin_utc(self) -> None:
|
|
58
|
+
"""Render `timestamptz` in UTC regardless of the machine's timezone.
|
|
59
|
+
|
|
60
|
+
A timestamptz renders through the session timezone, so two sides whose
|
|
61
|
+
sessions differ turn the same instant into different text and every row
|
|
62
|
+
holding one reports as changed - a false positive indistinguishable
|
|
63
|
+
from catastrophic data loss. A timestamptz is an instant; comparing
|
|
64
|
+
instants in UTC is correct and deterministic. Naive `timestamp` columns
|
|
65
|
+
carry no zone and are unaffected.
|
|
66
|
+
"""
|
|
67
|
+
self._conn.execute("set TimeZone='UTC'")
|
|
68
|
+
|
|
69
|
+
def cancel(self) -> None:
|
|
70
|
+
self._conn.interrupt()
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
self._conn.close()
|
|
74
|
+
|
|
75
|
+
def query(self, sql: str) -> list[tuple[Any, ...]]:
|
|
76
|
+
return self._conn.execute(sql).fetchall()
|
|
77
|
+
|
|
78
|
+
def quote(self, identifier: str) -> str:
|
|
79
|
+
return '"' + identifier.replace('"', '""') + '"'
|
|
80
|
+
|
|
81
|
+
# ----------------------------------------------------------- rendering
|
|
82
|
+
|
|
83
|
+
def normalize(self, column: Column) -> str:
|
|
84
|
+
c = self.quote(column.name)
|
|
85
|
+
t = column.logical_type
|
|
86
|
+
if t is LogicalType.INTEGER:
|
|
87
|
+
expr = f"cast({c} as varchar)"
|
|
88
|
+
elif t is LogicalType.FLOAT:
|
|
89
|
+
# Infinity and NaN cannot be cast to DECIMAL - DuckDB raises
|
|
90
|
+
# "Could not cast value inf to DECIMAL(38,6)" and the whole diff
|
|
91
|
+
# dies. They are ordinary in float columns (any division by zero
|
|
92
|
+
# produces one), so render them as fixed tokens that PostgreSQL
|
|
93
|
+
# spells the same way.
|
|
94
|
+
expr = (
|
|
95
|
+
f"case when isinf({c}) then (case when {c} > 0 then 'Infinity' "
|
|
96
|
+
f"else '-Infinity' end) "
|
|
97
|
+
f"when isnan({c}) then 'NaN' "
|
|
98
|
+
f"else cast(cast({c} as decimal(38,{self.float_scale})) as varchar) end"
|
|
99
|
+
)
|
|
100
|
+
elif t is LogicalType.DECIMAL:
|
|
101
|
+
expr = f"cast(cast({c} as decimal(38,{self.float_scale})) as varchar)"
|
|
102
|
+
elif t is LogicalType.BOOLEAN:
|
|
103
|
+
# `else` must not swallow NULL. With `case when c then 'true' else
|
|
104
|
+
# 'false' end` a NULL boolean renders as 'false' - identical to a
|
|
105
|
+
# real FALSE - so the coalesce below never fires and NULL-vs-FALSE
|
|
106
|
+
# reports as a match. Both engines agreed on the wrong answer,
|
|
107
|
+
# which is exactly why the encoding tests plant differences.
|
|
108
|
+
expr = f"case when {c} then 'true' when not {c} then 'false' end"
|
|
109
|
+
elif t is LogicalType.DATE:
|
|
110
|
+
expr = f"strftime({c}, '%Y-%m-%d')"
|
|
111
|
+
elif t is LogicalType.TIMESTAMP:
|
|
112
|
+
expr = f"strftime({c}, '%Y-%m-%d %H:%M:%S.%f')"
|
|
113
|
+
else:
|
|
114
|
+
expr = f"cast({c} as varchar)"
|
|
115
|
+
return f"coalesce({expr}, '{NULL_SENTINEL}')"
|
|
116
|
+
|
|
117
|
+
def hash_expr(self, text_expr: str) -> str:
|
|
118
|
+
return f"cast(('0x' || substr(md5({text_expr}), 1, {HASH_HEX_CHARS})) as bigint)"
|
|
119
|
+
|
|
120
|
+
def int_div(self, numerator: str, denominator: str) -> str:
|
|
121
|
+
# `//` is DuckDB's truncating integer division. Plain `/` would promote
|
|
122
|
+
# to DOUBLE and silently lose precision on large key ranges.
|
|
123
|
+
return f"(({numerator}) // ({denominator}))"
|
|
124
|
+
|
|
125
|
+
def wide_int(self, expr: str) -> str:
|
|
126
|
+
# hugeint is 128-bit, which the key offset cannot overflow.
|
|
127
|
+
return f"cast(({expr}) as hugeint)"
|
|
128
|
+
|
|
129
|
+
def sum_wide(self, expr: str) -> str:
|
|
130
|
+
# DECIMAL(38,0) holds sums far beyond any realistic row count * 2^60.
|
|
131
|
+
return f"coalesce(sum(cast(({expr}) as decimal(38,0))), 0)"
|
|
132
|
+
|