Skip to content

Commit 7e7309b

Browse files
djwaldoclaude
andcommitted
fix(thoughtspot): eight defects found by a max-level review of the converter
All eight reproduce; each is verified against its own failing case and pinned by a regression test. 848 tests pass (was 824). SILENT DOUBLE AGGREGATION. `_AGGREGATE_CALL_NAMES` knew `group_aggregate` alone, above a comment asserting "there is exactly one such construct", while this package's own reverse inventory registered four shorthands (group_sum, group_count, group_stddev, group_variance) and two further *_aggregate_op variants. A MEASURE column using any of them was classified as a scalar formula and had its own `aggregation` composed on top: `sum ( group_sum ( ... ) )`, no issue raised, wrong number. The vocabulary is now derived from one source. CARDINALITY SURVIVED A STALE SWAP WITNESS. An Ossie relationship has no cardinality field -- `from` is the many side, `to` the one side, so direction IS cardinality. When the endpoint-swap witness went stale the swap was correctly abandoned but the stashed cardinality was still read, declaring the join backwards; ThoughtSpot uses cardinality for fan-out, so the model returned multiplied rows. Gated on the stale-swap condition, which also honours that issue's own promise to emit "exactly as a hand-authored relationship would". A PARENTHESISED JOIN CONDITION LOST ITS RELATIONSHIP. `formula._scan` tracks paren depth, so `( [A::x] = [B::y] and [A::p] = [B::q] )` -- an ordinary TML spelling -- put every `and` at depth 1: nothing split, no equality pair matched, and the whole relationship was demoted to an unrepresentable-join stash entry, leaving the datasets disconnected and `derive_keys` with no candidate. AN ALIASED SELF-JOIN EMITTED DUPLICATE TABLE DOCUMENTS. N datasets over one warehouse table produced N Table documents of the same name; filename disambiguation then hid the collision and the import created a duplicate Table object. One document per distinct table now, columns unioned. Found while fixing it: two model_tables[] entries with no distinguishing alias are ambiguous on import and were unreported -- now an ERROR. Also: `zip` truncated a join condition when from_columns/to_columns disagreed, dropping predicates silently (now refused, matching upstream #375's equal-arity rule); `_restore_ai_context` overwrote a faithfully stashed synonym_type; shared list objects made PyYAML emit anchor/alias pairs into every generated document and both fixtures; `_safe_target_path`'s condition let through the one case it existed to catch; `read_stash` and a non-UTF-8 input file escaped the CLI's handler as bare tracebacks. Fixtures regenerated, proven deep-equal to their previous parsed content. Both fixtures and emitted output validate against the current upstream schema; the TPC-DS fixture round-trips end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4ace101 commit 7e7309b

14 files changed

Lines changed: 462 additions & 33 deletions

File tree

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ def _safe_target_path(directory: Path, filename: str) -> Path:
124124
"""
125125
resolved_directory = directory.resolve()
126126
target = (resolved_directory / filename).resolve()
127-
if target != resolved_directory and resolved_directory not in target.parents:
127+
# `==`, not `!=`: a filename resolving to the output directory ITSELF is the
128+
# case this guard exists to catch, and the old `target != resolved_directory
129+
# and ...` spelling made that case short-circuit to False and pass.
130+
if target == resolved_directory or resolved_directory not in target.parents:
128131
raise ConversionError(
129132
f"refusing to write {filename!r}: it resolves outside the output directory "
130133
f"{resolved_directory}"
@@ -163,7 +166,7 @@ def _cmd_to_ossie(args: argparse.Namespace) -> int:
163166
texts = [(path, Path(path).read_text(encoding="utf-8")) for path in args.tml_files]
164167
document_set = tml.load_document_set(texts)
165168
result = tml_to_ossie.convert(document_set)
166-
except (ConversionError, OSError) as e:
169+
except (ConversionError, OSError, UnicodeDecodeError) as e:
167170
print(f"Error: {e}", file=sys.stderr)
168171
return 1
169172

@@ -204,7 +207,7 @@ def _cmd_to_tml(args: argparse.Namespace) -> int:
204207
# TML/Ossie content, and `dump_document_set` sanitises filenames for exactly this
205208
# reason (see `_safe_target_path`).
206209
targets = [(_safe_target_path(output_dir, name), text_) for name, text_ in files]
207-
except (ConversionError, OSError) as e:
210+
except (ConversionError, OSError, UnicodeDecodeError) as e:
208211
print(f"Error: {e}", file=sys.stderr)
209212
return 1
210213

‎converters/thoughtspot/src/ossie_thoughtspot/expressions/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from .catalog import CATALOG, CONVENTION_DIVERGENCES, spec_construct_names
3939
from .emit import emit_direct, emit_passthrough, emit_unmappable
4040
from .reverse import (
41+
GROUP_AGGREGATE_CALL_NAMES,
4142
REVERSE,
4243
ReverseConstruct,
4344
ReverseDisposition,
@@ -54,6 +55,7 @@
5455
"CONVENTION_DIVERGENCES",
5556
"Classification",
5657
"Construct",
58+
"GROUP_AGGREGATE_CALL_NAMES",
5759
"REVERSE",
5860
"ReverseConstruct",
5961
"ReverseDisposition",

‎converters/thoughtspot/src/ossie_thoughtspot/expressions/_types.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ class Variant(str, Enum):
5252
STRING = "sql_string_op"
5353
INT_AGGREGATE = "sql_int_aggregate_op"
5454
NUMBER_AGGREGATE = "sql_number_aggregate_op"
55+
# Declared for completeness of the *_aggregate_op family even though no
56+
# catalog row targets them today: `tml_to_ossie` derives "what already
57+
# aggregates" by filtering this enum on the `_aggregate_op` suffix, so a
58+
# missing member there is a missed double-aggregation guard, not merely an
59+
# absent rendering option. Both are named in the reverse inventory.
60+
STRING_AGGREGATE = "sql_string_aggregate_op"
61+
DATE_TIME_AGGREGATE = "sql_date_time_aggregate_op"
5562

5663

5764
class VariadicStyle(str, Enum):

‎converters/thoughtspot/src/ossie_thoughtspot/expressions/reverse.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,17 @@ def _dispatch(args: list[str], log: IssueLog, object_ref: str, connection_dialec
562562
),
563563
)
564564

565+
#: Every ThoughtSpot call name that performs a GROUPED aggregation: the general
566+
#: `group_aggregate` plus the shorthands above. Exported because `tml_to_ossie`
567+
#: must recognise all of them as "already aggregated" -- it previously named
568+
#: `group_aggregate` alone, on the stated belief that "there is exactly one such
569+
#: construct", and so composed a metric's own `aggregation` on top of a
570+
#: `group_sum(...)` formula: `sum ( group_sum ( ... ) )`, silently doubled.
571+
#: Derived from the inventory rather than retyped, so a shorthand added above is
572+
#: covered without a second edit.
573+
GROUP_AGGREGATE_CALL_NAMES = frozenset({"group_aggregate", *_GROUP_SHORTHAND_AGGREGATES})
574+
575+
565576
_SEMI_ADDITIVE_ISSUE = (
566577
"{name} declares a genuine partition and order axis, and that window clause round-trips "
567578
"faithfully — but semi-additivity is a roll-up declaration (do not re-sum this measure "

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,5 +149,11 @@ def derive_keys(
149149
),
150150
)
151151

152-
primary_key = seen[0] if len(seen) == 1 else None
152+
# `list(...)`, not `seen[0]`: returning the same list OBJECT in both slots
153+
# made PyYAML emit a YAML anchor/alias pair -- `primary_key: &id001` with
154+
# `unique_keys: [*id001]` -- into every generated document, and into both
155+
# committed fixtures, where the tests pinned it rather than caught it.
156+
# Aliases are valid YAML but a common hardening default disables them, and a
157+
# non-PyYAML reader that does not resolve them reads null.
158+
primary_key = list(seen[0]) if len(seen) == 1 else None
153159
return primary_key, seen

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

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,7 +1206,12 @@ def _restore_ai_context(properties: dict, ai_context: object, log: IssueLog, *,
12061206
synonyms = ai_context.get("synonyms")
12071207
if synonyms:
12081208
properties["synonyms"] = list(synonyms)
1209-
properties["synonym_type"] = "USER_DEFINED"
1209+
# Only when the source did not state one. This runs AFTER the stashed
1210+
# column_properties are merged in, so an unconditional assignment
1211+
# overwrote a faithfully round-tripped `AUTO_GENERATED` with
1212+
# `USER_DEFINED` and logged nothing -- changing a column's synonym
1213+
# provenance, which the never-a-silent-loss contract forbids.
1214+
properties.setdefault("synonym_type", "USER_DEFINED")
12101215
instructions = ai_context.get("instructions")
12111216
if instructions:
12121217
properties["ai_context"] = instructions
@@ -1582,7 +1587,22 @@ def _restore_relationship_condition(
15821587
"""The equality-only `on:` condition for a relationship with no stashed
15831588
`on_expression` -- reconstructed from `from_columns`/`to_columns` alone,
15841589
which is all a hand-authored relationship (no stash) has to go on."""
1585-
pairs = zip(from_columns or [], to_columns or [])
1590+
from_columns = from_columns or []
1591+
to_columns = to_columns or []
1592+
# `zip` truncates to the shorter list, so a relationship whose two arrays
1593+
# disagree emitted a condition covering only the shorter one -- the extra
1594+
# predicates vanished with nothing logged, and the imported join then
1595+
# matched MORE rows than the Ossie document declared. Every other arity
1596+
# mismatch in this package raises rather than truncating; upstream
1597+
# apache/ossie#375 made equal arity a validation rule, so a document
1598+
# reaching here unequal is malformed.
1599+
if len(from_columns) != len(to_columns):
1600+
raise ConversionError(
1601+
f"relationship {from_prefix!r} -> {to_prefix!r} has "
1602+
f"{len(from_columns)} from_columns and {len(to_columns)} to_columns; "
1603+
f"they must have equal arity to form a join condition"
1604+
)
1605+
pairs = zip(from_columns, to_columns)
15861606
return " and ".join(
15871607
f"{identifiers.format_column_ref(from_prefix, fc)} = "
15881608
f"{identifiers.format_column_ref(to_prefix, tc)}"
@@ -1702,7 +1722,27 @@ def _join_entry_for_relationship(rel: dict, log: IssueLog) -> tuple[str, dict, d
17021722
)
17031723
on_expression = _restore_relationship_condition(from_prefix, to_prefix, from_columns, to_columns)
17041724
join_type = _normalise_join_type(payload.get(RELATIONSHIP_STASH_TYPE) or "INNER")
1705-
cardinality = payload.get(RELATIONSHIP_STASH_CARDINALITY) or "MANY_TO_ONE"
1725+
# Gated on the SAME witness as the endpoint swap, because it states the same
1726+
# fact. An Ossie relationship carries no cardinality field: `from` is the many
1727+
# side and `to` is the one side, so direction IS cardinality. When the witness
1728+
# is stale the swap above is deliberately not undone and the orientation is
1729+
# taken live -- and a stashed `ONE_TO_MANY` read alongside that live
1730+
# orientation declares the join backwards, which ThoughtSpot uses for
1731+
# fan-out, so the model returns multiplied rows.
1732+
#
1733+
# The stale-swap issue already promises the join is emitted "exactly as a
1734+
# hand-authored relationship with no stash at all would be". A hand-authored
1735+
# one gets the MANY_TO_ONE default; reading the stash here broke that promise.
1736+
# Gated on the stale-swap condition itself, not on the witness directly: a
1737+
# relationship that never carried a swap stash (hand-authored, never round
1738+
# -tripped) has a cardinality that stands on its own, and there is nothing
1739+
# stale about it. Only a swap that WAS stashed and has since gone stale
1740+
# invalidates it.
1741+
stashed_swap_is_stale = had_stashed_swap and not endpoints_swapped
1742+
cardinality = (
1743+
"MANY_TO_ONE" if stashed_swap_is_stale
1744+
else (payload.get(RELATIONSHIP_STASH_CARDINALITY) or "MANY_TO_ONE")
1745+
)
17061746

17071747
live_name = rel.get("name")
17081748
stashed_referencing_join = payload.get(RELATIONSHIP_STASH_REFERENCING_JOIN)
@@ -1824,6 +1864,35 @@ def build_model(semantic_model: dict, tables: Sequence[TmlDocument], log: IssueL
18241864
table_entry: dict = {"name": table_ref}
18251865
if alias:
18261866
table_entry["alias"] = alias
1867+
# Two entries naming one table must be told apart by their aliases --
1868+
# `model_tables[].joins[].with` and every `[PREFIX::Column]` reference
1869+
# resolve by alias-or-name, so an undistinguished pair is ambiguous on
1870+
# import. Found while fixing the duplicate-Table-document defect: the
1871+
# aliased case is the normal self-join and is fine; this is the case
1872+
# where the aliases did not survive.
1873+
clash = next(
1874+
(e for e in model_tables
1875+
if e["name"] == table_entry["name"]
1876+
and e.get("alias") == table_entry.get("alias")),
1877+
None,
1878+
)
1879+
if clash is not None:
1880+
log.add(
1881+
code="TS-MODEL-TABLE-ENTRY-AMBIGUOUS",
1882+
severity=Severity.ERROR,
1883+
message=(
1884+
f"datasets {dataset_prefix!r} and an earlier one both resolve "
1885+
f"to table {table_ref!r} with the same alias "
1886+
f"({table_entry.get('alias')!r}); model_tables[] entries are "
1887+
f"referenced by alias-or-name, so the two are indistinguishable "
1888+
f"and every reference to either is ambiguous on import"
1889+
),
1890+
object_ref=f"dataset:{dataset_prefix}",
1891+
remedy=(
1892+
"Give the datasets distinct aliases, or a distinct source "
1893+
"table each if they are not a self-join."
1894+
),
1895+
)
18271896
model_tables.append(table_entry)
18281897
model_tables_by_prefix[dataset_prefix] = table_entry
18291898

@@ -2033,6 +2102,65 @@ class TmlConversion:
20332102
issues: IssueLog
20342103

20352104

2105+
def _deduplicate_table_documents(
2106+
tables: list[TmlDocument], log: IssueLog
2107+
) -> list[TmlDocument]:
2108+
"""One Table document per distinct table name, not one per dataset.
2109+
2110+
An aliased self-join is N Ossie datasets over ONE warehouse table -- a model
2111+
joining DATE_DIM twice as "Sold Date" and "Ship Date" is two datasets whose
2112+
stashed source table is the same. Building a document per dataset emitted
2113+
two Table documents both named `DATE_DIM`; `dump_document_set` then gave
2114+
them distinct FILEnames (`DATE_DIM.table.tml`, `DATE_DIM-2.table.tml`),
2115+
which hid the collision rather than surfacing it, and importing the set
2116+
created a duplicate ThoughtSpot Table object. The model side was already
2117+
correct: `model_tables[]` carries one entry per dataset with its own alias,
2118+
all pointing at the single table name.
2119+
2120+
Columns are unioned by name, first occurrence winning, because two aliases
2121+
of one table may surface different subsets of it and the Table document has
2122+
to hold every column any alias references. A body that differs beyond its
2123+
columns cannot be merged that way, so the first is kept and the difference
2124+
reported rather than silently resolved.
2125+
"""
2126+
by_name: dict[str, TmlDocument] = {}
2127+
order: list[str] = []
2128+
for table in tables:
2129+
name = table.body.get("name")
2130+
first = by_name.get(name)
2131+
if first is None:
2132+
by_name[name] = table
2133+
order.append(name)
2134+
continue
2135+
2136+
seen_columns = {c.get("name") for c in first.body.get("columns") or []}
2137+
for column in table.body.get("columns") or []:
2138+
if column.get("name") not in seen_columns:
2139+
first.body.setdefault("columns", []).append(column)
2140+
seen_columns.add(column.get("name"))
2141+
2142+
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"},
2145+
)
2146+
if ignoring_columns[0] != ignoring_columns[1]:
2147+
log.add(
2148+
code="TS-TABLE-ALIAS-BODY-DIVERGENT",
2149+
severity=Severity.WARNING,
2150+
message=(
2151+
f"two datasets resolve to table {name!r} but describe it "
2152+
f"differently (connection, description or properties); the "
2153+
f"first description is emitted and the second is not"
2154+
),
2155+
object_ref=f"table:{name}",
2156+
remedy=(
2157+
"Make the aliased datasets agree, or give them distinct "
2158+
"source tables if they are genuinely different tables."
2159+
),
2160+
)
2161+
return [by_name[name] for name in order]
2162+
2163+
20362164
def convert(ossie_document: dict) -> TmlConversion:
20372165
"""Convert one Ossie document into one ThoughtSpot TML document set.
20382166
@@ -2081,6 +2209,8 @@ def convert(ossie_document: dict) -> TmlConversion:
20812209
raise ConversionError("the Ossie document has no datasets to convert")
20822210

20832211
log = IssueLog()
2084-
tables = [build_table(dataset, log) for dataset in semantic_model.get("datasets") or []]
2212+
tables = _deduplicate_table_documents(
2213+
[build_table(dataset, log) for dataset in semantic_model.get("datasets") or []], log
2214+
)
20852215
model = build_model(semantic_model, tables, log)
20862216
return TmlConversion(documents=DocumentSet(model=model, tables=tuple(tables)), issues=log)

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,21 @@ def find_forbidden_key(value: Any, forbidden: frozenset[str] | None = None) -> s
7575

7676
def read_stash(obj: dict) -> dict[str, Any]:
7777
"""Return this object's parsed THOUGHTSPOT payload, or {} if it has none."""
78-
for entry in obj.get("custom_extensions") or []:
78+
extensions = obj.get("custom_extensions") or []
79+
if not isinstance(extensions, list):
80+
raise ConversionError(
81+
f"custom_extensions must be a list, not {type(extensions).__name__}"
82+
)
83+
for entry in extensions:
84+
# A hand-authored document can put anything here. Without this the
85+
# `.get` below raised a bare AttributeError, which escapes the CLI's
86+
# (ConversionError, OSError) handler and prints a traceback -- breaking
87+
# this module's own never-a-bare-traceback contract.
88+
if not isinstance(entry, dict):
89+
raise ConversionError(
90+
f"each custom_extensions entry must be a mapping, not "
91+
f"{type(entry).__name__}"
92+
)
7993
if entry.get("vendor_name") != VENDOR_KEY:
8094
continue
8195
raw = entry.get("data")

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

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@
129129
STASH_TML_NAME,
130130
)
131131
from .errors import ConversionError
132-
from .expressions import CATALOG, Variant, emit_direct
132+
from .expressions import CATALOG, GROUP_AGGREGATE_CALL_NAMES, Variant, emit_direct
133133
from .issues import IssueLog, Severity
134134
from .tml import DocumentSet
135135

