Skip to content

Commit 7391f45

Browse files
djwaldoclaude
andcommitted
fix(thoughtspot): unique formula ids, and stop erroring on a valid self-join
Three findings from a review of the previous nine commits. Two are regressions those commits introduced — the pattern this round was meant to break. DUPLICATE `formulas[].id`, EMITTED SILENTLY. `formulas[].id` has two sources sharing one namespace, and neither checked the other: an id PRESERVED from the source stash, and one MINTED from the display name. Narrowing `_DisplayNameAllocator`'s fold to what ThoughtSpot actually treats as equal -- correct in itself — removed the only thing that had been masking the minted case, so "Order Amount" and "Order-Amount" both emitted `formula_order_amount` with no issue. A hand-authored metric minting an id equal to a preserved one collided the same way. Either makes every `[formula_X]` reference ambiguous, and ThoughtSpot parses an ambiguous bracket reference as search tokens rather than failing — so the import succeeds and the model is wrong. Ids now come from one allocator across both sources, the surfacing column's `formula_id` is rewritten in lockstep, and a rename is reported. AN ORDINARY SELF-JOIN NOW FAILED. The column-conflict check added last commit compared whole entries, but a Table's `columns[]` mixes field-derived entries (name / db_column_name / db_column_properties) with verbatim `unsurfaced_columns` stash entries carrying raw TML keys. A column surfaced through one alias and unsurfaced through the other compared unequal: ERROR, non-zero exit, on a document that was fine — and one that converted cleanly before the fix. Only the keys that decide WHICH warehouse column is read are compared now, which is what the message always claimed. THE CLI CASEFOLD FIX HAD NO TEST. `-o out.yaml --issues OUT.YAML` was pinned by nothing, so the guard could regress silently. Its sibling in `dump_document_set` was pinned; this one was missed by the commit that went looking for exactly this. All three mutation-checked. 923 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8e93e69 commit 7391f45

3 files changed

Lines changed: 187 additions & 1 deletion

File tree

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

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1242,6 +1242,15 @@ def _field_physical_display_name(field: dict) -> str | None:
12421242
#: name. Two documents carrying it are not evidence of one warehouse object.
12431243
_UNNAMED_TABLE = "<unnamed>"
12441244

1245+
#: The keys that decide WHICH warehouse column an entry reads. Only a
1246+
#: disagreement on one of these is a conflict; comparing whole entries flagged
1247+
#: an ordinary self-join, because a Table's `columns[]` mixes field-derived
1248+
#: entries (name / db_column_name / db_column_properties) with verbatim
1249+
#: `unsurfaced_columns` stash entries carrying raw TML keys, and a column
1250+
#: surfaced through one alias and unsurfaced through the other compared unequal
1251+
#: -- an ERROR, and a non-zero exit, on a document that was fine.
1252+
_COLUMN_BINDING_KEYS = ("db_column_name", "sql_output_column")
1253+
12451254
#: Every body key that can hold columns, for callers that must ignore all of them.
12461255
_COLUMN_KEYS = frozenset({"columns", "sql_view_columns"})
12471256

