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.
- execsql/config.py +238 -310
- execsql/db/base.py +0 -1
- execsql/db/duckdb.py +6 -7
- execsql/db/sqlite.py +47 -47
- execsql/gui/base.py +173 -28
- execsql/gui/console.py +50 -13
- execsql/gui/desktop.py +70 -28
- execsql/gui/tui.py +57 -32
- execsql/metacommands/conditions.py +0 -24
- execsql/metacommands/io_export.py +6 -0
- execsql/metacommands/io_import.py +5 -5
- execsql/metacommands/upsert.py +17 -33
- execsql/models.py +0 -1
- execsql/parser.py +22 -23
- execsql/script/engine.py +2 -0
- execsql/types.py +28 -30
- execsql/utils/datetime.py +52 -246
- execsql/utils/errors.py +0 -19
- execsql/utils/fileio.py +0 -8
- {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/METADATA +2 -1
- {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/RECORD +40 -41
- execsql/constants.py +0 -370
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/WHEEL +0 -0
- {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/entry_points.txt +0 -0
- {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/licenses/LICENSE.txt +0 -0
- {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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
) ->
|
|
21
|
-
"""
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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(
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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(*
|
|
68
|
+
print(fmt.format(*headers_str), file=file)
|
|
44
69
|
print(sep, file=file)
|
|
45
|
-
for
|
|
46
|
-
print(fmt.format(*
|
|
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(
|
|
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(
|
|
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
|
-
|
|
527
|
-
|
|
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
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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)
|