Skip to content

Commit fce4b92

Browse files
djwaldoclaude
andcommitted
fix(thoughtspot): SQL View merge read the wrong column key; output paths could collide
Both findings from @jbonofre's second review, and both are defects in code from this PR's previous two commits rather than pre-existing ones. 859 tests pass. SQL VIEW COLUMNS WERE DROPPED BY THE MERGE I ADDED. `_deduplicate_table_documents` merged only `columns`, but a SQL View keeps its output columns under `sql_view_columns`. Two aliased datasets over one SQL View therefore lost the second's fields entirely, `build_model` then raised TS-MODEL-COLUMN-ID-MISSING for the fields that were dropped, and the body-divergence comparison fired spuriously because it excluded only `columns` too. So the fix for duplicate Table documents was worse than the defect it replaced. Reading the wrong column key is this converter's most repeated mistake -- three separate silent failures now -- so the kind-to-key rule is named once, in `_column_key_for`, and both call sites use it. The divergence comparison ignores both keys, and a kind mismatch is itself a divergence. A RUN COULD DESTROY ITS OWN OUTPUT. `--issues` and `-o` resolving to one path passed the does-it-already-exist check on a fresh run; `_write_issues` then replaced the converted document with the JSON issue log and the command exited 0. The same collision was reachable between `--issues` and a generated `to-tml` filename. Both commands now reject a repeated target before writing anything, compared after `resolve()` so two spellings of one file are caught, and regardless of `--force` -- which licenses overwriting files that were already there, not destroying an output this run just produced. Both fixes are mutation-checked: reverting either fails the new tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a05e3b4 commit fce4b92

4 files changed

Lines changed: 170 additions & 8 deletions

File tree

‎converters/thoughtspot/src/ossie_thoughtspot/cli.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,32 @@ def _safe_target_path(directory: Path, filename: str) -> Path:
135135
return target
136136

137137

138+
def _colliding(paths: list[Path]) -> list[Path]:
139+
"""Any path this run would write more than once.
140+
141+
Compared after `resolve()`, because two spellings of one file (a relative
142+
path and an absolute one, a symlinked directory) are the same file on disk.
143+
Checked BEFORE any write: `--issues` pointing at `-o` passed the
144+
does-it-already-exist check on a fresh run, and then `_write_issues`
145+
replaced the converted document with the JSON issue log and the command
146+
exited 0. `--force` does not license this -- it permits overwriting files
147+
that were already there, not destroying one of this run's own outputs.
148+
"""
149+
seen: dict[Path, int] = {}
150+
for path in paths:
151+
resolved = path.resolve()
152+
seen[resolved] = seen.get(resolved, 0) + 1
153+
return sorted(path for path, count in seen.items() if count > 1)
154+
155+
156+
def _refuse_collision(paths: list[Path]) -> str:
157+
names = ", ".join(str(p) for p in paths)
158+
return (
159+
f"refusing to write the same path twice in one run: {names}. "
160+
f"--issues must name a different file from the converted output."
161+
)
162+
163+
138164
def _existing(paths: list[Path]) -> list[Path]:
139165
return [p for p in paths if p.exists()]
140166

@@ -171,6 +197,10 @@ def _cmd_to_ossie(args: argparse.Namespace) -> int:
171197
return 1
172198

173199
targets = [output_path] + ([issues_path] if issues_path else [])
200+
collisions = _colliding(targets)
201+
if collisions:
202+
print(f"Error: {_refuse_collision(collisions)}", file=sys.stderr)
203+
return 1
174204
if not args.force:
175205
existing = _existing(targets)
176206
if existing:
@@ -212,6 +242,10 @@ def _cmd_to_tml(args: argparse.Namespace) -> int:
212242
return 1
213243

214244
all_targets = [path for path, _ in targets] + ([issues_path] if issues_path else [])
245+
collisions = _colliding(all_targets)
246+
if collisions:
247+
print(f"Error: {_refuse_collision(collisions)}", file=sys.stderr)
248+
return 1
215249
if not args.force:
216250
existing = _existing(all_targets)
217251
if existing:

‎converters/thoughtspot/src/ossie_thoughtspot/ossie_to_thoughtspot.py‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,11 +1179,26 @@ def _field_physical_display_name(field: dict) -> str | None:
11791179
return None
11801180

11811181