@@ -1921,6 +1930,8 @@ def build_model(semantic_model: dict, tables: Sequence[TmlDocument], log: IssueL
19211930
tables_by_name = {t.body.get("name"): t for t in tables}
19221931

19231932
model_tables: list[dict] = []
1933+
#: Every `formulas[].id` already handed out, across BOTH sources of them.
1934+
taken_formula_ids: set[str] = set()
19241935
model_tables_by_prefix: dict[str, dict] = {}
19251936
table_doc_by_prefix: dict[str, TmlDocument | None] = {}
19261937

@@ -2020,13 +2031,15 @@ def build_model(semantic_model: dict, tables: Sequence[TmlDocument], log: IssueL
20202031
columns_entry, formulas_entry = built
20212032
columns.append(columns_entry)
20222033
if formulas_entry is not None:
2034+
_allocate_formula_id(formulas_entry, columns_entry, taken_formula_ids, log)
20232035
formulas.append(formulas_entry)
20242036

20252037
for metric in semantic_model.get("metrics") or []:
20262038
built = _build_metric(metric, allocator, resolve_field, log)
20272039
if built is None:
20282040
continue
20292041
formulas_entry, columns_entry = built
2042+
_allocate_formula_id(formulas_entry, columns_entry, taken_formula_ids, log)
20302043
formulas.append(formulas_entry)
20312044
columns.append(columns_entry)
20322045

@@ -2211,6 +2224,55 @@ class TmlConversion:
22112224
issues: IssueLog
22122225

22132226

2227+
def _allocate_formula_id(
2228+
formulas_entry: dict, columns_entry: dict, taken: set[str], log: IssueLog
2229+
) -> None:
2230+
"""Give this formula an id no other formula in the model holds.
2231+
2232+
`formulas[].id` comes from two places that share one namespace and neither
2233+
of which checks the other: a PRESERVED source id (the stash), and one MINTED
2234+
from the display name. Two ways for them to collide, both silent until now:
2235+
2236+
- two display names folding to one minted id. `_DisplayNameAllocator` used
2237+
to mask this by renaming one of the display names first, so narrowing its
2238+
fold to what ThoughtSpot actually treats as equal -- correct in itself --
2239+
exposed it;
2240+
- a hand-authored metric whose minted id happens to equal a preserved one.
2241+
2242+
A duplicate id makes every `[formula_X]` reference to it ambiguous, and
2243+
ThoughtSpot resolves an ambiguous bracket reference by parsing it as search
2244+
tokens rather than failing, so the import succeeds and the model is wrong.
2245+
The surfacing column's `formula_id` is rewritten in lockstep, which is why
2246+
this runs where both halves are in hand.
2247+
"""
2248+
original = formulas_entry["id"]
2249+
candidate, suffix = original, 1
2250+
while candidate in taken:
2251+
suffix += 1
2252+
candidate = f"{original}_{suffix}"
2253+
taken.add(candidate)
2254+
if candidate == original:
2255+
return
2256+
log.add(
2257+
code="TS-MODEL-FORMULA-ID-COLLISION",
2258+
severity=Severity.WARNING,
2259+
message=(
2260+
f"formula {formulas_entry.get('name')!r} would take id {original!r}, "
2261+
f"which another formula in this model already holds; it is emitted as "
2262+
f"{candidate!r} instead, because a duplicate id makes every reference "
2263+
f"to it ambiguous"
2264+
),
2265+
object_ref=f"formula:{formulas_entry.get('name')}",
2266+
remedy=(
2267+
"Rename one of the colliding formulas if the generated id matters to "
2268+
"a cross-reference written by hand."
2269+
),
2270+
)
2271+
formulas_entry["id"] = candidate
2272+
if columns_entry is not None and columns_entry.get("formula_id") == original:
2273+
columns_entry["formula_id"] = candidate
2274+
2275+
22142276
def _deduplicate_table_documents(
22152277
tables: list[TmlDocument], log: IssueLog
22162278
) -> list[TmlDocument]:
@@ -2282,7 +2344,11 @@ def _deduplicate_table_documents(
22822344
if previous is None:
22832345
first.body.setdefault(column_key, []).append(column)
22842346
existing[column_name] = column
2285-
elif previous != column:
2347+
elif any(
2348+
previous.get(key) != column.get(key)
2349+
and key in previous and key in column
2350+
for key in _COLUMN_BINDING_KEYS
2351+
):
22862352
log.add(
22872353
code="TS-TABLE-COLUMN-CONFLICT",
22882354
severity=Severity.ERROR,

‎converters/thoughtspot/tests/test_cli.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,18 @@ def test_to_tml_refuses_issues_aimed_at_a_generated_document(self, tmp_path):
385385
"--issues", str(out_dir / "orders.table.tml"),
386386
]) == 1
387387

388+
def test_paths_differing_only_by_case_still_collide(self, tmp_path):
389+
# `Path.resolve()` does not fold case and the default filesystem on
390+
# macOS and Windows does, so this named ONE file and the issue log
391+
# overwrote the converted document with exit 0 -- the defect the guard
392+
# was written for, surviving its own fix. The parallel fix in
393+
# `dump_document_set` was pinned; this one was not.
394+
assert cli.main([
395+
"to-ossie", *[str(p) for p in _tml_paths("minimal")],
396+
"-o", str(tmp_path / "out.yaml"), "--issues", str(tmp_path / "OUT.YAML"),
397+
]) == 1
398+
assert not list(tmp_path.iterdir()), "nothing should have been written"
399+
388400
def test_distinct_paths_are_still_accepted(self, tmp_path):
389401
target, issues = tmp_path / "out.yaml", tmp_path / "issues.json"
390402
assert cli.main([

‎converters/thoughtspot/tests/test_ossie_to_thoughtspot.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,3 +1098,111 @@ def test_an_unrenamed_dataset_keeps_its_alias_and_says_nothing(self):
10981098
]})
10991099
assert result.documents.model.body["model_tables"][0]["alias"] == "emp"
11001100
assert "TS-DATASET-ALIAS-STALE" not in [i["code"] for i in result.issues.as_dicts()]
1101+
1102+
1103+
class TestFormulaIdsAreUniqueAcrossBothSources:
1104+
"""`formulas[].id` has two sources sharing one namespace.
1105+
1106+
An id is either PRESERVED from the source document's stash or MINTED from
1107+
the display name, and neither checked the other. Two ways to collide:
1108+
two display names folding to one minted id (which `_DisplayNameAllocator`
1109+
used to mask, so narrowing its fold to what ThoughtSpot actually treats as
1110+
equal exposed it), and a hand-authored metric whose minted id equals a
1111+
preserved one. A duplicate id makes every `[formula_X]` reference to it
1112+
ambiguous, and ThoughtSpot parses an ambiguous bracket reference as search
1113+
tokens rather than failing -- so the import succeeds and the model is wrong.
1114+
"""
1115+
1116+
@staticmethod
1117+
def _document(*metrics):
1118+
return {
1119+
"version": "0.2.0.dev0", "name": "M",
1120+
"datasets": [{"name": "t", "source": "D.S.T", "fields": [
1121+
{"name": "a", "expression": {"dialects": [
1122+
{"dialect": "THOUGHTSPOT", "expression": "[t::a]"}]}}]}],
1123+
"metrics": list(metrics),
1124+
}
1125+
1126+
@staticmethod
1127+
def _metric(name, expression, preserved_id=None):
1128+
metric = {"name": name, "expression": {"dialects": [
1129+
{"dialect": "THOUGHTSPOT", "expression": expression}]}}
1130+
if preserved_id:
1131+
metric["custom_extensions"] = [{"vendor_name": "THOUGHTSPOT", "data": json.dumps(
1132+
{"_v": 1, "formula_id": preserved_id})}]
1133+
return metric
1134+
1135+
def test_two_names_minting_one_id_are_separated(self):
1136+
result = convert(self._document(
1137+
self._metric("Order Amount", "sum ( [t::a] )"),
1138+
self._metric("Order-Amount", "max ( [t::a] )"),
1139+
))
1140+
ids = [f["id"] for f in result.documents.model.body["formulas"]]
1141+
assert len(ids) == len(set(ids)), f"duplicate formula ids: {ids}"
1142+
1143+
def test_a_minted_id_never_collides_with_a_preserved_one(self):
1144+
result = convert(self._document(
1145+
self._metric("Net Margin", "sum ( [t::a] )", preserved_id="formula_margin"),
1146+
self._metric("Margin", "max ( [t::a] )"),
1147+
))
1148+
ids = [f["id"] for f in result.documents.model.body["formulas"]]
1149+
assert ids[0] == "formula_margin", "the preserved id must win"
1150+
assert len(ids) == len(set(ids)), f"duplicate formula ids: {ids}"
1151+
1152+
def test_the_surfacing_column_follows_the_renamed_id(self):
1153+
# A renamed id that the column still points at by its OLD value would
1154+
# leave the column bound to nothing.
1155+
result = convert(self._document(
1156+
self._metric("Order Amount", "sum ( [t::a] )"),
1157+
self._metric("Order-Amount", "max ( [t::a] )"),
1158+
))
1159+
body = result.documents.model.body
1160+
emitted = {f["id"] for f in body["formulas"]}
1161+
referenced = {c["formula_id"] for c in body["columns"] if c.get("formula_id")}
1162+
assert referenced <= emitted, f"columns point at missing ids: {referenced - emitted}"
1163+
1164+
def test_the_rename_is_reported(self):
1165+
result = convert(self._document(
1166+
self._metric("Order Amount", "sum ( [t::a] )"),
1167+
self._metric("Order-Amount", "max ( [t::a] )"),
1168+
))
1169+
assert "TS-MODEL-FORMULA-ID-COLLISION" in [i["code"] for i in result.issues.as_dicts()]
1170+
1171+
1172+
class TestAnAliasedSelfJoinWithUnsurfacedColumnsIsNotAConflict:
1173+
"""A Table's `columns[]` mixes two shapes; only the BINDING keys conflict.
1174+
1175+
Field-derived entries carry exactly name/db_column_name/db_column_properties;
1176+
verbatim `unsurfaced_columns` stash entries carry raw TML (`properties`,
1177+
`description`, ...). Comparing whole entries made a column surfaced through
1178+
one alias and unsurfaced through the other compare unequal -- an ERROR, and
1179+
a non-zero exit, on a document that was perfectly fine.
1180+
"""
1181+
1182+
def test_an_ordinary_aliased_self_join_reports_no_conflict(self):
1183+
table = TmlDocument(kind="table", guid=None, body={
1184+
"name": "DATE_DIM", "db": "D", "schema": "S", "db_table": "DATE_DIM",
1185+
"connection": {"name": "Conn"},
1186+
"columns": [
1187+
{"name": c, "db_column_name": c.upper(),
1188+
"properties": {"column_type": "ATTRIBUTE"},
1189+
"db_column_properties": {"data_type": "DATE"}}
1190+
for c in ("d_date", "d_year")
1191+
]})
1192+
model = TmlDocument(kind="model", guid=None, body={
1193+
"name": "M",
1194+
"model_tables": [{"name": "DATE_DIM", "alias": "sold"},
1195+
{"name": "DATE_DIM", "alias": "ship"}],
1196+
"columns": [
1197+
{"name": "Sold Date", "column_id": "sold::d_date",
1198+
"properties": {"column_type": "ATTRIBUTE"}},
1199+
{"name": "Sold Year", "column_id": "sold::d_year",
1200+
"properties": {"column_type": "ATTRIBUTE"}},
1201+
{"name": "Ship Date", "column_id": "ship::d_date",
1202+
"properties": {"column_type": "ATTRIBUTE"}},
1203+
]})
1204+
ossie = tml_to_ossie_convert(DocumentSet(model=model, tables=(table,)))
1205+
result = convert(ossie.model)
1206+
codes = [i["code"] for i in result.issues.as_dicts()]
1207+
assert "TS-TABLE-COLUMN-CONFLICT" not in codes
1208+
assert not result.issues.has_errors()

0 commit comments

Comments
 (0)