execsql2 2.15.0__py3-none-any.whl → 2.15.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.
Files changed (41) hide show
  1. execsql/config.py +238 -310
  2. execsql/db/base.py +0 -1
  3. execsql/db/duckdb.py +6 -7
  4. execsql/db/sqlite.py +47 -47
  5. execsql/gui/base.py +173 -28
  6. execsql/gui/console.py +50 -13
  7. execsql/gui/desktop.py +70 -28
  8. execsql/gui/tui.py +57 -32
  9. execsql/metacommands/conditions.py +0 -24
  10. execsql/metacommands/io_export.py +6 -0
  11. execsql/metacommands/io_import.py +5 -5
  12. execsql/metacommands/upsert.py +17 -33
  13. execsql/models.py +0 -1
  14. execsql/parser.py +22 -23
  15. execsql/script/engine.py +2 -0
  16. execsql/types.py +28 -30
  17. execsql/utils/datetime.py +52 -246
  18. execsql/utils/errors.py +0 -19
  19. execsql/utils/fileio.py +0 -8
  20. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/METADATA +2 -1
  21. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/RECORD +40 -41
  22. execsql/constants.py +0 -370
  23. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/README.md +0 -0
  24. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  25. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  26. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/execsql.conf +0 -0
  27. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  28. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  29. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  30. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  31. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  32. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  33. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  34. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/script_template.sql +0 -0
  35. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  36. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  37. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  38. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/WHEEL +0 -0
  39. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/entry_points.txt +0 -0
  40. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/licenses/LICENSE.txt +0 -0
  41. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/licenses/NOTICE +0 -0
execsql/db/duckdb.py CHANGED
@@ -81,11 +81,10 @@ class DuckDBDatabase(Database):
81
81
  def schema_exists(self, schema_name: str) -> bool:
82
82
  """Return True if the named schema exists in the current DuckDB catalog."""
83
83
  # In DuckDB, the 'schemata' view is not limited to the current database.
84
- curs = self.cursor()
85
- curs.execute(
86
- "SELECT schema_name FROM information_schema.schemata WHERE schema_name = ? and catalog_name = ?;",
87
- (schema_name, self.catalog_name),
88
- )
89
- rows = curs.fetchall()
90
- curs.close()
84
+ with self._cursor() as curs:
85
+ curs.execute(
86
+ "SELECT schema_name FROM information_schema.schemata WHERE schema_name = ? and catalog_name = ?;",
87
+ (schema_name, self.catalog_name),
88
+ )
89
+ rows = curs.fetchall()
91
90
  return len(rows) > 0
execsql/db/sqlite.py CHANGED
@@ -82,21 +82,21 @@ class SQLiteDatabase(Database):
82
82
 
83
83
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
84
84
  """Return True if the named table exists in the SQLite database."""
85
- curs = self.cursor()
86
- sql = "select name from sqlite_master where type='table' and name=?;"
87
- try:
88
- curs.execute(sql, (table_name,))
89
- except ErrInfo:
90
- raise
91
- except Exception as e:
92
- self.rollback()
93
- raise ErrInfo(
94
- type="db",
95
- command_text=sql,
96
- exception_msg=exception_desc(),
97
- other_msg=f'Failed test for existence of SQLite table "{table_name}";',
98
- ) from e
99
- rows = curs.fetchall()
85
+ with self._cursor() as curs:
86
+ sql = "select name from sqlite_master where type='table' and name=?;"
87
+ try:
88
+ curs.execute(sql, (table_name,))
89
+ except ErrInfo:
90
+ raise
91
+ except Exception as e:
92
+ self.rollback()
93
+ raise ErrInfo(
94
+ type="db",
95
+ command_text=sql,
96
+ exception_msg=exception_desc(),
97
+ other_msg=f'Failed test for existence of SQLite table "{table_name}";',
98
+ ) from e
99
+ rows = curs.fetchall()
100
100
  return len(rows) > 0
101
101
 