1182+
#: Every body key that can hold columns, for callers that must ignore all of them.
1183+
_COLUMN_KEYS = frozenset({"columns", "sql_view_columns"})
1184+
1185+
1186+
def _column_key_for(kind: str) -> str:
1187+
"""Which body key holds a document's columns.
1188+
1189+
A SQL View stores them under `sql_view_columns`, a Table under `columns`.
1190+
Named once because reading the wrong one is this converter's most repeated
1191+
mistake: it has now caused three separate silent failures, most recently in
1192+
`_deduplicate_table_documents`, which merged only `columns` and so dropped
1193+
every field of a second dataset sharing one SQL View.
1194+
"""
1195+
return "sql_view_columns" if kind == "sql_view" else "columns"
1196+
1197+
11821198
def _physical_columns_of(table_doc: TmlDocument | None) -> list[dict]:
11831199
if table_doc is None:
11841200
return []
1185-
key = "sql_view_columns" if table_doc.kind == "sql_view" else "columns"
1186-
return table_doc.body.get(key) or []
1201+
return table_doc.body.get(_column_key_for(table_doc.kind)) or []
11871202

11881203

11891204
def _restore_ai_context(properties: dict, ai_context: object, log: IssueLog, *, object_ref: str) -> None:
@@ -2133,17 +2148,21 @@ def _deduplicate_table_documents(
21332148
order.append(name)
21342149
continue
21352150

2136-
seen_columns = {c.get("name") for c in first.body.get("columns") or []}
2137-
for column in table.body.get("columns") or []:
2151+
column_key = _column_key_for(table.kind)
2152+
seen_columns = {c.get("name") for c in first.body.get(column_key) or []}
2153+
for column in table.body.get(column_key) or []:
21382154
if column.get("name") not in seen_columns:
2139-
first.body.setdefault("columns", []).append(column)
2155+
first.body.setdefault(column_key, []).append(column)
21402156
seen_columns.add(column.get("name"))
21412157

2158+
# Both column keys are excluded from the divergence comparison, not just
2159+
# the one this kind uses: leaving `sql_view_columns` in made every merged
2160+
# SQL View report a spurious body difference on top of losing the data.
21422161
ignoring_columns = (
2143-
{k: v for k, v in first.body.items() if k != "columns"},
2144-
{k: v for k, v in table.body.items() if k != "columns"},
2162+
{k: v for k, v in first.body.items() if k not in _COLUMN_KEYS},
2163+
{k: v for k, v in table.body.items() if k not in _COLUMN_KEYS},
21452164
)
2146-
if ignoring_columns[0] != ignoring_columns[1]:
2165+
if first.kind != table.kind or ignoring_columns[0] != ignoring_columns[1]:
21472166
log.add(
21482167
code="TS-TABLE-ALIAS-BODY-DIVERGENT",
21492168
severity=Severity.WARNING,

‎converters/thoughtspot/tests/test_cli.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,56 @@ def test_ossie_to_thoughtspot_convert_agrees_with_the_cli_on_filenames(tmp_path)
339339
out_dir = tmp_path / "out"
340340
cli.main(["to-tml", str(ossie_path), "-o", str(out_dir)])
341341
assert {p.name for p in out_dir.iterdir()} == expected
342+
343+
344+
class TestOutputPathsMustNotCollide:
345+
"""No run may write the same path twice.
346+
347+
`--issues` pointing at `-o` passed the does-it-already-exist check on a
348+
fresh run, then `_write_issues` replaced the converted document with the
349+
JSON issue log and the command exited 0 -- the output silently destroyed by
350+
the same invocation that produced it.
351+
"""
352+
353+
def test_to_ossie_refuses_issues_equal_to_output(self, tmp_path):
354+
target = tmp_path / "out.yaml"
355+
code = cli.main([
356+
"to-ossie", *[str(p) for p in _tml_paths("minimal")],
357+
"-o", str(target), "--issues", str(target),
358+
])
359+
assert code == 1
360+
assert not target.exists(), "nothing should have been written"
361+
362+
def test_force_does_not_license_a_self_collision(self, tmp_path):
363+
# --force permits overwriting files that were already there; it does not
364+
# permit destroying one of this run's own outputs.
365+
target = tmp_path / "out.yaml"
366+
assert cli.main([
367+
"to-ossie", *[str(p) for p in _tml_paths("minimal")],
368+
"-o", str(target), "--issues", str(target), "--force",
369+
]) == 1
370+
371+
def test_a_relative_and_absolute_spelling_of_one_path_still_collides(self, tmp_path, monkeypatch):
372+
# Compared after resolve(), because two spellings are one file on disk.
373+
monkeypatch.chdir(tmp_path)
374+
assert cli.main([
375+
"to-ossie", *[str(p) for p in _tml_paths("minimal")],
376+
"-o", str(tmp_path / "out.yaml"), "--issues", "out.yaml",
377+
]) == 1
378+
379+
def test_to_tml_refuses_issues_aimed_at_a_generated_document(self, tmp_path):
380+
ossie_file = tmp_path / "in.yaml"
381+
_write_ossie_yaml_from_fixture("minimal", ossie_file)
382+
out_dir = tmp_path / "tml"
383+
assert cli.main([
384+
"to-tml", str(ossie_file), "-o", str(out_dir),
385+
"--issues", str(out_dir / "orders.table.tml"),
386+
]) == 1
387+
388+
def test_distinct_paths_are_still_accepted(self, tmp_path):
389+
target, issues = tmp_path / "out.yaml", tmp_path / "issues.json"
390+
assert cli.main([
391+
"to-ossie", *[str(p) for p in _tml_paths("minimal")],
392+
"-o", str(target), "--issues", str(issues),
393+
] ) == 0
394+
assert target.exists() and issues.exists()

‎converters/thoughtspot/tests/test_ossie_to_thoughtspot.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,3 +896,59 @@ def test_two_entries_with_no_distinguishing_alias_are_an_error(self):
896896
codes = [i["code"] for i in result.issues.as_dicts()]
897897
assert "TS-MODEL-TABLE-ENTRY-AMBIGUOUS" in codes
898898
assert result.issues.has_errors()
899+
900+
901+
class TestMergingTwoDatasetsThatShareOneSqlView:
902+
"""A SQL View keeps its columns under `sql_view_columns`, not `columns`.
903+
904+
`_deduplicate_table_documents` merged only `columns`, so two aliased
905+
datasets over one SQL View lost the second's fields entirely -- and then
906+
`build_model`, checking both aliases against the single merged document,
907+
raised TS-MODEL-COLUMN-ID-MISSING for the fields that were dropped. The
908+
spurious body-divergence warning fired too, because `sql_view_columns` was
909+
not excluded from that comparison either.
910+
911+
Reading the wrong column key is this converter's most repeated mistake --
912+
three separate silent failures now -- which is why the kind-to-key rule is
913+
named once in `_column_key_for`.
914+
"""
915+
916+
@staticmethod
917+
def _sql_view_dataset(alias, column):
918+
payload = {
919+
"_v": 1, "connection_name": "C", "tml_name": "RETURNS_SV",
920+
"alias": alias, "sql_output_columns": {column: column},
921+
}
922+
return {
923+
"name": alias, "source": "SELECT 1",
924+
"fields": [{
925+
"name": column,
926+
"expression": {"dialects": [
927+
{"dialect": "THOUGHTSPOT", "expression": f"[{alias}::{column}]"}
928+
]},
929+
}],
930+
"custom_extensions": [
931+
{"vendor_name": "THOUGHTSPOT", "data": json.dumps(payload)}
932+
],
933+
}
934+
935+
def _convert(self):
936+
return convert({"version": "0.2.0.dev0", "name": "M", "datasets": [
937+
self._sql_view_dataset("A", "c_a"),
938+
self._sql_view_dataset("B", "c_b"),
939+
]})
940+
941+
def test_both_aliases_columns_survive_the_merge(self):
942+
[document] = self._convert().documents.tables
943+
assert document.kind == "sql_view"
944+
assert [c["name"] for c in document.body["sql_view_columns"]] == ["c_a", "c_b"]
945+
946+
def test_no_column_is_reported_missing(self):
947+
result = self._convert()
948+
codes = [i["code"] for i in result.issues.as_dicts()]
949+
assert "TS-MODEL-COLUMN-ID-MISSING" not in codes
950+
assert not result.issues.has_errors()
951+
952+
def test_no_spurious_body_divergence_is_reported(self):
953+
codes = [i["code"] for i in self._convert().issues.as_dicts()]
954+
assert "TS-TABLE-ALIAS-BODY-DIVERGENT" not in codes

0 commit comments

Comments
 (0)