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.
@@ -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
+