102
102
  def column_exists(
@@ -111,40 +111,40 @@ class SQLiteDatabase(Database):
111
111
 
112
112
  def table_columns(self, table_name: str, schema_name: str | None = None) -> list[str]:
113
113
  """Return a list of column names for the given SQLite table."""
114
- curs = self.cursor()
115
- quoted_tbl = self.quote_identifier(table_name)
116
- sql = f"select * from {quoted_tbl} where 1=0;"
117
- try:
118
- curs.execute(sql)
119
- except ErrInfo:
120
- raise
121
- except Exception as e:
122
- self.rollback()
123
- raise ErrInfo(
124
- type="db",
125
- command_text=sql,
126
- exception_msg=exception_desc(),
127
- other_msg=f"Failed to get column names for table {table_name} of {self.name()}",
128
- ) from e
129
- return [d[0] for d in curs.description]
114
+ with self._cursor() as curs:
115
+ quoted_tbl = self.quote_identifier(table_name)
116
+ sql = f"select * from {quoted_tbl} where 1=0;"
117
+ try:
118
+ curs.execute(sql)
119
+ except ErrInfo:
120
+ raise
121
+ except Exception as e:
122
+ self.rollback()
123
+ raise ErrInfo(
124
+ type="db",
125
+ command_text=sql,
126
+ exception_msg=exception_desc(),
127
+ other_msg=f"Failed to get column names for table {table_name} of {self.name()}",
128
+ ) from e
129
+ return [d[0] for d in curs.description]
130
130
 
131
131
  def view_exists(self, view_name: str) -> bool:
132
132
  """Return True if the named view exists in the SQLite database."""
133
- curs = self.cursor()
134
- sql = "select name from sqlite_master where type='view' and name=?;"
135
- try:
136
- curs.execute(sql, (view_name,))
137
- except ErrInfo:
138
- raise
139
- except Exception as e:
140
- self.rollback()
141
- raise ErrInfo(
142
- type="db",
143
- command_text=sql,
144
- exception_msg=exception_desc(),
145
- other_msg=f'Failed test for existence of SQLite view "{view_name}";',
146
- ) from e
147
- rows = curs.fetchall()
133
+ with self._cursor() as curs:
134
+ sql = "select name from sqlite_master where type='view' and name=?;"
135
+ try:
136
+ curs.execute(sql, (view_name,))
137
+ except ErrInfo:
138
+ raise
139
+ except Exception as e:
140
+ self.rollback()
141
+ raise ErrInfo(
142
+ type="db",
143
+ command_text=sql,
144
+ exception_msg=exception_desc(),
145
+ other_msg=f'Failed test for existence of SQLite view "{view_name}";',
146
+ ) from e
147
+ rows = curs.fetchall()
148
148
  return len(rows) > 0
149
149
 
150
150
  def schema_exists(self, schema_name: str) -> bool:
@@ -194,7 +194,6 @@ class SQLiteDatabase(Database):
194
194
  type="error",
195
195
  other_msg=f"Too few values on data line {datalineno} of input.",
196
196
  )
197
- linedata = [line[ix] for ix in data_indexes]
198
197
  if _state.conf.trim_strings or _state.conf.replace_newlines or not _state.conf.empty_strings:
199
198
  for i in range(len(line)):
200
199
  if line[i] is not None and isinstance(line[i], _state.stringtypes):
@@ -204,6 +203,7 @@ class SQLiteDatabase(Database):
204
203
  line[i] = re.sub(r"[\s\t]*[\r\n]+[\s\t]*", " ", line[i])
205
204
  if not _state.conf.empty_strings and line[i].strip() == "":
206
205
  line[i] = None
206
+ linedata = [line[ix] for ix in data_indexes]
207
207
  # Convert datetime, time, and Decimal values to strings.
208
208
  for i in range(len(linedata)):
209
209
  if type(linedata[i]) in (datetime.datetime, datetime.time, Decimal):
execsql/gui/base.py CHANGED
@@ -3,53 +3,166 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
6
7
  from typing import TYPE_CHECKING, Any
7
8
 
8
9
  if TYPE_CHECKING:
9
10
  pass
10
11
 
11
- __all__ = ["GuiBackend", "compare_stats"]
12
+ __all__ = ["DiffResult", "GuiBackend", "compare_stats", "compute_row_diffs"]
12
13
 
14
+ DIFF_MARKER = "● "
13
15
 
