Skip to content

Commit 9892bf1

Browse files
djwaldoclaude
andcommitted
fix(thoughtspot): a bare group_aggregate keeps its column aggregation
Not every already-aggregating formula makes the surfacing column's `aggregation` inert, and the converter treated them as if it did. Established by domain review and confirmed against a live cluster: sum ( ... ) column aggregation is a NO-OP group_sum / group_average ( ... ) NO-OP -- these behave like ordinary formulas sum ( group_aggregate ( ... ) ) NO-OP -- wrapped, so the outer call rules group_aggregate ( ... ) BARE USED -- like a raw column, ThoughtSpot may apply the column's aggregation to it The code grouped all four as "the documented no-op", naming `group_aggregate ( ... )` in that comment explicitly, and discarded the value with nothing logged -- deliberately, since warning on the genuinely inert shapes would train readers to ignore the issue log. So the one shape where the property is load-bearing lost it silently: a changed answer, not a changed spelling. Ossie's metric expression has nowhere to put it, so it is preserved verbatim in the stash and restored, rather than discarded or folded into the expression -- folding would change what the formula means, since wrapping a bare `group_aggregate` is exactly what makes the property inert. Also recorded, because it was nearly acted on as a defect: `sum([SALES])` with `aggregation: SUM` is genuinely a no-op. A converted model carrying both was imported into a live cluster and returned numbers identical to its source. An earlier reading of a `NESTED_AGGREGATE_NOT_SUPPORTED` error as a converter defect was wrong -- the nesting was in the QUERY, which wrapped an already-aggregated formula in SUM(). Mutation-checked. 932 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 93b43f7 commit 9892bf1

5 files changed

Lines changed: 120 additions & 7 deletions

File tree

