Skip to content

Commit d63aaf8

Browse files
Fix diff/list edge cases and add resolver fallback tests
1 parent f1b2921 commit d63aaf8

File tree

7 files changed

+161
-11
lines changed

7 files changed

+161
-11
lines changed

sqlcompare/compare/comparator.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,19 +163,35 @@ def get_column_diff_query(self, column: str) -> str:
163163
f"FROM {self.tables['join']} WHERE {cond}"
164164
)
165165

166+
def _empty_diff_query(self) -> str:
167+
idx_expr = ", ".join(
168+
[f'CAST(NULL AS VARCHAR) AS "{c}"' for c in self.index_cols]
169+
)
170+
select_parts = [idx_expr] if idx_expr else []
171+
select_parts.extend(
172+
[
173+
'CAST(NULL AS VARCHAR) AS "COLUMN"',
174+
'CAST(NULL AS VARCHAR) AS "BEFORE"',
175+
'CAST(NULL AS VARCHAR) AS "CURRENT"',
176+
]
177+
)
178+
return "SELECT " + ", ".join(select_parts) + " WHERE 1=0"
179+
166180
def get_diff_query(self, column: str = None, limit: int = None) -> str:
167181
if column:
168182
# Case-insensitive match for the specific column
169183
match = next(
170184
(c for c in self.common_cols if c.upper() == column.upper()), None
171185
)
172186
if not match:
173-
# Still return an empty result if column not found in common cols
174-
return "SELECT NULL AS ID, NULL AS COLUMN, NULL AS BEFORE, NULL AS CURRENT WHERE 1=0"
187+
return self._empty_diff_query()
175188
query = self.get_column_diff_query(match)
176189
else:
177-
queries = [self.get_column_diff_query(c) for c in self.common_cols]
178-
query = " UNION ALL ".join(queries)
190+
if not self.common_cols:
191+
query = self._empty_diff_query()
192+
else:
193+
queries = [self.get_column_diff_query(c) for c in self.common_cols]
194+
query = " UNION ALL ".join(queries)
179195

180196
if limit:
181197
query += f" LIMIT {limit}"