@@ -711,12 +711,19 @@ def convert_field(
711711
#: it appears in an expression — see `_contains_aggregate_call`.
712712
_NATIVE_AGGREGATE_SPECS = (*_AGGREGATION_CATALOG_SPEC.values(), "MEDIAN(expr)")
713713

714-
#: `group_aggregate` is ThoughtSpot's own construct for a grouped/windowed
715-
#: aggregation — the *performant* pattern the catalog's window-function rows
716-
#: prefer over a raw `sql_*_aggregate_op` pass-through — and it is not a target of
717-
#: any TML `aggregation` enum value, so it cannot come from `_AGGREGATION_CATALOG_SPEC`.
718-
#: There is exactly one such construct, so it is named directly rather than derived.
719-
_GROUP_AGGREGATE_CALL = "group_aggregate"
714+
#: ThoughtSpot's grouped/windowed aggregations — the *performant* pattern the
715+
#: catalog's window-function rows prefer over a raw `sql_*_aggregate_op`
716+
#: pass-through. None is the target of any TML `aggregation` enum value, so they
717+
#: cannot come from `_AGGREGATION_CATALOG_SPEC`.
718+
#:
719+
#: Imported from the reverse inventory rather than named here. This constant
720+
#: previously read `"group_aggregate"` alone, above a comment asserting "there is
721+
#: exactly one such construct" — while the same package's reverse inventory
722+
#: registered four more (`group_sum`, `group_count`, `group_stddev`,
723+
#: `group_variance`). A MEASURE column carrying `group_sum ( ... )` was therefore
724+
#: classified as a scalar formula and had its own `aggregation` composed on top,
725+
#: emitting `sum ( group_sum ( ... ) )` with no issue raised.
726+
_GROUP_AGGREGATE_CALLS = GROUP_AGGREGATE_CALL_NAMES
720727

721728
#: Every `Variant` that denotes an *aggregate* `sql_*_op` pass-through wrapper,
722729
#: derived by filtering the enum on its own `_aggregate_op` naming convention
@@ -740,7 +747,7 @@ def convert_field(
740747
formula.split_call(emit_direct(CATALOG[spec], ["x"]))[0].lower()
741748
for spec in _NATIVE_AGGREGATE_SPECS
742749
)
743-
| {_GROUP_AGGREGATE_CALL}
750+
| _GROUP_AGGREGATE_CALLS
744751
| _SQL_AGGREGATE_OP_CALLS
745752
)
746753