‎converters/thoughtspot/docs/vendor-payload.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Every `custom_extensions` entry this converter writes uses `vendor_name` `THOUGH
3838
| Key | Scope | Classification | Treatment on the return trip |
3939
|---|---|---|---|
4040
| `tml_name` | Shared | shadows_derivable | Restored only if reconstructing it from the live document still agrees with the stashed value (self-verifying — no separate witness key); disagreement re-derives instead. |
41+
| `column_aggregation_value` | Metric | information_only | Restored as-is whenever present — nothing on the Ossie side could have diverged from it. |
4142
| `tml_obj_id` | Model | information_only | Restored as-is whenever present — nothing on the Ossie side could have diverged from it. |
4243
| `formula_id` | Field | information_only | Restored as-is whenever present — nothing on the Ossie side could have diverged from it. |
4344
| `db_column_name` | Field | shadows_derivable | Restored only if its witness companion key still matches the live document's current value; a mismatch means the document changed since the stash was written, so the value is re-derived instead. |

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,20 @@
9999
#: must agree on the exact spelling and nothing else enforces that.
100100
FIELD_STASH_DB_COLUMN_NAME = "db_column_name"
101101

102+
#: A surfacing column's `aggregation` that is LOAD-BEARING and has no home in
103+
#: the Ossie metric's own expression, so it is preserved verbatim instead.
104+
#:
105+
#: The one shape this is for: a formula whose outer call is a BARE
106+
#: `group_aggregate ( ... )`. Every other already-aggregating shape makes the
107+
#: column's aggregation a genuine no-op -- `sum ( ... )`, and the
108+
#: `group_sum`/`group_average` shorthands, all behave that way (verified on a
109+
#: live cluster: a model carrying `sum([SALES])` WITH `aggregation: SUM`
110+
#: returns the same number as the source). A bare `group_aggregate` does not:
111+
#: like a raw column, ThoughtSpot may APPLY the column aggregation to it. It
112+
#: was being discarded with the others and with nothing logged, which silently
113+
#: changes the number.
114+
METRIC_STASH_COLUMN_AGGREGATION = "column_aggregation_value"
115+
102116
#: The source TML `obj_id` -- ThoughtSpot's own PORTABLE object handle, e.g.
103117
#: `SampleRetail-Apparel-LH-58435d2b` (display name, then the first segment of
104118
#: the GUID). Stashed under a distinct payload key so the forbidden-key scan,
@@ -473,6 +487,8 @@ class StashKeyClass(Enum):
473487
# it -- TML's id is independent of its name, so renaming the metric in Ossie
474488
# must NOT change the id, or every cross-reference written against it breaks.
475489
# Ossie has no object-identity concept, so nothing here can diverge from it.
490+
# No Ossie counterpart: the metric's expression cannot carry it.
491+
METRIC_STASH_COLUMN_AGGREGATION: StashKeyClass.INFORMATION_ONLY,
476492
MODEL_STASH_OBJ_ID: StashKeyClass.INFORMATION_ONLY,
477493
FIELD_STASH_FORMULA_ID: StashKeyClass.INFORMATION_ONLY,
478494
FIELD_STASH_DB_COLUMN_NAME: StashKeyClass.SHADOWS_DERIVABLE,

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
FIELD_STASH_DATA_TYPE_WITNESS,
9292
FIELD_STASH_DB_COLUMN_NAME,
9393
FIELD_STASH_DB_COLUMN_NAME_WITNESS,
94+
METRIC_STASH_COLUMN_AGGREGATION,
9495
METRIC_SHAPE_COLUMN_AGGREGATION,
9596
METRIC_SHAPE_FORMULA,
9697
METRIC_SHAPE_SCALAR_FORMULA_PLUS_AGGREGATION,
@@ -1562,6 +1563,15 @@ def _build_metric(
15621563
else:
15631564
properties["aggregation"], formula_expr = decomposed
15641565

1566+
# A LOAD-BEARING aggregation, preserved verbatim because the metric's
1567+
# expression had nowhere to carry it: a bare `group_aggregate ( ... )`
1568+
# takes its column's aggregation the way a raw column does, unlike every
1569+
# other already-aggregating shape. Restored before the conventional path
1570+
# below, which would otherwise not set one at all for this shape.
1571+
preserved_aggregation = payload.get(METRIC_STASH_COLUMN_AGGREGATION)
1572+
if preserved_aggregation and "aggregation" not in properties:
1573+
properties["aggregation"] = preserved_aggregation
1574+
15651575
if "aggregation" not in properties:
15661576
conventional = _outer_aggregation_of(ts_expr)
15671577
if conventional is not None:

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

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
FIELD_STASH_DB_COLUMN_NAME,
106106
FIELD_STASH_FORMULA_ID,
107107
FIELD_STASH_DB_COLUMN_NAME_WITNESS,
108+
METRIC_STASH_COLUMN_AGGREGATION,
108109
METRIC_SHAPE_COLUMN_AGGREGATION,
109110
METRIC_SHAPE_FORMULA,
110111
METRIC_SHAPE_SCALAR_FORMULA_PLUS_AGGREGATION,
@@ -755,6 +756,19 @@ def convert_field(
755756
)
756757

757758

759+
def _is_bare_group_aggregate(expr: str) -> bool:
760+
"""Whether `expr`'s outer call is `group_aggregate` itself.
761+
762+
The distinction that matters: a bare `group_aggregate ( ... )` takes its
763+
surfacing column's `aggregation` the way a raw column does, while
764+
`sum ( group_aggregate ( ... ) )` -- wrapped -- does not, and neither do the
765+
`group_sum`/`group_average` shorthands, which behave like ordinary
766+
formulas.
767+
"""
768+
call = formula.split_call(expr)
769+
return call is not None and call[0].strip().lower() == "group_aggregate"
770+
771+
758772
def _outer_call_is_aggregate(expr: str) -> bool:
759773
"""Whether `expr`'s own outer call (not something nested inside it) is a
760774
native ThoughtSpot aggregate.
@@ -959,6 +973,7 @@ def convert_metric(
959973
)
960974
aggregation_raw = "NONE"
961975
aggregation = _AGGREGATION[aggregation_raw]
976+
load_bearing_aggregation: str | None = None
962977

963978
if "column_id" in column:
964979
metric_shape = METRIC_SHAPE_COLUMN_AGGREGATION
@@ -1021,13 +1036,30 @@ def convert_metric(
10211036
expr, resolve, log, object_ref=object_ref, kind="metric"
10221037
)
10231038
elif _outer_call_is_aggregate(expr):
1024-
# The documented no-op: the expression's own call already
1025-
# aggregates (sum ( ... ), group_aggregate ( ... ), ...), and
1026-
# ThoughtSpot's UI sets a column aggregation on a formula column
1027-
# like this routinely, whether or not it is redundant. Discarding
1028-
# it here is expected, not a loss, so nothing is logged --
1029-
# warning on this common, correct shape would train readers to
1030-
# ignore the issue log entirely.
1039+
# Mostly the documented no-op: the expression's own call already
1040+
# aggregates, and ThoughtSpot's UI sets a column aggregation on a
1041+
# formula column like this routinely whether or not it is
1042+
# redundant. Discarding it is expected, not a loss, so nothing is
1043+
# logged -- warning on this common, correct shape would train
1044+
# readers to ignore the issue log entirely. Confirmed on a live
1045+
# cluster: a model carrying `sum([SALES])` WITH `aggregation: SUM`
1046+
# returns the same numbers as the source.
1047+
#
1048+
# ONE EXCEPTION, and it changes the answer. A BARE
1049+
# `group_aggregate ( ... )` -- not itself wrapped in an aggregate --
1050+
# behaves like a raw column: ThoughtSpot may APPLY the column's
1051+
# aggregation to it. (`sum ( group_aggregate ( ... ) )` is wrapped,
1052+
# so there the property is inert again, as are the
1053+
# `group_sum`/`group_average` shorthands, which behave like any
1054+
# other formula.) This comment used to name `group_aggregate` among
1055+
# the no-ops, and the value was dropped with the rest -- silently,
1056+
# since nothing is logged on this path.
1057+
#
1058+
# Ossie's metric expression has nowhere to put it, so it is
1059+
# preserved verbatim in the stash rather than discarded or folded
1060+
# into the expression, which would change what the formula means.
1061+
if _is_bare_group_aggregate(expr):
1062+
load_bearing_aggregation = aggregation_raw
10311063
metric_shape = METRIC_SHAPE_FORMULA
10321064
dialects = expression_entries(
10331065
expr, resolve, log, object_ref=object_ref, kind="metric"
@@ -1081,6 +1113,8 @@ def convert_metric(
10811113
stash_payload[STASH_TML_NAME] = display_name
10821114
if metric_shape != METRIC_SHAPE_FORMULA:
10831115
stash_payload[METRIC_STASH_SHAPE] = metric_shape
1116+
if load_bearing_aggregation is not None:
1117+
stash_payload[METRIC_STASH_COLUMN_AGGREGATION] = load_bearing_aggregation
10841118
metric = _write_stash_safely(metric, stash_payload, log, object_ref)
10851119

10861120
description = column.get("description")

‎converters/thoughtspot/tests/test_roundtrip.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,3 +957,55 @@ def test_the_models_obj_id_survives_the_round_trip():
957957
# The guid must still never travel.
958958
assert "11111111-2222" not in emitted
959959
assert "guid:" not in emitted
960+
961+
962+
class TestABareGroupAggregateKeepsItsColumnAggregation:
963+
"""Not every already-aggregating formula makes the column property inert.
964+
965+
Verified against a live ThoughtSpot cluster and against domain review:
966+
967+
- `sum ( ... )`, and the `group_sum`/`group_average` shorthands, behave like
968+
ordinary formulas -- the surfacing column's `aggregation` is a NO-OP. A
969+
model carrying `sum([SALES])` WITH `aggregation: SUM` returns the same
970+
numbers as its source.
971+
- `sum ( group_aggregate ( ... ) )` -- wrapped -- is inert for the same reason.
972+
- a BARE `group_aggregate ( ... )` is NOT. Like a raw column, ThoughtSpot may
973+
APPLY the column's aggregation to it.
974+
975+
The converter grouped all of them as "the documented no-op" and discarded
976+
the value, with nothing logged, so the one shape where it is load-bearing
977+
silently lost it -- a changed answer, not a changed spelling.
978+
"""
979+
980+
BARE = "group_aggregate ( sum ( [T::a] ) , query_groups ( ) , query_filters ( ) )"
981+
982+
@staticmethod
983+
def _round_trip(expr, aggregation):
984+
table = TmlDocument(kind="table", guid=None, body={
985+
"name": "T", "db": "D", "schema": "S", "db_table": "T",
986+
"connection": {"name": "Conn"},
987+
"columns": [{"name": c, "db_column_name": c.upper(),
988+
"db_column_properties": {"data_type": "DOUBLE"}} for c in ("a", "r")]})
989+
properties = {"column_type": "MEASURE"}
990+
if aggregation:
991+
properties["aggregation"] = aggregation
992+
model = TmlDocument(kind="model", guid=None, body={
993+
"name": "M", "model_tables": [{"name": "T"}],
994+
"formulas": [{"id": "f1", "name": "Metric", "expr": expr}],
995+
"columns": [{"name": "Metric", "formula_id": "f1", "properties": properties}]})
996+
ossie = tml_to_ossie.convert(DocumentSet(model=model, tables=(table,)))
997+
returned = ossie_to_thoughtspot.convert(ossie.model).documents.model.body
998+
column = next(c for c in returned["columns"] if c["name"] == "Metric")
999+
return (column.get("properties") or {}).get("aggregation")
1000+
1001+
def test_a_bare_group_aggregate_keeps_it(self):
1002+
assert self._round_trip(self.BARE, "SUM") == "SUM"
1003+
1004+
def test_a_bare_group_aggregate_without_one_gains_none(self):
1005+
assert self._round_trip(self.BARE, None) is None
1006+
1007+
def test_a_wrapped_group_aggregate_is_unaffected(self):
1008+
assert self._round_trip(f"sum ( {self.BARE} )", "SUM") == "SUM"
1009+
1010+
def test_a_plain_aggregate_is_unaffected(self):
1011+
assert self._round_trip("sum ( [T::a] )", "SUM") == "SUM"

0 commit comments

Comments
 (0)