sqlcompare/db/connection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class DBConnection:
3838
1. Use default connection if conn_id is None (SQLCOMPARE_CONN_DEFAULT, DTK_CONN_DEFAULT)
3939
2. Direct URL (if contains "://")
4040
3. Environment variables: SQLCOMPARE_CONN_<NAME>, DTK_CONN_<NAME>
41-
4. YAML files: ~/.sqlcompare/connections.yml, ~/.dtk/connections.yml
41+
4. YAML files: ~/.config/sqlcompare/connections.yaml/.yml, ~/.dtk/connections.yml
4242
"""
4343

4444
conn_id: str | None = None

sqlcompare/db/resolver.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,37 @@ def _build_not_found_error(conn_id: str, normalized_name: str) -> str:
8383

8484
message_parts.append("")
8585
message_parts.append("Searched YAML files:")
86-
for yaml_file in LIBRARY_CONNECTIONS:
86+
for yaml_file in _iter_yaml_candidates(LIBRARY_CONNECTIONS):
8787
expanded = Path(yaml_file).expanduser()
8888
exists = " (found)" if expanded.exists() else " (not found)"
8989
message_parts.append(f" - {yaml_file}{exists}")
9090

9191
return "\n".join(message_parts)
9292

9393

94+
def _iter_yaml_candidates(paths: list[str]) -> list[str]:
95+
"""Return configured YAML paths plus sibling .yml/.yaml fallbacks."""
96+
candidates: list[str] = []
97+
seen: set[str] = set()
98+
99+
for raw_path in paths:
100+
expanded = str(Path(raw_path).expanduser())
101+
base = Path(expanded)
102+
options = [expanded]
103+
if base.suffix.lower() == ".yml":
104+
options.append(str(base.with_suffix(".yaml")))
105+
elif base.suffix.lower() == ".yaml":
106+
options.append(str(base.with_suffix(".yml")))
107+
108+
for candidate in options:
109+
if candidate in seen:
110+
continue
111+
seen.add(candidate)
112+
candidates.append(candidate)
113+
114+
return candidates
115+
116+
94117
def resolve_connection_url(conn_id_or_url: str | None) -> str:
95118
"""
96119
Resolve a connection ID to a database URL.
@@ -127,7 +150,7 @@ def resolve_connection_url(conn_id_or_url: str | None) -> str:
127150
url = resolve_connection_url("/path/to/data.csv")
128151
129152
# YAML file lookup (if not in env)
130-
# Searches ~/.sqlcompare/connections.yml, then ~/.dtk/connections.yml
153+
# Searches ~/.config/sqlcompare/connections.yaml/.yml, then ~/.dtk/connections.yml
131154
"""
132155
# Step 0: Use default connection if None (search all DEFAULT_CONN_IDS)
133156
if conn_id_or_url is None:
@@ -161,8 +184,8 @@ def resolve_connection_url(conn_id_or_url: str | None) -> str:
161184
if url_str:
162185
return url_str
163186

164-
# Step 4: Search ALL YAML files (in order)
165-
for yaml_path in LIBRARY_CONNECTIONS:
187+
# Step 4: Search ALL YAML files (in order), with .yml/.yaml fallback for each path
188+
for yaml_path in _iter_yaml_candidates(LIBRARY_CONNECTIONS):
166189
connections = _load_connections_from_yaml(yaml_path)
167190

168191
# YAML uses raw conn_id (not normalized) for matching

sqlcompare/list_diffs.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ def list_diffs(pattern: str | None, test: str | None) -> None:
3939
for run_id, run in runs.items():
4040
if pattern and pattern.lower() not in run_id.lower():
4141
continue
42-
if test and test.lower() not in run.get("conn", "").lower():
42+
conn_name = run.get("conn") or ""
43+
if test and test.lower() not in conn_name.lower():
4344
continue
44-
filtered_matches.append((run_id, run.get("conn", "db"), 0, datetime.now()))
45+
filtered_matches.append((run_id, conn_name or "db", 0, datetime.now()))
4546

4647
if not filtered_matches:
4748
log.info("📭 No diff data found matching your criteria.")

tests/test_cli_analyze.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,38 @@ def test_analyze_diff_column_limit_uses_total_count(tmp_path, monkeypatch) -> No
179179
assert "Loaded diff data: 5 total differences" in inspect_result.output
180180
assert "Filtered to 5 differences for column 'value'" in inspect_result.output
181181
assert "Showing 2 of 5 differences" in inspect_result.output
182+
183+
184+
def test_analyze_diff_with_only_index_columns(tmp_path, monkeypatch) -> None:
185+
db_path = tmp_path / "sqlcompare_index_only.duckdb"
186+
with DBConnection(f"duckdb:///{db_path}") as db:
187+
db.execute("CREATE TABLE previous (id INTEGER)")
188+
db.execute("CREATE TABLE current (id INTEGER)")
189+
db.execute("INSERT INTO previous VALUES (1), (2)")
190+
db.execute("INSERT INTO current VALUES (1), (3)")
191+
192+
config_dir = tmp_path / "config"
193+
set_cli_env(
194+
monkeypatch,
195+
config_dir,
196+
"duckdb_index_only",
197+
f"duckdb:///{db_path}",
198+
)
199+
runner = CliRunner()
200+
compare_result = runner.invoke(
201+
app,
202+
[
203+
"table",
204+
"previous",
205+
"current",
206+
"id",
207+
"--connection",
208+
"duckdb_index_only",
209+
],
210+
)
211+
assert compare_result.exit_code == 0, compare_result.output
212+
213+
diff_id = next(iter(load_test_runs().keys()))
214+
inspect_result = runner.invoke(app, ["inspect", diff_id])
215+
assert inspect_result.exit_code == 0, inspect_result.output
216+
assert "Loaded diff data: 0 total differences" in inspect_result.output

tests/test_cli_list_diffs.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import yaml
34
from typer.testing import CliRunner
45

56
from sqlcompare.cli import app
@@ -46,3 +47,28 @@ def test_list_diffs_with_filters(tmp_path, monkeypatch) -> None:
4647
no_match = runner.invoke(app, ["list-diffs", "no_such_diff"])
4748
assert no_match.exit_code == 0, no_match.output
4849
assert "No diff data found" in no_match.output
50+
51+
52+
def test_list_diffs_filter_handles_none_conn(tmp_path, monkeypatch) -> None:
53+
config_dir = tmp_path / "config"
54+
runs_dir = config_dir / "runs"
55+
runs_dir.mkdir(parents=True, exist_ok=True)
56+
(runs_dir / "manual_run.yaml").write_text(
57+
yaml.safe_dump(
58+
{
59+
"tables": {"previous": "p", "new": "n", "join": "j"},
60+
"index_cols": ["id"],
61+
"cols_prev": ["id"],
62+
"cols_new": ["id"],
63+
"common_cols": [],
64+
"conn": None,
65+
}
66+
),
67+
encoding="utf-8",
68+
)
69+
monkeypatch.setenv("SQLCOMPARE_CONFIG_DIR", str(config_dir))
70+
71+
runner = CliRunner()
72+
result = runner.invoke(app, ["list-diffs", "--test", "duckdb"])
73+
assert result.exit_code == 0, result.output
74+
assert "No diff data found" in result.output

tests/test_db_resolver.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from __future__ import annotations
2+
3+
import yaml
4+
5+
from sqlcompare.db import resolver
6+
7+
8+
def test_resolve_connection_url_falls_back_from_yaml_to_yml(tmp_path, monkeypatch) -> None:
9+
configured_path = tmp_path / "connections.yaml"
10+
fallback_path = tmp_path / "connections.yml"
11+
fallback_path.write_text(
12+
yaml.safe_dump(
13+
{
14+
"yaml_fallback_conn": {
15+
"drivername": "sqlite",
16+
"database": ":memory:",
17+
}
18+
}
19+
),
20+
encoding="utf-8",
21+
)
22+
23+
monkeypatch.setattr(resolver, "ENV_PREFIXS", [])
24+
monkeypatch.setattr(resolver, "LIBRARY_CONNECTIONS", [str(configured_path)])
25+
26+
resolved = resolver.resolve_connection_url("yaml_fallback_conn")
27+
assert resolved == "sqlite:///:memory:"
28+
29+
30+
def test_resolve_connection_url_falls_back_from_yml_to_yaml(tmp_path, monkeypatch) -> None:
31+
configured_path = tmp_path / "connections.yml"
32+
fallback_path = tmp_path / "connections.yaml"
33+
fallback_path.write_text(
34+
yaml.safe_dump(
35+
{
36+
"yml_fallback_conn": {
37+
"drivername": "sqlite",
38+
"database": ":memory:",
39+
}
40+
}
41+
),
42+
encoding="utf-8",
43+
)
44+
45+
monkeypatch.setattr(resolver, "ENV_PREFIXS", [])
46+
monkeypatch.setattr(resolver, "LIBRARY_CONNECTIONS", [str(configured_path)])
47+
48+
resolved = resolver.resolve_connection_url("yml_fallback_conn")
49+
assert resolved == "sqlite:///:memory:"

0 commit comments

Comments
 (0)