@@ -1567,6 +1574,32 @@ def _split_top_level_and(text: str) -> list[str]:
15671574
return [p for p in parts if p]
15681575

15691576

1577+
def _strip_wrapping_parens(text: str) -> str:
1578+
"""`( x )` -> `x`, repeatedly, but only when the parens truly wrap the whole.
1579+
1580+
`(a) and (b)` is left alone: its first `(` closes before the end, so the
1581+
outer pair is not a wrapper. Depth comes from `formula._scan`, the same
1582+
tracker the rest of this module splits on, so a paren inside a quoted
1583+
literal or a `[TABLE::Column]` body never counts.
1584+
"""
1585+
stripped = text.strip()
1586+
while stripped.startswith("(") and stripped.endswith(")"):
1587+
# `_scan` reports depth 0 AT the opening paren and again AT its match,
1588+
# so the wrapper test is whether depth returns to 0 strictly between
1589+
# them -- index 0 and the final index are both 0 for a true wrapper.
1590+
depth_reaches_zero_early = any(
1591+
depth == 0 and 0 < index < len(stripped) - 1
1592+
for index, _ch, depth, _in_quote in formula._scan(stripped)
1593+
)
1594+
if depth_reaches_zero_early:
1595+
return stripped
1596+
inner = stripped[1:-1].strip()
1597+
if not inner:
1598+
return stripped
1599+
stripped = inner
1600+
return stripped
1601+
1602+
15701603
def _parse_join_condition(
15711604
on_expression: str, from_prefix: str, to_prefix: str
15721605
) -> tuple[list[tuple[str, str]], list[str]]:
@@ -1587,7 +1620,15 @@ def _parse_join_condition(
15871620
"""
15881621
equality_pairs: list[tuple[str, str]] = []
15891622
residuals: list[str] = []
1590-
for part in _split_top_level_and(on_expression):
1623+
# Redundant wrapping parentheses are stripped before the split and again per
1624+
# part. `formula._scan` tracks paren depth -- correct for a general
1625+
# expression, and exactly wrong here: a condition written
1626+
# `( [A::x] = [B::y] and [A::p] = [B::q] )`, an ordinary TML spelling, puts
1627+
# every `and` at depth 1, so nothing split, no equality pair matched, and the
1628+
# whole relationship was demoted to an unrepresentable-join stash entry --
1629+
# leaving the Ossie datasets disconnected and `derive_keys` with no candidate.
1630+
for part in _split_top_level_and(_strip_wrapping_parens(on_expression)):
1631+
part = _strip_wrapping_parens(part)
15911632
match = _EQUALITY_PAIR_RE.match(part)
15921633
if match is None:
15931634
residuals.append(part)

‎converters/thoughtspot/tests/fixtures/minimal/expected.ossie.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ datasets:
4242
{"data_type": "DOUBLE"}, "name": "order_total"}]}'
4343
- name: customers
4444
source: MINIMAL.PUBLIC.CUSTOMERS
45-
primary_key: &id001
45+
primary_key:
4646
- customer_id
4747
unique_keys:
48-
- *id001
48+
- - customer_id
4949
fields:
5050
- name: customer_name
5151
label: customer_name

0 commit comments

Comments
 (0)