fix(thoughtspot): guard wrongly-typed TML values from bare tracebacks - #508
Conversation
Wrongly-typed values in a hand-edited TML document raised bare TypeError/AttributeError during to-ossie conversion, breaking the converter's "never a bare traceback" contract. PR apache#482 covered the column and model name cases; this covers the remaining scalar reads it enumerated as untouched: - aggregation as a list or dict (membership test on an unhashable value) - a formula expr of the wrong type, on both the field and metric paths - a physical data_type of the wrong type (unhashable in datatypes.to_ossie) - a db_column_name of the wrong type (crashed identifiers.normalise) - a join 'on' of the wrong type (AttributeError on .strip()) Each now logs a TS-* WARNING and degrades (skip, or fall back), matching the surrounding degrade-and-continue pattern, rather than aborting the whole document. The container-type cases (columns, model_tables, properties, join destination) and the to-tml stash direction from apache#469 are left for a follow-up. Refs apache#469
| object_ref=object_ref, | ||
| ) | ||
| return None | ||
| if not isinstance(formula_entry["expr"], str): |
There was a problem hiding this comment.
This makes convert_field return None, but convert() then stashes the same value. The unattributed-formula block only checks "expr" in formula entry before copying formula_entry["expr"] into unattributed_formulas, and the unsurfaced-formulas loop does the same for a formula no column references, with no issue logged at all.
End to end, a formulas[] entry with expr: 42, expr: [ORDERS::Region] or an empty expr: gives to-ossie exit 0, and to-tml on that output then fails with a bare TypeError. With expr: 2024-01-01 (parsed as a YAML date), to-ossie itself fails in json.dumps when writing the stash.
Could the check move to where the formulas dict is built? Reporting a non-string expr once there and removing the key would send all four readers down the existing "entry has no expr" paths, and the two copies of this guard in convert_field and convert_metric would no longer be needed.
| # A non-string db_column_name (wrong type in a hand-edited document) is | ||
| # unusable as an identifier basis and would crash identifiers.normalise in | ||
| # the caller; treat it as absent, the same as a missing db_column_name. | ||
| if not isinstance(db_column_name, str): |
There was a problem hiding this comment.
db_column_name has other readers that bypass this helper, so a non-string value is not really read as absent:
resolve()interpolates it into the ANSI_SQL expression.db_column_name: 42emitsORDERS.42and a list emitsORDERS.['REGION'], with exit 0 and no issue. A missing name returnsNonethere and suppresses the ANSI sibling._physical_column_stashcopies it into the field stash.42is stashed without a report, and a YAML date fails injson.dumps.
The first two read from physical_columns_by_prefix, which is built by _normalized_physical_columns, so sanitizing in _normalize_physical_column would cover both in one place. It would also be worth logging an issue there, since the value is currently dropped without any report.
| apply to, so `from`/`to` there stay exactly TML's own, unswapped. | ||
| """ | ||
| object_ref = f"relationship:{name}" | ||
| if on_expression is not None and not isinstance(on_expression, str): |
There was a problem hiding this comment.
is not None also catches falsy non-strings that used to mean "no condition". on: [], on: {}, on: false and on: 0 reported TS-JOIN-NO-CONDITION on main and now report TS-JOIN-MALFORMED. If that change is not intended:
| if on_expression is not None and not isinstance(on_expression, str): | |
| if on_expression and not isinstance(on_expression, str): |
If it is intended, could you add a test that pins it?
| # a bare AttributeError on the .strip() below; report it as a malformed | ||
| # condition instead, the same degrade-and-skip the parse failure gets. | ||
| log.add( | ||
| code="TS-JOIN-MALFORMED", |
There was a problem hiding this comment.
This branch returns (None, None, False), so the join is dropped, the same as the TS-JOIN-NO-CONDITION path below. The existing TS-JOIN-MALFORMED path does the opposite: it keeps the join in unrepresentable_joins and its message says so. With this change one code means both "preserved" and "discarded", and the comment above ("the same degrade-and-skip the parse failure gets") does not match what the parse failure does.
Could this use a separate code, or at least say in the message that the join is not preserved? If you would rather preserve it, note that _unrepresentable_entry stores on verbatim, so the stash and to-tml would then have to cope with the non-string value. The docstring line "both None when there is no condition at all to report" needs updating either way.
| assert "TS-METRIC-FORMULA-INVALID" in _codes(log) | ||
|
|
||
|
|
||
| def test_non_string_field_formula_expr_is_reported_and_skipped(): |
There was a problem hiding this comment.
These tests call the helpers directly, so none of them checks the contract the module docstring states (no bare traceback from the converter). This one passes on field is None while convert() still stashes the same expr and to-tml fails on the result.
Could you add cases that run a document carrying each wrong-typed value through convert(), and through to-tml for the values that end up in the stash, asserting on the issue log and the emitted document?
Separately, test_non_string_aggregation_falls_back_to_none_and_is_reported only asserts metric is not None, so it does not check the NONE fallback in its name.
Address review on apache#508. The per-reader guards left the wrongly-typed values reaching the model-scope stash, so to-tml still raised bare tracebacks. Move each guard to where the data is built, so every reader is covered, and test end to end through convert() and to-tml. - Formula expr: sanitize once where the formulas dict is built, dropping a non-string expr (TS-FORMULA-EXPR-INVALID) so convert_field, convert_metric, and both stash paths take their existing "no expr" path. Removes the two per-reader guards. - db_column_name: sanitize in _physical_columns_with_valid_db_names when physical_columns_by_prefix is built (TS-COLUMN-DB-NAME-INVALID), so the resolver, the field stash, and _physical_db_column_name all read it as absent instead of emitting ORDERS.42 or crashing json.dumps. - join on: only a truthy non-string is invalid, so falsy values keep their prior TS-JOIN-NO-CONDITION meaning; a truthy one is dropped under a distinct TS-JOIN-CONDITION-INVALID (not TS-JOIN-MALFORMED, which preserves the join), and the docstring is updated. - Strengthen the aggregation test to assert the NONE fallback. Refs apache#469
|
Thanks, this was a good catch across the board. You're right that guarding at each reader left the values reaching the stash and Formula
Join Join Tests. Rewrote them to run documents through Full ThoughtSpot suite: 977 passed on Python 3.10 and 3.12. The container-type cases and the |
kayemkim
left a comment
There was a problem hiding this comment.
The rework reads well, especially moving the formula check to where the dict is built so the four readers need nothing.
I ran the second revision merged onto current main through the thoughtspot workflow on 3.10 to 3.14, 977 passed. I also drove ten wrong-typed shapes through cli.main, to-ossie and then to-tml on its output, on main and on this branch. On main, expr as an int, a list, null or a YAML date, db_column_name as a date, on: 42, aggregation: [SUM] and data_type: [INT64] all end in a bare TypeError or AttributeError, and db_column_name: 42 exits 0 with nothing reported. On this branch each one exits 0 with the expected code and to-tml completes, and on: [] still reports TS-JOIN-NO-CONDITION.
One shape outside the five you scoped, for the follow-up list rather than this PR: a formula id that YAML parses as a date still raises on this branch, from _write_stash_safely into stash.write_stash's json.dumps, because the unsurfaced-formula stash carries the id verbatim.
jbonofre
left a comment
There was a problem hiding this comment.
LGTM! Thanks!
Just a note: I would have added an end-to-end test for data_type to test_tml_to_ossie_typed_values.py. We could do that as a follow-up not a big deal.
Summary
Refs #469. A hand-edited ThoughtSpot TML document can carry a value of the wrong Python type. During
to-ossieconversion those escaped as bareTypeError/AttributeError, breaking the converter's own "never a bare traceback" contract (the CLI only catchesConversionError/OSError/UnicodeDecodeError).#482 covered the column and model
namecases and explicitly listed the remainingto-ossiescalar reads as untouched. This PR adds a targeted type guard at each of those read boundaries, mirroring the surrounding degrade-and-continue pattern (log.add(code="TS-*", severity=WARNING, ...)then skip or fall back) rather than a broadexcept:aggregationas a list/dictTypeError(membership test on unhashable)TS-METRIC-AGGREGATION-UNKNOWN, treated asNONEexprwrong type (field)TypeErrorin the formula parserTS-FIELD-FORMULA-INVALID, field skippedexprwrong type (metric)TypeErrorin the formula parserTS-METRIC-FORMULA-INVALID, metric skippeddata_typewrong typeTypeError(unhashable indatatypes.to_ossie)TS-{KIND}-DATATYPE-UNMAPPED, no datatype emitteddb_column_namewrong typeTypeErrorinidentifiers.normaliseonwrong typeAttributeErroron.strip()TS-JOIN-MALFORMED, join skippedTests
New
tests/test_tml_to_ossie_typed_values.pycovers all six cases. Verified they fail with bare tracebacks onmainand pass with the guards. Full ThoughtSpot suite: 977 passed on Python 3.10 and 3.12 (971 existing + 6 new).Scope / follow-up
Deliberately scoped to the
to-ossiescalar reads #482 enumerated. The remaining #469 items — the wrong container type forcolumns/model_tables/properties/joindestination, and the entireto-tmlstash + schema-invalid direction — are a separate class and left for a follow-up PR.