14
- def compare_stats(
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Value comparison helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ def _values_equal(a: Any, b: Any) -> bool:
23
+ """Compare two cell values using native equality.
24
+
25
+ Rules:
26
+ - ``None == None`` → True
27
+ - ``None`` vs any non-None → False (NULL is distinct from empty string)
28
+ - Numeric types are compared numerically (``int(1) == float(1.0)``,
29
+ ``Decimal("10.00") == Decimal("10.0")``)
30
+ - All other types use ``==``
31
+ - Falls back to ``repr()`` comparison for exotic types that raise on ``==``
32
+ """
33
+ if a is None and b is None:
34
+ return True
35
+ if a is None or b is None:
36
+ return False
37
+ try:
38
+ return bool(a == b)
39
+ except (TypeError, ValueError):
40
+ return repr(a) == repr(b)
41
+
42
+
43
+ def _pk_tuple(row: list | tuple, pk_indices: list[int]) -> tuple:
44
+ """Extract a PK value tuple from *row*.
45
+
46
+ PK values are stringified so that type differences in key columns
47
+ (e.g. ``int(1)`` vs ``str("1")``) still match rows correctly.
48
+ ``None`` is preserved as-is so distinct NULL-keyed rows are not
49
+ collapsed (in practice PK columns are NOT NULL).
50
+ """
51
+ return tuple(str(row[i]) if row[i] is not None else None for i in pk_indices)
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # DiffResult and compute_row_diffs
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ @dataclass
60
+ class DiffResult:
61
+ """Per-row diff state for compare dialogs.
62
+
63
+ Attributes:
64
+ table1_row_states: ``"match"`` / ``"changed"`` / ``"only_t1"`` per row.
65
+ table2_row_states: Same for table 2.
66
+ table1_changed_cols: For each table-1 row, set of column names that differ.
67
+ table2_changed_cols: Same for table 2.
68
+ summary: Human-readable summary string.
69
+ """
70
+
71
+ table1_row_states: list[str] = field(default_factory=list)
72
+ table2_row_states: list[str] = field(default_factory=list)
73
+ table1_changed_cols: list[set[str]] = field(default_factory=list)
74
+ table2_changed_cols: list[set[str]] = field(default_factory=list)
75
+ summary: str = ""
76
+
77
+
78
+ def compute_row_diffs(
15
79
  headers1: list,
16
80
  rows1: list,
17
81
  headers2: list,
18
82
  rows2: list,
19
83
  keylist: list,
20
- ) -> str:
21
- """Return a one-line diff summary for compare dialogs.
84
+ ) -> DiffResult | None:
85
+ """Compare two tables row-by-row and return per-cell diff information.
86
+
87
+ Rows are matched by the key columns in *keylist*, not by position.
88
+ Columns are matched by header name, not index — headers may differ in
89
+ order or membership. Only columns present in **both** headers (minus
90
+ key columns) are compared for cell-level diffs.
22
91
 
23
- Computes matching rows, differing rows, and rows only in one table
24
- based on the given key columns. Returns an empty string when *keylist*
25
- is empty (stats cannot be computed without keys).
92
+ Values are compared with native Python equality via :func:`_values_equal`:
93
+ numeric types compare numerically, ``None`` is distinct from ``""``,
94
+ and exotic types fall back to ``repr()``.
95
+
96
+ When duplicate PK values exist in a table, the **first** row with that
97
+ key is kept and later duplicates are ignored.
98
+
99
+ Returns ``None`` when *keylist* is empty or a key column is missing
100
+ from either header.
26
101
  """
27
102
  if not keylist:
28
- return ""
29
- key_idx1 = [i for i, h in enumerate(headers1) if str(h) in keylist]
30
- key_idx2 = [i for i, h in enumerate(headers2) if str(h) in keylist]
103
+ return None
104
+ headers1_str = [str(h) for h in headers1]
105
+ headers2_str = [str(h) for h in headers2]
106
+ key_idx1 = [i for i, h in enumerate(headers1_str) if h in keylist]
107
+ key_idx2 = [i for i, h in enumerate(headers2_str) if h in keylist]
31
108
  if not key_idx1 or not key_idx2:
32
- return ""
33
-
34
- def _kv(row: list | tuple, idxs: list) -> tuple:
35
- return tuple(str(row[i]) if row[i] is not None else "" for i in idxs)
109
+ return None
110
+
111
+ # Build PK -> row-index maps (first occurrence wins for duplicates).
112
+ pk_map1: dict[tuple, int] = {}
113
+ for i, r in enumerate(rows1):
114
+ k = _pk_tuple(r, key_idx1)
115
+ if k not in pk_map1:
116
+ pk_map1[k] = i
117
+ pk_map2: dict[tuple, int] = {}
118
+ for i, r in enumerate(rows2):
119
+ k = _pk_tuple(r, key_idx2)
120
+ if k not in pk_map2:
121
+ pk_map2[k] = i
122
+
123
+ keys1 = set(pk_map1)
124
+ keys2 = set(pk_map2)
125
+ common = keys1 & keys2
126
+
127
+ # Shared non-key columns eligible for cell comparison.
128
+ key_set = set(keylist)
129
+ h1_idx = {h: i for i, h in enumerate(headers1_str)}
130
+ h2_idx = {h: i for i, h in enumerate(headers2_str)}
131
+ shared_cols = [h for h in headers1_str if h in h2_idx and h not in key_set]
132
+
133
+ # Initialise result lists.
134
+ t1_states: list[str] = [""] * len(rows1)
135
+ t2_states: list[str] = [""] * len(rows2)
136
+ t1_changed: list[set[str]] = [set() for _ in rows1]
137
+ t2_changed: list[set[str]] = [set() for _ in rows2]
138
+
139
+ for k in keys1 - keys2:
140
+ t1_states[pk_map1[k]] = "only_t1"
141
+ for k in keys2 - keys1:
142
+ t2_states[pk_map2[k]] = "only_t2"
36
143
 
37
- keys1 = {_kv(r, key_idx1) for r in rows1}
38
- keys2 = {_kv(r, key_idx2) for r in rows2}
39
- only1 = len(keys1 - keys2)
40
- only2 = len(keys2 - keys1)
41
- common_keys = keys1 & keys2
42
- row_map1 = {_kv(r, key_idx1): r for r in rows1}
43
- row_map2 = {_kv(r, key_idx2): r for r in rows2}
44
144
  matching = 0
45
145
  differing = 0
46
- for k in common_keys:
47
- r1 = [str(v) if v is not None else "" for v in row_map1[k]]
48
- r2 = [str(v) if v is not None else "" for v in row_map2[k]]
49
- if r1 == r2:
50
- matching += 1
51
- else:
146
+ for k in common:
147
+ i1 = pk_map1[k]
148
+ i2 = pk_map2[k]
149
+ changed: set[str] = set()
150
+ for col in shared_cols:
151
+ if not _values_equal(rows1[i1][h1_idx[col]], rows2[i2][h2_idx[col]]):
152
+ changed.add(col)
153
+ if changed:
154
+ t1_states[i1] = "changed"
155
+ t2_states[i2] = "changed"
156
+ t1_changed[i1] = changed
157
+ t2_changed[i2] = set(changed)
52
158
  differing += 1
159
+ else:
160
+ t1_states[i1] = "match"
161
+ t2_states[i2] = "match"
162
+ matching += 1
163
+
164
+ only1 = len(keys1 - keys2)
165
+ only2 = len(keys2 - keys1)
53
166
  parts: list[str] = []
54
167
  if matching:
55
168
  parts.append(f"{matching:,} matching")
@@ -59,7 +172,39 @@ def compare_stats(
59
172
  parts.append(f"{only1:,} only in Table 1")
60
173
  if only2:
61
174
  parts.append(f"{only2:,} only in Table 2")
62
- return " | ".join(parts) if parts else "Tables are identical"
175
+ summary = " | ".join(parts) if parts else "Tables are identical"
176
+
177
+ return DiffResult(
178
+ table1_row_states=t1_states,
179
+ table2_row_states=t2_states,
180
+ table1_changed_cols=t1_changed,
181
+ table2_changed_cols=t2_changed,
182
+ summary=summary,
183
+ )
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # compare_stats — delegates to compute_row_diffs for consistency
188
+ # ---------------------------------------------------------------------------
189
+
190
+
191
+ def compare_stats(
192
+ headers1: list,
193
+ rows1: list,
194
+ headers2: list,
195
+ rows2: list,
196
+ keylist: list,
197
+ ) -> str:
198
+ """Return a one-line diff summary for compare dialogs.
199
+
200
+ Delegates to :func:`compute_row_diffs` so that the summary and the
201
+ cell-level diff always agree. Returns an empty string when *keylist*
202
+ is empty or key columns are missing.
203
+ """
204
+ result = compute_row_diffs(headers1, rows1, headers2, rows2, keylist)
205
+ if result is None:
206
+ return ""
207
+ return result.summary
63
208
 
64
209
 
65
210
  class GuiBackend(ABC):
execsql/gui/console.py CHANGED
@@ -11,7 +11,7 @@ import sys
11
11
  import time
12
12
  from typing import Any
13
13
 
14
- from execsql.gui.base import GuiBackend, compare_stats as _compare_stats
14
+ from execsql.gui.base import DIFF_MARKER, GuiBackend, compare_stats as _compare_stats, compute_row_diffs
15
15
 
16
16
  __all__ = ["ConsoleBackend"]
17
17
 
@@ -28,22 +28,47 @@ def _row_count_text(n: int) -> str:
28
28
  return f"{n:,} row{'s' if n != 1 else ''}"
29
29
 
30
30
 
31
- def _print_table(headers: list, rows: list, file: Any = None) -> None:
32
- """Print a simple ASCII table to the given file (default stderr)."""
31
+ def _print_table(
32
+ headers: list,
33
+ rows: list,
34
+ file: Any = None,
35
+ row_states: list[str] | None = None,
36
+ changed_cols: list[set[str]] | None = None,
37
+ ) -> None:
38
+ """Print a simple ASCII table to the given file (default stderr).
39
+
40
+ When *row_states* and *changed_cols* are provided, cells that differ in
41
+ ``"changed"`` rows are prefixed with the diff marker (``"● "``).
42
+ """
33
43
  if file is None:
34
44
  file = sys.stderr
35
45
  if not headers:
36
46
  return
37
- col_widths = [len(str(h)) for h in headers]
38
- for row in rows:
39
- for i, cell in enumerate(row):
40
- col_widths[i] = max(col_widths[i], len(str(cell) if cell is not None else ""))
47
+ headers_str = [str(h) for h in headers]
48
+
49
+ # Pre-compute display values so column widths account for markers.
50
+ display_rows: list[list[str]] = []
51
+ for ridx, row in enumerate(rows):
52
+ cells: list[str] = []
53
+ state = row_states[ridx] if row_states and ridx < len(row_states) else ""
54
+ diff_set = changed_cols[ridx] if changed_cols and ridx < len(changed_cols) else set()
55
+ for ci, cell in enumerate(row):
56
+ val = str(cell) if cell is not None else ""
57
+ if state == "changed" and ci < len(headers_str) and headers_str[ci] in diff_set:
58
+ val = f"{DIFF_MARKER}{val}"
59
+ cells.append(val)
60
+ display_rows.append(cells)
61
+
62
+ col_widths = [len(h) for h in headers_str]
63
+ for cells in display_rows:
64
+ for i, cell in enumerate(cells):
65
+ col_widths[i] = max(col_widths[i], len(cell))
41
66
  fmt = " " + " ".join(f"{{:<{w}}}" for w in col_widths)
42
67
  sep = " " + " ".join("-" * w for w in col_widths)
43
- print(fmt.format(*[str(h) for h in headers]), file=file)
68
+ print(fmt.format(*headers_str), file=file)
44
69
  print(sep, file=file)
45
- for row in rows:
46
- print(fmt.format(*[str(c) if c is not None else "" for c in row]), file=file)
70
+ for cells in display_rows:
71
+ print(fmt.format(*cells), file=file)
47
72
 
48
73
 
49
74
  def _prompt_buttons(button_list: list) -> int | None:
@@ -256,19 +281,31 @@ class ConsoleBackend(GuiBackend):
256
281
  headers2 = args.get("headers2", [])
257
282
  rows2 = args.get("rows2", [])
258
283
  button_list = args.get("button_list", [("Continue", 1, "<Return>")])
284
+ keylist = [str(k) for k in args.get("keylist", [])]
285
+
286
+ diff = compute_row_diffs(headers1, rows1, headers2, rows2, keylist) if keylist else None
259
287
 
260
288
  print(f"\n=== {title} ===", file=sys.stderr)
261
289
  if message:
262
290
  print(message, file=sys.stderr)
263
291
  _print_help_url(args)
264
292
  print("\n--- Table 1 ---", file=sys.stderr)
265
- _print_table(headers1, rows1)
293
+ _print_table(
294
+ headers1,
295
+ rows1,
296
+ row_states=diff.table1_row_states if diff else None,
297
+ changed_cols=diff.table1_changed_cols if diff else None,
298
+ )
266
299
  print(f" {_row_count_text(len(rows1))}", file=sys.stderr)
267
300
  print("\n--- Table 2 ---", file=sys.stderr)
268
- _print_table(headers2, rows2)
301
+ _print_table(
302
+ headers2,
303
+ rows2,
304
+ row_states=diff.table2_row_states if diff else None,
305
+ changed_cols=diff.table2_changed_cols if diff else None,
306
+ )
269
307
  print(f" {_row_count_text(len(rows2))}", file=sys.stderr)
270
308
 
271
- keylist = [str(k) for k in args.get("keylist", [])]
272
309
  summary = _compare_stats(headers1, rows1, headers2, rows2, keylist)
273
310
  if summary:
274
311
  print(f"\n {summary}", file=sys.stderr)
execsql/gui/desktop.py CHANGED
@@ -50,7 +50,7 @@ def _center_window(win: tk.Tk | tk.Toplevel, width: int = 600, height: int = 400
50
50
  win.geometry(f"{width}x{height}+{x}+{y}")
51
51
 
52
52
 
53
- from execsql.gui.base import compare_stats as _compare_stats
53
+ from execsql.gui.base import DIFF_MARKER, compare_stats as _compare_stats, compute_row_diffs
54
54
 
55
55
 
56
56
  def _add_help_button(frame: tk.Frame, url: str | None) -> None:
@@ -516,6 +516,10 @@ class CompareDialog:
516
516
  key_idx1 = [i for i, h in enumerate(headers1_str) if h in keylist]
517
517
  key_idx2 = [i for i, h in enumerate(headers2_str) if h in keylist]
518
518
 
519
+ # Syncing uses display-consistent string normalization (treeview
520
+ # values are strings). The diff engine in base.py has its own
521
+ # _pk_tuple with native-equality semantics — it does not share
522
+ # these maps.
519
523
  def _kv(row, idxs):
520
524
  return tuple(str(row[i]) if row[i] is not None else "" for i in idxs)
521
525
 
@@ -523,8 +527,15 @@ class CompareDialog:
523
527
  iids2 = tree2.get_children()
524
528
  iid_to_kv1 = {iid: _kv(rows1[i], key_idx1) for i, iid in enumerate(iids1)}
525
529
  iid_to_kv2 = {iid: _kv(rows2[i], key_idx2) for i, iid in enumerate(iids2)}
526
- kv_to_iid1 = {v: k for k, v in iid_to_kv1.items()}
527
- kv_to_iid2 = {v: k for k, v in iid_to_kv2.items()}
530
+ # First occurrence wins for duplicate PKs (consistent with compute_row_diffs).
531
+ kv_to_iid1: dict[tuple, str] = {}
532
+ for iid, kv in iid_to_kv1.items():
533
+ if kv not in kv_to_iid1:
534
+ kv_to_iid1[kv] = iid
535
+ kv_to_iid2: dict[tuple, str] = {}
536
+ for iid, kv in iid_to_kv2.items():
537
+ if kv not in kv_to_iid2:
538
+ kv_to_iid2[kv] = iid
528
539
 
529
540
  def _on_click1(event):
530
541
  sel_item = tree1.focus()
@@ -556,35 +567,66 @@ class CompareDialog:
556
567
  tree2.tag_configure("diff_changed", background="#f5d98e", foreground="#3a2e00")
557
568
  tree2.tag_configure("diff_only", background="#f5a3a3", foreground="#3a0a0a")
558
569
  _diff_on = [False]
570
+ _diff_result = compute_row_diffs(headers1, rows1, headers2, rows2, keylist)
571
+ _original_values1: dict[str, tuple] = {}
572
+ _original_values2: dict[str, tuple] = {}
573
+
574
+ def _apply_diffs(
575
+ tree: ttk.Treeview,
576
+ iids: tuple,
577
+ row_states: list[str],
578
+ changed_cols: list[set[str]],
579
+ headers_str: list[str],
580
+ originals: dict[str, tuple],
581
+ turn_on: bool,
582
+ ) -> None:
583
+ iids_list = list(iids)
584
+ if not turn_on:
585
+ for iid in iids:
586
+ tree.item(iid, tags=())
587
+ if iid in originals:
588
+ tree.item(iid, values=originals[iid])
589
+ originals.clear()
590
+ return
591
+ for iid in iids:
592
+ ridx = iids_list.index(iid)
593
+ state = row_states[ridx]
594
+ if state == "only_t1" or state == "only_t2":
595
+ tree.item(iid, tags=("diff_only",))
596
+ elif state == "match":
597
+ tree.item(iid, tags=("diff_match",))
598
+ elif state == "changed":
599
+ tree.item(iid, tags=("diff_changed",))
600
+ originals[iid] = tree.item(iid, "values")
601
+ vals = list(tree.item(iid, "values"))
602
+ diff_set = changed_cols[ridx]
603
+ for ci, col_name in enumerate(headers_str):
604
+ if col_name in diff_set and ci < len(vals):
605
+ vals[ci] = f"{DIFF_MARKER}{vals[ci]}"
606
+ tree.item(iid, values=vals)
559
607
 
560
608
  def _toggle_diffs():
561
609
  _diff_on[0] = not _diff_on[0]
562
- if not _diff_on[0]:
563
- for iid in tree1.get_children():
564
- tree1.item(iid, tags=())
565
- for iid in tree2.get_children():
566
- tree2.item(iid, tags=())
610
+ if _diff_result is None:
567
611
  return
568
- keys1_set = set(iid_to_kv1.values())
569
- keys2_set = set(iid_to_kv2.values())
570
- for iid, kv in iid_to_kv1.items():
571
- if kv not in keys2_set:
572
- tree1.item(iid, tags=("diff_only",))
573
- else:
574
- r1 = [str(v) if v is not None else "" for v in rows1[list(iids1).index(iid)]]
575
- match_iid = kv_to_iid2.get(kv)
576
- if match_iid:
577
- r2 = [str(v) if v is not None else "" for v in rows2[list(iids2).index(match_iid)]]
578
- tree1.item(iid, tags=("diff_match",) if r1 == r2 else ("diff_changed",))
579
- for iid, kv in iid_to_kv2.items():
580
- if kv not in keys1_set:
581
- tree2.item(iid, tags=("diff_only",))
582
- else:
583
- r2 = [str(v) if v is not None else "" for v in rows2[list(iids2).index(iid)]]
584
- match_iid = kv_to_iid1.get(kv)
585
- if match_iid:
586
- r1 = [str(v) if v is not None else "" for v in rows1[list(iids1).index(match_iid)]]
587
- tree2.item(iid, tags=("diff_match",) if r1 == r2 else ("diff_changed",))
612
+ _apply_diffs(
613
+ tree1,
614
+ iids1,
615
+ _diff_result.table1_row_states,
616
+ _diff_result.table1_changed_cols,
617
+ [str(h) for h in headers1],
618
+ _original_values1,
619
+ _diff_on[0],
620
+ )
621
+ _apply_diffs(
622
+ tree2,
623
+ iids2,
624
+ _diff_result.table2_row_states,
625
+ _diff_result.table2_changed_cols,
626
+ [str(h) for h in headers2],
627
+ _original_values2,
628
+ _diff_on[0],
629
+ )
588
630
 
589
631
  ttk.Button(diff_frame, text="Highlight Diffs", command=_toggle_diffs).pack(side=tk.LEFT)
590
632
  ttk.Label(diff_frame, text=" ").pack(side=tk.LEFT)