Conversation
…ualified
`tml_to_ossie` emitted a field's ANSI_SQL sibling as `<dataset>.<warehouse
column>` -- `Dim_Customer.Customer_Name`. Both halves are real, but they come
from different namespaces, so the pair resolves in neither:
- the qualifier is the OSSIE DATASET name, which is ThoughtSpot's Table-object
name and need not be the warehouse table. 44 of 164 datasets across 31 real
models differ (dataset `Dim_Customer`, source
`NEWRETAIL.SMALLRETAIL.NewRetail_Customer_Dimension`), so it is not runnable
SQL -- there is no such table;
- the column half is the warehouse column, not the Ossie field identifier, so
it is not a resolvable logical reference either whenever the display name
differs from the column name.
Measured over the 31-model corpus: 211 of 612 references named nothing the
document declares, in 20 of 31 models, counting case-insensitively. Upstream's
validator cannot see it -- it checks that an expression parses as SQL, and
`Dim_Customer.Customer_Name` parses fine.
Every sibling converter writes a field's expression as the BARE warehouse
column: databricks emits `l_linenumber` for a field named `line_number` and
`d_year` for `sold_year`, nvidia emits `name` for `customer_name`, and gooddata,
omni and orionbelt do the same. The physical column was never the problem; the
qualifier was.
A METRIC keeps its qualifier, and the asymmetry is scope rather than
inconsistency. A field belongs to one dataset, whose `source` already names the
warehouse table, so a bare column is unambiguous. A metric is model-scoped and
may reference any dataset, so `SUM(amount)` stops being unambiguous as soon as
two datasets have an `amount`; nvidia qualifies for that reason
(`SUM(orders.subtotal)`). `expression_entries` already receives `kind`, so the
distinction needed no new plumbing.
`resolve` now returns `(dataset, column)` instead of `"dataset.column"`. Its two
callers want different halves -- one emits the column, the other reads the
dataset to attribute a formula -- and the joined string meant the second
re-parsed what the first then emitted whole.
Verified: 211 -> 0 unresolvable references across the corpus; 31/31 still pass
upstream's validator; and the returned TML is byte-identical for all 195
documents, since the reverse leg reads the THOUGHTSPOT dialect and never this
one. The two expected fixtures are regenerated -- 37 changed lines, all of them
`expression:`.
942 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@jbonofre both of your follow-up comments on #364 are addressed here, and the first one Your ANSI_SQL comment was right. A field's portable expression named the warehouse Verifying that turned up a second one. Metrics carried a
The remaining 128 metrics are formula-shaped and need real expression translation -- Your other comment, on One ask: the workflow run needs approving. No CI has run on this PR. 951 tests pass One thing worth knowing beyond this PR, because it is not converter-specific. A skipped check reported as a pass. Every "validator passed" figure I measured during this |
A ThoughtSpot display name is not automatically a valid SQL identifier. Names
carry spaces, colons, percent signs and parentheses, and emitted raw they do not
merely look wrong -- they do not parse. `SUM(cargo.Custom Clearance Time (min))`
and `SUM(HV: STORES.LATITUDE)` are both rejected by sqlglot, which is the parser
Apache's own `validation/validate.py` uses.
Per `core-spec/expression_language.md`, identifiers follow ANSI SQL naming and
the Ossie dialect's quote character is the double quote. A name that is not a
regular identifier is therefore double-quoted, with embedded quotes doubled.
Quoted only WHEN NEEDED, which is semantic rather than cosmetic: the spec notes
regular identifiers compare case-insensitively while quoted ones compare
verbatim, so quoting a name that does not need it changes how a consumer
matches it.
Measured over 31 real models with the validator's SQL checks actually running:
before 29 passed 2 failed 7 [SQL] findings
after 31 passed 0 failed 0 [SQL] findings
`sqlglot` joins the dev group, because none of that was visible without it.
`validation/validate.py` prints "sqlglot not installed, skipping SQL
validation" and then "Validation PASSED" -- a skipped check reported as a pass,
which is why these 7 findings sat unseen. The new parse test is
`importorskip`-guarded like its siblings; the dependency means it runs.
950 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
80cb8ba to
5153329
Compare
Withdrawing the metric half of this PRAn independent adversarial review found that the metric change was wrong, and I have removed it. This PR is now the field fix (#459) plus identifier quoting. #462 stays open; I no longer think the fix I proposed there is the right one. What was wrong. The metric commit emitted Why it matters more than it sounds. The reviewer ran every Ossie-to-vendor converter in the tree against the output. They split three ways:
On a name collision the first group sums the wrong column. Power BI emits I had justified the dataset qualifier by pointing at nvidia's What I missed, and why. My own round-trip harness measures unresolvable references by iterating a dataset's Where #462 goes now. The right fix is a design question rather than a patch: either MEASURE columns are declared as Ossie fields and metrics reference them by field name, or metrics keep no portable expression. That is the same namespace question the spec leaves open — The review also found several pre-existing defects in the merged converter, which I am filing separately. |
…ound trip
Ossie has one way to say "this metric does not aggregate"; TML has two -- the
`aggregation` key absent, or present with the value NONE. `_AGGREGATION["NONE"]`
is `None`, which is also what `properties.get("aggregation", "NONE")` yields for
an absent key, so both collapsed identically on the way in and the write side
could not tell them apart.
An explicit NONE therefore came back ABSENT, and ThoughtSpot applies its own
default to an absent key. A per-row ratio the author declared un-aggregated was
returned as a column ThoughtSpot rolls up: the sum of ratios instead of the
ratio. Exit 0 both directions, INFO-level logging only.
Recorded in the stash, which is the mechanism this converter already uses for a
ThoughtSpot value Ossie has nowhere to put.
Only where the aggregation is LOAD-BEARING: a raw column, or a formula that does
not already aggregate. On a formula whose outer call is an aggregate the column
property is a documented no-op, so NONE and absent genuinely do mean the same
thing there, and stashing it would put a payload on a document that needs none.
An existing test (`..._stashes_nothing`) catches that, and caught it here.
Recognising it has to happen in the `aggregation is None` branch rather than the
scalar branch below: an explicit NONE takes the first branch and never reaches
the second, which is what made the first attempt at this a no-op.
Part of apache#467. The OTHER half of that issue -- whether an ABSENT key should mean
SUM rather than NONE -- is NOT changed here. That rests on ThoughtSpot's
documented default, and neither this repo's TML schema reference nor the
developer documentation I can reach states one. It changes numbers, so it wants
a live confirmation rather than an inference; the issue records what was
checked.
Found by an independent adversarial review. Mutation-checked: removing the
restoration fails the new round-trip test.
952 tests pass; the 31-model corpus is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, not NONE Completes apache#467. ThoughtSpot's default for a MEASURE column with no `aggregation` key is SUM (confirmed by ThoughtSpot). This converter read absent as NONE, so a column the product sums came across as a raw per-row value -- a different number, silently. The default may only be APPLIED where this converter can see that nothing already aggregates, and a blanket default is unsafe. Composed everywhere, the tpcds fixture's `profit_margin` became sum ( [formula_total_profit] / [formula_total_sales] ) A ratio of two aggregates, wrapped in another one. That is the silent double-aggregation this converter exists to avoid, and it is invisible to `_contains_aggregate_call`, which cannot see through a formula cross-reference. So the default is applied to a raw column and to a genuinely scalar formula, and declined where the expression references other formulas -- with TS-METRIC-AGGREGATION-DEFAULT-UNRESOLVED naming them. A loud loss rather than a quiet wrong answer, which is this converter's standing preference. Two smaller corrections fall out: - TS-METRIC-AGGREGATION-ALREADY-AGGREGATED said "the column-level aggregation 'SUM' was ignored" for columns whose author wrote no aggregation at all. It now distinguishes a defaulted value from a declared one. - A DEFAULTED aggregation is no longer stashed for a bare `group_aggregate`. Preserving it would add an `aggregation` key where the source had none -- a byte the round trip should not invent -- and absent still means SUM on the way back, so nothing is lost by leaving it absent. Real exports are unaffected: 0 of 476 MEASURE columns across 31 real models omit the key, and the corpus output is byte-identical before and after. This bites hand-authored and minimal TML, which is exactly what a portable format invites. 952 tests pass; four new ones, including the cross-reference case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
||
| #: A regular ANSI SQL identifier: letter or underscore, then letters, digits or | ||
| #: underscores. Anything else has to be double-quoted to survive a SQL parser. | ||
| _REGULAR_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") |
There was a problem hiding this comment.
_REGULAR_IDENTIFIER doesn't check for SQL reserved keywords. Per ANSI SQL (and core-spec/expression_language.md), regular identifiers cannot be reserved words.
When a bare field column happens to be a SQL keyword like on (which is present in the TPC-DS fixture below), _sql_identifier leaves it unquoted as on. A query like SELECT on then fails SQL parsing.
Could we check name.upper() against a set of SQL reserved keywords here?
_SQL_RESERVED = frozenset({
"ALL", "AND", "ANY", "AS", "ASC", "BETWEEN", "BY", "CASE", "CHECK",
"CREATE", "CROSS", "DEFAULT", "DESC", "DISTINCT", "DROP", "ELSE",
"END", "EXCEPT", "EXISTS", "FALSE", "FOR", "FROM", "FULL", "GROUP",
"HAVING", "IN", "INNER", "INSERT", "INTERSECT", "INTO", "IS", "JOIN",
"LEFT", "LIKE", "LIMIT", "NOT", "NULL", "OFFSET", "ON", "OR", "ORDER",
"OUTER", "PRIMARY", "RIGHT", "SELECT", "SOME", "TABLE", "THEN", "TRUE",
"UNION", "UNIQUE", "USER", "USING", "WHEN", "WHERE", "WITH",
})
And in _sql_identifier:
if _REGULAR_IDENTIFIER.match(name) and name.upper() not in _SQL_RESERVED:
return name
return '"' + name.replace('"', '""') + '"'
| expression: '[store::on]' | ||
| - dialect: ANSI_SQL | ||
| expression: store.on | ||
| expression: 'on' |
There was a problem hiding this comment.
This fixture currently fails validation/validate.py when sqlglot is installed:
python validation/validate.py converters/thoughtspot/tests/fixtures/tpcds/expected.ossie.yaml
Output:
Validation FAILED with 1 error(s):
[SQL] Field 'store.on' in model 'tpcds_retail_model' (ANSI_SQL): Invalid expression / Unexpected token. Line 1, Col: 9.
Once _sql_identifier quotes reserved keywords, this should be expression: '"on"'.
| # that check silently skipped -- and so did the validator's, which reports | ||
| # "Validation PASSED" while printing "sqlglot not installed, skipping SQL | ||
| # validation". Guarded by pytest.importorskip, same discipline as the two below. | ||
| "sqlglot>=25.0", |
There was a problem hiding this comment.
Adding sqlglot to the dev group is a great improvement.
As a follow-up in tests/test_fixtures.py, test_expected_output_validates_against_the_upstream_schema currently only validates against ossie-schema.json and doesn't run SQL validation. If we also invoked validate_sql (or validation/validate.py) there when sqlglot is available, pytest would catch SQL parse errors in fixtures (like the store.on issue) directly during test runs.
| # The point of the exercise. Skipped rather than silently passing when | ||
| # sqlglot is absent -- the validator's own "not installed" path reports | ||
| # PASSED, which is how this defect survived unseen. | ||
| sqlglot = pytest.importorskip("sqlglot") |
There was a problem hiding this comment.
Could we add a test case here for a field whose warehouse column is a SQL reserved keyword (e.g. orders.on or orders.where) asserting that:
- It is quoted as
"on". sqlglot.parse_on(f"SELECT {portable}")parses successfully.
| # against the real definitions; this converter cannot, so it | ||
| # declines rather than guessing a wrapper around them. | ||
| log.add( | ||
| code="TS-METRIC-AGGREGATION-DEFAULT-UNRESOLVED", |
There was a problem hiding this comment.
Great catch and handling here. Declining to compose sum(...) around cross-referencing formulas and warning via TS-METRIC-AGGREGATION-DEFAULT-UNRESOLVED avoids silent double-aggregation bugs (sum([formula_a] / [formula_b])).
Fixes #459.
Two commits on the portable-expression surface. #462 is no longer in scope — see the comment below for why the metric half was withdrawn.
Commit 1 — a field's portable expression must not be dataset-qualified (#459)
A field's
ANSI_SQLexpression was<dataset>.<warehouse column>—Dim_Customer.Customer_Name. Both halves are real but come from different namespaces, so the pair resolves in neither: the qualifier is the Ossie dataset name (ThoughtSpot's Table-object name, which need not be the warehouse table — 44 of 164 datasets differ), while the column half is the warehouse column rather than the field identifier.211 of 612 references named nothing the document declares, across 20 of 31 models.
Every sibling converter writes a field's expression as the bare warehouse column — databricks
l_linenumberforline_number, nvidianameforcustomer_name, likewise gooddata, omni, orionbelt.Commit 2 — quote an identifier that is not a regular one
SUM(cargo.Custom Clearance Time (min))andSUM(HV: STORES.LATITUDE)are rejected by sqlglot, the parser Apache's validator uses. Percore-spec/expression_language.mdidentifiers follow ANSI SQL naming and the Ossie quote character is the double quote, so a non-regular identifier is quoted, embedded quotes doubled — and only when needed, since regular identifiers compare case-insensitively while quoted ones compare verbatim.With the validator's SQL checks actually running:
[SQL]findingssqlglotjoins the dev group.validation/validate.pyprintssqlglot not installed, skipping SQL validationand thenValidation PASSED— a skipped check reported as a pass, which is why those 7 findings sat unseen.Test plan
THOUGHTSPOTdialect🤖 Generated with Claude Code