You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@ossie.apache.org by "djwaldo (via GitHub)" <gi...@apache.org> on 2026/09/07 08:30:21 UTC
[PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
djwaldo opened a new pull request, #364:
URL: https://github.com/apache/ossie/pull/364
Adds `converters/thoughtspot/` — a bidirectional converter between ThoughtSpot's semantic model format (Model TML plus its Table and SQL View documents) and the Ossie semantic model.
Proposed in #285 (consolidating #269); `THOUGHTSPOT` was added to the `Dialect` enum in #351.
## Scope
| Ossie | ThoughtSpot |
|---|---|
| `datasets` | Model `model_tables[]` + a Table or SQL View document |
| `fields` | Model `columns[]` with `column_type: ATTRIBUTE` |
| `metrics` | Model `columns[]` with `column_type: MEASURE`, always via `formulas[]` |
| `relationships` | Model inline joins and Table `joins_with[]` |
| `custom_extensions[THOUGHTSPOT]` | everything with no Ossie home |
One structural note that shapes the whole design: **one Ossie semantic model corresponds to 1 + N TML documents**, not one file. The converter reads and writes the set.
## Expression handling — the design decision most likely to draw questions
ThoughtSpot's formula language is not SQL. It has its own syntax (`concat ( [a] , [b] )`, `[TABLE::Column]` references, `{ }` grouping) which no SQL dialect parses.
**A ThoughtSpot formula is carried verbatim under a `THOUGHTSPOT` dialect entry. A portable `ANSI_SQL` sibling is added only where the whole expression is a bare column reference — the one shape where portability is certain. No SQL dialect is ever re-rendered into another, in either direction.**
That is deliberate, and the reasoning is in `converters/thoughtspot/README.md`:
- The specification's own stated default is to pass unknown values through, and `dialects[]` exists precisely so an untranslatable expression can still travel tagged with the dialect it is valid in.
- No converter in this repository re-renders an expression across dialects. The Databricks converter carries expressions verbatim under its own vendor dialect and reads that first.
- Round-trip fidelity is therefore exact rather than best-effort: the return leg reads the verbatim entry.
`expressions/catalog.py` maps all 146 constructs the Ossie expression language defines to a ThoughtSpot rendering (108 direct, 37 via `sql_*` passthrough, 1 unmappable) and is used for `Ossie → TML`. `expressions/reverse.py` records which ThoughtSpot-native functions compose into a portable Ossie expression; it is present for testing and future use and is **not yet wired into the shipped `TML → Ossie` path** — the README says so explicitly rather than leaving it to be inferred.
## Reference documentation is generated from the code
`converters/thoughtspot/docs/` is produced by `tools/generate_reference_docs.py` from `CATALOG`, the reverse inventory, the datatype map and the vendor-payload key classification. A test compares the committed documents byte-for-byte against fresh generator output, so they cannot drift from the implementation.
## Testing
804 tests, offline, on Python 3.10–3.14.
- Both directions, unit-level.
- **Round-trip both ways** over two fixture sets, with preservation and translation asserted *separately* — the return leg reads the verbatim dialect entry, so a round trip alone proves preservation and says nothing about translation quality.
- The issue log is asserted as a first-class output: every declared loss must be reported *before* it happens, and nothing may be reported that did not occur.
- **Property-based** round trips (`hypothesis`, test-only) over deliberately awkward identifiers — YAML 1.1 boolean tokens, names colliding only after normalisation, non-ASCII, names containing `::`. This found two crashes the example fixtures never produced.
- Fixtures mirror `examples/tpcds_semantic_model.yaml` so this converter is comparable to its siblings; both expected documents validate against `core-spec/ossie-schema.json` and `validation/validate.py`.
## Dependencies
`PyYAML>=6.0` and nothing else at runtime. `hypothesis` and `jsonschema` are test-only.
## Provenance
All TML fixtures and all code are original, authored for this contribution. Nothing derives from ThoughtSpot's `thoughtspot_tml` library or any other existing converter.
## One question for reviewers
The relationship-level mapping and the key-derivation rule admit two readings of whether a `ONE_TO_MANY` cardinality inverts a relationship's orientation. The implementation keeps `from`/`to` structural and flips only the key-derivation candidate, which satisfies `validation/validate.py`. I would rather have that settled by someone who owns the specification than guess and encode the guess in tests.
## Note on history
81 commits, conventional-commit style. Happy to squash to a smaller set of logical commits if that is preferred for review.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "jbonofre (via GitHub)" <gi...@apache.org>.
jbonofre commented on PR #364:
URL: https://github.com/apache/ossie/pull/364#issuecomment-5788665721
@djwaldo thank you so much! I will do a new pass.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "djwaldo (via GitHub)" <gi...@apache.org>.
djwaldo commented on PR #364:
URL: https://github.com/apache/ossie/pull/364#issuecomment-5813194142
Your two reviews prompted a much wider pass. Seventeen commits since, and the most useful
thing in them did not come from more code review — it came from running converted models
against a live ThoughtSpot cluster.
**Round-tripped 30 real customer models** (161 tables, 1,471 columns, 202 formulas, 146 joins),
export → Ossie → TML. All 30 pass `validation/validate.py`, and no model loses a table, column
or join without saying so.
**Then imported one back into the cluster and compared the numbers.** A three-table joined
model, converted and re-imported, answers identically to its source — totals, five regions, a
formula-derived attribute, three distinct counts, to the last decimal.
That exercise found four defects that five rounds of adversarial review and 900+ tests had all
missed, because every one of them is invisible to document-level validation:
- **`with` was omitted from a referencing join.** Mandatory on every `model_tables[].joins[]`
entry. The document passes the Ossie schema, passes `validation/validate.py`, and ThoughtSpot
refuses it: *"Compulsory Field worksheet->model_tables(2nd)->joins(1st)->with is not
populated."* Nothing caught it because the hand-authored fixtures omitted `with` too — code
and tests shared one misunderstanding and agreed with each other.
- **A bare `group_aggregate(...)` lost its column `aggregation`.** For `sum(...)` and the
`group_sum` shorthands that property is genuinely inert, and the converter discarded it for
all of them. A bare `group_aggregate` is the exception: like a raw column, ThoughtSpot applies
it. A changed answer, silently.
- **Formulas that no `columns[]` entry surfaces were dropped.** These are hidden helpers other
formulas reference — a date-parameter model is the usual case. 32 of 41 formulas in one real
model, and the visible ones referencing them came back with dangling references.
- **`obj_id` was discarded** along with `guid` and `fqn`. It is not the same kind of thing: it
is the handle that survives import and lets a re-import update the object it came from instead
of duplicating it.
Also fixed since your review: a formula cross-reference rebinding to a *different* formula;
duplicate relationship names producing a document `validation/validate.py` rejects; a renamed
dataset dangling every reference to itself; a Table merged with a SQL View; display-name
collisions invented between names ThoughtSpot treats as distinct. 824 tests then, 936 now, every
fix pinned by a test that fails when it is reverted.
**Two things worth saying plainly.** Several of these defects were *asserted by our own tests* —
one sweep derived each row's expected arity from the row's own template, so nothing could ever
disagree with itself; another pinned its defect and wrote the misreading into a comment as fact.
And four of the defects fixed in later rounds were introduced by earlier rounds' fixes. The rate
is dropping and the suite is much harder to fool than it was, but I would not describe this as
finished, and I would rather say so than present seventeen commits as convergence.
CI needs approving again on the new head.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "jbonofre (via GitHub)" <gi...@apache.org>.
jbonofre commented on code in PR #364:
URL: https://github.com/apache/ossie/pull/364#discussion_r4071594408
##########
converters/thoughtspot/src/ossie_thoughtspot/expressions/catalog.py:
##########
@@ -0,0 +1,1931 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""The catalog: every specification construct mapped to a ThoughtSpot rendering.
+
+`CATALOG` is organised into families below — Aggregate functions, Type conversion,
+Date/time functions, String functions, Mathematical + Conditional functions, Operators
+and constructs, and Window functions — one block per family, together covering the full
+specification. `spec_construct_names()` is an oracle read from the **upstream**
+`core-spec/expression_language.md`: it independently reports every construct the
+specification defines, so a construct added upstream in the future fails this package's
+build instead of silently going unsupported (see `test_catalog_covers_the_spec.py`).
+
+Extraction approach
+--------------------
+The spec document mixes three kinds of content that must be told apart:
+
+1. Genuine constructs — a named function, operator or literal form the
+ specification defines, almost always as one row of a markdown table whose
+ row carries a `Syntax` column (e.g. `SUM(expr)`), or, for a handful of
+ operators/keywords with no table of their own, a row of the top-level
+ "Supported SQL Constructs" table.
+2. Argument vocabularies — the `EXTRACT`/`DATE_PART` parts, the `DATE_TRUNC`
+ precisions, the `TO_DATE`/`TO_CHAR` format tokens and the `CAST` target
+ types. These describe values an argument may take, not constructs in
+ their own right, and must be excluded.
+3. Informative tables — the per-engine "Common Dialect Variations" table and
+ the "Cross-Reference: Tool Mappings" section describe *other products'*
+ spellings (Tableau, Looker Studio, DAX, and per-engine SQL). Names that
+ appear only there are not Ossie constructs.
+
+The exclusions are keyed off the document's own structure — a table's own header naming
+and section heading text ("Not Supported in Expressions", "Common Dialect Variations",
+"Cross-Reference") — rather than a hardcoded list of names to drop, so a renamed or added
+upstream construct cannot go silently unnoticed. `_extract_tables()` only looks at lines
+starting with "|", so the argument-vocabulary bullet lists are simply invisible to it.
+
+Spelling: `CATALOG` keys must match `spec_construct_names()` exactly
+------------------------------------------------------------------------------
+`spec_construct_names()` is the oracle for a `CATALOG` key's exact spelling, not any
+mapping document's prose. Several rows write their Ossie-side syntax differently than
+this parser extracts it; a `CATALOG` entry keyed on a mapping document's own wording
+instead of this function's output will read as an "invented" construct even though it is
+a real, intended row. When adding or editing a row, run `spec_construct_names()` and
+match a member of it exactly, rather than transcribing another document's column text.
+Grouped by which extractor produces the divergent spelling:
+
+- **`_extract_tables()`** (an ordinary table with a `Syntax` column):
+ - Alias pairs the spec merges into ONE table row keep this parser's single
+ extracted spelling: `CEIL(x)` (not `CEIL(x) / CEILING(x)`), `TRUNC(x, d)`
+ (not `.../ TRUNCATE(x, d)`).
+ - Two-alternative-syntax rows keep the spec's own joining word, "or":
+ `"CURRENT_DATE or CURRENT_DATE()"`, `"CURRENT_TIMESTAMP or
+ CURRENT_TIMESTAMP()"`, `"CURRENT_TIME or CURRENT_TIME()"`.
+ - The merged boolean-literal row (`BOOLEAN`'s `Syntax` cell) is one entry,
+ comma-joined: `"TRUE, FALSE"`.
+ - The Boolean Functions table's `AND`/`OR` rows keep the spec's own
+ `expr1`/`expr2` placeholder names: `"expr1 AND expr2"`, `"expr1 OR expr2"`.
+- **`_extract_summary_rows()`** (the top-level "Supported SQL Constructs"
+ table, bare backtick token): `BETWEEN`, `IN`, `NOT IN`, `IS NULL`, `IS NOT NULL`,
+ `CASE WHEN`, and the raw symbols `+ - * / % = <> != < > <= >=`.
+- **`_extract_null_safe_comparison_operators()`** (the "Null-Safe Comparison"
+ code fence, not the summary table): `IS DISTINCT FROM`, `IS NOT DISTINCT FROM`.
+- **`_extract_extraction_syntax_functions()`** (the "Alternative Extraction
+ Syntax" code fence, bare token, no argument list): `EXTRACT`, `DATE_PART`.
+- **`_extract_single_construct_headings()`** (a standalone heading with no
+ table, bare token): `CAST`, `TRY_CAST` (not `CAST(expression AS target_type)`).
+
+See `CONVENTION_DIVERGENCES` below for the (much shorter) list of constructs that have
+no entry in `spec_construct_names()` at all and are exempted instead.
+"""
+import re
+from pathlib import Path
+
+from ._types import Classification, Construct, Variant
+
+# --------------------------------------------------------------------------
+# CATALOG: organised into families, one block per family below.
+# --------------------------------------------------------------------------
+CATALOG: dict[str, Construct] = {}
+
+# --------------------------------------------------------------------------
+# Aggregate functions — 18 rows: 12 direct / 6 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Aggregate functions" section
+# (thoughtspot-agent-skills repo — not vendored here; prose above/below the table
+# read in full).
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "SUM(expr)": Construct(
+ "SUM(expr)", Classification.DIRECT, template="sum ( {0} )",
+ ),
+ "COUNT(expr)": Construct(
+ "COUNT(expr)", Classification.DIRECT, template="count ( {0} )",
+ note="Counts non-null values on both sides.",
+ ),
+ "COUNT(*)": Construct(
+ "COUNT(*)", Classification.DIRECT, template="count ( {0} )",
+ note=(
+ "ThoughtSpot has no count(*); the row count is count() over a column "
+ "known to be non-null. The converter uses the dataset's primary_key "
+ "when the model declares one, and raises an issue rather than "
+ "guessing a column when it does not."
+ ),
+ ),
+ "COUNT(DISTINCT expr)": Construct(
+ "COUNT(DISTINCT expr)", Classification.DIRECT, template="unique count ( {0} )",
+ note=(
+ "A space, not an underscore. count_distinct(...) is rejected by the "
+ "formula parser."
+ ),
+ ),
+ "AVG(expr)": Construct(
+ "AVG(expr)", Classification.DIRECT, template="average ( {0} )",
+ ),
+ "MIN(expr)": Construct(
+ "MIN(expr)", Classification.DIRECT, template="min ( {0} )",
+ note=(
+ "ThoughtSpot min is aggregate-only — it never compares two columns "
+ "row-wise. Scalar two-argument minima are LEAST, a separate row."
+ ),
+ ),
+ "MAX(expr)": Construct(
+ "MAX(expr)", Classification.DIRECT, template="max ( {0} )",
+ note="Aggregate-only, as MIN.",
+ ),
+ "STDDEV(expr)": Construct(
+ "STDDEV(expr)", Classification.DIRECT, template="stddev ( {0} )",
+ note="Sample standard deviation on both sides.",
+ ),
+ "STDDEV_POP(expr)": Construct(
+ "STDDEV_POP(expr)", Classification.PASSTHROUGH,
+ template="STDDEV_POP({0})", variant=Variant.NUMBER_AGGREGATE,
+ note=(
+ "ThoughtSpot stddev is sample-only; there is no population form, and "
+ "substituting it would change the divisor from n-1 to n."
+ ),
+ ),
+ "STDDEV_SAMP(expr)": Construct(
+ "STDDEV_SAMP(expr)", Classification.DIRECT, template="stddev ( {0} )",
+ note="Specification alias for STDDEV (:171).",
+ ),
+ "VARIANCE(expr)": Construct(
+ "VARIANCE(expr)", Classification.DIRECT, template="variance ( {0} )",
+ note="Sample variance on both sides.",
+ ),
+ "VAR_POP(expr)": Construct(
+ "VAR_POP(expr)", Classification.PASSTHROUGH,
+ template="VAR_POP({0})", variant=Variant.NUMBER_AGGREGATE,
+ note="Same divisor reason as STDDEV_POP.",
+ ),
+ "VAR_SAMP(expr)": Construct(
+ "VAR_SAMP(expr)", Classification.DIRECT, template="variance ( {0} )",
+ note="Specification alias for VARIANCE (:174).",
+ ),
+ "MEDIAN(expr)": Construct(
+ "MEDIAN(expr)", Classification.DIRECT, template="median ( {0} )",
+ ),
+ "PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY expr)": Construct(
+ "PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY expr)", Classification.PASSTHROUGH,
+ template="PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY {0})",
+ variant=Variant.NUMBER_AGGREGATE,
+ note=(
+ "No native percentile function. p is a literal in the specification's "
+ "syntax, so it is baked into the template rather than passed as a "
+ "placeholder. p = 0.5 is the one case with a native equivalent — "
+ "median ( [x] ) — and the converter should prefer it."
+ ),
+ ),
+ "PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY expr)": Construct(
+ "PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY expr)", Classification.PASSTHROUGH,
+ template="PERCENTILE_DISC(0.75) WITHIN GROUP (ORDER BY {0})",
+ variant=Variant.NUMBER_AGGREGATE,
+ note=(
+ "As PERCENTILE_CONT; the discrete/interpolated distinction is "
+ "preserved only because the template is emitted verbatim."
+ ),
+ ),
+ "APPROX_COUNT_DISTINCT(expr)": Construct(
+ "APPROX_COUNT_DISTINCT(expr)", Classification.PASSTHROUGH,
+ template="APPROX_COUNT_DISTINCT({0})", variant=Variant.INT_AGGREGATE,
+ note=(
+ "ThoughtSpot's unique count ( [x] ) is the exact-semantics "
+ "alternative: same answer to within the sketch's ~2% error, at "
+ "exact-count cost. The converter emits the pass-through by default "
+ "— the specification chose approximate deliberately — and offers "
+ "the exact form as a documented downgrade."
+ ),
+ ),
+ "APPROX_PERCENTILE(expr, p)": Construct(
+ "APPROX_PERCENTILE(expr, p)", Classification.PASSTHROUGH,
+ template="APPROX_PERCENTILE({0}, 0.5)", variant=Variant.NUMBER_AGGREGATE,
+ note="p baked into the template as for the exact percentiles.",
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# Type conversion — 2 rows: 2 direct / 0 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Type conversion" section.
+#
+# CAST/TRY_CAST are direct rows whose target-type argument vocabulary is only
+# partly covered (5 of 8 types direct, 3 fall back to a pass-through) — the
+# per-type dispatch is not counted as its own construct (the target-type
+# table is an argument vocabulary, marked "not counted" in the mapping
+# document) and is not resolved here. Resolving a
+# `CAST` occurrence to an actual formula from its target type would need an
+# expression parser, which is out of scope, and whether to take on a sqlglot
+# dependency for that is still unresolved; `template` records the document's
+# own ThoughtSpot-column text for traceability rather than a
+# directly-substitutable formula.
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "CAST": Construct(
+ "CAST", Classification.DIRECT,
+ template="per-type — see the target-type table below",
+ note=(
+ "5 of the 8 specified target types are direct; the other three — "
+ "BOOLEAN, TIMESTAMP and TIME — fall back to a pass-through."
+ ),
+ ),
+ "TRY_CAST": Construct(
+ "TRY_CAST", Classification.DIRECT,
+ template="the same functions as CAST",
+ note=(
+ "ThoughtSpot's to_integer / to_double / to_string already return "
+ "NULL on failure, which is exactly TRY_CAST semantics — so the two "
+ "rows share a mapping and it is CAST, not TRY_CAST, that is the "
+ "imprecise one. A strict CAST that must error rather than null is "
+ "not expressible; the converter records that in the issue log when "
+ "the source distinguishes them."
+ ),
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# Date/time functions — 24 rows: 17 direct / 7 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Date/time functions" section
+# (thoughtspot-agent-skills repo — not vendored here; prose above/below the table
+# read in full).
+#
+# EXTRACT/DATE_PART date-parts, DATE_TRUNC precisions, DATEADD/DATEDIFF parts and
+# TO_DATE/TO_CHAR format tokens are argument vocabularies and get no
+# entry of their own (see the mapping document's "(not counted — arguments)"
+# sub-tables). EXTRACT, DATE_PART, DATE_TRUNC(part, date_expr),
+# DATEADD(part, amount, date_expr) and DATEDIFF(part, start_date, end_date) are
+# themselves still DIRECT rows in the 24 — the per-argument dispatch happens for
+# each, but the dispatch table itself is out of catalog scope (same pattern as
+# CAST/TRY_CAST in Type conversion above): `template` records the mapping document's own
+# ThoughtSpot-column text for traceability, and the real per-argument content
+# (which native function each part/precision rewrites to, and the argument-order
+# caveats) is recorded in `note`.
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "CURRENT_DATE or CURRENT_DATE()": Construct(
+ "CURRENT_DATE or CURRENT_DATE()", Classification.DIRECT, template="today ( )",
+ note="Both specification spellings map to the same function.",
+ ),
+ "CURRENT_TIMESTAMP or CURRENT_TIMESTAMP()": Construct(
+ "CURRENT_TIMESTAMP or CURRENT_TIMESTAMP()", Classification.DIRECT, template="now ( )",
+ ),
+ "CURRENT_TIME or CURRENT_TIME()": Construct(
+ "CURRENT_TIME or CURRENT_TIME()", Classification.DIRECT,
+ template="time ( now ( ) )",
+ note=(
+ "ThoughtSpot has no current-time function, but time ( ) extracts "
+ "the time part of a datetime, so the composition is exact."
+ ),
+ ),
+ "YEAR(date_expr)": Construct(
+ "YEAR(date_expr)", Classification.DIRECT, template="year ( {0} )",
+ ),
+ "QUARTER(date_expr)": Construct(
+ "QUARTER(date_expr)", Classification.DIRECT, template="quarter_number ( {0} )",
+ note="The function is quarter_number, not quarter.",
+ ),
+ "MONTH(date_expr)": Construct(
+ "MONTH(date_expr)", Classification.DIRECT, template="month_number ( {0} )",
+ note=(
+ "Not month ( ) — ThoughtSpot's month returns the month NAME "
+ "('January'); month_number returns 1-12, which is what the "
+ "specification means. Mapping to month would silently change "
+ "the column's type from integer to string."
+ ),
+ ),
+ "DAY(date_expr)": Construct(
+ "DAY(date_expr)", Classification.DIRECT, template="day ( {0} )",
+ note="Day of month, 1-31 on both sides.",
+ ),
+ "DAYOFYEAR(date_expr)": Construct(
+ "DAYOFYEAR(date_expr)", Classification.DIRECT,
+ template="day_number_of_year ( {0} )",
+ note="The function is day_number_of_year, not day_of_year.",
+ ),
+ "HOUR(timestamp_expr)": Construct(
+ "HOUR(timestamp_expr)", Classification.DIRECT, template="hour_of_day ( {0} )",
+ note="The function is hour_of_day, not hour.",
+ ),
+ "MINUTE(timestamp_expr)": Construct(
+ "MINUTE(timestamp_expr)", Classification.PASSTHROUGH,
+ template="MINUTE({0})", variant=Variant.INT,
+ note=(
+ "No native minute-of-hour extractor; add_minutes and "
+ "diff_minutes exist but neither extracts."
+ ),
+ ),
+ "SECOND(timestamp_expr)": Construct(
+ "SECOND(timestamp_expr)", Classification.PASSTHROUGH,
+ template="SECOND({0})", variant=Variant.INT,
+ note="As MINUTE.",
+ ),
+ "EXTRACT": Construct(
+ "EXTRACT", Classification.DIRECT,
+ template="per-part — see the date-part table below",
+ note=(
+ "Rewritten to the part's own ThoughtSpot function; there is no "
+ "generic extractor. 8 of the 11 specified parts are direct "
+ "(YEAR->year, QUARTER->quarter_number, MONTH->month_number, "
+ "WEEK->week_number_of_year, DAY->day, "
+ "DAYOFWEEK->day_number_of_week, DAYOFYEAR->day_number_of_year, "
+ "HOUR->hour_of_day); MINUTE, SECOND and MILLISECOND fall back "
+ "to sql_int_op."
+ ),
+ ),
+ "DATE_PART": Construct(
+ "DATE_PART", Classification.DIRECT,
+ template="per-part — see the date-part table below",
+ note="Identical treatment to EXTRACT; the two spellings collapse onto one rewrite (:276-279).",
+ ),
+ "DATE_TRUNC(part, date_expr)": Construct(
+ "DATE_TRUNC(part, date_expr)", Classification.DIRECT,
+ template="per-precision — see the truncation table below",
+ note=(
+ "ThoughtSpot has no date_trunc. The start_of_* family covers 7 "
+ "of the 8 specified precisions ('year'->start_of_year, "
+ "'quarter'->start_of_quarter, 'month'->start_of_month, "
+ "'week'->start_of_week, 'day'->date ( ), 'hour'->start_of_hour, "
+ "'minute'->start_of_min — the function is start_of_min, not "
+ "start_of_minute); 'second' falls back to sql_date_time_op. "
+ "The specification says week truncation is Monday-start; "
+ "ThoughtSpot's week start is an instance setting, so the "
+ "converter verifies alignment and raises an issue when it cannot."
+ ),
+ ),
+ "DATEADD(part, amount, date_expr)": Construct(
+ "DATEADD(part, amount, date_expr)", Classification.DIRECT,
+ template="per-part add_* — see the arithmetic table below",
+ note=(
+ "Argument order differs: ThoughtSpot is add_days ( [d] , n ), "
+ "the specification is DATEADD(day, n, d). Every specified part "
+ "is reachable: day->add_days, week->add_weeks, "
+ "month->add_months, year->add_years, minute->add_minutes, "
+ "second->add_seconds, plus two by arithmetic on a coarser unit "
+ "since there is no native add_quarters or add_hours: "
+ "quarter->add_months ( [d] , 3 * n ), "
+ "hour->add_minutes ( [d] , 60 * n )."
+ ),
+ ),
+ "DATEDIFF(part, start_date, end_date)": Construct(
+ "DATEDIFF(part, start_date, end_date)", Classification.DIRECT,
+ template="per-part diff_* — see the arithmetic table below",
+ note=(
+ "Argument order is reversed: ThoughtSpot is "
+ "diff_days ( [end] , [start] ) — end first. Getting this wrong "
+ "silently negates every duration in the model. day->diff_days, "
+ "week->diff_weeks, month->diff_months, quarter->diff_quarters, "
+ "year->diff_years, hour->diff_hours, minute->diff_minutes, "
+ "second->diff_time (returns seconds)."
+ ),
+ ),
+ "DATE '2024-01-15'": Construct(
+ "DATE '2024-01-15'", Classification.DIRECT,
+ template="to_date ( '{0}' , 'yyyy-MM-dd' )",
+ note=(
+ "A bare '2024-01-15' in a ThoughtSpot formula is parsed as "
+ "arithmetic (2024 - 1 - 15), so the typed literal must always "
+ "be wrapped. to_date takes exactly two arguments, so the "
+ "converter supplies the ISO format model; {0} is the literal "
+ "date string."
+ ),
+ ),
+ "TIMESTAMP_NTZ '2024-01-15 10:30:00'": Construct(
+ "TIMESTAMP_NTZ '2024-01-15 10:30:00'", Classification.PASSTHROUGH,
+ template="CAST('2024-01-15 10:30:00' AS TIMESTAMP)",
+ variant=Variant.DATE_TIME,
+ note=(
+ "to_date returns a DATE and drops the time part, so there is "
+ "no native way to construct a wall-clock timestamp. A "
+ "zero-placeholder template is a documented form of the "
+ "pass-through — the document's own worked example is recorded "
+ "verbatim here; a real occurrence's literal value is "
+ "substituted per-occurrence when the template is built, out of "
+ "this catalog's scope (same as CAST's per-type dispatch)."
+ ),
+ ),
+ "TIME '10:30:00'": Construct(
+ "TIME '10:30:00'", Classification.PASSTHROUGH,
+ template="CAST('10:30:00' AS TIME)",
+ variant=Variant.DATE_TIME,
+ note=(
+ "ThoughtSpot has no TIME column type — time ( ) extracts a "
+ "time FROM a datetime, it does not construct one — so the "
+ "pass-through returns DATETIME and the date part is whatever "
+ "the warehouse defaults to. Flagged with an issue for that "
+ "reason, not only for the dialect."
+ ),
+ ),
+ "TO_DATE(string)": Construct(
+ "TO_DATE(string)", Classification.DIRECT,
+ template="to_date ( {0} , 'yyyy-MM-dd' )",
+ note=(
+ "The single-argument ISO form. ThoughtSpot's to_date is "
+ "strictly two-argument, so the converter supplies 'yyyy-MM-dd'."
+ ),
+ ),
+ "TO_TIMESTAMP(string)": Construct(
+ "TO_TIMESTAMP(string)", Classification.PASSTHROUGH,
+ template="TO_TIMESTAMP({0})", variant=Variant.DATE_TIME,
+ note="to_date is date-only; parsing to a timestamp would drop the time silently.",
+ ),
+ "TO_DATE(string, format)": Construct(
+ "TO_DATE(string, format)", Classification.DIRECT,
+ template="to_date ( {0} , <translated format> )",
+ note=(
+ "EXPERIMENTAL. Format tokens are translated, not passed "
+ "through — see the format-token table. ThoughtSpot accepts "
+ "Java/LDML tokens (yyyy-MM-dd) and strptime %-codes, which "
+ "between them cover the specification's entire portable core."
+ ),
+ ),
+ "TO_TIMESTAMP(string, format)": Construct(
+ "TO_TIMESTAMP(string, format)", Classification.PASSTHROUGH,
+ template="TO_TIMESTAMP({0}, 'YYYY-MM-DD HH24:MI:SS')",
+ variant=Variant.DATE_TIME,
+ note=(
+ "EXPERIMENTAL. Date-only to_date again. The format model "
+ "inside the template is the warehouse's, not Ossie's, so the "
+ "token translation table does not apply — this is the "
+ "sharpest case of the pass-through caveat."
+ ),
+ ),
+ "TO_CHAR(date_expr, format)": Construct(
+ "TO_CHAR(date_expr, format)", Classification.PASSTHROUGH,
+ template="TO_CHAR({0}, 'YYYY-MM')", variant=Variant.STRING,
+ note=(
+ "EXPERIMENTAL. ThoughtSpot has no general date formatter. "
+ "Single-token formats do have native equivalents and the "
+ "converter prefers them: 'YYYY' -> year_name ( [d] ), "
+ "'MONTH' -> month ( [d] ), 'DAY' -> day_of_week ( [d] ). Those "
+ "three return locale-dependent text on both sides."
+ ),
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# String functions — 21 rows: 10 direct / 11 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "String functions" section
+# (thoughtspot-agent-skills repo — not vendored here; prose above/below the table
+# read in full).
+#
+# This family is over half passthrough, and the reasons run against intuition
+# rather than with it: LOWER/UPPER/TRIM/LTRIM/RTRIM/REPLACE are passthrough not
+# because they behave differently in ThoughtSpot but because ThoughtSpot has no
+# native equivalent at all (live-verified 2026-07-29: TRIM and REPLACE were
+# rejected with "Search did not find ..."). STARTSWITH/ENDSWITH run the other
+# way: also no native function, but their compositions use only native
+# functions (strpos/substr/strlen), so they stay direct. There is no
+# regular-expression support of any kind, so every REGEXP_* row is passthrough
+# with no native fallback.
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "CONCAT(str1, str2, ...)": Construct(
+ "CONCAT(str1, str2, ...)", Classification.DIRECT,
+ template="concat ( {0} , {1} , ... )",
Review Comment:
The `DIRECT` template for `CONCAT`/`GREATEST`/`LEAST` embed a literal `...` token that `emit_direct` `str.format` never fills. Every real call (including the base 2-argument case) renders with a stray literal in the output, e.g. `concat ( [T::First] , [T::Last] , ... )`, which is invalid ThoughSpot syntax (I believe, but you know better than me 😄 ). The catalog-wide sweep test only checks that `emit_direct` doesn't raise, so this has no coverage that would catch it.
I think it needs a template that actually varies with arg count, or a dedicated N renderer instead of a fixed `DIRECT` string.
Thoughts?
##########
converters/thoughtspot/src/ossie_thoughtspot/tml_to_ossie.py:
##########
@@ -0,0 +1,2347 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Convert a ThoughtSpot Model column into an Ossie field.
+
+A ThoughtSpot Model `columns[]` entry becomes an Ossie field when it declares
+`column_type: ATTRIBUTE`. A `MEASURE` column belongs to a metric instead, converted
+elsewhere — this module returns `None` for one rather than building a field that would
+duplicate the metric conversion.
+
+Four properties of the mapping are easy to get subtly wrong, because getting them wrong
+still produces a document that imports and looks plausible.
+
+The formula string carried in the THOUGHTSPOT dialect entry is the exact `expr` text from
+the source document, untouched — never rebuilt from a parsed name/arguments shape. The
+reverse direction reads this entry, and the two are compared by exact string equality, so
+any reformatting here — even whitespace-only — breaks that comparison on the way back,
+regardless of how well the rest of the conversion went.
+
+A portable ANSI_SQL sibling is only ever added next to that verbatim entry, and only when
+it can be produced with certainty rather than a guess: a bare column reference is the one
+shape handled here. Anything else — a function call, an expression combining several
+references, a reference this document's resolver cannot place — is left as
+THOUGHTSPOT-only, with an issue recording why, rather than emitting a translation nobody
+checked.
+
+A field's identifier and its display label are two different values. `name` is a
+normalised, portable identifier derived from the ThoughtSpot column's display name;
+`label` carries that display name exactly as written. Writing the display name into
+`name`, or the normalised form into `label`, silently breaks both. When the display
+name has no ASCII form at all for `identifiers.normalise` to fold onto (a CJK-only,
+Cyrillic-only, or Greek-only name), `name` falls back to a different, still-usable
+identifier instead of raising — see `_field_or_metric_identifier` — while `label`
+still carries the exact original, unaffected either way.
+
+Finally, a computed column is model-scoped in ThoughtSpot but has to live inside exactly
+one dataset in Ossie. It is attributed to the dataset every one of its column references
+resolves to. When those references disagree — two or more different datasets, one that
+cannot be resolved at all, or none at all to go on — no dataset is obviously correct, so
+none is guessed: the attribution fails, an issue records why, and the field is not built.
+Preserving the formula for a caller's model-level stash is that caller's job from there —
+this module only sees one column at a time and has no access to the enclosing document.
+
+A `MEASURE` column becomes a metric instead of a field, built by `convert_metric` below.
+Its one genuinely tricky rule is easy to get backwards in a way that still imports cleanly
+and produces wrong numbers: the surfacing column's `aggregation` is load-bearing on a
+`column_id` metric and on a *scalar*-formula metric (the two compose — `AGG(<scalar
+expr>)`, never the bare scalar) but a no-op on a formula whose own outer call already
+aggregates (`sum ( ... )`, `group_aggregate ( ... )`, ...) — a common, correct shape
+ThoughtSpot's UI produces routinely, so discarding a redundant column aggregation there
+is silent by design. Composing when the rule says no-op, or leaving bare when the rule
+says compose, silently changes the grain the metric evaluates at while the model still
+imports. There is a third, rarer case an outer-call check alone cannot see: an aggregate
+*nested inside* a still-scalar outer call, as in `round ( sum ( ... ) , 2 )` — `round` is
+not itself an aggregate, but the expression as a whole already is one. That case is the
+one worth a warning, because it is the one shape where a reader might reasonably expect
+composition and not get it. `_outer_call_is_aggregate` decides the first two cases;
+`_contains_aggregate_call` — checked only once the outer call is not itself an aggregate —
+decides the third. Both read ThoughtSpot's aggregate call names off the same expression
+catalog `_compose_aggregate_entries` uses to build the composed rendering — one source for
+every one of these jobs, so they cannot silently drift apart the way independently
+hand-typed lists
+could. And unlike a field, a metric has no `label`: when identifier normalisation changes the
+identifier, the exact display name has nowhere to go but the `custom_extensions` stash.
+"""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Callable
+
+from . import datatypes, formula, identifiers, keys, stash
+from .constants import (
+ DATASET_STASH_ALIAS,
+ DATASET_STASH_CONNECTION_NAME,
+ DATASET_STASH_SOURCE_PARTS,
+ DATASET_STASH_SOURCE_PARTS_DB,
+ DATASET_STASH_SOURCE_PARTS_DB_TABLE,
+ DATASET_STASH_SOURCE_PARTS_SCHEMA,
+ DATASET_STASH_SQL_OUTPUT_COLUMNS,
+ DATASET_STASH_TABLE_NAME,
+ DATASET_STASH_TML_OBJECT,
+ DATASET_STASH_TML_OBJECT_WITNESS,
+ DATASET_STASH_UNSURFACED_COLUMNS,
+ DIALECT,
+ DOCUMENT_VERSION,
+ FIELD_STASH_COLUMN_PROPERTIES,
+ FIELD_STASH_DATA_TYPE,
+ FIELD_STASH_DATA_TYPE_WITNESS,
+ FIELD_STASH_DB_COLUMN_NAME,
+ FIELD_STASH_DB_COLUMN_NAME_WITNESS,
+ METRIC_SHAPE_COLUMN_AGGREGATION,
+ METRIC_SHAPE_FORMULA,
+ METRIC_SHAPE_SCALAR_FORMULA_PLUS_AGGREGATION,
+ METRIC_STASH_SHAPE,
+ MODEL_STASH_ACTION_OBJECT_ASSOCIATIONS,
+ MODEL_STASH_COLUMN_GROUPS,
+ MODEL_STASH_CONSTRAINTS,
+ MODEL_STASH_FILTERS,
+ MODEL_STASH_LESSON_PLANS,
+ MODEL_STASH_MODEL_JOINS_WITH,
+ MODEL_STASH_MODEL_PROPERTIES,
+ MODEL_STASH_PARAMETERS,
+ MODEL_STASH_UNATTRIBUTED_FORMULAS,
+ MODEL_STASH_UNREPRESENTABLE_JOINS,
+ PORTABLE_DIALECT,
+ RELATIONSHIP_STASH_CARDINALITY,
+ RELATIONSHIP_STASH_ENDPOINTS_SWAPPED,
+ RELATIONSHIP_STASH_ENDPOINTS_SWAPPED_WITNESS,
+ RELATIONSHIP_STASH_JOIN_SHAPE,
+ RELATIONSHIP_STASH_ON_EXPRESSION,
+ RELATIONSHIP_STASH_ON_EXPRESSION_WITNESS,
+ RELATIONSHIP_STASH_REFERENCING_JOIN,
+ RELATIONSHIP_STASH_TYPE,
+ STASH_TML_NAME,
+)
+from .errors import ConversionError
+from .expressions import CATALOG, Variant, emit_direct
+from .issues import IssueLog, Severity
+from .tml import DocumentSet
+
+
+def expression_entries(
+ expr: str,
+ resolve: Callable[[str, str], str | None],
+ log: IssueLog,
+ *,
+ object_ref: str,
+ kind: str = "field",
+) -> list[dict[str, str]]:
+ """The dialect entries for one ThoughtSpot expression.
+
+ The THOUGHTSPOT entry always comes first and always carries `expr` unmodified —
+ whatever else this function decides, that entry is what makes the expression
+ recoverable later, character for character. A second, ANSI_SQL entry is appended
+ only when the whole expression is a bare column reference the resolver can place in
+ a dataset. Every other shape — a runtime parameter, a formula cross-reference, an
+ unresolvable reference, a function call, a compound expression — gets an issue
+ instead of a guessed translation, and only the verbatim entry is returned.
+
+ A runtime parameter and a formula cross-reference are the same textual shape (a
+ bracketed name with no `::` qualifier) but different constructs, and are reported
+ under different codes for it: `formula.find_formula_refs`/`find_parameter_refs`
+ share one definition of which is which (`formula.FORMULA_REFERENCE_PREFIX`) so this
+ function and the Ossie -> TML model builder — which mints exactly this prefix and
+ rewrites references that carry it — cannot silently disagree about the convention.
+ Both are logged when both are present in the same expression, each under its own
+ code, rather than one masking the other.
+
+ `kind` names the Ossie object this expression belongs to ("field" or "metric") —
+ used only in issue text, so a metric's non-portability issue reads "...evaluate
+ this metric" rather than the field-shaped default. `object_ref` already carries
+ this distinction (`field:...` vs `metric:...`); `kind` exists so the message
+ itself agrees with it instead of contradicting it.
+ """
+ entries: list[dict[str, str]] = [{"dialect": DIALECT, "expression": expr}]
+
+ # `dict.fromkeys` dedupes while preserving first-seen order -- a
+ # parameter or cross-reference used twice in one expression (a
+ # discount applied on both sides of a ratio, say) is one fact worth
+ # reporting once, not a message that reads as two distinct unresolved
+ # names when only one name repeats.
+ formula_refs = list(dict.fromkeys(formula.find_formula_refs(expr)))
+ parameters = list(dict.fromkeys(formula.find_parameter_refs(expr)))
+ if formula_refs or parameters:
+ if formula_refs:
+ log.add(
+ code="TS-EXPR-FORMULA-REFERENCE",
+ severity=Severity.INFO,
+ message=(
+ f"expression references the formula(s) {', '.join(formula_refs)} "
+ f"by cross-reference; a portable sibling would require inlining "
+ f"the referenced formula's own expression, which this converter "
+ f"does not attempt, so only the THOUGHTSPOT dialect entry is "
+ f"emitted"
+ ),
+ object_ref=object_ref,
+ )
+ if parameters:
+ log.add(
+ code="TS-EXPR-PARAM",
+ severity=Severity.WARNING,
+ message=(
+ f"expression references the ThoughtSpot runtime parameter(s) "
+ f"{', '.join(parameters)}, which have no Ossie equivalent; no "
+ f"portable expression is emitted"
+ ),
+ object_ref=object_ref,
+ )
+ return entries
+
+ bare = formula.is_bare_column_ref(expr)
+ if bare is not None:
+ target = resolve(*bare)
+ if target is None:
+ log.add(
+ code="TS-EXPR-UNRESOLVED",
+ severity=Severity.WARNING,
+ message=(
+ f"reference {identifiers.format_column_ref(*bare)} resolves to "
+ f"no dataset field; no portable expression is emitted"
+ ),
+ object_ref=object_ref,
+ )
+ return entries
+ entries.append({"dialect": PORTABLE_DIALECT, "expression": target})
+ return entries
+
+ # Anything else is a function call or a multi-reference expression. Producing a
+ # portable sibling for one would need an expression tree this converter does not
+ # build — see the module docstring — so the non-portability is recorded instead
+ # of guessed at.
+ log.add(
+ code="TS-EXPR-THOUGHTSPOT-ONLY",
+ severity=Severity.INFO,
+ message=(
+ "expression is emitted in the THOUGHTSPOT dialect only; a consumer that "
+ f"does not implement it will not be able to evaluate this {kind}"
+ ),
+ object_ref=object_ref,
+ )
+ return entries
+
+
+def attribute_dataset(
+ expr: str,
+ resolve: Callable[[str, str], str | None],
+ log: IssueLog,
+ *,
+ object_ref: str,
+) -> str | None:
+ """The single dataset every column reference in `expr` resolves to, or `None`.
+
+ A computed column has to be placed inside exactly one Ossie dataset. That is safe
+ only when every reference in the expression agrees on the same one: no references
+ at all is no evidence to place it by, a reference the resolver cannot place is
+ missing evidence, and references landing in two or more datasets is contradictory
+ evidence. Each of those returns `None` and logs why, rather than falling back to
+ the first candidate found or any other default — a wrong guess here would silently
+ move a field into the wrong dataset, or invent a home for one that references
+ nothing at all.
+
+ Runtime parameter references carry no dataset and take no part in this decision;
+ an expression can be fully attributed while still not being portable, and
+ `expression_entries` is what reports the latter.
+ """
+ refs = formula.find_column_refs(expr)
+ if not refs:
+ log.add(
+ code="TS-FIELD-NO-REFERENCES",
+ severity=Severity.WARNING,
+ message=(
+ "expression contains no column references, so it cannot be "
+ "attributed to a dataset"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ datasets: list[str] = []
+ unresolved: list[str] = []
+ for table, column in refs:
+ target = resolve(table, column)
+ if target is None:
+ unresolved.append(identifiers.format_column_ref(table, column))
+ continue
+ dataset = target.split(".", 1)[0]
+ if dataset not in datasets:
+ datasets.append(dataset)
+
+ if unresolved:
+ log.add(
+ code="TS-FIELD-UNRESOLVED-REFERENCE",
+ severity=Severity.WARNING,
+ message=(
+ f"reference(s) {', '.join(unresolved)} resolve to no dataset field; "
+ f"the expression cannot be attributed with confidence"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ if len(datasets) > 1:
+ log.add(
+ code="TS-FIELD-UNATTRIBUTED",
+ severity=Severity.WARNING,
+ message=(
+ f"references resolve to {len(datasets)} different datasets "
+ f"({', '.join(datasets)}); a computed field spanning more than one "
+ f"dataset cannot be attributed, and should be preserved as an "
+ f"unattributed formula rather than emitted as a field"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ return datasets[0]
+
+
+def _physical_datatype(
+ table_name: str,
+ column_name: str,
+ table_lookup: Callable[[str], dict | None],
+ log: IssueLog,
+ *,
+ object_ref: str,
+ kind: str = "field",
+) -> str | None:
+ """The Ossie datatype for a physical column, or `None` when it cannot be found.
+
+ Matched by the physical column's own display name — what a Model `column_id`
+ suffix names — not by its warehouse column name.
+
+ `None` covers two different situations, and only one of them is a loss worth
+ logging. A column with no `data_type` at all has nothing to drop — `datatype`
+ is optional in Ossie, and `datatypes.to_ossie` documents omission as a
+ legitimate answer, so this stays silent. A column whose `data_type` *is*
+ present but unrecognised by `datatypes.to_ossie` is different: the warehouse
+ told us the type and it is about to be dropped on the floor, so that case
+ logs an issue naming the type before returning `None`.
+
+ `kind` ("field" or "metric") names the Ossie object being built, both in the
+ issue code (`TS-FIELD-...` vs `TS-METRIC-...`) and in the message text, so a
+ metric calling this does not raise a `TS-FIELD-*` code or say "field" about
+ itself — `object_ref` already says `metric:...`, and the code and message
+ need to agree with it.
+ """
+ code_prefix = f"TS-{kind.upper()}"
+ table = table_lookup(table_name)
+ physical = None
+ if table is not None:
+ for candidate in table.get("columns", []):
+ if candidate.get("name") == column_name:
+ physical = candidate
+ break
+ if physical is None:
+ log.add(
+ code=f"{code_prefix}-PHYSICAL-COLUMN-MISSING",
+ severity=Severity.WARNING,
+ message=(
+ f"physical column {column_name!r} was not found on table "
+ f"{table_name!r}; no datatype is emitted for this {kind}"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+ data_type = (physical.get("db_column_properties") or {}).get("data_type")
+ if data_type is None:
+ return None
+ ossie_type = datatypes.to_ossie(data_type)
+ if ossie_type is None:
+ log.add(
+ code=f"{code_prefix}-DATATYPE-UNMAPPED",
+ severity=Severity.WARNING,
+ message=(
+ f"physical column {column_name!r} on table {table_name!r} has "
+ f"warehouse data_type {data_type!r}, which has no Ossie "
+ f"equivalent; no datatype is emitted for this {kind}"
+ ),
+ object_ref=object_ref,
+ )
+ return ossie_type
+
+
+def _ai_context(properties: dict) -> dict | str | None:
+ """Fold synonyms and free-text AI context into one Ossie `ai_context` value.
+
+ Synonyms need the object form to have somewhere to live; free-text context on its
+ own stays a bare string, the simpler of the two shapes Ossie accepts.
+ """
+ synonyms = properties.get("synonyms")
+ instructions = properties.get("ai_context")
+ if synonyms and instructions:
+ return {"synonyms": list(synonyms), "instructions": instructions}
+ if synonyms:
+ return {"synonyms": list(synonyms)}
+ if instructions:
+ return instructions
+ return None
+
+
+def _physical_db_column_name(
+ table_name: str, column_name: str, table_lookup: Callable[[str], dict | None]
+) -> str | None:
+ """The warehouse `db_column_name` of the physical column matching
+ `column_name` (its own display name — what a Model `column_id` suffix
+ names) on `table_name`, or `None` when the table, the column, or a
+ `db_column_name` on it can't be found.
+
+ Used only as a fallback identifier basis — see
+ `_field_or_metric_identifier` — for a column whose *display* name has no
+ ASCII form: the underlying warehouse column name is almost always ASCII
+ even then, and unique within its table by construction, so it survives
+ where the display name doesn't.
+ """
+ table = table_lookup(table_name)
+ if table is None:
+ return None
+ physical = next(
+ (p for p in table.get("columns", []) if p.get("name") == column_name), None
+ )
+ if physical is None:
+ return None
+ return physical.get("db_column_name")
+
+
+def _field_or_metric_identifier(
+ display_name: str,
+ physical_hint: str | None,
+ allocator: identifiers.Allocator,
+ log: IssueLog,
+ *,
+ kind: str,
+ object_ref: str,
+) -> str:
+ """The Ossie identifier for a field or metric's ThoughtSpot display name.
+
+ The common case is `identifiers.normalise(display_name)`, unchanged. The
+ exceptional case — `display_name` has no ASCII alphanumerics for
+ `normalise` to fold onto (a CJK-only, Cyrillic-only, Greek-only, or
+ punctuation-only name) — used to propagate as `ValueError` out of
+ `convert_field`/`convert_metric` entirely, caught by `convert()`'s own
+ try/except and misreported as a malformed *column reference* (the
+ exception is the same type `identifiers.split_column_ref` raises for a
+ genuinely ambiguous reference, and `convert()` could not tell the two
+ apart from outside). That also meant the column was dropped rather than
+ converted — the same graceful-degradation gap `TS-MODEL-NAME-UNNORMALISABLE`
+ already closed at Model scope, here extended to Field/Metric scope, and
+ reported under its own code so the two failures are never conflated again.
+
+ The fallback identifier, in preference order:
+
+ 1. `physical_hint` — normally the underlying warehouse column's own
+ `db_column_name` (see `_physical_db_column_name`), for a
+ column_id-backed column. A warehouse identifier is almost always
+ ASCII even when the display name labelling it is not, and it is
+ unique within its own table by construction (two columns cannot
+ share one warehouse name) — so no collision-avoidance is needed for
+ this branch; it is naturally distinct the same way an ordinary,
+ successfully-normalised identifier is (this converter does not
+ collision-check those either, a pre-existing and separate gap — see
+ `_index_attribute_columns`).
+ 2. A fixed placeholder — `kind` itself, i.e. `"field"` or `"metric"` —
+ allocated through `allocator`. Reached only when there is no
+ `physical_hint` at all (a formula-backed column with no physical
+ grounding) or the hint itself also has no ASCII form. `allocator` is
+ shared by the caller across every column that can reach this branch,
+ so two columns that would otherwise both become `"field"` instead
+ become `"field"` and `"field_2"` — distinct, per the `Allocator`
+ collision-suffix contract in `identifiers.py`.
+
+ Either fallback always differs from `display_name`, so a caller that
+ already stashes the original display name whenever the identifier
+ differs from it (metrics do; fields carry it in `label` instead, which
+ is populated independently of this call and needs no stash) picks this
+ case up for free — nothing here writes a stash entry itself, only logs
+ the WARNING naming what happened.
+ """
+ try:
+ return identifiers.normalise(display_name)
Review Comment:
`_field_or_metric_identifier` common-case path returns `identifiers.normalise(display_name)` directly with no collision check against identifiers already minted in this scope, unlike the Ossie -> TML allocator, which always checks.
Two ATTRIBUTE columns display-named `"Order Date"` and `"Order-Date"` both normalise to `order_date` and end up as two fields sharing one identifier in the same dataset, with nothing logged.
Same gap applied to Metrics, which are unique model-wide rather than per-table. This path needs to go through the same collision checked allocator the other direction uses.
##########
converters/thoughtspot/src/ossie_thoughtspot/tml.py:
##########
@@ -0,0 +1,247 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""TML's structural half: the 1+N document set, and the serialisation invariants.
+
+Deliberately holds no Ossie vocabulary — it is the ThoughtSpot file format and nothing else,
+which is what makes it unit-testable without a fixture from the other side.
+
+Four invariants live here so no caller has to carry them. `guid` is read and never written,
+at any depth: it belongs at the document root, and a nested one — anywhere in the body, not
+only there — is *silently ignored* on import while ThoughtSpot creates a duplicate object
+with the same name. A formula expression containing braces is emitted as a `>-` block scalar
+or the YAML will not parse on re-read. Tables are emitted before the model, which references
+each one by name, so ordering is load-bearing. And everything goes through the YAML 1.2 codec
+so a column, synonym, or parameter value of `on`, `off`, `yes`, or `no` survives as the string
+it is instead of being coerced to a boolean.
+
+Filenames minted for a document set are sanitised and length-capped: a table name is
+user-controlled data and may contain characters a filesystem treats specially — a path
+separator, a `..` component, a Windows-reserved device name, more bytes than a single path
+component allows — so `dump_document_set` never writes one through unexamined, and never
+lets two documents land on the same filename.
+"""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Sequence
+
+import yaml
+
+from . import _yaml
+from .errors import ConversionError
+
+#: The TML root keys this converter handles. Ossie's scope is the semantic model, so
+#: answers, liveboards and the rest are not merely unsupported but out of scope.
+_KINDS = ("model", "table", "sql_view")
+
+#: Filename suffix per kind, matching ThoughtSpot's own export convention.
+_SUFFIX = {"model": "model.tml", "table": "table.tml", "sql_view": "sql_view.tml"}
+
+#: Characters forbidden in a filename component on POSIX (`/`) or Windows
+#: (`< > : " / \ | ? *` plus control characters). Anything else — including a plain
+#: `.` — is left alone so an ordinary name is emitted unchanged.
+_FORBIDDEN_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
+
+#: Windows device names that are reserved regardless of extension (`CON`, `CON.txt`,
+#: `com1.bak`, ... are all reserved) and regardless of case.
+_RESERVED_WINDOWS_NAMES = frozenset(
+ {"CON", "PRN", "AUX", "NUL"}
+ | {f"COM{i}" for i in range(1, 10)}
+ | {f"LPT{i}" for i in range(1, 10)}
+)
+
+#: Most filesystems cap a single path component at 255 bytes. A ThoughtSpot table or
+#: model name carries no length limit of its own, so a name at or past that boundary
+#: has to be shortened before it becomes a filename, not left to fail at write time.
+_MAX_FILENAME_BYTES = 255
+
+
+class _BlockScalar(str):
+ """A string the dumper must emit as a folded block scalar. See `block_scalar`."""
+
+
+def _represent_block(dumper: yaml.SafeDumper, data: _BlockScalar) -> yaml.ScalarNode:
+ return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style=">")
+
+
+yaml.add_representer(_BlockScalar, _represent_block, Dumper=_yaml.Yaml12Dumper)
+
+
+def block_scalar(text: str) -> str:
+ """Mark `text` for `>-` emission. Returns a `str`, so callers need not care."""
+ return _BlockScalar(text)
+
+
+@dataclass(frozen=True)
+class TmlDocument:
+ kind: str
+ body: dict
+ guid: str | None
+ source: str | None = None
+
+
+@dataclass(frozen=True)
+class DocumentSet:
+ model: TmlDocument
+ tables: tuple[TmlDocument, ...]
+
+ def table_by_name(self, name: str) -> TmlDocument | None:
+ """The table or SQL view whose `name` matches, or `None`.
+
+ Model `model_tables[]` entries reference a table by this name (or by an `alias`
+ that the caller resolves first), so this is the join between the two documents.
+ """
+ for table in self.tables:
+ if table.body.get("name") == name:
+ return table
+ return None
+
+
+def load_document(text: str, *, source: str | None = None) -> TmlDocument:
+ """Parse one TML document. Raises `ConversionError` rather than a bare YAML error."""
+ data = _yaml.load(text)
+ if not isinstance(data, dict):
+ raise ConversionError(f"{source or '<input>'} is not a TML document: expected a mapping")
+ present = [kind for kind in _KINDS if kind in data]
+ if not present:
+ raise ConversionError(
+ f"{source or '<input>'} is not a TML document this converter handles: "
+ f"expected one of {', '.join(_KINDS)} at the root"
+ )
+ if len(present) > 1:
+ raise ConversionError(
+ f"{source or '<input>'} declares more than one root kind ({', '.join(present)})"
+ )
+ kind = present[0]
+ body = data[kind]
+ if not isinstance(body, dict):
+ raise ConversionError(f"{source or '<input>'}: {kind} must be a mapping")
+ return TmlDocument(kind=kind, body=body, guid=data.get("guid"), source=source)
+
+
+def load_document_set(texts: Sequence[tuple[str, str]]) -> DocumentSet:
+ """Load `(source, text)` pairs into exactly one model plus its tables, in any order."""
+ documents = [load_document(text, source=source) for source, text in texts]
+ models = [d for d in documents if d.kind == "model"]
+ tables = tuple(d for d in documents if d.kind in ("table", "sql_view"))
+ if not models:
+ raise ConversionError("the document set contains no model document")
+ if len(models) > 1:
+ names = ", ".join(str(m.body.get("name")) for m in models)
+ raise ConversionError(f"the document set contains more than one model document: {names}")
+ return DocumentSet(model=models[0], tables=tables)
+
+
+def _strip_nested_guids(value: object) -> object:
+ """A copy of `value` with every `guid` key removed, at every depth.
+
+ A nested `guid` — on a column entry, a join, anywhere below the document root — is
+ silently ignored on import and ThoughtSpot creates a duplicate object rather than
+ updating the existing one, exactly like a root-level `guid` would; this closes that
+ off at every level rather than only the root. `fqn` is left alone: it is legitimate
+ inside a model's table references and stripping it would break them. The input is
+ never mutated — `TmlDocument.body` belongs to the caller, who may reasonably dump
+ the same document twice or inspect it afterwards.
+ """
+ if isinstance(value, dict):
+ return {k: _strip_nested_guids(v) for k, v in value.items() if k != "guid"}
+ if isinstance(value, list):
+ return [_strip_nested_guids(v) for v in value]
+ if isinstance(value, tuple):
+ return tuple(_strip_nested_guids(v) for v in value)
+ return value
+
+
+def dump_document(document: TmlDocument) -> str:
+ """Serialise one document. `guid` is stripped unconditionally, at every depth of
+ the body — not only at the document root."""
+ return _yaml.dump({document.kind: _strip_nested_guids(document.body)})
+
+
+def _safe_filename_component(name: object) -> str:
+ """A ThoughtSpot table/model name, made safe to use as a filename stem.
+
+ Every character forbidden by POSIX (`/`) or Windows (`< > : " / \\ | ? *` and
+ control characters) is replaced with `_`. Trailing dots and spaces are trimmed —
+ Windows drops them silently, which could otherwise make two distinct names collide
+ invisibly. A name that is empty, `.`, or `..` after that, and a Windows-reserved
+ device name (`CON`, `COM1`, ...) regardless of what follows the first dot, each get
+ a safe fallback. An ordinary name such as `ORDERS` or `store_sales` is returned
+ exactly as given. Length is not handled here — `dump_document_set` caps it once it
+ knows how much room the suffix and a possible disambiguating counter need.
+ """
+ text = name if isinstance(name, str) else ""
+ cleaned = _FORBIDDEN_FILENAME_CHARS.sub("_", text).rstrip(" .")
+ if cleaned in ("", ".", ".."):
+ cleaned = "_unnamed"
+ elif cleaned.split(".", 1)[0].upper() in _RESERVED_WINDOWS_NAMES:
+ cleaned = f"_{cleaned}"
+ return cleaned
+
+
+def _truncate_utf8(text: str, max_bytes: int) -> str:
+ """`text`, cut down to at most `max_bytes` UTF-8 bytes, never splitting a
+ multi-byte character in half."""
+ encoded = text.encode("utf-8")
+ if len(encoded) <= max_bytes:
+ return text
+ cut = max_bytes
+ while cut > 0:
+ try:
+ return encoded[:cut].decode("utf-8")
+ except UnicodeDecodeError:
+ cut -= 1
+ return ""
+
+
+def dump_document_set(document_set: DocumentSet) -> list[tuple[str, str]]:
+ """`(filename, text)` for every document, tables first — the model references them
+ by name, so they must exist before it does.
+
+ Each filename's stem is sanitised (`_safe_filename_component`) and truncated to
+ leave room, within the 255-byte filesystem component limit, for both the suffix and
+ a disambiguating counter. Every candidate filename is reserved as it is minted: if
+ it is already taken — two source names sanitising to the same stem, a truncated
+ long name colliding with another, or a counter-suffixed name happening to land on
+ some other document's plain name — the counter advances and a fresh candidate is
+ tried until one is free. No two documents in one `DocumentSet` can ever be handed
+ the same filename.
+ """
+ documents = list(document_set.tables) + [document_set.model]
+ # However many documents there are, that is also the most candidates any single one
+ # could need to try before finding a free filename (there are only that many
+ # filenames already claimed to collide with) — one extra digit of headroom besides.
+ counter_reserve = len(f"-{len(documents) + 1}")
+
+ used: set[str] = set()
+ out = []
+ for document in documents:
+ suffix = _SUFFIX[document.kind]
+ max_stem_bytes = _MAX_FILENAME_BYTES - len(f".{suffix}") - counter_reserve
+ stem = _safe_filename_component(document.body.get("name", document.kind))
+ stem = _truncate_utf8(stem, max_stem_bytes).rstrip(" .") or "_unnamed"
+
+ candidate = f"{stem}.{suffix}"
+ counter = 1
+ while candidate in used:
Review Comment:
`dump_document_set()` filename collision avoidance compares names case-sentivitively, but the default target filesystem (macOS, Windows) are case-insensitive. Two documents named `Store_Sales` and `STORE_SALES` get distinct looking filenames with no counter suffix, and the second write silently overwrites the first on disk (exit code 0, nothing logged).
I think collision check needs to normalise case (or otherwise account for case-insensitive filesystems) before deciding two names are distinct.
##########
converters/thoughtspot/src/ossie_thoughtspot/expressions/catalog.py:
##########
@@ -0,0 +1,1931 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""The catalog: every specification construct mapped to a ThoughtSpot rendering.
+
+`CATALOG` is organised into families below — Aggregate functions, Type conversion,
+Date/time functions, String functions, Mathematical + Conditional functions, Operators
+and constructs, and Window functions — one block per family, together covering the full
+specification. `spec_construct_names()` is an oracle read from the **upstream**
+`core-spec/expression_language.md`: it independently reports every construct the
+specification defines, so a construct added upstream in the future fails this package's
+build instead of silently going unsupported (see `test_catalog_covers_the_spec.py`).
+
+Extraction approach
+--------------------
+The spec document mixes three kinds of content that must be told apart:
+
+1. Genuine constructs — a named function, operator or literal form the
+ specification defines, almost always as one row of a markdown table whose
+ row carries a `Syntax` column (e.g. `SUM(expr)`), or, for a handful of
+ operators/keywords with no table of their own, a row of the top-level
+ "Supported SQL Constructs" table.
+2. Argument vocabularies — the `EXTRACT`/`DATE_PART` parts, the `DATE_TRUNC`
+ precisions, the `TO_DATE`/`TO_CHAR` format tokens and the `CAST` target
+ types. These describe values an argument may take, not constructs in
+ their own right, and must be excluded.
+3. Informative tables — the per-engine "Common Dialect Variations" table and
+ the "Cross-Reference: Tool Mappings" section describe *other products'*
+ spellings (Tableau, Looker Studio, DAX, and per-engine SQL). Names that
+ appear only there are not Ossie constructs.
+
+The exclusions are keyed off the document's own structure — a table's own header naming
+and section heading text ("Not Supported in Expressions", "Common Dialect Variations",
+"Cross-Reference") — rather than a hardcoded list of names to drop, so a renamed or added
+upstream construct cannot go silently unnoticed. `_extract_tables()` only looks at lines
+starting with "|", so the argument-vocabulary bullet lists are simply invisible to it.
+
+Spelling: `CATALOG` keys must match `spec_construct_names()` exactly
+------------------------------------------------------------------------------
+`spec_construct_names()` is the oracle for a `CATALOG` key's exact spelling, not any
+mapping document's prose. Several rows write their Ossie-side syntax differently than
+this parser extracts it; a `CATALOG` entry keyed on a mapping document's own wording
+instead of this function's output will read as an "invented" construct even though it is
+a real, intended row. When adding or editing a row, run `spec_construct_names()` and
+match a member of it exactly, rather than transcribing another document's column text.
+Grouped by which extractor produces the divergent spelling:
+
+- **`_extract_tables()`** (an ordinary table with a `Syntax` column):
+ - Alias pairs the spec merges into ONE table row keep this parser's single
+ extracted spelling: `CEIL(x)` (not `CEIL(x) / CEILING(x)`), `TRUNC(x, d)`
+ (not `.../ TRUNCATE(x, d)`).
+ - Two-alternative-syntax rows keep the spec's own joining word, "or":
+ `"CURRENT_DATE or CURRENT_DATE()"`, `"CURRENT_TIMESTAMP or
+ CURRENT_TIMESTAMP()"`, `"CURRENT_TIME or CURRENT_TIME()"`.
+ - The merged boolean-literal row (`BOOLEAN`'s `Syntax` cell) is one entry,
+ comma-joined: `"TRUE, FALSE"`.
+ - The Boolean Functions table's `AND`/`OR` rows keep the spec's own
+ `expr1`/`expr2` placeholder names: `"expr1 AND expr2"`, `"expr1 OR expr2"`.
+- **`_extract_summary_rows()`** (the top-level "Supported SQL Constructs"
+ table, bare backtick token): `BETWEEN`, `IN`, `NOT IN`, `IS NULL`, `IS NOT NULL`,
+ `CASE WHEN`, and the raw symbols `+ - * / % = <> != < > <= >=`.
+- **`_extract_null_safe_comparison_operators()`** (the "Null-Safe Comparison"
+ code fence, not the summary table): `IS DISTINCT FROM`, `IS NOT DISTINCT FROM`.
+- **`_extract_extraction_syntax_functions()`** (the "Alternative Extraction
+ Syntax" code fence, bare token, no argument list): `EXTRACT`, `DATE_PART`.
+- **`_extract_single_construct_headings()`** (a standalone heading with no
+ table, bare token): `CAST`, `TRY_CAST` (not `CAST(expression AS target_type)`).
+
+See `CONVENTION_DIVERGENCES` below for the (much shorter) list of constructs that have
+no entry in `spec_construct_names()` at all and are exempted instead.
+"""
+import re
+from pathlib import Path
+
+from ._types import Classification, Construct, Variant
+
+# --------------------------------------------------------------------------
+# CATALOG: organised into families, one block per family below.
+# --------------------------------------------------------------------------
+CATALOG: dict[str, Construct] = {}
+
+# --------------------------------------------------------------------------
+# Aggregate functions — 18 rows: 12 direct / 6 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Aggregate functions" section
+# (thoughtspot-agent-skills repo — not vendored here; prose above/below the table
+# read in full).
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "SUM(expr)": Construct(
+ "SUM(expr)", Classification.DIRECT, template="sum ( {0} )",
+ ),
+ "COUNT(expr)": Construct(
+ "COUNT(expr)", Classification.DIRECT, template="count ( {0} )",
+ note="Counts non-null values on both sides.",
+ ),
+ "COUNT(*)": Construct(
+ "COUNT(*)", Classification.DIRECT, template="count ( {0} )",
+ note=(
+ "ThoughtSpot has no count(*); the row count is count() over a column "
+ "known to be non-null. The converter uses the dataset's primary_key "
+ "when the model declares one, and raises an issue rather than "
+ "guessing a column when it does not."
+ ),
+ ),
+ "COUNT(DISTINCT expr)": Construct(
+ "COUNT(DISTINCT expr)", Classification.DIRECT, template="unique count ( {0} )",
+ note=(
+ "A space, not an underscore. count_distinct(...) is rejected by the "
+ "formula parser."
+ ),
+ ),
+ "AVG(expr)": Construct(
+ "AVG(expr)", Classification.DIRECT, template="average ( {0} )",
+ ),
+ "MIN(expr)": Construct(
+ "MIN(expr)", Classification.DIRECT, template="min ( {0} )",
+ note=(
+ "ThoughtSpot min is aggregate-only — it never compares two columns "
+ "row-wise. Scalar two-argument minima are LEAST, a separate row."
+ ),
+ ),
+ "MAX(expr)": Construct(
+ "MAX(expr)", Classification.DIRECT, template="max ( {0} )",
+ note="Aggregate-only, as MIN.",
+ ),
+ "STDDEV(expr)": Construct(
+ "STDDEV(expr)", Classification.DIRECT, template="stddev ( {0} )",
+ note="Sample standard deviation on both sides.",
+ ),
+ "STDDEV_POP(expr)": Construct(
+ "STDDEV_POP(expr)", Classification.PASSTHROUGH,
+ template="STDDEV_POP({0})", variant=Variant.NUMBER_AGGREGATE,
+ note=(
+ "ThoughtSpot stddev is sample-only; there is no population form, and "
+ "substituting it would change the divisor from n-1 to n."
+ ),
+ ),
+ "STDDEV_SAMP(expr)": Construct(
+ "STDDEV_SAMP(expr)", Classification.DIRECT, template="stddev ( {0} )",
+ note="Specification alias for STDDEV (:171).",
+ ),
+ "VARIANCE(expr)": Construct(
+ "VARIANCE(expr)", Classification.DIRECT, template="variance ( {0} )",
+ note="Sample variance on both sides.",
+ ),
+ "VAR_POP(expr)": Construct(
+ "VAR_POP(expr)", Classification.PASSTHROUGH,
+ template="VAR_POP({0})", variant=Variant.NUMBER_AGGREGATE,
+ note="Same divisor reason as STDDEV_POP.",
+ ),
+ "VAR_SAMP(expr)": Construct(
+ "VAR_SAMP(expr)", Classification.DIRECT, template="variance ( {0} )",
+ note="Specification alias for VARIANCE (:174).",
+ ),
+ "MEDIAN(expr)": Construct(
+ "MEDIAN(expr)", Classification.DIRECT, template="median ( {0} )",
+ ),
+ "PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY expr)": Construct(
+ "PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY expr)", Classification.PASSTHROUGH,
+ template="PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY {0})",
+ variant=Variant.NUMBER_AGGREGATE,
+ note=(
+ "No native percentile function. p is a literal in the specification's "
+ "syntax, so it is baked into the template rather than passed as a "
+ "placeholder. p = 0.5 is the one case with a native equivalent — "
+ "median ( [x] ) — and the converter should prefer it."
+ ),
+ ),
+ "PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY expr)": Construct(
+ "PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY expr)", Classification.PASSTHROUGH,
+ template="PERCENTILE_DISC(0.75) WITHIN GROUP (ORDER BY {0})",
+ variant=Variant.NUMBER_AGGREGATE,
+ note=(
+ "As PERCENTILE_CONT; the discrete/interpolated distinction is "
+ "preserved only because the template is emitted verbatim."
+ ),
+ ),
+ "APPROX_COUNT_DISTINCT(expr)": Construct(
+ "APPROX_COUNT_DISTINCT(expr)", Classification.PASSTHROUGH,
+ template="APPROX_COUNT_DISTINCT({0})", variant=Variant.INT_AGGREGATE,
+ note=(
+ "ThoughtSpot's unique count ( [x] ) is the exact-semantics "
+ "alternative: same answer to within the sketch's ~2% error, at "
+ "exact-count cost. The converter emits the pass-through by default "
+ "— the specification chose approximate deliberately — and offers "
+ "the exact form as a documented downgrade."
+ ),
+ ),
+ "APPROX_PERCENTILE(expr, p)": Construct(
+ "APPROX_PERCENTILE(expr, p)", Classification.PASSTHROUGH,
+ template="APPROX_PERCENTILE({0}, 0.5)", variant=Variant.NUMBER_AGGREGATE,
+ note="p baked into the template as for the exact percentiles.",
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# Type conversion — 2 rows: 2 direct / 0 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Type conversion" section.
+#
+# CAST/TRY_CAST are direct rows whose target-type argument vocabulary is only
+# partly covered (5 of 8 types direct, 3 fall back to a pass-through) — the
+# per-type dispatch is not counted as its own construct (the target-type
+# table is an argument vocabulary, marked "not counted" in the mapping
+# document) and is not resolved here. Resolving a
+# `CAST` occurrence to an actual formula from its target type would need an
+# expression parser, which is out of scope, and whether to take on a sqlglot
+# dependency for that is still unresolved; `template` records the document's
+# own ThoughtSpot-column text for traceability rather than a
+# directly-substitutable formula.
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "CAST": Construct(
+ "CAST", Classification.DIRECT,
+ template="per-type — see the target-type table below",
+ note=(
+ "5 of the 8 specified target types are direct; the other three — "
+ "BOOLEAN, TIMESTAMP and TIME — fall back to a pass-through."
+ ),
+ ),
+ "TRY_CAST": Construct(
+ "TRY_CAST", Classification.DIRECT,
+ template="the same functions as CAST",
+ note=(
+ "ThoughtSpot's to_integer / to_double / to_string already return "
+ "NULL on failure, which is exactly TRY_CAST semantics — so the two "
+ "rows share a mapping and it is CAST, not TRY_CAST, that is the "
+ "imprecise one. A strict CAST that must error rather than null is "
+ "not expressible; the converter records that in the issue log when "
+ "the source distinguishes them."
+ ),
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# Date/time functions — 24 rows: 17 direct / 7 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Date/time functions" section
+# (thoughtspot-agent-skills repo — not vendored here; prose above/below the table
+# read in full).
+#
+# EXTRACT/DATE_PART date-parts, DATE_TRUNC precisions, DATEADD/DATEDIFF parts and
+# TO_DATE/TO_CHAR format tokens are argument vocabularies and get no
+# entry of their own (see the mapping document's "(not counted — arguments)"
+# sub-tables). EXTRACT, DATE_PART, DATE_TRUNC(part, date_expr),
+# DATEADD(part, amount, date_expr) and DATEDIFF(part, start_date, end_date) are
+# themselves still DIRECT rows in the 24 — the per-argument dispatch happens for
+# each, but the dispatch table itself is out of catalog scope (same pattern as
+# CAST/TRY_CAST in Type conversion above): `template` records the mapping document's own
+# ThoughtSpot-column text for traceability, and the real per-argument content
+# (which native function each part/precision rewrites to, and the argument-order
+# caveats) is recorded in `note`.
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "CURRENT_DATE or CURRENT_DATE()": Construct(
+ "CURRENT_DATE or CURRENT_DATE()", Classification.DIRECT, template="today ( )",
+ note="Both specification spellings map to the same function.",
+ ),
+ "CURRENT_TIMESTAMP or CURRENT_TIMESTAMP()": Construct(
+ "CURRENT_TIMESTAMP or CURRENT_TIMESTAMP()", Classification.DIRECT, template="now ( )",
+ ),
+ "CURRENT_TIME or CURRENT_TIME()": Construct(
+ "CURRENT_TIME or CURRENT_TIME()", Classification.DIRECT,
+ template="time ( now ( ) )",
+ note=(
+ "ThoughtSpot has no current-time function, but time ( ) extracts "
+ "the time part of a datetime, so the composition is exact."
+ ),
+ ),
+ "YEAR(date_expr)": Construct(
+ "YEAR(date_expr)", Classification.DIRECT, template="year ( {0} )",
+ ),
+ "QUARTER(date_expr)": Construct(
+ "QUARTER(date_expr)", Classification.DIRECT, template="quarter_number ( {0} )",
+ note="The function is quarter_number, not quarter.",
+ ),
+ "MONTH(date_expr)": Construct(
+ "MONTH(date_expr)", Classification.DIRECT, template="month_number ( {0} )",
+ note=(
+ "Not month ( ) — ThoughtSpot's month returns the month NAME "
+ "('January'); month_number returns 1-12, which is what the "
+ "specification means. Mapping to month would silently change "
+ "the column's type from integer to string."
+ ),
+ ),
+ "DAY(date_expr)": Construct(
+ "DAY(date_expr)", Classification.DIRECT, template="day ( {0} )",
+ note="Day of month, 1-31 on both sides.",
+ ),
+ "DAYOFYEAR(date_expr)": Construct(
+ "DAYOFYEAR(date_expr)", Classification.DIRECT,
+ template="day_number_of_year ( {0} )",
+ note="The function is day_number_of_year, not day_of_year.",
+ ),
+ "HOUR(timestamp_expr)": Construct(
+ "HOUR(timestamp_expr)", Classification.DIRECT, template="hour_of_day ( {0} )",
+ note="The function is hour_of_day, not hour.",
+ ),
+ "MINUTE(timestamp_expr)": Construct(
+ "MINUTE(timestamp_expr)", Classification.PASSTHROUGH,
+ template="MINUTE({0})", variant=Variant.INT,
+ note=(
+ "No native minute-of-hour extractor; add_minutes and "
+ "diff_minutes exist but neither extracts."
+ ),
+ ),
+ "SECOND(timestamp_expr)": Construct(
+ "SECOND(timestamp_expr)", Classification.PASSTHROUGH,
+ template="SECOND({0})", variant=Variant.INT,
+ note="As MINUTE.",
+ ),
+ "EXTRACT": Construct(
+ "EXTRACT", Classification.DIRECT,
+ template="per-part — see the date-part table below",
+ note=(
+ "Rewritten to the part's own ThoughtSpot function; there is no "
+ "generic extractor. 8 of the 11 specified parts are direct "
+ "(YEAR->year, QUARTER->quarter_number, MONTH->month_number, "
+ "WEEK->week_number_of_year, DAY->day, "
+ "DAYOFWEEK->day_number_of_week, DAYOFYEAR->day_number_of_year, "
+ "HOUR->hour_of_day); MINUTE, SECOND and MILLISECOND fall back "
+ "to sql_int_op."
+ ),
+ ),
+ "DATE_PART": Construct(
+ "DATE_PART", Classification.DIRECT,
+ template="per-part — see the date-part table below",
+ note="Identical treatment to EXTRACT; the two spellings collapse onto one rewrite (:276-279).",
+ ),
+ "DATE_TRUNC(part, date_expr)": Construct(
+ "DATE_TRUNC(part, date_expr)", Classification.DIRECT,
+ template="per-precision — see the truncation table below",
+ note=(
+ "ThoughtSpot has no date_trunc. The start_of_* family covers 7 "
+ "of the 8 specified precisions ('year'->start_of_year, "
+ "'quarter'->start_of_quarter, 'month'->start_of_month, "
+ "'week'->start_of_week, 'day'->date ( ), 'hour'->start_of_hour, "
+ "'minute'->start_of_min — the function is start_of_min, not "
+ "start_of_minute); 'second' falls back to sql_date_time_op. "
+ "The specification says week truncation is Monday-start; "
+ "ThoughtSpot's week start is an instance setting, so the "
+ "converter verifies alignment and raises an issue when it cannot."
+ ),
+ ),
+ "DATEADD(part, amount, date_expr)": Construct(
+ "DATEADD(part, amount, date_expr)", Classification.DIRECT,
+ template="per-part add_* — see the arithmetic table below",
+ note=(
+ "Argument order differs: ThoughtSpot is add_days ( [d] , n ), "
+ "the specification is DATEADD(day, n, d). Every specified part "
+ "is reachable: day->add_days, week->add_weeks, "
+ "month->add_months, year->add_years, minute->add_minutes, "
+ "second->add_seconds, plus two by arithmetic on a coarser unit "
+ "since there is no native add_quarters or add_hours: "
+ "quarter->add_months ( [d] , 3 * n ), "
+ "hour->add_minutes ( [d] , 60 * n )."
+ ),
+ ),
+ "DATEDIFF(part, start_date, end_date)": Construct(
+ "DATEDIFF(part, start_date, end_date)", Classification.DIRECT,
+ template="per-part diff_* — see the arithmetic table below",
+ note=(
+ "Argument order is reversed: ThoughtSpot is "
+ "diff_days ( [end] , [start] ) — end first. Getting this wrong "
+ "silently negates every duration in the model. day->diff_days, "
+ "week->diff_weeks, month->diff_months, quarter->diff_quarters, "
+ "year->diff_years, hour->diff_hours, minute->diff_minutes, "
+ "second->diff_time (returns seconds)."
+ ),
+ ),
+ "DATE '2024-01-15'": Construct(
+ "DATE '2024-01-15'", Classification.DIRECT,
+ template="to_date ( '{0}' , 'yyyy-MM-dd' )",
+ note=(
+ "A bare '2024-01-15' in a ThoughtSpot formula is parsed as "
+ "arithmetic (2024 - 1 - 15), so the typed literal must always "
+ "be wrapped. to_date takes exactly two arguments, so the "
+ "converter supplies the ISO format model; {0} is the literal "
+ "date string."
+ ),
+ ),
+ "TIMESTAMP_NTZ '2024-01-15 10:30:00'": Construct(
+ "TIMESTAMP_NTZ '2024-01-15 10:30:00'", Classification.PASSTHROUGH,
+ template="CAST('2024-01-15 10:30:00' AS TIMESTAMP)",
+ variant=Variant.DATE_TIME,
+ note=(
+ "to_date returns a DATE and drops the time part, so there is "
+ "no native way to construct a wall-clock timestamp. A "
+ "zero-placeholder template is a documented form of the "
+ "pass-through — the document's own worked example is recorded "
+ "verbatim here; a real occurrence's literal value is "
+ "substituted per-occurrence when the template is built, out of "
+ "this catalog's scope (same as CAST's per-type dispatch)."
+ ),
+ ),
+ "TIME '10:30:00'": Construct(
+ "TIME '10:30:00'", Classification.PASSTHROUGH,
+ template="CAST('10:30:00' AS TIME)",
+ variant=Variant.DATE_TIME,
+ note=(
+ "ThoughtSpot has no TIME column type — time ( ) extracts a "
+ "time FROM a datetime, it does not construct one — so the "
+ "pass-through returns DATETIME and the date part is whatever "
+ "the warehouse defaults to. Flagged with an issue for that "
+ "reason, not only for the dialect."
+ ),
+ ),
+ "TO_DATE(string)": Construct(
+ "TO_DATE(string)", Classification.DIRECT,
+ template="to_date ( {0} , 'yyyy-MM-dd' )",
+ note=(
+ "The single-argument ISO form. ThoughtSpot's to_date is "
+ "strictly two-argument, so the converter supplies 'yyyy-MM-dd'."
+ ),
+ ),
+ "TO_TIMESTAMP(string)": Construct(
+ "TO_TIMESTAMP(string)", Classification.PASSTHROUGH,
+ template="TO_TIMESTAMP({0})", variant=Variant.DATE_TIME,
+ note="to_date is date-only; parsing to a timestamp would drop the time silently.",
+ ),
+ "TO_DATE(string, format)": Construct(
+ "TO_DATE(string, format)", Classification.DIRECT,
+ template="to_date ( {0} , <translated format> )",
+ note=(
+ "EXPERIMENTAL. Format tokens are translated, not passed "
+ "through — see the format-token table. ThoughtSpot accepts "
+ "Java/LDML tokens (yyyy-MM-dd) and strptime %-codes, which "
+ "between them cover the specification's entire portable core."
+ ),
+ ),
+ "TO_TIMESTAMP(string, format)": Construct(
+ "TO_TIMESTAMP(string, format)", Classification.PASSTHROUGH,
+ template="TO_TIMESTAMP({0}, 'YYYY-MM-DD HH24:MI:SS')",
+ variant=Variant.DATE_TIME,
+ note=(
+ "EXPERIMENTAL. Date-only to_date again. The format model "
+ "inside the template is the warehouse's, not Ossie's, so the "
+ "token translation table does not apply — this is the "
+ "sharpest case of the pass-through caveat."
+ ),
+ ),
+ "TO_CHAR(date_expr, format)": Construct(
+ "TO_CHAR(date_expr, format)", Classification.PASSTHROUGH,
+ template="TO_CHAR({0}, 'YYYY-MM')", variant=Variant.STRING,
+ note=(
+ "EXPERIMENTAL. ThoughtSpot has no general date formatter. "
+ "Single-token formats do have native equivalents and the "
+ "converter prefers them: 'YYYY' -> year_name ( [d] ), "
+ "'MONTH' -> month ( [d] ), 'DAY' -> day_of_week ( [d] ). Those "
+ "three return locale-dependent text on both sides."
+ ),
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# String functions — 21 rows: 10 direct / 11 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "String functions" section
+# (thoughtspot-agent-skills repo — not vendored here; prose above/below the table
+# read in full).
+#
+# This family is over half passthrough, and the reasons run against intuition
+# rather than with it: LOWER/UPPER/TRIM/LTRIM/RTRIM/REPLACE are passthrough not
+# because they behave differently in ThoughtSpot but because ThoughtSpot has no
+# native equivalent at all (live-verified 2026-07-29: TRIM and REPLACE were
+# rejected with "Search did not find ..."). STARTSWITH/ENDSWITH run the other
+# way: also no native function, but their compositions use only native
+# functions (strpos/substr/strlen), so they stay direct. There is no
+# regular-expression support of any kind, so every REGEXP_* row is passthrough
+# with no native fallback.
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "CONCAT(str1, str2, ...)": Construct(
+ "CONCAT(str1, str2, ...)", Classification.DIRECT,
+ template="concat ( {0} , {1} , ... )",
+ note=(
+ "N-ary on both sides. + does not concatenate in ThoughtSpot — "
+ "it is numeric-only and the parser rejects string operands, so "
+ "both || and CONCAT land here."
+ ),
+ ),
+ "LENGTH(str)": Construct(
+ "LENGTH(str)", Classification.DIRECT, template="strlen ( {0} )",
+ note="Characters, not bytes, on both sides.",
+ ),
+ "LOWER(str)": Construct(
+ "LOWER(str)", Classification.PASSTHROUGH,
+ template="LOWER({0})", variant=Variant.STRING,
+ note="There is no native lower in ThoughtSpot.",
+ ),
+ "UPPER(str)": Construct(
+ "UPPER(str)", Classification.PASSTHROUGH,
+ template="UPPER({0})", variant=Variant.STRING,
+ note=(
+ "There is no native upper in ThoughtSpot. LOWER/UPPER are the "
+ "most-used functions in the whole passthrough set, and their "
+ "absence is also what forces ILIKE and case-insensitive "
+ "comparison into pass-throughs."
+ ),
+ ),
+ "TRIM(str)": Construct(
+ "TRIM(str)", Classification.PASSTHROUGH,
+ template="TRIM({0})", variant=Variant.STRING,
+ note=(
+ "There is no native trim in ThoughtSpot — live-verified "
+ "2026-07-29, rejected with 'Search did not find \"trim (\"'. "
+ "The whole trim family is a pass-through, not just the "
+ "one-sided forms."
+ ),
+ ),
+ "LTRIM(str)": Construct(
+ "LTRIM(str)", Classification.PASSTHROUGH,
+ template="LTRIM({0})", variant=Variant.STRING,
+ note="No native ltrim — live-verified 2026-07-29.",
+ ),
+ "RTRIM(str)": Construct(
+ "RTRIM(str)", Classification.PASSTHROUGH,
+ template="RTRIM({0})", variant=Variant.STRING,
+ note="As LTRIM.",
+ ),
+ "LEFT(str, n)": Construct(
+ "LEFT(str, n)", Classification.DIRECT, template="left ( {0} , {1} )",
+ ),
+ "RIGHT(str, n)": Construct(
+ "RIGHT(str, n)", Classification.DIRECT, template="right ( {0} , {1} )",
+ ),
+ "SUBSTRING(str, start, length)": Construct(
+ "SUBSTRING(str, start, length)", Classification.DIRECT,
+ template="substr ( {0} , {1} - 1 , {2} )",
+ note=(
+ "Index base differs. ANSI SUBSTRING is 1-based; ThoughtSpot's "
+ "substr is 0-based. The -1 is mandatory and is the single most "
+ "likely off-by-one in the whole mapping. When start is an "
+ "expression rather than a literal, the arithmetic is emitted "
+ "rather than folded."
+ ),
+ ),
+ "REPLACE(str, from, to)": Construct(
+ "REPLACE(str, from, to)", Classification.PASSTHROUGH,
+ template="REPLACE({0}, {1}, {2})",
+ variant=Variant.STRING,
+ note=(
+ "There is no native replace in ThoughtSpot — live-verified "
+ "2026-07-29, rejected with 'Search did not find \"replace (\"'."
+ ),
+ ),
+ "SPLIT_PART(str, delimiter, part)": Construct(
+ "SPLIT_PART(str, delimiter, part)", Classification.PASSTHROUGH,
+ template="SPLIT_PART({0}, {1}, {2})",
+ variant=Variant.STRING,
+ note=(
+ "ThoughtSpot has no tokenising function at all — not split, "
+ "split_part or an nth-occurrence search — so there is no "
+ "composition to fall back on."
+ ),
+ ),
+ "POSITION(substr IN str)": Construct(
+ "POSITION(substr IN str)", Classification.DIRECT,
+ template="strpos ( {1} , {0} )",
+ note=(
+ "Operand order is reversed (haystack first in ThoughtSpot) and "
+ "the specification's infix IN form becomes a comma. 1-based, "
+ "returning 0 when absent, on both sides."
+ ),
+ ),
+ "CHARINDEX(substr, str)": Construct(
+ "CHARINDEX(substr, str)", Classification.DIRECT,
+ template="strpos ( {1} , {0} )",
+ note=(
+ "Specification alias for POSITION (:419) with the operands "
+ "already in prefix order; the reversal is the same."
+ ),
+ ),
+ "CONTAINS(str, substr)": Construct(
+ "CONTAINS(str, substr)", Classification.DIRECT,
+ template="contains ( {0} , {1} )",
+ note="Returns boolean on both sides.",
+ ),
+ "STARTSWITH(str, prefix)": Construct(
+ "STARTSWITH(str, prefix)", Classification.DIRECT,
+ template="strpos ( {0} , {1} ) = 1",
+ note=(
+ "There is no native starts_with — live-verified 2026-07-29. "
+ "Still direct because the composition is exact and uses only "
+ "native functions: strpos is 1-based, so a true prefix sits "
+ "at position 1."
+ ),
+ ),
+ "ENDSWITH(str, suffix)": Construct(
+ "ENDSWITH(str, suffix)", Classification.DIRECT,
+ template="substr ( {0} , strlen ( {0} ) - strlen ( {1} ) , strlen ( {1} ) ) = {1}",
+ note=(
+ "There is no native ends_with — live-verified 2026-07-29. "
+ "Direct by composition, as STARTSWITH."
+ ),
+ ),
+ "REGEXP_LIKE(str, pattern)": Construct(
+ "REGEXP_LIKE(str, pattern)", Classification.PASSTHROUGH,
+ template="REGEXP_LIKE({0}, {1})",
+ variant=Variant.BOOL,
+ note=(
+ "Boolean return, so not sql_string_op. ThoughtSpot has no "
+ "regular-expression support of any kind."
+ ),
+ ),
+ "REGEXP_EXTRACT(str, pattern)": Construct(
+ "REGEXP_EXTRACT(str, pattern)", Classification.PASSTHROUGH,
+ template="REGEXP_SUBSTR({0}, {1})",
+ variant=Variant.STRING,
+ note=(
+ "The function name inside the template is dialect-specific — "
+ "Snowflake spells it REGEXP_SUBSTR, others REGEXP_EXTRACT — so "
+ "the converter selects it from the connection's dialect and "
+ "raises an issue when the dialect is unknown."
+ ),
+ ),
+ "REGEXP_REPLACE(str, pattern, replacement)": Construct(
+ "REGEXP_REPLACE(str, pattern, replacement)", Classification.PASSTHROUGH,
+ template="REGEXP_REPLACE({0},{1},{2})",
+ variant=Variant.STRING,
+ note=(
+ "Name is portable; the pattern dialect (POSIX vs PCRE, "
+ "backreference syntax) is not."
+ ),
+ ),
+ "REGEXP_COUNT(str, pattern)": Construct(
+ "REGEXP_COUNT(str, pattern)", Classification.PASSTHROUGH,
+ template="REGEXP_COUNT({0}, {1})",
+ variant=Variant.INT,
+ note="Integer return.",
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# Mathematical + Conditional functions — 34 rows: 32 direct /
+# 2 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Mathematical functions" and
+# "Conditional functions" sections (thoughtspot-agent-skills repo — not
+# vendored here; prose above/below the tables read in full).
+#
+# Nearly every row here is direct, several by composition: SIGN has
+# no native function but composes exactly as a three-way `if` chain — the
+# trailing `else 0` is mandatory, ThoughtSpot rejects an `if` with no `else`.
+# RADIANS/DEGREES are bare dialect-free arithmetic, not passthroughs. PI is a
+# literal at the precision ThoughtSpot's own documented trig composites use.
+# ThoughtSpot's trigonometry is degrees-native while the specification is
+# radians-native, so SIN/COS/TAN convert degrees->radians on the way in
+# (`* 180 / pi`) and ASIN/ACOS/ATAN convert radians->degrees on the way out
+# (`* pi / 180`) — opposite directions, easy to transpose by mistake.
+# GREATEST/LEAST are deliberately NOT mapped to max/min: ThoughtSpot's max/min
+# are aggregate-only, so that mapping would both collapse the row-wise N-ary
+# result to one value and flip it from attribute to measure.
+#
+# Only two rows are passthrough: TRUNC/TRUNCATE (no native truncation — floor
+# only agrees with it for x >= 0, d = 0, and round disagrees at every
+# half-value) and ATAN2 (quadrant-aware and defined where x = 0, so it is not
+# a two-argument ATAN composition, unlike every other inverse trig function
+# in this family).
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "ABS(x)": Construct(
+ "ABS(x)", Classification.DIRECT, template="abs ( {0} )",
+ ),
+ "ROUND(x, d)": Construct(
+ "ROUND(x, d)", Classification.DIRECT, template="round ( {0} , {1} )",
+ ),
+ "FLOOR(x)": Construct(
+ "FLOOR(x)", Classification.DIRECT, template="floor ( {0} )",
+ ),
+ "CEIL(x)": Construct(
+ "CEIL(x)", Classification.DIRECT, template="ceil ( {0} )",
+ note="Specification alias pair CEIL(x) / CEILING(x); both spellings map to ceil.",
+ ),
+ "TRUNC(x, d)": Construct(
+ "TRUNC(x, d)", Classification.PASSTHROUGH,
+ template="TRUNC({0}, {1})", variant=Variant.DOUBLE,
+ note=(
+ "Specification alias pair TRUNC(x, d) / TRUNCATE(x, d). "
+ "ThoughtSpot has no truncation function. floor agrees with "
+ "TRUNC only for x >= 0 and d = 0, and round disagrees at every "
+ "half-value, so neither is a safe substitute."
+ ),
+ ),
+ "MOD(x, y)": Construct(
+ "MOD(x, y)", Classification.DIRECT, template="mod ( {0} , {1} )",
+ note="Sign-of-result for negative operands follows the warehouse on both sides.",
+ ),
+ "SIGN(x)": Construct(
+ "SIGN(x)", Classification.DIRECT,
+ template="if ( {0} > 0 ) then 1 else if ( {0} < 0 ) then -1 else 0",
+ note=(
+ "No native sign, but the three-way result is exactly "
+ "expressible as an if chain. The else 0 is required — "
+ "ThoughtSpot rejects an if chain with no else."
+ ),
+ ),
+ "POWER(x, y)": Construct(
+ "POWER(x, y)", Classification.DIRECT, template="pow ( {0} , {1} )",
+ note="The function is pow. power is rejected by the parser.",
+ ),
+ "SQRT(x)": Construct(
+ "SQRT(x)", Classification.DIRECT, template="sqrt ( {0} )",
+ ),
+ "EXP(x)": Construct(
+ "EXP(x)", Classification.DIRECT, template="exp ( {0} )",
+ ),
+ "LN(x)": Construct(
+ "LN(x)", Classification.DIRECT, template="ln ( {0} )",
+ ),
+ "LOG(base, x)": Construct(
+ "LOG(base, x)", Classification.DIRECT,
+ template="safe_divide ( ln ( {1} ) , ln ( {0} ) )",
+ note=(
+ "ThoughtSpot has fixed-base log2 and log10 only; base is a "
+ "runtime argument here, not a literal known at catalog time, "
+ "so the general change-of-base composition is the one "
+ "template that is exact for every base. safe_divide rather "
+ "than / guards base = 1."
+ ),
+ ),
+ "LOG10(x)": Construct(
+ "LOG10(x)", Classification.DIRECT, template="log10 ( {0} )",
+ ),
+ "SIN(x)": Construct(
+ "SIN(x)", Classification.DIRECT,
+ template="sin ( {0} * 180 / 3.14159265358979 )",
+ note=(
+ "ThoughtSpot trigonometry is in degrees; the specification is "
+ "in radians. The conversion is mandatory — a bare sin ( {0} ) "
+ "returns the sine of x degrees and is wrong for every "
+ "non-zero input."
+ ),
+ ),
+ "COS(x)": Construct(
+ "COS(x)", Classification.DIRECT,
+ template="cos ( {0} * 180 / 3.14159265358979 )",
+ note="Degrees, as SIN.",
+ ),
+ "TAN(x)": Construct(
+ "TAN(x)", Classification.DIRECT,
+ template="tan ( {0} * 180 / 3.14159265358979 )",
+ note="Degrees, as SIN.",
+ ),
+ "ASIN(x)": Construct(
+ "ASIN(x)", Classification.DIRECT,
+ template="( asin ( {0} ) * 3.14159265358979 / 180 )",
+ note=(
+ "Inverse functions convert the other way: ThoughtSpot returns "
+ "degrees, the specification expects radians."
+ ),
+ ),
+ "ACOS(x)": Construct(
+ "ACOS(x)", Classification.DIRECT,
+ template="( acos ( {0} ) * 3.14159265358979 / 180 )",
+ note="Degrees -> radians, as ASIN.",
+ ),
+ "ATAN(x)": Construct(
+ "ATAN(x)", Classification.DIRECT,
+ template="( atan ( {0} ) * 3.14159265358979 / 180 )",
+ note="Degrees -> radians, as ASIN.",
+ ),
+ "ATAN2(y, x)": Construct(
+ "ATAN2(y, x)", Classification.PASSTHROUGH,
+ template="ATAN2({0}, {1})", variant=Variant.DOUBLE,
+ note=(
+ "atan2 is not a two-argument atan — it is quadrant-aware and "
+ "defined where x = 0. Composing it from atan plus sign tests "
+ "is possible but the branch table is easy to get wrong at the "
+ "axes, so the pass-through is the honest mapping."
+ ),
+ ),
+ "RADIANS(degrees)": Construct(
+ "RADIANS(degrees)", Classification.DIRECT,
+ template="{0} * 3.14159265358979 / 180",
+ note="No native radians; the arithmetic is exact and dialect-free.",
+ ),
+ "DEGREES(radians)": Construct(
+ "DEGREES(radians)", Classification.DIRECT,
+ template="{0} * 180 / 3.14159265358979",
+ note="No native degrees; as RADIANS.",
+ ),
+ "PI()": Construct(
+ "PI()", Classification.DIRECT, template="3.14159265358979",
+ note=(
+ "No native pi. The literal is emitted at the precision "
+ "ThoughtSpot's own documented composites use; "
+ 'sql_double_op ( "pi()" ) is available where full warehouse '
+ "precision matters."
+ ),
+ ),
+ "GREATEST(x, y, ...)": Construct(
+ "GREATEST(x, y, ...)", Classification.DIRECT,
+ template="greatest ( {0} , {1} , ... )",
+ note=(
+ "Not max. ThoughtSpot's max is an aggregate; greatest is the "
+ "row-wise N-ary function. Mapping GREATEST to max would "
+ "collapse the column to one value and also flip it from "
+ "attribute to measure."
+ ),
+ ),
+ "LEAST(x, y, ...)": Construct(
+ "LEAST(x, y, ...)", Classification.DIRECT,
+ template="least ( {0} , {1} , ... )",
+ note="Not min, for the same reason as GREATEST.",
+ ),
+ "IF(condition, true_result, false_result)": Construct(
+ "IF(condition, true_result, false_result)", Classification.DIRECT,
+ template="if ( {0} ) then {1} else {2}",
+ note=(
+ "The parentheses around the condition are mandatory for TML "
+ "import — without them the parser reports \"Expecting keyword "
+ "'('\". Applies to every condition shape, including a bare "
+ "BOOL column reference."
+ ),
+ ),
+ "IFF(condition, true_result, false_result)": Construct(
+ "IFF(condition, true_result, false_result)", Classification.DIRECT,
+ template="if ( {0} ) then {1} else {2}",
+ note="Specification alias for IF.",
+ ),
+ "NULLIF(expr1, expr2)": Construct(
+ "NULLIF(expr1, expr2)", Classification.DIRECT,
+ template="nullif ( {0} , {1} )",
+ ),
+ "COALESCE(expr1, expr2, ...)": Construct(
+ "COALESCE(expr1, expr2, ...)", Classification.DIRECT,
+ template="ifnull ( {0} , ifnull ( {1} , {2} ) )",
+ note=(
+ "ThoughtSpot's ifnull is strictly two-argument, so an N-ary "
+ "COALESCE becomes a right-nested chain. Two arguments is the "
+ "common case and needs no nesting."
+ ),
+ ),
+ "IFNULL(expr, default)": Construct(
+ "IFNULL(expr, default)", Classification.DIRECT,
+ template="ifnull ( {0} , {1} )",
+ ),
+ "NVL(expr, default)": Construct(
+ "NVL(expr, default)", Classification.DIRECT,
+ template="ifnull ( {0} , {1} )",
+ note="Specification alias for two-argument COALESCE.",
+ ),
+ "NVL2(expr, not_null_result, null_result)": Construct(
+ "NVL2(expr, not_null_result, null_result)", Classification.DIRECT,
+ template="if ( isnotnull ( {0} ) ) then {1} else {2}",
+ note="No native three-way null function; the composition is exact.",
+ ),
+ "ZEROIFNULL(expr)": Construct(
+ "ZEROIFNULL(expr)", Classification.DIRECT,
+ template="ifnull ( {0} , 0 )",
+ ),
+ "NULLIFZERO(expr)": Construct(
+ "NULLIFZERO(expr)", Classification.DIRECT,
+ template="nullif ( {0} , 0 )",
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# Operators and constructs — 33 rows: 30 direct / 2 passthrough /
+# 1 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Operators and constructs"
+# section (thoughtspot-agent-skills repo — not vendored here; prose above/below
+# the table read in full).
+#
+# The document's own section header states that CASE (both forms) and the
+# boolean literals/operators are rowed HERE, not under Conditional functions —
+# confirmed by the arithmetic: 25 Math + 9 Conditional (the Mathematical +
+# Conditional functions family above) + 33 here would double-count CASE otherwise.
+#
+# spec_construct_names() extracts the BARE operator/keyword token for most of
+# this family, not the document's own "a + b"-style worked-example row header —
+# confirmed live before writing this block (see test_catalog_operators.py's
+# docstring for the full list). Six rows have no discrete spec table row at
+# all and are keyed via CONVENTION_DIVERGENCES instead: unary -x/+x, the
+# simple CASE form, Parentheses, the DISTINCT modifier, the column/metric
+# reference, and EXISTS_IN() itself.
+#
+# This family holds the single UNMAPPABLE row in the whole 146-row catalog:
+# EXISTS_IN() is named at :131 as the sanctioned way to filter on a subquery,
+# but the specification defines it nowhere — no signature, no argument order,
+# no semantics, absent from every function table. Construct.__post_init__
+# forbids a template or variant on an UNMAPPABLE row, so this is the one entry
+# in the whole file with neither.
+#
+# LIKE is direct despite ThoughtSpot having no native starts_with/ends_with:
+# the prefix/suffix/contains compositions it needs use only native functions,
+# the same reasoning as the String functions family's STARTSWITH/
+# ENDSWITH rows above. ILIKE is passthrough for the opposite reason —
+# case-insensitive matching has no native form, and the usual lower()-fold
+# workaround is itself a passthrough, so there is nothing to compose from. The
+# DISTINCT aggregate modifier is passthrough for every aggregate except COUNT,
+# which already has its own native unique count row (COUNT(DISTINCT expr),
+# in Aggregate functions above).
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "+": Construct(
+ "+", Classification.DIRECT, template="{0} + {1}",
+ note=(
+ "Numeric only. ThoughtSpot's + rejects string operands, so a + "
+ "that concatenates on the source side must become concat ( ). "
+ "The specification does not overload +, so this only bites "
+ "when translating a dialect expression."
+ ),
+ ),
+ "-": Construct(
+ "-", Classification.DIRECT, template="{0} - {1}",
+ ),
+ "*": Construct(
+ "*", Classification.DIRECT, template="{0} * {1}",
+ ),
+ "/": Construct(
+ "/", Classification.DIRECT, template="{0} / {1}",
+ note=(
+ "Both yield NULL (or a warehouse error) on divide-by-zero. "
+ "ThoughtSpot's safe_divide returns 0, not NULL, so it is not "
+ "a faithful substitute and is used only where the source "
+ "itself guards the denominator."
+ ),
+ ),
+ "%": Construct(
+ "%", Classification.DIRECT, template="mod ( {0} , {1} )",
+ note="ThoughtSpot has no % operator — the modulo is the function.",
+ ),
+ "-x / +x (unary)": Construct(
+ "-x / +x (unary)", Classification.DIRECT,
+ template="per-spelling — see note",
+ note=(
+ "CONVENTION_DIVERGENCE: unary +/- is named only in the "
+ "'Operator Precedence' list, never a table row. This row "
+ "merges two Ossie spellings that need DIFFERENT output — "
+ "unary minus is -[x] (negation), unary plus is the identity "
+ "([x] unchanged) — so a single {0}-substitutable template "
+ "would be wrong for whichever spelling didn't produce it: "
+ "an earlier draft used template=\"-{0}\", which is correct "
+ "for -x but silently negates a parsed +x node (right arg "
+ "count, wrong semantics, no exception — the arg-count guard "
+ "cannot catch it). Forced external dispatch instead, the "
+ "same treatment as TRUE, FALSE below and CAST's per-type "
+ "table: the caller must choose -{0} or {0} "
+ "unchanged based on which spelling it parsed, rather than "
+ "getting a plausible-looking wrong answer from this row. "
+ "Unary minus is where the bare-date-literal trap "
+ "originates: '2024-05-01' unquoted is parsed as "
+ "2024 - 5 - 1. Date literals are always wrapped in "
+ "to_date ( )."
+ ),
+ ),
+ "=": Construct(
+ "=", Classification.DIRECT, template="{0} = {1}",
+ ),
+ "<>": Construct(
+ "<>", Classification.DIRECT, template="{0} <> {1}",
+ ),
+ "!=": Construct(
+ "!=", Classification.DIRECT, template="{0} != {1}",
+ note="ThoughtSpot accepts both inequality spellings, so the two rows are independent and both direct.",
+ ),
+ "<": Construct(
+ "<", Classification.DIRECT, template="{0} < {1}",
+ ),
+ ">": Construct(
+ ">", Classification.DIRECT, template="{0} > {1}",
+ ),
+ "<=": Construct(
+ "<=", Classification.DIRECT, template="{0} <= {1}",
+ ),
+ ">=": Construct(
+ ">=", Classification.DIRECT, template="{0} >= {1}",
+ ),
+ "expr1 AND expr2": Construct(
+ "expr1 AND expr2", Classification.DIRECT, template="{0} and {1}",
+ note="Lower-case, infix.",
+ ),
+ "expr1 OR expr2": Construct(
+ "expr1 OR expr2", Classification.DIRECT, template="{0} or {1}",
+ note="Lower-case, infix.",
+ ),
+ "NOT expr": Construct(
+ "NOT expr", Classification.DIRECT, template="not ( {0} )",
+ note=(
+ "Function form with parentheses, not a prefix operator — "
+ "not [x] does not parse."
+ ),
+ ),
+ "BETWEEN": Construct(
+ "BETWEEN", Classification.DIRECT,
+ template="{0} between {1} and {2}",
+ note="Inclusive on both sides.",
+ ),
+ "IN": Construct(
+ "IN", Classification.DIRECT,
+ template="{0} in {{ {1} , {2} , ... }}",
Review Comment:
Same `...` literal defect as the `CONCAT` template, plus this one is hardcoded to exactly two values.
`emit_direct(CATALOG["IN"], [...])` with any list other than 2 times either renders the stray `...` or doesn't match the template arity at all: there's no correct rendering path for real IN lists of 1, 3, or 4+ values.
##########
converters/thoughtspot/src/ossie_thoughtspot/expressions/catalog.py:
##########
@@ -0,0 +1,1931 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""The catalog: every specification construct mapped to a ThoughtSpot rendering.
+
+`CATALOG` is organised into families below — Aggregate functions, Type conversion,
+Date/time functions, String functions, Mathematical + Conditional functions, Operators
+and constructs, and Window functions — one block per family, together covering the full
+specification. `spec_construct_names()` is an oracle read from the **upstream**
+`core-spec/expression_language.md`: it independently reports every construct the
+specification defines, so a construct added upstream in the future fails this package's
+build instead of silently going unsupported (see `test_catalog_covers_the_spec.py`).
+
+Extraction approach
+--------------------
+The spec document mixes three kinds of content that must be told apart:
+
+1. Genuine constructs — a named function, operator or literal form the
+ specification defines, almost always as one row of a markdown table whose
+ row carries a `Syntax` column (e.g. `SUM(expr)`), or, for a handful of
+ operators/keywords with no table of their own, a row of the top-level
+ "Supported SQL Constructs" table.
+2. Argument vocabularies — the `EXTRACT`/`DATE_PART` parts, the `DATE_TRUNC`
+ precisions, the `TO_DATE`/`TO_CHAR` format tokens and the `CAST` target
+ types. These describe values an argument may take, not constructs in
+ their own right, and must be excluded.
+3. Informative tables — the per-engine "Common Dialect Variations" table and
+ the "Cross-Reference: Tool Mappings" section describe *other products'*
+ spellings (Tableau, Looker Studio, DAX, and per-engine SQL). Names that
+ appear only there are not Ossie constructs.
+
+The exclusions are keyed off the document's own structure — a table's own header naming
+and section heading text ("Not Supported in Expressions", "Common Dialect Variations",
+"Cross-Reference") — rather than a hardcoded list of names to drop, so a renamed or added
+upstream construct cannot go silently unnoticed. `_extract_tables()` only looks at lines
+starting with "|", so the argument-vocabulary bullet lists are simply invisible to it.
+
+Spelling: `CATALOG` keys must match `spec_construct_names()` exactly
+------------------------------------------------------------------------------
+`spec_construct_names()` is the oracle for a `CATALOG` key's exact spelling, not any
+mapping document's prose. Several rows write their Ossie-side syntax differently than
+this parser extracts it; a `CATALOG` entry keyed on a mapping document's own wording
+instead of this function's output will read as an "invented" construct even though it is
+a real, intended row. When adding or editing a row, run `spec_construct_names()` and
+match a member of it exactly, rather than transcribing another document's column text.
+Grouped by which extractor produces the divergent spelling:
+
+- **`_extract_tables()`** (an ordinary table with a `Syntax` column):
+ - Alias pairs the spec merges into ONE table row keep this parser's single
+ extracted spelling: `CEIL(x)` (not `CEIL(x) / CEILING(x)`), `TRUNC(x, d)`
+ (not `.../ TRUNCATE(x, d)`).
+ - Two-alternative-syntax rows keep the spec's own joining word, "or":
+ `"CURRENT_DATE or CURRENT_DATE()"`, `"CURRENT_TIMESTAMP or
+ CURRENT_TIMESTAMP()"`, `"CURRENT_TIME or CURRENT_TIME()"`.
+ - The merged boolean-literal row (`BOOLEAN`'s `Syntax` cell) is one entry,
+ comma-joined: `"TRUE, FALSE"`.
+ - The Boolean Functions table's `AND`/`OR` rows keep the spec's own
+ `expr1`/`expr2` placeholder names: `"expr1 AND expr2"`, `"expr1 OR expr2"`.
+- **`_extract_summary_rows()`** (the top-level "Supported SQL Constructs"
+ table, bare backtick token): `BETWEEN`, `IN`, `NOT IN`, `IS NULL`, `IS NOT NULL`,
+ `CASE WHEN`, and the raw symbols `+ - * / % = <> != < > <= >=`.
+- **`_extract_null_safe_comparison_operators()`** (the "Null-Safe Comparison"
+ code fence, not the summary table): `IS DISTINCT FROM`, `IS NOT DISTINCT FROM`.
+- **`_extract_extraction_syntax_functions()`** (the "Alternative Extraction
+ Syntax" code fence, bare token, no argument list): `EXTRACT`, `DATE_PART`.
+- **`_extract_single_construct_headings()`** (a standalone heading with no
+ table, bare token): `CAST`, `TRY_CAST` (not `CAST(expression AS target_type)`).
+
+See `CONVENTION_DIVERGENCES` below for the (much shorter) list of constructs that have
+no entry in `spec_construct_names()` at all and are exempted instead.
+"""
+import re
+from pathlib import Path
+
+from ._types import Classification, Construct, Variant
+
+# --------------------------------------------------------------------------
+# CATALOG: organised into families, one block per family below.
+# --------------------------------------------------------------------------
+CATALOG: dict[str, Construct] = {}
+
+# --------------------------------------------------------------------------
+# Aggregate functions — 18 rows: 12 direct / 6 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Aggregate functions" section
+# (thoughtspot-agent-skills repo — not vendored here; prose above/below the table
+# read in full).
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "SUM(expr)": Construct(
+ "SUM(expr)", Classification.DIRECT, template="sum ( {0} )",
+ ),
+ "COUNT(expr)": Construct(
+ "COUNT(expr)", Classification.DIRECT, template="count ( {0} )",
+ note="Counts non-null values on both sides.",
+ ),
+ "COUNT(*)": Construct(
+ "COUNT(*)", Classification.DIRECT, template="count ( {0} )",
+ note=(
+ "ThoughtSpot has no count(*); the row count is count() over a column "
+ "known to be non-null. The converter uses the dataset's primary_key "
+ "when the model declares one, and raises an issue rather than "
+ "guessing a column when it does not."
+ ),
+ ),
+ "COUNT(DISTINCT expr)": Construct(
+ "COUNT(DISTINCT expr)", Classification.DIRECT, template="unique count ( {0} )",
+ note=(
+ "A space, not an underscore. count_distinct(...) is rejected by the "
+ "formula parser."
+ ),
+ ),
+ "AVG(expr)": Construct(
+ "AVG(expr)", Classification.DIRECT, template="average ( {0} )",
+ ),
+ "MIN(expr)": Construct(
+ "MIN(expr)", Classification.DIRECT, template="min ( {0} )",
+ note=(
+ "ThoughtSpot min is aggregate-only — it never compares two columns "
+ "row-wise. Scalar two-argument minima are LEAST, a separate row."
+ ),
+ ),
+ "MAX(expr)": Construct(
+ "MAX(expr)", Classification.DIRECT, template="max ( {0} )",
+ note="Aggregate-only, as MIN.",
+ ),
+ "STDDEV(expr)": Construct(
+ "STDDEV(expr)", Classification.DIRECT, template="stddev ( {0} )",
+ note="Sample standard deviation on both sides.",
+ ),
+ "STDDEV_POP(expr)": Construct(
+ "STDDEV_POP(expr)", Classification.PASSTHROUGH,
+ template="STDDEV_POP({0})", variant=Variant.NUMBER_AGGREGATE,
+ note=(
+ "ThoughtSpot stddev is sample-only; there is no population form, and "
+ "substituting it would change the divisor from n-1 to n."
+ ),
+ ),
+ "STDDEV_SAMP(expr)": Construct(
+ "STDDEV_SAMP(expr)", Classification.DIRECT, template="stddev ( {0} )",
+ note="Specification alias for STDDEV (:171).",
+ ),
+ "VARIANCE(expr)": Construct(
+ "VARIANCE(expr)", Classification.DIRECT, template="variance ( {0} )",
+ note="Sample variance on both sides.",
+ ),
+ "VAR_POP(expr)": Construct(
+ "VAR_POP(expr)", Classification.PASSTHROUGH,
+ template="VAR_POP({0})", variant=Variant.NUMBER_AGGREGATE,
+ note="Same divisor reason as STDDEV_POP.",
+ ),
+ "VAR_SAMP(expr)": Construct(
+ "VAR_SAMP(expr)", Classification.DIRECT, template="variance ( {0} )",
+ note="Specification alias for VARIANCE (:174).",
+ ),
+ "MEDIAN(expr)": Construct(
+ "MEDIAN(expr)", Classification.DIRECT, template="median ( {0} )",
+ ),
+ "PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY expr)": Construct(
+ "PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY expr)", Classification.PASSTHROUGH,
+ template="PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY {0})",
+ variant=Variant.NUMBER_AGGREGATE,
+ note=(
+ "No native percentile function. p is a literal in the specification's "
+ "syntax, so it is baked into the template rather than passed as a "
+ "placeholder. p = 0.5 is the one case with a native equivalent — "
+ "median ( [x] ) — and the converter should prefer it."
+ ),
+ ),
+ "PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY expr)": Construct(
+ "PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY expr)", Classification.PASSTHROUGH,
+ template="PERCENTILE_DISC(0.75) WITHIN GROUP (ORDER BY {0})",
+ variant=Variant.NUMBER_AGGREGATE,
+ note=(
+ "As PERCENTILE_CONT; the discrete/interpolated distinction is "
+ "preserved only because the template is emitted verbatim."
+ ),
+ ),
+ "APPROX_COUNT_DISTINCT(expr)": Construct(
+ "APPROX_COUNT_DISTINCT(expr)", Classification.PASSTHROUGH,
+ template="APPROX_COUNT_DISTINCT({0})", variant=Variant.INT_AGGREGATE,
+ note=(
+ "ThoughtSpot's unique count ( [x] ) is the exact-semantics "
+ "alternative: same answer to within the sketch's ~2% error, at "
+ "exact-count cost. The converter emits the pass-through by default "
+ "— the specification chose approximate deliberately — and offers "
+ "the exact form as a documented downgrade."
+ ),
+ ),
+ "APPROX_PERCENTILE(expr, p)": Construct(
+ "APPROX_PERCENTILE(expr, p)", Classification.PASSTHROUGH,
+ template="APPROX_PERCENTILE({0}, 0.5)", variant=Variant.NUMBER_AGGREGATE,
+ note="p baked into the template as for the exact percentiles.",
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# Type conversion — 2 rows: 2 direct / 0 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Type conversion" section.
+#
+# CAST/TRY_CAST are direct rows whose target-type argument vocabulary is only
+# partly covered (5 of 8 types direct, 3 fall back to a pass-through) — the
+# per-type dispatch is not counted as its own construct (the target-type
+# table is an argument vocabulary, marked "not counted" in the mapping
+# document) and is not resolved here. Resolving a
+# `CAST` occurrence to an actual formula from its target type would need an
+# expression parser, which is out of scope, and whether to take on a sqlglot
+# dependency for that is still unresolved; `template` records the document's
+# own ThoughtSpot-column text for traceability rather than a
+# directly-substitutable formula.
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "CAST": Construct(
+ "CAST", Classification.DIRECT,
+ template="per-type — see the target-type table below",
+ note=(
+ "5 of the 8 specified target types are direct; the other three — "
+ "BOOLEAN, TIMESTAMP and TIME — fall back to a pass-through."
+ ),
+ ),
+ "TRY_CAST": Construct(
+ "TRY_CAST", Classification.DIRECT,
+ template="the same functions as CAST",
+ note=(
+ "ThoughtSpot's to_integer / to_double / to_string already return "
+ "NULL on failure, which is exactly TRY_CAST semantics — so the two "
+ "rows share a mapping and it is CAST, not TRY_CAST, that is the "
+ "imprecise one. A strict CAST that must error rather than null is "
+ "not expressible; the converter records that in the issue log when "
+ "the source distinguishes them."
+ ),
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# Date/time functions — 24 rows: 17 direct / 7 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Date/time functions" section
+# (thoughtspot-agent-skills repo — not vendored here; prose above/below the table
+# read in full).
+#
+# EXTRACT/DATE_PART date-parts, DATE_TRUNC precisions, DATEADD/DATEDIFF parts and
+# TO_DATE/TO_CHAR format tokens are argument vocabularies and get no
+# entry of their own (see the mapping document's "(not counted — arguments)"
+# sub-tables). EXTRACT, DATE_PART, DATE_TRUNC(part, date_expr),
+# DATEADD(part, amount, date_expr) and DATEDIFF(part, start_date, end_date) are
+# themselves still DIRECT rows in the 24 — the per-argument dispatch happens for
+# each, but the dispatch table itself is out of catalog scope (same pattern as
+# CAST/TRY_CAST in Type conversion above): `template` records the mapping document's own
+# ThoughtSpot-column text for traceability, and the real per-argument content
+# (which native function each part/precision rewrites to, and the argument-order
+# caveats) is recorded in `note`.
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "CURRENT_DATE or CURRENT_DATE()": Construct(
+ "CURRENT_DATE or CURRENT_DATE()", Classification.DIRECT, template="today ( )",
+ note="Both specification spellings map to the same function.",
+ ),
+ "CURRENT_TIMESTAMP or CURRENT_TIMESTAMP()": Construct(
+ "CURRENT_TIMESTAMP or CURRENT_TIMESTAMP()", Classification.DIRECT, template="now ( )",
+ ),
+ "CURRENT_TIME or CURRENT_TIME()": Construct(
+ "CURRENT_TIME or CURRENT_TIME()", Classification.DIRECT,
+ template="time ( now ( ) )",
+ note=(
+ "ThoughtSpot has no current-time function, but time ( ) extracts "
+ "the time part of a datetime, so the composition is exact."
+ ),
+ ),
+ "YEAR(date_expr)": Construct(
+ "YEAR(date_expr)", Classification.DIRECT, template="year ( {0} )",
+ ),
+ "QUARTER(date_expr)": Construct(
+ "QUARTER(date_expr)", Classification.DIRECT, template="quarter_number ( {0} )",
+ note="The function is quarter_number, not quarter.",
+ ),
+ "MONTH(date_expr)": Construct(
+ "MONTH(date_expr)", Classification.DIRECT, template="month_number ( {0} )",
+ note=(
+ "Not month ( ) — ThoughtSpot's month returns the month NAME "
+ "('January'); month_number returns 1-12, which is what the "
+ "specification means. Mapping to month would silently change "
+ "the column's type from integer to string."
+ ),
+ ),
+ "DAY(date_expr)": Construct(
+ "DAY(date_expr)", Classification.DIRECT, template="day ( {0} )",
+ note="Day of month, 1-31 on both sides.",
+ ),
+ "DAYOFYEAR(date_expr)": Construct(
+ "DAYOFYEAR(date_expr)", Classification.DIRECT,
+ template="day_number_of_year ( {0} )",
+ note="The function is day_number_of_year, not day_of_year.",
+ ),
+ "HOUR(timestamp_expr)": Construct(
+ "HOUR(timestamp_expr)", Classification.DIRECT, template="hour_of_day ( {0} )",
+ note="The function is hour_of_day, not hour.",
+ ),
+ "MINUTE(timestamp_expr)": Construct(
+ "MINUTE(timestamp_expr)", Classification.PASSTHROUGH,
+ template="MINUTE({0})", variant=Variant.INT,
+ note=(
+ "No native minute-of-hour extractor; add_minutes and "
+ "diff_minutes exist but neither extracts."
+ ),
+ ),
+ "SECOND(timestamp_expr)": Construct(
+ "SECOND(timestamp_expr)", Classification.PASSTHROUGH,
+ template="SECOND({0})", variant=Variant.INT,
+ note="As MINUTE.",
+ ),
+ "EXTRACT": Construct(
+ "EXTRACT", Classification.DIRECT,
+ template="per-part — see the date-part table below",
+ note=(
+ "Rewritten to the part's own ThoughtSpot function; there is no "
+ "generic extractor. 8 of the 11 specified parts are direct "
+ "(YEAR->year, QUARTER->quarter_number, MONTH->month_number, "
+ "WEEK->week_number_of_year, DAY->day, "
+ "DAYOFWEEK->day_number_of_week, DAYOFYEAR->day_number_of_year, "
+ "HOUR->hour_of_day); MINUTE, SECOND and MILLISECOND fall back "
+ "to sql_int_op."
+ ),
+ ),
+ "DATE_PART": Construct(
+ "DATE_PART", Classification.DIRECT,
+ template="per-part — see the date-part table below",
+ note="Identical treatment to EXTRACT; the two spellings collapse onto one rewrite (:276-279).",
+ ),
+ "DATE_TRUNC(part, date_expr)": Construct(
+ "DATE_TRUNC(part, date_expr)", Classification.DIRECT,
+ template="per-precision — see the truncation table below",
+ note=(
+ "ThoughtSpot has no date_trunc. The start_of_* family covers 7 "
+ "of the 8 specified precisions ('year'->start_of_year, "
+ "'quarter'->start_of_quarter, 'month'->start_of_month, "
+ "'week'->start_of_week, 'day'->date ( ), 'hour'->start_of_hour, "
+ "'minute'->start_of_min — the function is start_of_min, not "
+ "start_of_minute); 'second' falls back to sql_date_time_op. "
+ "The specification says week truncation is Monday-start; "
+ "ThoughtSpot's week start is an instance setting, so the "
+ "converter verifies alignment and raises an issue when it cannot."
+ ),
+ ),
+ "DATEADD(part, amount, date_expr)": Construct(
+ "DATEADD(part, amount, date_expr)", Classification.DIRECT,
+ template="per-part add_* — see the arithmetic table below",
+ note=(
+ "Argument order differs: ThoughtSpot is add_days ( [d] , n ), "
+ "the specification is DATEADD(day, n, d). Every specified part "
+ "is reachable: day->add_days, week->add_weeks, "
+ "month->add_months, year->add_years, minute->add_minutes, "
+ "second->add_seconds, plus two by arithmetic on a coarser unit "
+ "since there is no native add_quarters or add_hours: "
+ "quarter->add_months ( [d] , 3 * n ), "
+ "hour->add_minutes ( [d] , 60 * n )."
+ ),
+ ),
+ "DATEDIFF(part, start_date, end_date)": Construct(
+ "DATEDIFF(part, start_date, end_date)", Classification.DIRECT,
+ template="per-part diff_* — see the arithmetic table below",
+ note=(
+ "Argument order is reversed: ThoughtSpot is "
+ "diff_days ( [end] , [start] ) — end first. Getting this wrong "
+ "silently negates every duration in the model. day->diff_days, "
+ "week->diff_weeks, month->diff_months, quarter->diff_quarters, "
+ "year->diff_years, hour->diff_hours, minute->diff_minutes, "
+ "second->diff_time (returns seconds)."
+ ),
+ ),
+ "DATE '2024-01-15'": Construct(
+ "DATE '2024-01-15'", Classification.DIRECT,
+ template="to_date ( '{0}' , 'yyyy-MM-dd' )",
+ note=(
+ "A bare '2024-01-15' in a ThoughtSpot formula is parsed as "
+ "arithmetic (2024 - 1 - 15), so the typed literal must always "
+ "be wrapped. to_date takes exactly two arguments, so the "
+ "converter supplies the ISO format model; {0} is the literal "
+ "date string."
+ ),
+ ),
+ "TIMESTAMP_NTZ '2024-01-15 10:30:00'": Construct(
+ "TIMESTAMP_NTZ '2024-01-15 10:30:00'", Classification.PASSTHROUGH,
+ template="CAST('2024-01-15 10:30:00' AS TIMESTAMP)",
+ variant=Variant.DATE_TIME,
+ note=(
+ "to_date returns a DATE and drops the time part, so there is "
+ "no native way to construct a wall-clock timestamp. A "
+ "zero-placeholder template is a documented form of the "
+ "pass-through — the document's own worked example is recorded "
+ "verbatim here; a real occurrence's literal value is "
+ "substituted per-occurrence when the template is built, out of "
+ "this catalog's scope (same as CAST's per-type dispatch)."
+ ),
+ ),
+ "TIME '10:30:00'": Construct(
+ "TIME '10:30:00'", Classification.PASSTHROUGH,
+ template="CAST('10:30:00' AS TIME)",
+ variant=Variant.DATE_TIME,
+ note=(
+ "ThoughtSpot has no TIME column type — time ( ) extracts a "
+ "time FROM a datetime, it does not construct one — so the "
+ "pass-through returns DATETIME and the date part is whatever "
+ "the warehouse defaults to. Flagged with an issue for that "
+ "reason, not only for the dialect."
+ ),
+ ),
+ "TO_DATE(string)": Construct(
+ "TO_DATE(string)", Classification.DIRECT,
+ template="to_date ( {0} , 'yyyy-MM-dd' )",
+ note=(
+ "The single-argument ISO form. ThoughtSpot's to_date is "
+ "strictly two-argument, so the converter supplies 'yyyy-MM-dd'."
+ ),
+ ),
+ "TO_TIMESTAMP(string)": Construct(
+ "TO_TIMESTAMP(string)", Classification.PASSTHROUGH,
+ template="TO_TIMESTAMP({0})", variant=Variant.DATE_TIME,
+ note="to_date is date-only; parsing to a timestamp would drop the time silently.",
+ ),
+ "TO_DATE(string, format)": Construct(
+ "TO_DATE(string, format)", Classification.DIRECT,
+ template="to_date ( {0} , <translated format> )",
+ note=(
+ "EXPERIMENTAL. Format tokens are translated, not passed "
+ "through — see the format-token table. ThoughtSpot accepts "
+ "Java/LDML tokens (yyyy-MM-dd) and strptime %-codes, which "
+ "between them cover the specification's entire portable core."
+ ),
+ ),
+ "TO_TIMESTAMP(string, format)": Construct(
+ "TO_TIMESTAMP(string, format)", Classification.PASSTHROUGH,
+ template="TO_TIMESTAMP({0}, 'YYYY-MM-DD HH24:MI:SS')",
+ variant=Variant.DATE_TIME,
+ note=(
+ "EXPERIMENTAL. Date-only to_date again. The format model "
+ "inside the template is the warehouse's, not Ossie's, so the "
+ "token translation table does not apply — this is the "
+ "sharpest case of the pass-through caveat."
+ ),
+ ),
+ "TO_CHAR(date_expr, format)": Construct(
+ "TO_CHAR(date_expr, format)", Classification.PASSTHROUGH,
+ template="TO_CHAR({0}, 'YYYY-MM')", variant=Variant.STRING,
+ note=(
+ "EXPERIMENTAL. ThoughtSpot has no general date formatter. "
+ "Single-token formats do have native equivalents and the "
+ "converter prefers them: 'YYYY' -> year_name ( [d] ), "
+ "'MONTH' -> month ( [d] ), 'DAY' -> day_of_week ( [d] ). Those "
+ "three return locale-dependent text on both sides."
+ ),
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# String functions — 21 rows: 10 direct / 11 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "String functions" section
+# (thoughtspot-agent-skills repo — not vendored here; prose above/below the table
+# read in full).
+#
+# This family is over half passthrough, and the reasons run against intuition
+# rather than with it: LOWER/UPPER/TRIM/LTRIM/RTRIM/REPLACE are passthrough not
+# because they behave differently in ThoughtSpot but because ThoughtSpot has no
+# native equivalent at all (live-verified 2026-07-29: TRIM and REPLACE were
+# rejected with "Search did not find ..."). STARTSWITH/ENDSWITH run the other
+# way: also no native function, but their compositions use only native
+# functions (strpos/substr/strlen), so they stay direct. There is no
+# regular-expression support of any kind, so every REGEXP_* row is passthrough
+# with no native fallback.
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "CONCAT(str1, str2, ...)": Construct(
+ "CONCAT(str1, str2, ...)", Classification.DIRECT,
+ template="concat ( {0} , {1} , ... )",
+ note=(
+ "N-ary on both sides. + does not concatenate in ThoughtSpot — "
+ "it is numeric-only and the parser rejects string operands, so "
+ "both || and CONCAT land here."
+ ),
+ ),
+ "LENGTH(str)": Construct(
+ "LENGTH(str)", Classification.DIRECT, template="strlen ( {0} )",
+ note="Characters, not bytes, on both sides.",
+ ),
+ "LOWER(str)": Construct(
+ "LOWER(str)", Classification.PASSTHROUGH,
+ template="LOWER({0})", variant=Variant.STRING,
+ note="There is no native lower in ThoughtSpot.",
+ ),
+ "UPPER(str)": Construct(
+ "UPPER(str)", Classification.PASSTHROUGH,
+ template="UPPER({0})", variant=Variant.STRING,
+ note=(
+ "There is no native upper in ThoughtSpot. LOWER/UPPER are the "
+ "most-used functions in the whole passthrough set, and their "
+ "absence is also what forces ILIKE and case-insensitive "
+ "comparison into pass-throughs."
+ ),
+ ),
+ "TRIM(str)": Construct(
+ "TRIM(str)", Classification.PASSTHROUGH,
+ template="TRIM({0})", variant=Variant.STRING,
+ note=(
+ "There is no native trim in ThoughtSpot — live-verified "
+ "2026-07-29, rejected with 'Search did not find \"trim (\"'. "
+ "The whole trim family is a pass-through, not just the "
+ "one-sided forms."
+ ),
+ ),
+ "LTRIM(str)": Construct(
+ "LTRIM(str)", Classification.PASSTHROUGH,
+ template="LTRIM({0})", variant=Variant.STRING,
+ note="No native ltrim — live-verified 2026-07-29.",
+ ),
+ "RTRIM(str)": Construct(
+ "RTRIM(str)", Classification.PASSTHROUGH,
+ template="RTRIM({0})", variant=Variant.STRING,
+ note="As LTRIM.",
+ ),
+ "LEFT(str, n)": Construct(
+ "LEFT(str, n)", Classification.DIRECT, template="left ( {0} , {1} )",
+ ),
+ "RIGHT(str, n)": Construct(
+ "RIGHT(str, n)", Classification.DIRECT, template="right ( {0} , {1} )",
+ ),
+ "SUBSTRING(str, start, length)": Construct(
+ "SUBSTRING(str, start, length)", Classification.DIRECT,
+ template="substr ( {0} , {1} - 1 , {2} )",
+ note=(
+ "Index base differs. ANSI SUBSTRING is 1-based; ThoughtSpot's "
+ "substr is 0-based. The -1 is mandatory and is the single most "
+ "likely off-by-one in the whole mapping. When start is an "
+ "expression rather than a literal, the arithmetic is emitted "
+ "rather than folded."
+ ),
+ ),
+ "REPLACE(str, from, to)": Construct(
+ "REPLACE(str, from, to)", Classification.PASSTHROUGH,
+ template="REPLACE({0}, {1}, {2})",
+ variant=Variant.STRING,
+ note=(
+ "There is no native replace in ThoughtSpot — live-verified "
+ "2026-07-29, rejected with 'Search did not find \"replace (\"'."
+ ),
+ ),
+ "SPLIT_PART(str, delimiter, part)": Construct(
+ "SPLIT_PART(str, delimiter, part)", Classification.PASSTHROUGH,
+ template="SPLIT_PART({0}, {1}, {2})",
+ variant=Variant.STRING,
+ note=(
+ "ThoughtSpot has no tokenising function at all — not split, "
+ "split_part or an nth-occurrence search — so there is no "
+ "composition to fall back on."
+ ),
+ ),
+ "POSITION(substr IN str)": Construct(
+ "POSITION(substr IN str)", Classification.DIRECT,
+ template="strpos ( {1} , {0} )",
+ note=(
+ "Operand order is reversed (haystack first in ThoughtSpot) and "
+ "the specification's infix IN form becomes a comma. 1-based, "
+ "returning 0 when absent, on both sides."
+ ),
+ ),
+ "CHARINDEX(substr, str)": Construct(
+ "CHARINDEX(substr, str)", Classification.DIRECT,
+ template="strpos ( {1} , {0} )",
+ note=(
+ "Specification alias for POSITION (:419) with the operands "
+ "already in prefix order; the reversal is the same."
+ ),
+ ),
+ "CONTAINS(str, substr)": Construct(
+ "CONTAINS(str, substr)", Classification.DIRECT,
+ template="contains ( {0} , {1} )",
+ note="Returns boolean on both sides.",
+ ),
+ "STARTSWITH(str, prefix)": Construct(
+ "STARTSWITH(str, prefix)", Classification.DIRECT,
+ template="strpos ( {0} , {1} ) = 1",
+ note=(
+ "There is no native starts_with — live-verified 2026-07-29. "
+ "Still direct because the composition is exact and uses only "
+ "native functions: strpos is 1-based, so a true prefix sits "
+ "at position 1."
+ ),
+ ),
+ "ENDSWITH(str, suffix)": Construct(
+ "ENDSWITH(str, suffix)", Classification.DIRECT,
+ template="substr ( {0} , strlen ( {0} ) - strlen ( {1} ) , strlen ( {1} ) ) = {1}",
+ note=(
+ "There is no native ends_with — live-verified 2026-07-29. "
+ "Direct by composition, as STARTSWITH."
+ ),
+ ),
+ "REGEXP_LIKE(str, pattern)": Construct(
+ "REGEXP_LIKE(str, pattern)", Classification.PASSTHROUGH,
+ template="REGEXP_LIKE({0}, {1})",
+ variant=Variant.BOOL,
+ note=(
+ "Boolean return, so not sql_string_op. ThoughtSpot has no "
+ "regular-expression support of any kind."
+ ),
+ ),
+ "REGEXP_EXTRACT(str, pattern)": Construct(
+ "REGEXP_EXTRACT(str, pattern)", Classification.PASSTHROUGH,
+ template="REGEXP_SUBSTR({0}, {1})",
+ variant=Variant.STRING,
+ note=(
+ "The function name inside the template is dialect-specific — "
+ "Snowflake spells it REGEXP_SUBSTR, others REGEXP_EXTRACT — so "
+ "the converter selects it from the connection's dialect and "
+ "raises an issue when the dialect is unknown."
+ ),
+ ),
+ "REGEXP_REPLACE(str, pattern, replacement)": Construct(
+ "REGEXP_REPLACE(str, pattern, replacement)", Classification.PASSTHROUGH,
+ template="REGEXP_REPLACE({0},{1},{2})",
+ variant=Variant.STRING,
+ note=(
+ "Name is portable; the pattern dialect (POSIX vs PCRE, "
+ "backreference syntax) is not."
+ ),
+ ),
+ "REGEXP_COUNT(str, pattern)": Construct(
+ "REGEXP_COUNT(str, pattern)", Classification.PASSTHROUGH,
+ template="REGEXP_COUNT({0}, {1})",
+ variant=Variant.INT,
+ note="Integer return.",
+ ),
+ }
+)
+
+# --------------------------------------------------------------------------
+# Mathematical + Conditional functions — 34 rows: 32 direct /
+# 2 passthrough / 0 unmappable.
+# Source: docs/ossie/ts-ossie-function-mapping.md, "Mathematical functions" and
+# "Conditional functions" sections (thoughtspot-agent-skills repo — not
+# vendored here; prose above/below the tables read in full).
+#
+# Nearly every row here is direct, several by composition: SIGN has
+# no native function but composes exactly as a three-way `if` chain — the
+# trailing `else 0` is mandatory, ThoughtSpot rejects an `if` with no `else`.
+# RADIANS/DEGREES are bare dialect-free arithmetic, not passthroughs. PI is a
+# literal at the precision ThoughtSpot's own documented trig composites use.
+# ThoughtSpot's trigonometry is degrees-native while the specification is
+# radians-native, so SIN/COS/TAN convert degrees->radians on the way in
+# (`* 180 / pi`) and ASIN/ACOS/ATAN convert radians->degrees on the way out
+# (`* pi / 180`) — opposite directions, easy to transpose by mistake.
+# GREATEST/LEAST are deliberately NOT mapped to max/min: ThoughtSpot's max/min
+# are aggregate-only, so that mapping would both collapse the row-wise N-ary
+# result to one value and flip it from attribute to measure.
+#
+# Only two rows are passthrough: TRUNC/TRUNCATE (no native truncation — floor
+# only agrees with it for x >= 0, d = 0, and round disagrees at every
+# half-value) and ATAN2 (quadrant-aware and defined where x = 0, so it is not
+# a two-argument ATAN composition, unlike every other inverse trig function
+# in this family).
+# --------------------------------------------------------------------------
+CATALOG.update(
+ {
+ "ABS(x)": Construct(
+ "ABS(x)", Classification.DIRECT, template="abs ( {0} )",
+ ),
+ "ROUND(x, d)": Construct(
+ "ROUND(x, d)", Classification.DIRECT, template="round ( {0} , {1} )",
+ ),
+ "FLOOR(x)": Construct(
+ "FLOOR(x)", Classification.DIRECT, template="floor ( {0} )",
+ ),
+ "CEIL(x)": Construct(
+ "CEIL(x)", Classification.DIRECT, template="ceil ( {0} )",
+ note="Specification alias pair CEIL(x) / CEILING(x); both spellings map to ceil.",
+ ),
+ "TRUNC(x, d)": Construct(
+ "TRUNC(x, d)", Classification.PASSTHROUGH,
+ template="TRUNC({0}, {1})", variant=Variant.DOUBLE,
+ note=(
+ "Specification alias pair TRUNC(x, d) / TRUNCATE(x, d). "
+ "ThoughtSpot has no truncation function. floor agrees with "
+ "TRUNC only for x >= 0 and d = 0, and round disagrees at every "
+ "half-value, so neither is a safe substitute."
+ ),
+ ),
+ "MOD(x, y)": Construct(
+ "MOD(x, y)", Classification.DIRECT, template="mod ( {0} , {1} )",
+ note="Sign-of-result for negative operands follows the warehouse on both sides.",
+ ),
+ "SIGN(x)": Construct(
+ "SIGN(x)", Classification.DIRECT,
+ template="if ( {0} > 0 ) then 1 else if ( {0} < 0 ) then -1 else 0",
+ note=(
+ "No native sign, but the three-way result is exactly "
+ "expressible as an if chain. The else 0 is required — "
+ "ThoughtSpot rejects an if chain with no else."
+ ),
+ ),
+ "POWER(x, y)": Construct(
+ "POWER(x, y)", Classification.DIRECT, template="pow ( {0} , {1} )",
+ note="The function is pow. power is rejected by the parser.",
+ ),
+ "SQRT(x)": Construct(
+ "SQRT(x)", Classification.DIRECT, template="sqrt ( {0} )",
+ ),
+ "EXP(x)": Construct(
+ "EXP(x)", Classification.DIRECT, template="exp ( {0} )",
+ ),
+ "LN(x)": Construct(
+ "LN(x)", Classification.DIRECT, template="ln ( {0} )",
+ ),
+ "LOG(base, x)": Construct(
+ "LOG(base, x)", Classification.DIRECT,
+ template="safe_divide ( ln ( {1} ) , ln ( {0} ) )",
+ note=(
+ "ThoughtSpot has fixed-base log2 and log10 only; base is a "
+ "runtime argument here, not a literal known at catalog time, "
+ "so the general change-of-base composition is the one "
+ "template that is exact for every base. safe_divide rather "
+ "than / guards base = 1."
+ ),
+ ),
+ "LOG10(x)": Construct(
+ "LOG10(x)", Classification.DIRECT, template="log10 ( {0} )",
+ ),
+ "SIN(x)": Construct(
+ "SIN(x)", Classification.DIRECT,
+ template="sin ( {0} * 180 / 3.14159265358979 )",
+ note=(
+ "ThoughtSpot trigonometry is in degrees; the specification is "
+ "in radians. The conversion is mandatory — a bare sin ( {0} ) "
+ "returns the sine of x degrees and is wrong for every "
+ "non-zero input."
+ ),
+ ),
+ "COS(x)": Construct(
+ "COS(x)", Classification.DIRECT,
+ template="cos ( {0} * 180 / 3.14159265358979 )",
+ note="Degrees, as SIN.",
+ ),
+ "TAN(x)": Construct(
+ "TAN(x)", Classification.DIRECT,
+ template="tan ( {0} * 180 / 3.14159265358979 )",
+ note="Degrees, as SIN.",
+ ),
+ "ASIN(x)": Construct(
+ "ASIN(x)", Classification.DIRECT,
+ template="( asin ( {0} ) * 3.14159265358979 / 180 )",
+ note=(
+ "Inverse functions convert the other way: ThoughtSpot returns "
+ "degrees, the specification expects radians."
+ ),
+ ),
+ "ACOS(x)": Construct(
+ "ACOS(x)", Classification.DIRECT,
+ template="( acos ( {0} ) * 3.14159265358979 / 180 )",
+ note="Degrees -> radians, as ASIN.",
+ ),
+ "ATAN(x)": Construct(
+ "ATAN(x)", Classification.DIRECT,
+ template="( atan ( {0} ) * 3.14159265358979 / 180 )",
+ note="Degrees -> radians, as ASIN.",
+ ),
+ "ATAN2(y, x)": Construct(
+ "ATAN2(y, x)", Classification.PASSTHROUGH,
+ template="ATAN2({0}, {1})", variant=Variant.DOUBLE,
+ note=(
+ "atan2 is not a two-argument atan — it is quadrant-aware and "
+ "defined where x = 0. Composing it from atan plus sign tests "
+ "is possible but the branch table is easy to get wrong at the "
+ "axes, so the pass-through is the honest mapping."
+ ),
+ ),
+ "RADIANS(degrees)": Construct(
+ "RADIANS(degrees)", Classification.DIRECT,
+ template="{0} * 3.14159265358979 / 180",
+ note="No native radians; the arithmetic is exact and dialect-free.",
+ ),
+ "DEGREES(radians)": Construct(
+ "DEGREES(radians)", Classification.DIRECT,
+ template="{0} * 180 / 3.14159265358979",
+ note="No native degrees; as RADIANS.",
+ ),
+ "PI()": Construct(
+ "PI()", Classification.DIRECT, template="3.14159265358979",
+ note=(
+ "No native pi. The literal is emitted at the precision "
+ "ThoughtSpot's own documented composites use; "
+ 'sql_double_op ( "pi()" ) is available where full warehouse '
+ "precision matters."
+ ),
+ ),
+ "GREATEST(x, y, ...)": Construct(
+ "GREATEST(x, y, ...)", Classification.DIRECT,
+ template="greatest ( {0} , {1} , ... )",
+ note=(
+ "Not max. ThoughtSpot's max is an aggregate; greatest is the "
+ "row-wise N-ary function. Mapping GREATEST to max would "
+ "collapse the column to one value and also flip it from "
+ "attribute to measure."
+ ),
+ ),
+ "LEAST(x, y, ...)": Construct(
+ "LEAST(x, y, ...)", Classification.DIRECT,
+ template="least ( {0} , {1} , ... )",
+ note="Not min, for the same reason as GREATEST.",
+ ),
+ "IF(condition, true_result, false_result)": Construct(
+ "IF(condition, true_result, false_result)", Classification.DIRECT,
+ template="if ( {0} ) then {1} else {2}",
+ note=(
+ "The parentheses around the condition are mandatory for TML "
+ "import — without them the parser reports \"Expecting keyword "
+ "'('\". Applies to every condition shape, including a bare "
+ "BOOL column reference."
+ ),
+ ),
+ "IFF(condition, true_result, false_result)": Construct(
+ "IFF(condition, true_result, false_result)", Classification.DIRECT,
+ template="if ( {0} ) then {1} else {2}",
+ note="Specification alias for IF.",
+ ),
+ "NULLIF(expr1, expr2)": Construct(
+ "NULLIF(expr1, expr2)", Classification.DIRECT,
+ template="nullif ( {0} , {1} )",
+ ),
+ "COALESCE(expr1, expr2, ...)": Construct(
+ "COALESCE(expr1, expr2, ...)", Classification.DIRECT,
+ template="ifnull ( {0} , ifnull ( {1} , {2} ) )",
Review Comment:
COALESCE's DIRECT template hardcodes exactly 3 arguments, but the comment above it notes 2 is the common case. `emit_direct(CATALOG["COALESCE(expr1, expr2, ...)"], ["a", "b"])` raises `ValueError: ...expects 3 arguments, got 2`. Same failure for 4+. The two most common real-world arities both crash.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "djwaldo (via GitHub)" <gi...@apache.org>.
djwaldo commented on PR #364:
URL: https://github.com/apache/ossie/pull/364#issuecomment-5706394740
i've Rebased onto current `main` and migrated to the single-model document format from #383, the model's fields now sit at the document root, and a document still carrying the `semantic_model` wrapper is rejected by name rather than unwrapped, matching #396.
823 tests pass on 3.10–3.14, offline. Both fixtures and the emitted output validate against the current `core-spec/ossie-schema.json`, and `to-ossie` → `to-tml` round-trips the TPC-DS fixture end to end.
Two things I can't do from this side:
- **CI has never run.** Every workflow on the branch sits at `action_required`, so it needs a
committer to approve the run.
- **No reviewer yet.** Open since 7 September.
Could a committer approve CI and take a look?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "djwaldo (via GitHub)" <gi...@apache.org>.
djwaldo commented on PR #364:
URL: https://github.com/apache/ossie/pull/364#issuecomment-5657588763
Rebased onto current `main`, the branch is up to date and the tests are green (816, offline,
Python 3.10–3.14).
One thing I cannot do from my side: **no CI has run on this PR since it was opened.** I believe
it needs a maintainer to approve the workflow run, since this is a fork PR and I have not had
one approved on this repo before. `converter-thoughtspot-ci.yml` is in the branch and its
`pull_request` path filter covers `converters/thoughtspot/**`, so it should pick up once
approved.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "jbonofre (via GitHub)" <gi...@apache.org>.
jbonofre commented on PR #364:
URL: https://github.com/apache/ossie/pull/364#issuecomment-5814073436
@djwaldo amazing work! Thanks for the complete pass! I don't have ThoughtSpot cluster and I'm not ThoughtSpot expert as you are 😄 I just checked the code.
Let me do another pass, but I propose to merge quickly (we can always improve the converter later).
Thanks again!
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "jbonofre (via GitHub)" <gi...@apache.org>.
jbonofre commented on code in PR #364:
URL: https://github.com/apache/ossie/pull/364#discussion_r4094499659
##########
converters/thoughtspot/src/ossie_thoughtspot/cli.py:
##########
@@ -0,0 +1,283 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Command-line interface for the Apache Ossie <-> ThoughtSpot TML converter.
+
+ ossie-thoughtspot to-ossie <tml-file>... -o <out.yaml> [--issues <issues.json>]
+ ossie-thoughtspot to-tml <ossie.yaml> -o <out-dir> [--issues <issues.json>]
+
+`to-ossie` reads a Model TML document plus the Table/SQL View documents it references
+and writes one Apache Ossie semantic model. `to-tml` reads one Apache Ossie semantic
+model and writes the corresponding TML document set -- one file per document, tables
+before the model -- into an output directory.
+
+`-o`/`--output` is required in both directions: `to-ossie` writes exactly one file, and
+`to-tml` writes a set of files that has no single-file stdout representation, so unlike
+some sibling converters there is no "default: stdout" fallback here.
+
+Every declared loss or degradation the conversion records is written as a JSON array of
+issues -- to `--issues` when given, to stderr otherwise -- and never mixed into the
+document output. The process exits 1 when that issue log contains an ERROR-severity
+issue, 0 otherwise: a conversion that only warned or informed about a declared loss is
+still a successful conversion, and failing the exit code on a warning would just teach
+scripts to ignore it.
+
+Neither subcommand overwrites an existing output file unless `--force` is given; without
+it, a target that already exists refuses the run before anything is written.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+from . import _yaml, tml, tml_to_ossie, ossie_to_thoughtspot
+from .errors import ConversionError
+from .issues import IssueLog
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="ossie-thoughtspot",
+ description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ sub = parser.add_subparsers(dest="command")
+ sub.required = True # set as attribute (the add_subparsers kwarg is 3.7+)
+
+ to_ossie = sub.add_parser(
+ "to-ossie", help="ThoughtSpot TML documents -> one Apache Ossie semantic model"
+ )
+ to_ossie.add_argument(
+ "tml_files",
+ nargs="+",
+ metavar="TML_FILE",
+ help="the Model TML document plus every Table/SQL View document it references",
+ )
+ to_ossie.add_argument(
+ "-o", "--output", required=True, metavar="FILE", help="output Apache Ossie YAML file"
+ )
+ to_ossie.add_argument(
+ "--issues",
+ metavar="FILE",
+ help="write the issue log as JSON to this path (default: stderr)",
+ )
+ to_ossie.add_argument(
+ "--force",
+ action="store_true",
+ help="overwrite the output file (and --issues file) if either already exists; "
+ "without this flag, an existing target refuses the run before writing anything",
+ )
+
+ to_tml = sub.add_parser(
+ "to-tml", help="one Apache Ossie semantic model -> ThoughtSpot TML documents"
+ )
+ to_tml.add_argument("ossie_file", metavar="OSSIE_FILE", help="Apache Ossie YAML document")
+ to_tml.add_argument(
+ "-o",
+ "--output",
+ required=True,
+ metavar="DIR",
+ help="output directory; one TML file per document is written here (tables before "
+ "the model), created if it does not already exist",
+ )
+ to_tml.add_argument(
+ "--issues",
+ metavar="FILE",
+ help="write the issue log as JSON to this path (default: stderr)",
+ )
+ to_tml.add_argument(
+ "--force",
+ action="store_true",
+ help="overwrite output files (and --issues file) if any already exist; without "
+ "this flag, an existing target refuses the run before writing anything",
+ )
+ return parser
+
+
+def _safe_target_path(directory: Path, filename: str) -> Path:
+ """`directory / filename`, refusing to resolve outside `directory`.
+
+ `tml.dump_document_set` already sanitises `filename` (a document's `name` is
+ user-controlled TML content, and a hostile one -- `../../etc/passwd` -- previously
+ produced a path that escaped its output directory), so this should never trigger in
+ practice. It exists anyway because this module is the first caller that actually
+ writes these documents to disk, and a defence that lives only in the dumper is not
+ one this module can verify it still has. Both paths are resolved before comparing --
+ a naive string-prefix check is wrong on a filesystem with symlinks in play, e.g.
+ macOS where `/tmp` is a symlink to `/private/tmp`.
+ """
+ resolved_directory = directory.resolve()
+ target = (resolved_directory / filename).resolve()
+ # `==`, not `!=`: a filename resolving to the output directory ITSELF is the
+ # case this guard exists to catch, and the old `target != resolved_directory
+ # and ...` spelling made that case short-circuit to False and pass.
+ if target == resolved_directory or resolved_directory not in target.parents:
+ raise ConversionError(
+ f"refusing to write {filename!r}: it resolves outside the output directory "
+ f"{resolved_directory}"
+ )
+ return target
+
+
+def _colliding(paths: list[Path]) -> list[Path]:
+ """Any path this run would write more than once.
+
+ Compared after `resolve()`, because two spellings of one file (a relative
+ path and an absolute one, a symlinked directory) are the same file on disk.
+ Checked BEFORE any write: `--issues` pointing at `-o` passed the
+ does-it-already-exist check on a fresh run, and then `_write_issues`
+ replaced the converted document with the JSON issue log and the command
+ exited 0. `--force` does not license this -- it permits overwriting files
+ that were already there, not destroying one of this run's own outputs.
+ """
+ # Case-folded as well as resolved. `Path.resolve()` does not fold case, and
+ # the default filesystem on macOS and Windows does -- so `-o out.yaml
+ # --issues OUT.YAML` named one file, slipped past this very guard, and the
+ # issue log overwrote the converted document with exit 0. `tml.py` already
+ # folds for exactly this reason; this guard was written a day later and did
+ # not, which is how the defect it exists to prevent survived its own fix.
+ seen: dict[str, Path] = {}
+ counts: dict[str, int] = {}
+ for path in paths:
+ key = str(path.resolve()).casefold()
+ seen.setdefault(key, path.resolve())
+ counts[key] = counts.get(key, 0) + 1
+ return sorted(seen[key] for key, count in counts.items() if count > 1)
+
+
+def _refuse_collision(paths: list[Path]) -> str:
+ names = ", ".join(str(p) for p in paths)
+ return (
+ f"refusing to write the same path twice in one run: {names}. "
+ f"--issues must name a different file from the converted output."
+ )
+
+
+def _existing(paths: list[Path]) -> list[Path]:
+ return [p for p in paths if p.exists()]
+
+
+def _refuse_overwrite(paths: list[Path]) -> str:
+ names = ", ".join(str(p) for p in paths)
+ return f"refusing to overwrite existing file(s): {names} (pass --force to overwrite)"
+
+
+def _write_issues(issues: IssueLog, issues_path: Path | None) -> None:
+ """Issues as a JSON array -- to `issues_path` when given, to stderr otherwise.
+
+ Always written, even when empty: a caller scripting against this output should be
+ able to rely on the shape (a JSON array) rather than on whether anything was said.
+ """
+ text = json.dumps(issues.as_dicts(), indent=2)
+ if issues_path is not None:
+ issues_path.parent.mkdir(parents=True, exist_ok=True)
+ issues_path.write_text(text + "\n", encoding="utf-8")
+ else:
+ print(text, file=sys.stderr)
+
+
+def _cmd_to_ossie(args: argparse.Namespace) -> int:
+ output_path = Path(args.output)
+ issues_path = Path(args.issues) if args.issues else None
+
+ try:
+ texts = [(path, Path(path).read_text(encoding="utf-8")) for path in args.tml_files]
+ document_set = tml.load_document_set(texts)
+ result = tml_to_ossie.convert(document_set)
+ except (ConversionError, OSError, UnicodeDecodeError) as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
+
+ targets = [output_path] + ([issues_path] if issues_path else [])
+ collisions = _colliding(targets)
+ if collisions:
+ print(f"Error: {_refuse_collision(collisions)}", file=sys.stderr)
+ return 1
+ if not args.force:
+ existing = _existing(targets)
+ if existing:
+ print(f"Error: {_refuse_overwrite(existing)}", file=sys.stderr)
+ return 1
+
+ try:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(_yaml.dump(result.model), encoding="utf-8")
+ _write_issues(result.issues, issues_path)
+ except OSError as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
+
Review Comment:
This only checks whether targets already exists: it doesn't detect duplicate paths among the generated files and `--issues`.
If `--issues` is equal to a new output path, the conversion writes the document and then overwrites it with issue JSON.
I think (as a follow-up) we should reject colliding paths before writing. The same problem exists in to-ossie.
##########
converters/thoughtspot/src/ossie_thoughtspot/tml_to_ossie.py:
##########
@@ -0,0 +1,2567 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Convert a ThoughtSpot Model column into an Ossie field.
+
+A ThoughtSpot Model `columns[]` entry becomes an Ossie field when it declares
+`column_type: ATTRIBUTE`. A `MEASURE` column belongs to a metric instead, converted
+elsewhere — this module returns `None` for one rather than building a field that would
+duplicate the metric conversion.
+
+Four properties of the mapping are easy to get subtly wrong, because getting them wrong
+still produces a document that imports and looks plausible.
+
+The formula string carried in the THOUGHTSPOT dialect entry is the exact `expr` text from
+the source document, untouched — never rebuilt from a parsed name/arguments shape. The
+reverse direction reads this entry, and the two are compared by exact string equality, so
+any reformatting here — even whitespace-only — breaks that comparison on the way back,
+regardless of how well the rest of the conversion went.
+
+A portable ANSI_SQL sibling is only ever added next to that verbatim entry, and only when
+it can be produced with certainty rather than a guess: a bare column reference is the one
+shape handled here. Anything else — a function call, an expression combining several
+references, a reference this document's resolver cannot place — is left as
+THOUGHTSPOT-only, with an issue recording why, rather than emitting a translation nobody
+checked.
+
+A field's identifier and its display label are two different values. `name` is a
+normalised, portable identifier derived from the ThoughtSpot column's display name;
+`label` carries that display name exactly as written. Writing the display name into
+`name`, or the normalised form into `label`, silently breaks both. When the display
+name has no ASCII form at all for `identifiers.normalise` to fold onto (a CJK-only,
+Cyrillic-only, or Greek-only name), `name` falls back to a different, still-usable
+identifier instead of raising — see `_field_or_metric_identifier` — while `label`
+still carries the exact original, unaffected either way.
+
+Finally, a computed column is model-scoped in ThoughtSpot but has to live inside exactly
+one dataset in Ossie. It is attributed to the dataset every one of its column references
+resolves to. When those references disagree — two or more different datasets, one that
+cannot be resolved at all, or none at all to go on — no dataset is obviously correct, so
+none is guessed: the attribution fails, an issue records why, and the field is not built.
+Preserving the formula for a caller's model-level stash is that caller's job from there —
+this module only sees one column at a time and has no access to the enclosing document.
+
+A `MEASURE` column becomes a metric instead of a field, built by `convert_metric` below.
+Its one genuinely tricky rule is easy to get backwards in a way that still imports cleanly
+and produces wrong numbers: the surfacing column's `aggregation` is load-bearing on a
+`column_id` metric and on a *scalar*-formula metric (the two compose — `AGG(<scalar
+expr>)`, never the bare scalar) but a no-op on a formula whose own outer call already
+aggregates (`sum ( ... )`, `group_aggregate ( ... )`, ...) — a common, correct shape
+ThoughtSpot's UI produces routinely, so discarding a redundant column aggregation there
+is silent by design. Composing when the rule says no-op, or leaving bare when the rule
+says compose, silently changes the grain the metric evaluates at while the model still
+imports. There is a third, rarer case an outer-call check alone cannot see: an aggregate
+*nested inside* a still-scalar outer call, as in `round ( sum ( ... ) , 2 )` — `round` is
+not itself an aggregate, but the expression as a whole already is one. That case is the
+one worth a warning, because it is the one shape where a reader might reasonably expect
+composition and not get it. `_outer_call_is_aggregate` decides the first two cases;
+`_contains_aggregate_call` — checked only once the outer call is not itself an aggregate —
+decides the third. Both read ThoughtSpot's aggregate call names off the same expression
+catalog `_compose_aggregate_entries` uses to build the composed rendering — one source for
+every one of these jobs, so they cannot silently drift apart the way independently
+hand-typed lists
+could. And unlike a field, a metric has no `label`: when identifier normalisation changes the
+identifier, the exact display name has nowhere to go but the `custom_extensions` stash.
+"""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Callable
+
+from . import datatypes, formula, identifiers, keys, stash
+from .constants import (
+ GROUP_AGGREGATE_CALL_NAMES,
+ DATASET_STASH_ALIAS,
+ DATASET_STASH_CONNECTION_NAME,
+ DATASET_STASH_SOURCE_PARTS,
+ DATASET_STASH_SOURCE_PARTS_DB,
+ DATASET_STASH_SOURCE_PARTS_DB_TABLE,
+ DATASET_STASH_SOURCE_PARTS_SCHEMA,
+ DATASET_STASH_SQL_OUTPUT_COLUMNS,
+ DATASET_STASH_TABLE_NAME,
+ DATASET_STASH_TML_OBJECT,
+ DATASET_STASH_TML_OBJECT_WITNESS,
+ DATASET_STASH_UNSURFACED_COLUMNS,
+ DIALECT,
+ DOCUMENT_VERSION,
+ FIELD_STASH_COLUMN_PROPERTIES,
+ FIELD_STASH_DATA_TYPE,
+ FIELD_STASH_DATA_TYPE_WITNESS,
+ FIELD_STASH_DB_COLUMN_NAME,
+ FIELD_STASH_FORMULA_ID,
+ FIELD_STASH_DB_COLUMN_NAME_WITNESS,
+ METRIC_STASH_COLUMN_AGGREGATION,
+ METRIC_SHAPE_COLUMN_AGGREGATION,
+ METRIC_SHAPE_FORMULA,
+ METRIC_SHAPE_SCALAR_FORMULA_PLUS_AGGREGATION,
+ METRIC_STASH_SHAPE,
+ MODEL_STASH_ACTION_OBJECT_ASSOCIATIONS,
+ MODEL_STASH_COLUMN_GROUPS,
+ MODEL_STASH_CONSTRAINTS,
+ MODEL_STASH_FILTERS,
+ MODEL_STASH_LESSON_PLANS,
+ MODEL_STASH_MODEL_JOINS_WITH,
+ MODEL_STASH_MODEL_PROPERTIES,
+ MODEL_STASH_PARAMETERS,
+ MODEL_STASH_UNSURFACED_FORMULAS,
+ MODEL_STASH_UNATTRIBUTED_FORMULAS,
+ MODEL_STASH_UNREPRESENTABLE_JOINS,
+ PORTABLE_DIALECT,
+ RELATIONSHIP_STASH_CARDINALITY,
+ RELATIONSHIP_STASH_ENDPOINTS_SWAPPED,
+ RELATIONSHIP_STASH_ENDPOINTS_SWAPPED_WITNESS,
+ RELATIONSHIP_STASH_JOIN_SHAPE,
+ RELATIONSHIP_STASH_ON_EXPRESSION,
+ RELATIONSHIP_STASH_ON_EXPRESSION_WITNESS,
+ RELATIONSHIP_STASH_REFERENCING_JOIN,
+ RELATIONSHIP_STASH_TYPE,
+ MODEL_STASH_OBJ_ID,
+ STASH_TML_NAME,
+)
+from .errors import ConversionError
+from .expressions import CATALOG, Variant, emit_direct
+from .issues import IssueLog, Severity
+from .tml import DocumentSet
+
+
+def expression_entries(
+ expr: str,
+ resolve: Callable[[str, str], str | None],
+ log: IssueLog,
+ *,
+ object_ref: str,
+ kind: str = "field",
+) -> list[dict[str, str]]:
+ """The dialect entries for one ThoughtSpot expression.
+
+ The THOUGHTSPOT entry always comes first and always carries `expr` unmodified —
+ whatever else this function decides, that entry is what makes the expression
+ recoverable later, character for character. A second, ANSI_SQL entry is appended
+ only when the whole expression is a bare column reference the resolver can place in
+ a dataset. Every other shape — a runtime parameter, a formula cross-reference, an
+ unresolvable reference, a function call, a compound expression — gets an issue
+ instead of a guessed translation, and only the verbatim entry is returned.
+
+ A runtime parameter and a formula cross-reference are the same textual shape (a
+ bracketed name with no `::` qualifier) but different constructs, and are reported
+ under different codes for it: `formula.find_formula_refs`/`find_parameter_refs`
+ share one definition of which is which (`formula.FORMULA_REFERENCE_PREFIX`) so this
+ function and the Ossie -> TML model builder — which mints exactly this prefix and
+ rewrites references that carry it — cannot silently disagree about the convention.
+ Both are logged when both are present in the same expression, each under its own
+ code, rather than one masking the other.
+
+ `kind` names the Ossie object this expression belongs to ("field" or "metric") —
+ used only in issue text, so a metric's non-portability issue reads "...evaluate
+ this metric" rather than the field-shaped default. `object_ref` already carries
+ this distinction (`field:...` vs `metric:...`); `kind` exists so the message
+ itself agrees with it instead of contradicting it.
+ """
+ entries: list[dict[str, str]] = [{"dialect": DIALECT, "expression": expr}]
+
+ # `dict.fromkeys` dedupes while preserving first-seen order -- a
+ # parameter or cross-reference used twice in one expression (a
+ # discount applied on both sides of a ratio, say) is one fact worth
+ # reporting once, not a message that reads as two distinct unresolved
+ # names when only one name repeats.
+ formula_refs = list(dict.fromkeys(formula.find_formula_refs(expr)))
+ parameters = list(dict.fromkeys(formula.find_parameter_refs(expr)))
+ if formula_refs or parameters:
+ if formula_refs:
+ log.add(
+ code="TS-EXPR-FORMULA-REFERENCE",
+ severity=Severity.INFO,
+ message=(
+ f"expression references the formula(s) {', '.join(formula_refs)} "
+ f"by cross-reference; a portable sibling would require inlining "
+ f"the referenced formula's own expression, which this converter "
+ f"does not attempt, so only the THOUGHTSPOT dialect entry is "
+ f"emitted"
+ ),
+ object_ref=object_ref,
+ )
+ if parameters:
+ log.add(
+ code="TS-EXPR-PARAM",
+ severity=Severity.WARNING,
+ message=(
+ f"expression references the ThoughtSpot runtime parameter(s) "
+ f"{', '.join(parameters)}, which have no Ossie equivalent; no "
+ f"portable expression is emitted"
+ ),
+ object_ref=object_ref,
+ )
+ return entries
+
+ bare = formula.is_bare_column_ref(expr)
+ if bare is not None:
+ target = resolve(*bare)
+ if target is None:
+ # INFO, not WARNING. A formula may reference any column of a
+ # joined table, including one the model does not surface as a
+ # column of its own -- ordinary modelling, and there is then no
+ # Ossie field for a portable expression to name. Nothing is lost:
+ # the THOUGHTSPOT dialect entry carries the expression verbatim.
+ #
+ # This is the same fact `TS-EXPR-THOUGHTSPOT-ONLY` already records
+ # at INFO -- "not portable", not "something is wrong" -- so a
+ # WARNING here was inconsistent as well as noisy: it fired 346
+ # times across 30 real models.
+ log.add(
+ code="TS-EXPR-UNRESOLVED",
+ severity=Severity.INFO,
+ message=(
+ f"reference {identifiers.format_column_ref(*bare)} names no "
+ f"field this model surfaces, so no portable ANSI_SQL sibling "
+ f"is emitted; the THOUGHTSPOT expression carries it verbatim"
+ ),
+ object_ref=object_ref,
+ )
+ return entries
+ entries.append({"dialect": PORTABLE_DIALECT, "expression": target})
+ return entries
+
+ # Anything else is a function call or a multi-reference expression. Producing a
+ # portable sibling for one would need an expression tree this converter does not
+ # build — see the module docstring — so the non-portability is recorded instead
+ # of guessed at.
+ log.add(
+ code="TS-EXPR-THOUGHTSPOT-ONLY",
+ severity=Severity.INFO,
+ message=(
+ "expression is emitted in the THOUGHTSPOT dialect only; a consumer that "
+ f"does not implement it will not be able to evaluate this {kind}"
+ ),
+ object_ref=object_ref,
+ )
+ return entries
+
+
+def attribute_dataset(
+ expr: str,
+ resolve: Callable[[str, str], str | None],
+ log: IssueLog,
+ *,
+ object_ref: str,
+) -> str | None:
+ """The single dataset every column reference in `expr` resolves to, or `None`.
+
+ A computed column has to be placed inside exactly one Ossie dataset. That is safe
+ only when every reference in the expression agrees on the same one: no references
+ at all is no evidence to place it by, a reference the resolver cannot place is
+ missing evidence, and references landing in two or more datasets is contradictory
+ evidence. Each of those returns `None` and logs why, rather than falling back to
+ the first candidate found or any other default — a wrong guess here would silently
+ move a field into the wrong dataset, or invent a home for one that references
+ nothing at all.
+
+ Runtime parameter references carry no dataset and take no part in this decision;
+ an expression can be fully attributed while still not being portable, and
+ `expression_entries` is what reports the latter.
+ """
+ refs = formula.find_column_refs(expr)
+ if not refs:
+ log.add(
+ code="TS-FIELD-NO-REFERENCES",
+ severity=Severity.WARNING,
+ message=(
+ "expression contains no column references, so it cannot be "
+ "attributed to a dataset"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ datasets: list[str] = []
+ unresolved: list[str] = []
+ for table, column in refs:
+ target = resolve(table, column)
+ if target is None:
+ unresolved.append(identifiers.format_column_ref(table, column))
+ continue
+ dataset = target.split(".", 1)[0]
+ if dataset not in datasets:
+ datasets.append(dataset)
+
+ if unresolved:
+ log.add(
+ code="TS-FIELD-UNRESOLVED-REFERENCE",
+ severity=Severity.WARNING,
+ message=(
+ f"reference(s) {', '.join(unresolved)} resolve to no dataset field; "
+ f"the expression cannot be attributed with confidence"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ if len(datasets) > 1:
+ log.add(
+ code="TS-FIELD-UNATTRIBUTED",
+ severity=Severity.WARNING,
+ message=(
+ f"references resolve to {len(datasets)} different datasets "
+ f"({', '.join(datasets)}); a computed field spanning more than one "
+ f"dataset cannot be attributed, and should be preserved as an "
+ f"unattributed formula rather than emitted as a field"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ return datasets[0]
+
+
+def _physical_datatype(
+ table_name: str,
+ column_name: str,
+ table_lookup: Callable[[str], dict | None],
+ log: IssueLog,
+ *,
+ object_ref: str,
+ kind: str = "field",
+) -> str | None:
+ """The Ossie datatype for a physical column, or `None` when it cannot be found.
+
+ Matched by the physical column's own display name — what a Model `column_id`
+ suffix names — not by its warehouse column name.
+
+ `None` covers two different situations, and only one of them is a loss worth
+ logging. A column with no `data_type` at all has nothing to drop — `datatype`
+ is optional in Ossie, and `datatypes.to_ossie` documents omission as a
+ legitimate answer, so this stays silent. A column whose `data_type` *is*
+ present but unrecognised by `datatypes.to_ossie` is different: the warehouse
+ told us the type and it is about to be dropped on the floor, so that case
+ logs an issue naming the type before returning `None`.
+
+ `kind` ("field" or "metric") names the Ossie object being built, both in the
+ issue code (`TS-FIELD-...` vs `TS-METRIC-...`) and in the message text, so a
+ metric calling this does not raise a `TS-FIELD-*` code or say "field" about
+ itself — `object_ref` already says `metric:...`, and the code and message
+ need to agree with it.
+ """
+ code_prefix = f"TS-{kind.upper()}"
+ table = table_lookup(table_name)
+ physical = None
+ if table is not None:
+ for candidate in table.get("columns", []):
+ if candidate.get("name") == column_name:
+ physical = candidate
+ break
+ if physical is None:
+ log.add(
+ code=f"{code_prefix}-PHYSICAL-COLUMN-MISSING",
+ severity=Severity.WARNING,
+ message=(
+ f"physical column {column_name!r} was not found on table "
+ f"{table_name!r}; no datatype is emitted for this {kind}"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+ data_type = (physical.get("db_column_properties") or {}).get("data_type")
+ if data_type is None:
+ return None
+ ossie_type = datatypes.to_ossie(data_type)
+ if ossie_type is None:
+ log.add(
+ code=f"{code_prefix}-DATATYPE-UNMAPPED",
+ severity=Severity.WARNING,
+ message=(
+ f"physical column {column_name!r} on table {table_name!r} has "
+ f"warehouse data_type {data_type!r}, which has no Ossie "
+ f"equivalent; no datatype is emitted for this {kind}"
+ ),
+ object_ref=object_ref,
+ )
+ return ossie_type
+
+
+def _ai_context(properties: dict) -> dict | str | None:
+ """Fold synonyms and free-text AI context into one Ossie `ai_context` value.
+
+ Synonyms need the object form to have somewhere to live; free-text context on its
+ own stays a bare string, the simpler of the two shapes Ossie accepts.
+ """
+ synonyms = properties.get("synonyms")
+ instructions = properties.get("ai_context")
+ if synonyms and instructions:
+ return {"synonyms": list(synonyms), "instructions": instructions}
+ if synonyms:
+ return {"synonyms": list(synonyms)}
+ if instructions:
+ return instructions
+ return None
+
+
+def _physical_db_column_name(
+ table_name: str, column_name: str, table_lookup: Callable[[str], dict | None]
+) -> str | None:
+ """The warehouse `db_column_name` of the physical column matching
+ `column_name` (its own display name — what a Model `column_id` suffix
+ names) on `table_name`, or `None` when the table, the column, or a
+ `db_column_name` on it can't be found.
+
+ Used only as a fallback identifier basis — see
+ `_field_or_metric_identifier` — for a column whose *display* name has no
+ ASCII form: the underlying warehouse column name is almost always ASCII
+ even then, and unique within its table by construction, so it survives
+ where the display name doesn't.
+ """
+ table = table_lookup(table_name)
+ if table is None:
+ return None
+ physical = next(
+ (p for p in table.get("columns", []) if p.get("name") == column_name), None
+ )
+ if physical is None:
+ return None
+ return physical.get("db_column_name")
+
+
+def _resolve_name_collision(
+ built: dict,
+ siblings: list[dict],
+ log: IssueLog,
+ *,
+ kind: str,
+ display_name: str,
+ scope: str,
+) -> dict:
+ """Give `built` a name no sibling already holds, reporting if it had to.
+
+ Identifier normalisation is many-to-one -- `"Order Date"`, `"Order-Date"`
+ and `"order date"` all fold to `order_date` -- so two distinct TML columns
+ can arrive here wanting one Ossie identifier. Emitting both produces a
+ document with two same-named objects in one scope: `validation/validate.py`
+ rejects exact-string duplicates, but the deeper problem is that every
+ reference to that name is now ambiguous, and nothing said so.
+
+ Comparison folds case, matching `identifiers.Allocator` and for the same
+ reason: Ossie resolves regular identifiers case-insensitively
+ (`core-spec/expression_language.md:77`), so `Amount` and `amount` are one
+ name to a consumer even though upstream validation only catches the exact
+ match.
+
+ The first arrival keeps the plain name; later ones take `_2`, `_3`, ... The
+ rename is reported at WARNING rather than applied quietly -- a renamed field
+ is a field whose identifier no longer matches what the modeller typed, and
+ the author is the only one who can decide whether that matters.
+ """
+ taken = {sibling["name"].casefold() for sibling in siblings}
+ name = built["name"]
+ if name.casefold() not in taken:
+ return built
+
+ suffix = 1
+ candidate = name
+ while candidate.casefold() in taken:
+ suffix += 1
+ candidate = f"{name}_{suffix}"
+
+ log.add(
+ code=f"TS-{kind.upper()}-NAME-COLLISION",
+ severity=Severity.WARNING,
+ message=(
+ f"{kind} {display_name!r} normalises to identifier {name!r}, which "
+ f"another {kind} in {scope} already holds; it is emitted as "
+ f"{candidate!r} instead"
+ ),
+ object_ref=f"{kind}:{display_name}",
+ remedy=(
+ f"Rename one of the colliding ThoughtSpot columns if the generated "
+ f"identifier matters to downstream consumers."
+ ),
+ )
+ # `name` already exists as a key, so this preserves its position in the
+ # mapping and therefore the emitted YAML's key order.
+ return {**built, "name": candidate}
+
+
+def _field_or_metric_identifier(
+ display_name: str,
+ physical_hint: str | None,
+ allocator: identifiers.Allocator,
+ log: IssueLog,
+ *,
+ kind: str,
+ object_ref: str,
+) -> str:
+ """The Ossie identifier for a field or metric's ThoughtSpot display name.
+
+ The common case is `identifiers.normalise(display_name)`, unchanged. The
+ exceptional case — `display_name` has no ASCII alphanumerics for
+ `normalise` to fold onto (a CJK-only, Cyrillic-only, Greek-only, or
+ punctuation-only name) — used to propagate as `ValueError` out of
+ `convert_field`/`convert_metric` entirely, caught by `convert()`'s own
+ try/except and misreported as a malformed *column reference* (the
+ exception is the same type `identifiers.split_column_ref` raises for a
+ genuinely ambiguous reference, and `convert()` could not tell the two
+ apart from outside). That also meant the column was dropped rather than
+ converted — the same graceful-degradation gap `TS-MODEL-NAME-UNNORMALISABLE`
+ already closed at Model scope, here extended to Field/Metric scope, and
+ reported under its own code so the two failures are never conflated again.
+
+ The fallback identifier, in preference order:
+
+ 1. `physical_hint` — normally the underlying warehouse column's own
+ `db_column_name` (see `_physical_db_column_name`), for a
+ column_id-backed column. A warehouse identifier is almost always
+ ASCII even when the display name labelling it is not, and it is
+ unique within its own table by construction (two columns cannot
+ share one warehouse name) — so no collision-avoidance is needed for
+ this branch; it is naturally distinct the same way an ordinary,
+ successfully-normalised identifier is (this converter does not
+ collision-check those either, a pre-existing and separate gap — see
+ `_index_attribute_columns`).
+ 2. A fixed placeholder — `kind` itself, i.e. `"field"` or `"metric"` —
+ allocated through `allocator`. Reached only when there is no
+ `physical_hint` at all (a formula-backed column with no physical
+ grounding) or the hint itself also has no ASCII form. `allocator` is
+ shared by the caller across every column that can reach this branch,
+ so two columns that would otherwise both become `"field"` instead
+ become `"field"` and `"field_2"` — distinct, per the `Allocator`
+ collision-suffix contract in `identifiers.py`.
+
+ Either fallback always differs from `display_name`, so a caller that
+ already stashes the original display name whenever the identifier
+ differs from it (metrics do; fields carry it in `label` instead, which
+ is populated independently of this call and needs no stash) picks this
+ case up for free — nothing here writes a stash entry itself, only logs
+ the WARNING naming what happened.
+ """
+ try:
+ return identifiers.normalise(display_name)
+ except ValueError:
+ pass
+
+ fallback: str | None = None
+ if physical_hint:
+ try:
+ fallback = identifiers.normalise(physical_hint)
+ except ValueError:
+ fallback = None
+ source = "the underlying warehouse column name" if fallback is not None else "a placeholder"
+ if fallback is None:
+ fallback = allocator.allocate(kind)
+
+ log.add(
+ code=f"TS-{kind.upper()}-NAME-UNNORMALISABLE",
+ severity=Severity.WARNING,
+ message=(
+ f"{kind} name {display_name!r} has no ASCII alphanumerics for "
+ f"normalise() to fold onto; {source} is used as its identifier "
+ f"instead: {fallback!r}"
+ ),
+ object_ref=object_ref,
+ )
+ return fallback
+
+
+def convert_field(
+ column: dict,
+ formulas: dict[str, dict],
+ table_lookup: Callable[[str], dict | None],
+ resolve: Callable[[str, str], str | None],
+ log: IssueLog,
+ allocator: identifiers.Allocator | None = None,
+) -> dict | None:
+ """Convert one Model `columns[]` entry into an Ossie field, or `None`.
+
+ `column` is a ThoughtSpot Model `columns[]` entry. A physical column carries
+ `column_id` (`TABLE::Column Name`); a computed column carries `formula_id`
+ instead, naming an entry in the model's `formulas[]` list. `formulas` is that
+ list reshaped into a lookup keyed by each entry's `id`, value the whole entry,
+ so `formulas[column["formula_id"]]["expr"]` is the expression text — this
+ function reads the expression from there, never from the column itself. A
+ `formula_id` absent from `formulas`, a column with neither key, or a
+ `column_type` that is not `ATTRIBUTE`, produces no field.
+
+ `allocator` scopes fallback-identifier collision avoidance when this
+ column's display name has no ASCII form — see
+ `_field_or_metric_identifier`. The caller (`convert()`) shares one
+ `Allocator` across every field in the model so two colliding fallbacks
+ never collide with each other; a caller that omits it (every existing
+ single-column test in this suite) gets a fresh, private one, which is
+ exactly as correct for a call that only ever converts one column at a
+ time.
+ """
+ properties = column.get("properties") or {}
+ if properties.get("column_type") != "ATTRIBUTE":
+ return None
+ if allocator is None:
+ allocator = identifiers.Allocator()
+
+ display_name = column["name"]
+ object_ref = f"field:{display_name}"
+
+ if "column_id" in column:
+ table_name, column_name = identifiers.split_column_ref(f"[{column['column_id']}]")
+ field_name = _field_or_metric_identifier(
+ display_name,
+ _physical_db_column_name(table_name, column_name, table_lookup),
+ allocator, log, kind="field", object_ref=object_ref,
+ )
+ field: dict = {"name": field_name, "label": display_name}
+ expr = identifiers.format_column_ref(table_name, column_name)
+ field["expression"] = {
+ "dialects": expression_entries(expr, resolve, log, object_ref=object_ref)
+ }
+ datatype = _physical_datatype(
+ table_name, column_name, table_lookup, log, object_ref=object_ref
+ )
+ if datatype is not None:
+ field["datatype"] = datatype
+ elif "formula_id" in column:
+ formula_id = column["formula_id"]
+ formula_entry = formulas.get(formula_id)
+ if formula_entry is None:
+ log.add(
+ code="TS-FIELD-FORMULA-MISSING",
+ severity=Severity.WARNING,
+ message=(
+ f"column {display_name!r} has formula_id {formula_id!r}, which "
+ f"matches no formulas[] entry; no field can be built"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+ if "expr" not in formula_entry:
+ log.add(
+ code="TS-FIELD-FORMULA-MISSING",
+ severity=Severity.WARNING,
+ message=(
+ f"column {display_name!r} has formula_id {formula_id!r}, whose "
+ f"formulas[] entry has no expr; no field can be built"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+ expr = formula_entry["expr"]
+ dataset = attribute_dataset(expr, resolve, log, object_ref=object_ref)
+ if dataset is None:
+ return None
+ field_name = _field_or_metric_identifier(
+ display_name, None, allocator, log, kind="field", object_ref=object_ref,
+ )
+ field: dict = {"name": field_name, "label": display_name}
+ field["expression"] = {
+ "dialects": expression_entries(expr, resolve, log, object_ref=object_ref)
+ }
+ else:
+ log.add(
+ code="TS-FIELD-NO-SOURCE",
+ severity=Severity.WARNING,
+ message=(
+ "column has neither a physical column_id nor a formula_id; "
+ "no field can be built"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ description = column.get("description")
+ if description:
+ field["description"] = description
+
+ ai_context = _ai_context(properties)
+ if ai_context is not None:
+ field["ai_context"] = ai_context
+
+ return field
+
+
+#: TML column aggregation -> the Ossie aggregate applied to the column expression.
+#: `NONE` means the column carries no aggregate at all, which is distinct from absent.
+_AGGREGATION = {
+ "SUM": "SUM", "COUNT": "COUNT", "AVERAGE": "AVG", "MIN": "MIN", "MAX": "MAX",
+ "COUNT_DISTINCT": "COUNT_DISTINCT", "STD_DEVIATION": "STDDEV", "VARIANCE": "VARIANCE",
+ "NONE": None,
+}
+
+#: Aggregations that always report themselves as Integer, regardless of the underlying
+#: physical column's own warehouse type — a COUNT of DOUBLEs is still a whole number
+#: of rows.
+_COUNT_AGGREGATIONS = frozenset({"COUNT", "COUNT_DISTINCT"})
+
+#: The three TML shapes a metric can arrive as (the stash's `shape` key), so a
+#: return trip can reproduce the source shape instead of collapsing every metric
+#: into the same one. The values themselves live in constants.py
+#: (METRIC_SHAPE_*) — shared with ossie_to_thoughtspot.py, the reader.
+#: `METRIC_SHAPE_FORMULA` is also what a document with no stash at all defaults
+#: to on the way back — a plain formulas[] entry, aggregate already baked into
+#: its expr — so it is the one value never worth writing to the stash: writing
+#: it or omitting it produces the same reconstruction either way.
+
+#: TML column aggregation -> the catalog `spec_name` whose DIRECT template is
+#: ThoughtSpot's own native rendering of that aggregate (`"sum ( {0} )"`,
+#: `"unique count ( {0} )"`, ...). Reused for two different jobs: composing the
+#: THOUGHTSPOT dialect entry for a load-bearing aggregation (`_compose_aggregate_entries`)
+#: and — via `_AGGREGATE_CALL_NAMES` below — recognising when a formula's own outer call
+#: is *already* one of these. Both jobs read the same catalog rows, so "what do we
+#: render" and "is this already rendered" cannot silently disagree the way two
+#: independently hand-typed lists could.
+_AGGREGATION_CATALOG_SPEC = {
+ "SUM": "SUM(expr)", "COUNT": "COUNT(expr)", "AVERAGE": "AVG(expr)",
+ "MIN": "MIN(expr)", "MAX": "MAX(expr)", "COUNT_DISTINCT": "COUNT(DISTINCT expr)",
+ "STD_DEVIATION": "STDDEV(expr)", "VARIANCE": "VARIANCE(expr)",
+}
+
+#: MEDIAN(expr) has no TML `aggregation` enum counterpart at all, but `median ( ... )`
+#: is a genuine native ThoughtSpot aggregate and must still be recognised as one when
+#: it appears in an expression — see `_contains_aggregate_call`.
+_NATIVE_AGGREGATE_SPECS = (*_AGGREGATION_CATALOG_SPEC.values(), "MEDIAN(expr)")
+
+#: ThoughtSpot's grouped/windowed aggregations — the *performant* pattern the
+#: catalog's window-function rows prefer over a raw `sql_*_aggregate_op`
+#: pass-through. None is the target of any TML `aggregation` enum value, so they
+#: cannot come from `_AGGREGATION_CATALOG_SPEC`.
+#:
+#: Imported from the reverse inventory rather than named here. This constant
+#: previously read `"group_aggregate"` alone, above a comment asserting "there is
+#: exactly one such construct" — while the same package's reverse inventory
+#: registered four more (`group_sum`, `group_count`, `group_stddev`,
+#: `group_variance`). A MEASURE column carrying `group_sum ( ... )` was therefore
+#: classified as a scalar formula and had its own `aggregation` composed on top,
+#: emitting `sum ( group_sum ( ... ) )` with no issue raised.
+_GROUP_AGGREGATE_CALLS = GROUP_AGGREGATE_CALL_NAMES
+
+#: Every `Variant` that denotes an *aggregate* `sql_*_op` pass-through wrapper,
+#: derived by filtering the enum on its own `_aggregate_op` naming convention
+#: rather than listing `sql_int_aggregate_op` / `sql_number_aggregate_op` by hand,
+#: so a future aggregate variant is covered the moment it is added to `_types.py`
+#: without a second edit here.
+_SQL_AGGREGATE_OP_CALLS = frozenset(
+ variant.value for variant in Variant if variant.value.endswith("_aggregate_op")
+)
+
+#: Every ThoughtSpot call name that already aggregates: the native DIRECT catalog
+#: templates (derived from the catalog itself, never retyped by hand, via the same
+#: `emit_direct` the rest of this package uses to render them — see the task report
+#: for why), plus all NINE `group_*` names (`group_aggregate` and the eight
+#: shorthands `group_sum`/`group_count`/`group_stddev`/`group_variance`/`group_max`/
+#: `group_min`/`group_average`/`group_unique_count`), and the `sql_*_aggregate_op`
+#: pass-through family.
+#: This set alone is not the whole safety story — see `_contains_aggregate_call`,
+#: which also scans for these names at *any* nesting depth, not only as an
+#: expression's own outer call, because the catalog will always hold aggregate
+#: constructs beyond whatever a fixed enumeration lists.
+_AGGREGATE_CALL_NAMES = (
+ frozenset(
+ formula.split_call(emit_direct(CATALOG[spec], ["x"]))[0].lower()
+ for spec in _NATIVE_AGGREGATE_SPECS
+ )
+ | _GROUP_AGGREGATE_CALLS
+ | _SQL_AGGREGATE_OP_CALLS
+)
+
+
+def _is_bare_group_aggregate(expr: str) -> bool:
+ """Whether `expr`'s outer call is `group_aggregate` itself.
+
+ The distinction that matters: a bare `group_aggregate ( ... )` takes its
+ surfacing column's `aggregation` the way a raw column does, while
+ `sum ( group_aggregate ( ... ) )` -- wrapped -- does not, and neither do the
+ `group_sum`/`group_average` shorthands, which behave like ordinary
+ formulas.
+ """
+ call = formula.split_call(expr)
+ return call is not None and call[0].strip().lower() == "group_aggregate"
+
+
+def _outer_call_is_aggregate(expr: str) -> bool:
+ """Whether `expr`'s own outer call (not something nested inside it) is a
+ native ThoughtSpot aggregate.
+
+ `formula.split_call` returning `None` — not a single outer call, as in
+ `[A::x] - [B::y]` — means there is no outer call for it to be one. Matching
+ is case-insensitive (ThoughtSpot's formula functions are not case-sensitive)
+ and compares the whole call name as one unit, so a two-word name like
+ `unique count` is matched by both words together, never by either alone.
+
+ This is the *documented no-op* case: `sum ( [T::x] )` with a column
+ aggregation of `SUM`, `AVERAGE`, or anything else is a real, common shape —
+ ThoughtSpot's UI sets an aggregation on a formula column routinely, whether
+ or not the formula's own expression already aggregates — and discarding a
+ redundant one here is expected behaviour, not a loss. See `convert_metric`
+ for why this case stays silent while `_contains_aggregate_call` below (an
+ aggregate *nested inside*, not as the outer call) is reported.
+ """
+ call = formula.split_call(expr)
+ if call is None:
+ return False
+ name, _args = call
+ return name.lower() in _AGGREGATE_CALL_NAMES
+
+
+def _contains_aggregate_call(expr: str) -> bool:
+ """Whether an aggregate call appears anywhere in `expr`, at any nesting depth.
+
+ Checking only `expr`'s own outer call (`_outer_call_is_aggregate`) is not
+ enough: an aggregate can be buried inside a scalar wrapper the outer call
+ does not name at all — `round ( sum ( [T::x] ) , 2 )` has `round` as its
+ outer call, not `sum`, but the expression as a whole still aggregates.
+ `formula.find_call_names` finds every call at every depth, so this checks
+ the whole expression rather than the single outer position. Matching is
+ case-insensitive and compares each call's whole name, exactly as
+ `_outer_call_is_aggregate` does.
+
+ Used only for the case `_outer_call_is_aggregate` already says `False` for:
+ see `convert_metric`, where an aggregate nested here (but not as the outer
+ call) is the one shape worth a warning — the outer-call case is silent by
+ design, and warning there too would fire on the common, correct case and
+ train readers to ignore the issue log.
+ """
+ return any(name.lower() in _AGGREGATE_CALL_NAMES for name in formula.find_call_names(expr))
+
+
+def _compose_aggregate_entries(
+ inner_expr: str,
+ aggregation_raw: str,
+ resolve: Callable[[str, str], str | None],
+ log: IssueLog,
+ *,
+ object_ref: str,
+) -> list[dict[str, str]]:
+ """Dialect entries for a load-bearing column aggregation wrapping `inner_expr`.
+
+ `inner_expr` is `[TABLE::Column]` for a physical column, or the verbatim scalar
+ `formulas[].expr` text for a formula-backed one — in both cases the text the
+ column-level `aggregation` rolls up. There is no single TML string that already
+ represents "this column plus its aggregation": TML records the two as separate
+ values (the Metric-level `aggregation` row in the construct-mapping document), so
+ — unlike `expression_entries` — building the THOUGHTSPOT entry here is a genuine
+ construction, not a reconstruction of something that already existed as one
+ string. `inner_expr` itself still travels through untouched, inside the wrapper
+ `emit_direct` builds around it.
+
+ The portable ANSI_SQL sibling is composed the same way, but only when
+ `inner_expr` itself produces one via `expression_entries` — wrapping a guess
+ around a non-portable inner expression would be exactly the kind of invented
+ translation this converter otherwise refuses to emit, and `expression_entries`
+ already logs why it can't when that happens.
+ """
+ construct = CATALOG[_AGGREGATION_CATALOG_SPEC[aggregation_raw]]
+ ts_expr = emit_direct(construct, [inner_expr])
+ entries: list[dict[str, str]] = [{"dialect": DIALECT, "expression": ts_expr}]
+
+ # This helper only ever composes a metric's aggregation (never a field's), so
+ # "metric" is hardcoded here rather than threaded through as a parameter.
+ inner_entries = expression_entries(
+ inner_expr, resolve, log, object_ref=object_ref, kind="metric"
+ )
+ inner_ansi = next(
+ (e["expression"] for e in inner_entries if e["dialect"] == PORTABLE_DIALECT), None
+ )
+ if inner_ansi is not None:
+ if aggregation_raw == "COUNT_DISTINCT":
+ ansi_expr = f"COUNT(DISTINCT {inner_ansi})"
+ else:
+ ansi_expr = f"{_AGGREGATION[aggregation_raw]}({inner_ansi})"
+ entries.append({"dialect": PORTABLE_DIALECT, "expression": ansi_expr})
+ return entries
+
+
+def _metric_datatype(
+ table_name: str,
+ column_name: str,
+ aggregation_raw: str,
+ table_lookup: Callable[[str], dict | None],
+ log: IssueLog,
+ *,
+ object_ref: str,
+) -> str | None:
+ """The Ossie datatype for a bare-aggregate-over-physical-column metric, or `None`.
+
+ `COUNT` and `COUNT(DISTINCT ...)` always report `Integer`, regardless of the
+ underlying column's own warehouse type — counting DOUBLEs still counts whole
+ rows. Every other aggregation (including `NONE`, a bare unaggregated column)
+ reports the physical column's own mapped type, via the same `_physical_datatype`
+ a field uses, so the absent-vs-unmapped distinction it already makes (nothing to
+ log for a column with no declared type; an issue for one whose declared type has
+ no Ossie mapping) applies here unchanged.
+ """
+ if aggregation_raw in _COUNT_AGGREGATIONS:
+ return "Integer"
+ return _physical_datatype(
+ table_name, column_name, table_lookup, log, object_ref=object_ref, kind="metric"
+ )
+
+
+def convert_metric(
+ column: dict,
+ formulas: dict[str, dict],
+ table_lookup: Callable[[str], dict | None],
+ resolve: Callable[[str, str], str | None],
+ log: IssueLog,
+ allocator: identifiers.Allocator | None = None,
+) -> dict | None:
+ """Convert one Model `columns[]` entry into an Ossie metric, or `None`.
+
+ `column` is a ThoughtSpot Model `columns[]` entry; only `column_type: MEASURE`
+ becomes a metric — an `ATTRIBUTE` column belongs to `convert_field` instead, and
+ building a metric for one here would produce two competing Ossie objects
+ surfacing the same TML column.
+
+ As with `convert_field`, a physical column carries `column_id` (`TABLE::Column
+ Name`) and a computed one carries `formula_id`, resolved against `formulas`
+ (keyed by each entry's `id`) exactly the same way. Either shape composes with
+ `properties.aggregation` per the Metric-level `aggregation` row: load-bearing on
+ a `column_id` metric and on a *scalar* formula (the two compose into
+ `AGG(<scalar expr>)`). Two different shapes are a no-op instead, and only one
+ of them is reported:
+
+ - The formula's own outer call already aggregates (`sum ( ... )`,
+ `group_aggregate ( ... )`, ...). This is the documented, common case —
+ ThoughtSpot's UI sets a column aggregation on a formula column routinely,
+ redundant or not — so the column-level value is discarded silently, without
+ logging anything. A warning here would fire on a large fraction of ordinary,
+ correct metrics and teach readers to stop reading the issue log.
+ - An aggregate is *nested* inside a still-scalar outer call
+ (`round ( sum ( ... ) , 2 )` — `round` is scalar, `sum` is buried one level
+ in). Composing here would silently double-aggregate an already-reduced
+ value, exactly as the first case would, but this is the one shape where a
+ reader might reasonably expect composition and not get it — so it is
+ reported.
+
+ See `_outer_call_is_aggregate` and `_contains_aggregate_call` for how the two
+ are told apart, and the module docstring for why detection and composition
+ share one source.
+
+ An unrecognised `aggregation` value (not one of TML's documented enum members)
+ is treated as `NONE` and logged — the value was present and could not be
+ understood, which is a loss worth reporting, unlike an absent `aggregation` key,
+ which defaults to `NONE` silently.
+
+ Metrics have no `label` field (unlike fields): when identifier normalisation changes
+ the identifier, the exact ThoughtSpot display name is stashed as `tml_name`
+ rather than carried in a dedicated field.
+
+ Which of the three TML shapes produced this metric — `column_aggregation`,
+ `scalar_formula_plus_aggregation`, or `formula` — is stashed as `shape`, so a
+ return trip can reproduce the source shape instead of collapsing all three
+ into one. `formula` is omitted rather than written: it is also what a
+ document with no stash defaults to on the way back, so writing it would
+ change nothing about the reconstruction while making the payload heavier.
+
+ `allocator` is `convert_field`'s own parameter, mirrored here — see its
+ docstring. Metrics are model-scoped in Ossie (unlike fields, scoped per
+ dataset), so `convert()` shares a *different* `Allocator` across metrics
+ than it does across fields; a caller that omits it gets a fresh, private
+ one, correct for a call that only ever converts one column.
+ """
+ properties = column.get("properties") or {}
+ if properties.get("column_type") != "MEASURE":
+ return None
+ if allocator is None:
+ allocator = identifiers.Allocator()
+
+ display_name = column["name"]
+ object_ref = f"metric:{display_name}"
+
+ aggregation_raw = properties.get("aggregation", "NONE")
+ if aggregation_raw not in _AGGREGATION:
+ log.add(
+ code="TS-METRIC-AGGREGATION-UNKNOWN",
+ severity=Severity.WARNING,
+ message=(
+ f"column {display_name!r} has aggregation {aggregation_raw!r}, which "
+ f"is not one of the TML aggregation values this converter recognises; "
+ f"treated as NONE"
+ ),
+ object_ref=object_ref,
+ )
+ aggregation_raw = "NONE"
+ aggregation = _AGGREGATION[aggregation_raw]
+ load_bearing_aggregation: str | None = None
+
+ if "column_id" in column:
+ metric_shape = METRIC_SHAPE_COLUMN_AGGREGATION
+ table_name, column_name = identifiers.split_column_ref(f"[{column['column_id']}]")
+ metric_name = _field_or_metric_identifier(
+ display_name,
+ _physical_db_column_name(table_name, column_name, table_lookup),
+ allocator, log, kind="metric", object_ref=object_ref,
+ )
+ metric: dict = {"name": metric_name}
+ field_ref = identifiers.format_column_ref(table_name, column_name)
+ if aggregation is None:
+ dialects = expression_entries(
+ field_ref, resolve, log, object_ref=object_ref, kind="metric"
+ )
+ else:
+ dialects = _compose_aggregate_entries(
+ field_ref, aggregation_raw, resolve, log, object_ref=object_ref
+ )
+ metric["expression"] = {"dialects": dialects}
+ datatype = _metric_datatype(
+ table_name, column_name, aggregation_raw, table_lookup, log, object_ref=object_ref
+ )
+ if datatype is not None:
+ metric["datatype"] = datatype
+ elif "formula_id" in column:
+ formula_id = column["formula_id"]
+ formula_entry = formulas.get(formula_id)
+ if formula_entry is None:
+ log.add(
+ code="TS-METRIC-FORMULA-MISSING",
+ severity=Severity.WARNING,
+ message=(
+ f"column {display_name!r} has formula_id {formula_id!r}, which "
+ f"matches no formulas[] entry; no metric can be built"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+ if "expr" not in formula_entry:
+ log.add(
+ code="TS-METRIC-FORMULA-MISSING",
+ severity=Severity.WARNING,
+ message=(
+ f"column {display_name!r} has formula_id {formula_id!r}, whose "
+ f"formulas[] entry has no expr; no metric can be built"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+ expr = formula_entry["expr"]
+ metric_name = _field_or_metric_identifier(
+ display_name, None, allocator, log, kind="metric", object_ref=object_ref,
+ )
+ metric: dict = {"name": metric_name}
+ if aggregation is None:
+ # Nothing to compose: the verbatim expr, untouched, is the whole metric.
+ metric_shape = METRIC_SHAPE_FORMULA
+ dialects = expression_entries(
+ expr, resolve, log, object_ref=object_ref, kind="metric"
+ )
+ elif _outer_call_is_aggregate(expr):
+ # Mostly the documented no-op: the expression's own call already
+ # aggregates, and ThoughtSpot's UI sets a column aggregation on a
+ # formula column like this routinely whether or not it is
+ # redundant. Discarding it is expected, not a loss, so nothing is
+ # logged -- warning on this common, correct shape would train
+ # readers to ignore the issue log entirely. Confirmed on a live
+ # cluster: a model carrying `sum([SALES])` WITH `aggregation: SUM`
+ # returns the same numbers as the source.
+ #
+ # ONE EXCEPTION, and it changes the answer. A BARE
+ # `group_aggregate ( ... )` -- not itself wrapped in an aggregate --
+ # behaves like a raw column: ThoughtSpot may APPLY the column's
+ # aggregation to it. (`sum ( group_aggregate ( ... ) )` is wrapped,
+ # so there the property is inert again, as are the
+ # `group_sum`/`group_average` shorthands, which behave like any
+ # other formula.) This comment used to name `group_aggregate` among
+ # the no-ops, and the value was dropped with the rest -- silently,
+ # since nothing is logged on this path.
+ #
+ # Ossie's metric expression has nowhere to put it, so it is
+ # preserved verbatim in the stash rather than discarded or folded
+ # into the expression, which would change what the formula means.
+ if _is_bare_group_aggregate(expr):
+ load_bearing_aggregation = aggregation_raw
+ metric_shape = METRIC_SHAPE_FORMULA
+ dialects = expression_entries(
+ expr, resolve, log, object_ref=object_ref, kind="metric"
+ )
+ elif _contains_aggregate_call(expr):
+ # Not the outer call, but an aggregate is nested somewhere inside
+ # (round ( sum ( ... ) , 2 ), or a sql_*_aggregate_op pass-through
+ # buried in a larger expression). This is the one shape where a
+ # reader might reasonably expect composition and not get it, so
+ # it is the one shape worth telling them about: composing here
+ # would silently double-aggregate an already-reduced value.
+ log.add(
+ code="TS-METRIC-AGGREGATION-ALREADY-AGGREGATED",
+ severity=Severity.WARNING,
+ message=(
+ f"column {display_name!r}'s expression already contains an "
+ f"aggregate; the column-level aggregation {aggregation_raw!r} "
+ f"was ignored to avoid double-aggregating"
+ ),
+ object_ref=object_ref,
+ )
+ metric_shape = METRIC_SHAPE_FORMULA
+ dialects = expression_entries(
+ expr, resolve, log, object_ref=object_ref, kind="metric"
+ )
+ else:
+ # A genuinely scalar expr: the column aggregation is load-bearing, so
+ # compose it.
+ metric_shape = METRIC_SHAPE_SCALAR_FORMULA_PLUS_AGGREGATION
+ dialects = _compose_aggregate_entries(
+ expr, aggregation_raw, resolve, log, object_ref=object_ref
+ )
+ metric["expression"] = {"dialects": dialects}
+ # A formula carries no declared type anywhere in TML — neither columns[] nor
+ # formulas[] has a data_type key — so datatype is always omitted
+ # here, and never logged: there was never a value here to lose.
+ else:
+ log.add(
+ code="TS-METRIC-NO-SOURCE",
+ severity=Severity.WARNING,
+ message=(
+ "column has neither a physical column_id nor a formula_id; "
+ "no metric can be built"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ stash_payload: dict = {}
+ if metric_name != display_name:
+ stash_payload[STASH_TML_NAME] = display_name
+ if metric_shape != METRIC_SHAPE_FORMULA:
+ stash_payload[METRIC_STASH_SHAPE] = metric_shape
+ if load_bearing_aggregation is not None:
+ stash_payload[METRIC_STASH_COLUMN_AGGREGATION] = load_bearing_aggregation
+ metric = _write_stash_safely(metric, stash_payload, log, object_ref)
+
+ description = column.get("description")
+ if description:
+ metric["description"] = description
+
+ ai_context = _ai_context(properties)
+ if ai_context is not None:
+ metric["ai_context"] = ai_context
+
+ return metric
+
+
+# ---------------------------------------------------------------------------
+# Assembly: datasets, the cross-model resolver, relationships, and convert()
+# ---------------------------------------------------------------------------
+#
+# Everything above converts one column at a time and takes `resolve` as a
+# given. Nothing above can build `resolve` itself -- it maps a raw TML
+# reference to "dataset.field", and that mapping cannot exist until every
+# dataset in the model is known. That is the one job only this section can
+# do, and everything else here exists to support it: datasets have to be
+# built first (so their names -- the model_tables[] alias-or-name, never
+# normalised -- are known), then `resolve` is a closure over that, then
+# fields and metrics are converted through it, then joins become
+# relationships, then keys are derived from the relationships that qualify.
+#
+# A malformed reference anywhere in a TML document (an ambiguous `column_id`
+# or join condition -- `identifiers.split_column_ref` raising on purpose
+# rather than mis-splitting) is caught per object: the object is skipped, an
+# issue names it, and the rest of the model still converts. Nothing here lets
+# one bad reference abort the whole conversion.
+
+
+def _index_attribute_columns(
+ columns: list[dict], log: IssueLog
+) -> set[tuple[str, str]]:
+ """`{(TABLE, physical column display name)}`, for every ATTRIBUTE
+ `columns[]` entry bound to a valid physical `column_id`.
+
+ This is the set `resolve()` gates cross-references against: a bare
+ `[TABLE::Column]` reference is portable only when it names a column the
+ model actually surfaces as a field. It has to be built ahead of Phase 3
+ (fields and metrics) because a formula processed early in that phase can
+ reference a column defined later in the same `columns[]` list -- the gate
+ needs to already know about every ATTRIBUTE column, not just the ones
+ converted so far.
+
+ Membership only -- no identifier value. An earlier revision stored
+ `identifiers.normalise(column["name"])` as the value here, on the theory
+ that `resolve()`'s caller needed to know the field's actual identifier
+ up front. It didn't: every real consumer of the value only ever tested
+ membership (see `resolve()` in `convert()`), and the one place that did
+ read the *value* (Phase 3.5's `sql_output_columns` stash) now reads
+ `built_field_names`, populated from the field `convert_field` actually
+ built, in `convert()`'s Phase 3 -- the one place that identifier is
+ truly known, rather than a second, independent recomputation of it here
+ that had to somehow stay in exact sync. That sync was already fragile
+ (see the note on `convert_field`'s own `allocator` parameter) and it
+ silently broke the moment a column's display name failed to normalise:
+ this index used to drop such a column from the gate entirely, on the
+ theory that `convert_field` would drop the field too -- true before
+ `convert_field` grew a fallback identifier, and a real correctness bug
+ once it did, because a cross-reference to that column then resolved to
+ nothing even though the field genuinely exists, and the column's own
+ physical entry was reported as unsurfaced despite having a real field.
+ Tracking membership only, independent of how the identifier is derived,
+ closes both without needing the two call sites to agree on anything.
+
+ A malformed `column_id` is caught here, per column, rather than aborting
+ the whole model: the column is left out of the index -- any expression
+ that references it resolves to nothing, which every caller already
+ treats as an ordinary unresolved reference -- and an issue names it.
+ """
+ index: set[tuple[str, str]] = set()
+ for column in columns:
+ properties = column.get("properties") or {}
+ if properties.get("column_type") != "ATTRIBUTE":
+ continue
+ column_id = column.get("column_id")
+ if not column_id:
+ continue
+ try:
+ table_name, physical_name = identifiers.split_column_ref(f"[{column_id}]")
+ except ValueError as exc:
+ log.add(
+ code="TS-COLUMN-ID-MALFORMED",
+ severity=Severity.WARNING,
+ message=(
+ f"column {column.get('name', '<unnamed>')!r} has a malformed "
+ f"column_id {column_id!r} ({exc}); it cannot be resolved by any "
+ f"expression that references it"
+ ),
+ object_ref=f"field:{column.get('name', '<unnamed>')}",
+ )
+ continue
+ index.add((table_name, physical_name))
+ return index
+
+
+def _raw_physical_columns(body: dict, kind: str) -> list[dict]:
+ """The verbatim physical-column list for a Table or SQL View document --
+ `columns[]` for a `table:`, `sql_view_columns[]` for a `sql_view:`.
+
+ Per the mapping document's SQL View row, a SQL View's columns live under
+ a different key entirely, not merely a differently-shaped entry under
+ the same one -- reading `.get("columns")` unconditionally finds nothing
+ on a SQL View document and every one of its columns silently vanishes
+ (no datatype, no unsurfaced_columns entry, nothing). This is the single
+ place that knows which key each kind uses; every reader of "this
+ dataset's physical columns" goes through here or through
+ `_normalized_physical_columns` below, never `body.get("columns")` directly.
+ """
+ key = "sql_view_columns" if kind == "sql_view" else "columns"
+ return body.get(key) or []
+
+
+def _normalize_physical_column(entry: dict, kind: str) -> dict:
+ """One physical column entry, reshaped so datatype lookup
+ (`_physical_datatype` in the field/metric converters) can read `name` /
+ `db_column_name` / `db_column_properties` the same way regardless of
+ which document kind it came from.
+
+ A Table column already has exactly this shape. A SQL View column binds
+ its physical reference via `sql_output_column` instead of
+ `db_column_name` -- "each bound to a query output alias via
+ sql_output_column", per the mapping document -- but is otherwise
+ documented as playing the same role, so `name` and
+ `db_column_properties` carry over unchanged.
+ """
+ if kind != "sql_view":
+ return entry
+ return {
+ "name": entry.get("name"),
+ "db_column_name": entry.get("sql_output_column"),
+ "db_column_properties": entry.get("db_column_properties"),
+ }
+
+
+def _normalized_physical_columns(body: dict, kind: str) -> list[dict]:
+ """`_raw_physical_columns`, each entry passed through
+ `_normalize_physical_column` -- the shape `table_lookup` hands to
+ `_physical_datatype`."""
+ return [_normalize_physical_column(entry, kind) for entry in _raw_physical_columns(body, kind)]
+
+
+#: The TML `db_column_properties.data_type` spelling `datatypes.to_tml` would
+#: emit by default for each Ossie datatype whose TML source has more than one
+#: valid spelling (the datatype map's Boolean and Float rows). Stashing the
+#: canonical spelling itself would be noise -- the reverse direction's own
+#: default already produces it; only the non-canonical spelling (`BOOL`,
+#: `FLOAT`) is worth recording.
+_CANONICAL_TML_SPELLING = {"Boolean": "BOOLEAN", "Float": "DOUBLE"}
+
+
+def _physical_column_stash(
+ column_id: str,
+ ossie_datatype: str | None,
+ physical_columns_by_prefix: dict[str, list[dict]],
+ dataset_stashes: dict[str, dict],
+) -> dict:
+ """Field/metric-level stash additions a physical column needs that
+ nothing else in this module records:
+
+ * `data_type` -- the exact warehouse spelling, only when it is not the
+ canonical one `datatypes.to_tml` would emit by default for
+ `ossie_datatype` (see `_CANONICAL_TML_SPELLING`). Documented in the
+ datatype map's Boolean row: "the connection's spelling is recorded in
+ the field stash's data_type key so the return trip re-emits the same
+ one" -- the same reasoning applies to Float's DOUBLE/FLOAT pair.
+
+ * `db_column_name` -- the exact warehouse column name, only when it
+ differs from the column's own display name. The forward direction
+ matches a physical column by display name only, so a round-tripped
+ bracket reference (`[TABLE::Column]`) carries the display name, never
+ the warehouse name, and the reverse direction has no other way to
+ recover it -- today it defaults to assuming the two are equal and
+ logs that assumption. This key is not in the pinned payload schema;
+ it closes a gap the schema itself does not yet cover. Only ever
+ stashed for a Table-backed column: a SQL View's own physical binding
+ (`sql_output_column`) already has its own dataset-level stash key
+ (`sql_output_columns`), so recording the same fact again here under a
+ different name would be redundant.
+ """
+ try:
+ table_name, physical_name = identifiers.split_column_ref(f"[{column_id}]")
+ except ValueError:
+ return {}
+ physical = next(
+ (p for p in physical_columns_by_prefix.get(table_name, []) if p.get("name") == physical_name),
+ None,
+ )
+ if physical is None:
+ return {}
+
+ payload: dict = {}
+ is_table = dataset_stashes.get(table_name, {}).get(DATASET_STASH_TML_OBJECT) != "sql_view"
+ db_column_name = physical.get("db_column_name")
+ if is_table and db_column_name is not None and db_column_name != physical_name:
+ payload[FIELD_STASH_DB_COLUMN_NAME] = db_column_name
+ # The witness: the column's own display name (the bracket's column
+ # part) this warehouse name was recorded against, so the reverse
+ # direction can tell whether the field still names the same
+ # physical column before trusting a warehouse name that may
+ # describe a different one now.
+ payload[FIELD_STASH_DB_COLUMN_NAME_WITNESS] = physical_name
+
+ raw_data_type = (physical.get("db_column_properties") or {}).get("data_type")
+ canonical = _CANONICAL_TML_SPELLING.get(ossie_datatype) if ossie_datatype else None
+ if raw_data_type is not None and canonical is not None and raw_data_type != canonical:
+ payload[FIELD_STASH_DATA_TYPE] = raw_data_type
+ # The witness: the Ossie datatype this spelling was derived from, so
+ # the reverse direction can tell a genuine edit (the field now
+ # declares a different datatype) from an unedited round trip before
+ # trusting a warehouse-specific spelling for a type it may no longer
+ # describe.
+ payload[FIELD_STASH_DATA_TYPE_WITNESS] = ossie_datatype
+
+ return payload
+
+
+#: Every `properties` key `convert_field` reads on the ATTRIBUTE path.
+#: Anything else in a column's `properties` dict is unconsumed and, per the
+#: fail-closed rule `_unconsumed_properties` implements, is stashed rather
+#: than silently dropped.
+_FIELD_CONSUMED_PROPERTIES = frozenset({"column_type", "synonyms", "ai_context"})
+
+#: Same, for `convert_metric`'s MEASURE path -- one key more than the field
+#: set: `aggregation` is load-bearing only for a metric.
+_METRIC_CONSUMED_PROPERTIES = _FIELD_CONSUMED_PROPERTIES | {"aggregation"}
+
+
+#: Identity-shaped keys that must never reach the portable document at any
+#: depth -- broader than `stash._FORBIDDEN_KEYS` (`guid`/`obj_id`/
+#: `fqn`, which `stash.write_stash` scans every payload for regardless of
+#: caller). `_unconsumed_properties` is the one place in this module that
+#: copies a property's *value* wholesale rather than rebuilding it field by
+#: field, so it is also the one place the two further identity keys this
+#: converter also tracks -- `dataset_id`, and `geo_config.
+#: custom_file_guid` naming a custom map -- are worth checking for
+#: specifically, ahead of `write_stash`'s own narrower check: the scan is
+#: `stash.find_forbidden_key`'s, shared rather than reimplemented here, only
+#: the wider vocabulary to check it against is local to this one call site.
+_DEEP_IDENTITY_KEYS = stash._FORBIDDEN_KEYS | {"dataset_id", "custom_file_guid"}
+
+
+def _unconsumed_properties(
+ properties: dict, consumed: frozenset[str], log: IssueLog, object_ref: str
+) -> dict:
+ """Every key in a column's `properties` dict that the converter did not
+ read, minus anything carrying instance-local identity at any
+ depth.
+
+ Deliberately the complement of `consumed`, not an enumeration of the
+ ThoughtSpot-only property names this module happens to know about today
+ (`index_type`, `value_casing`, ...): an enumeration silently drops the
+ next property ThoughtSpot adds, where the complement preserves it and is
+ correct by construction. `consumed` is what `convert_field`/
+ `convert_metric` actually read, reused here rather than duplicated, so
+ the two lists cannot drift apart the way two independently maintained
+ ones could.
+
+ A property whose value contains a forbidden key anywhere inside it is
+ dropped here -- with a WARNING logged naming it, so a per-column loss
+ stays a survivable one rather than the hard `ConversionError`
+ `stash.write_stash` would otherwise raise for it -- rather than
+ aborting the whole column's conversion over one contaminated property.
+ `write_stash` still re-checks (against its own narrower vocabulary)
+ whatever reaches it, so this is a caller earning its place with a softer
+ landing for a known case, not the only thing standing between identity
+ content and the output.
+ """
+ remainder: dict = {}
+ for key, value in properties.items():
+ if key in consumed:
+ continue
+ # Wrapping `{key: value}` rather than scanning `value` alone catches
+ # both shapes in one call: the property's own name being forbidden
+ # (a scalar `properties: {"guid": "..."}`, unlikely but not ruled
+ # out) and a forbidden key nested inside its value.
+ if stash.find_forbidden_key({key: value}, _DEEP_IDENTITY_KEYS) is not None:
+ log.add(
+ code="TS-PROPERTY-IDENTITY-DROPPED",
+ severity=Severity.WARNING,
+ message=(
+ f"property {key!r} contains instance-local identity "
+ f"content; it is dropped rather than carried into the "
+ f"portable document"
+ ),
+ object_ref=object_ref,
+ )
+ continue
+ remainder[key] = value
+ return remainder
+
+
+def _write_stash_safely(obj: dict, payload: dict, log: IssueLog, object_ref: str) -> dict:
+ """`stash.write_stash(obj, payload)`, catching its identity guard and turning a
+ would-be hard failure into a survivable, logged drop.
+
+ The payload content this module stashes is TML data read out of a
+ source file, not something the converter itself constructed -- an
+ identity key surfacing somewhere inside it is expected input, not a
+ programming error, and expected input must not abort the whole
+ conversion the way every other loss in this module does not. The guard
+ itself still lives at `stash.write_stash`, and still raises: that is
+ what makes it impossible to bypass, present caller or future one. This
+ is the one place that catches the raise and keeps going, generalising
+ the same choice `_unconsumed_properties` already makes for column
+ properties to every other stash site, rather than repeating a bespoke
+ pre-filter at each one.
+
+ A payload can have more than one contaminated top-level key, so this
+ retries after removing one at a time rather than assuming a single
+ pass suffices. If `stash.write_stash` ever raises for a reason other
+ than a forbidden key found in `payload` itself (a malformed *existing*
+ stash entry on `obj`, surfaced via its internal `read_stash` call, is
+ the one other case it can raise for) there is no payload key to blame,
+ and the exception is left to propagate rather than being swallowed.
+
+ Every call to `stash.write_stash` in this module goes through this
+ function -- including `convert_metric`'s own `tml_name`/`shape` payload,
+ which is hardcoded scalars today and so never actually exercises the
+ catch, but a future change that puts TML-derived content into it would
+ otherwise silently reinstate the abort-the-whole-conversion behaviour
+ this function exists to remove. A new call to `stash.write_stash`
+ added anywhere in this module should be a call to this function instead,
+ not a second bespoke exception.
+ """
+ cleaned = dict(payload)
+ while True:
+ try:
+ return stash.write_stash(obj, cleaned)
+ except ConversionError:
+ offender = next(
+ (
+ key for key, value in cleaned.items()
+ if stash.find_forbidden_key({key: value}) is not None
+ ),
+ None,
+ )
+ if offender is None:
+ raise
+ log.add(
+ code="TS-STASH-IDENTITY-DROPPED",
+ severity=Severity.WARNING,
+ message=(
+ f"stash field {offender!r} contains instance-local identity "
+ f"content; it is dropped rather than carried into the "
+ f"portable document"
+ ),
+ object_ref=object_ref,
+ )
+ del cleaned[offender]
+
+
+def _field_owner_dataset(
+ column: dict, formulas: dict[str, dict], resolve: Callable[[str, str], str | None]
+) -> str | None:
+ """Which dataset a *successfully built* field belongs in.
+
+ Only ever called after `convert_field` has already returned a non-`None`
+ field for this exact column, which makes every path here provably safe:
+ the physical branch re-parses the same `column_id` `convert_field` just
+ parsed without raising, and the formula branch re-runs `attribute_dataset`
+ on the same expression `convert_field` just attributed successfully --
+ and `attribute_dataset`'s success path never logs (only its failure paths
+ do), so repeating it here adds nothing to the issue log.
+ """
+ if "column_id" in column:
+ table_name, _column_name = identifiers.split_column_ref(f"[{column['column_id']}]")
+ return table_name
+ formula_entry = formulas.get(column.get("formula_id"))
+ if formula_entry is None or "expr" not in formula_entry:
+ return None
+ return attribute_dataset(formula_entry["expr"], resolve, IssueLog(), object_ref="")
+
+
+def _build_dataset(prefix: str, entry: dict, table_doc, log: IssueLog) -> tuple[dict, dict]:
+ """One `model_tables[]` entry, paired with its Table/SQL-View document,
+ into `(base Ossie dataset dict, its custom_extensions[THOUGHTSPOT] payload)`.
+
+ The base dict carries `name`/`source`/`description` only -- no `fields`
+ key yet. The caller fills that in once every dataset (and therefore the
+ resolver) exists, and calls `stash.write_stash` with the returned payload
+ once fields are attached, so key order in the final dict reads naturally
+ even though this function runs long before fields are known.
+
+ `prefix` becomes the dataset's Ossie `name` verbatim: `entry["alias"]`
+ when present, else `entry["name"]`, never run through
+ `identifiers.normalise` -- the Dataset-level mapping requires it to match
+ the model_tables[] reference name exactly, case-sensitive, since that is
+ also the prefix every `column_id`/join reference in this dataset uses.
+ """
+ body = table_doc.body
+ table_ref = entry.get("name")
+ alias = entry.get("alias")
+ kind = table_doc.kind
+
+ ds_stash: dict = {DATASET_STASH_TML_OBJECT: kind}
+ if alias:
+ ds_stash[DATASET_STASH_ALIAS] = alias
+ ds_stash[DATASET_STASH_TABLE_NAME] = table_ref
+
+ connection_name = (body.get("connection") or {}).get("name")
+ if connection_name:
+ ds_stash[DATASET_STASH_CONNECTION_NAME] = connection_name
+
+ if kind == "sql_view":
+ # Not separately stashed: `source` (below, the dataset's own live
+ # field) already carries this same query text, so a stash entry
+ # here would be a pure duplicate nothing ever reads back.
+ source = body.get("sql_query") or ""
+ else:
+ db = body.get("db") or ""
+ schema = body.get("schema") or ""
+ db_table = body.get("db_table") or table_ref or ""
+ if any("." in part for part in (db, schema, db_table)):
+ # A dotted source string would be ambiguous -- keep the parts too.
+ ds_stash[DATASET_STASH_SOURCE_PARTS] = {
+ DATASET_STASH_SOURCE_PARTS_DB: db,
+ DATASET_STASH_SOURCE_PARTS_SCHEMA: schema,
+ DATASET_STASH_SOURCE_PARTS_DB_TABLE: db_table,
+ }
+ source = ".".join((db, schema, db_table))
+
+ # The witness for DATASET_STASH_TML_OBJECT: the same `source` about to
+ # be written onto the dataset itself. Ossie -> TML compares its own
+ # current `source` against this snapshot before trusting the stashed
+ # kind -- a `source` rewritten from a query to a table reference (or
+ # back) since this was written makes the stashed kind stale.
+ ds_stash[DATASET_STASH_TML_OBJECT_WITNESS] = source
+
+ dataset: dict = {"name": prefix, "source": source}
+ description = body.get("description")
+ if description:
+ dataset["description"] = description
+
+ if body.get("rls_rules"):
+ # Row-level security policy is instance-local (it names groups
+ # that only exist on the source instance) and is never carried into
+ # the portable document. Per ThoughtSpot domain review this is now
+ # the primary RLS mechanism customers are migrating onto, so this is
+ # an ERROR-severity issue naming the table, not a quiet declared loss.
+ log.add(
+ code="TS-DATASET-RLS-RULES",
+ severity=Severity.ERROR,
+ message=(
+ f"table {table_ref!r} has row-level security rules (rls_rules); "
+ f"these reference instance-local groups and are not carried into "
+ f"the portable document -- data that was previously restricted is "
+ f"unrestricted until row-level security is reapplied on the "
+ f"target instance"
+ ),
+ object_ref=f"dataset:{prefix}",
+ remedy=(
+ "Reapply the table's row-level security rules manually on the "
+ "target instance after import."
+ ),
+ )
+
+ return dataset, ds_stash
+
+
+#: One equality pair, and nothing but: two bracketed references either side of
+#: a bare `=`. Anything else -- `>=`/`>`/`<`/`<=`, a literal on either side, or
+#: a genuine `=` between something that isn't two whole `[TABLE::Column]`
+#: references -- does not match, and is therefore a residual predicate.
+_EQUALITY_PAIR_RE = re.compile(r"^\s*(\[[^\]]+\])\s*=\s*(\[[^\]]+\])\s*$")
+_AND_RE = re.compile(r"\band\b", re.IGNORECASE)
+
+
+def _split_top_level_and(text: str) -> list[str]:
+ """Split a join condition on its top-level ` and ` operators.
+
+ Reuses `formula._scan` -- the same quote/bracket-depth tracker every
+ other reference-aware split in this package is built on -- so a literal
+ "and" inside a quoted literal, or inside a `[TABLE::Column]` body (a
+ table or column display name can genuinely contain the word, e.g.
+ `[Research and Development::Col]`), is never mistaken for the boolean
+ operator. An empty or whitespace-only `text` yields no parts.
+ """
+ if not text or not text.strip():
+ return []
+ context = {i: (depth, in_quote) for i, _ch, depth, in_quote in formula._scan(text)}
+ parts: list[str] = []
+ start = 0
+ for match in _AND_RE.finditer(text):
+ depth, in_quote = context.get(match.start(), (0, False))
+ if depth == 0 and not in_quote:
+ parts.append(text[start : match.start()].strip())
+ start = match.end()
+ parts.append(text[start:].strip())
+ return [p for p in parts if p]
+
+
+def _strip_wrapping_parens(text: str) -> str:
+ """`( x )` -> `x`, repeatedly, but only when the parens truly wrap the whole.
+
+ `(a) and (b)` is left alone: its first `(` closes before the end, so the
+ outer pair is not a wrapper. Depth comes from `formula._scan`, the same
+ tracker the rest of this module splits on, so a paren inside a quoted
+ literal or a `[TABLE::Column]` body never counts.
+ """
+ stripped = text.strip()
+ while stripped.startswith("(") and stripped.endswith(")"):
+ # `_scan` reports depth 0 AT the opening paren and again AT its match,
+ # so the wrapper test is whether depth returns to 0 strictly between
+ # them -- index 0 and the final index are both 0 for a true wrapper.
+ depth_reaches_zero_early = any(
+ depth == 0 and 0 < index < len(stripped) - 1
+ for index, _ch, depth, _in_quote in formula._scan(stripped)
+ )
+ if depth_reaches_zero_early:
+ return stripped
+ inner = stripped[1:-1].strip()
+ if not inner:
+ return stripped
+ stripped = inner
+ return stripped
+
+
+def _parse_join_condition(
+ on_expression: str, from_prefix: str, to_prefix: str
+) -> tuple[list[tuple[str, str]], list[str]]:
+ """Split a join condition into equality pairs and residual predicates.
+
+ Per the mapping document's *Non-equality joins* section: the condition is
+ split on its top-level `and`s; a part that is exactly `[FROM::a] = [TO::x]`
+ (in either orientation -- the equality is symmetric in TML, so the pair is
+ reoriented to `(from_col, to_col)` regardless of which side of `=` each
+ reference was written on) becomes one pair. Everything else -- `>=`, `>`,
+ `<`, `<=`, a comparison against a literal, or an equality naming some
+ table other than `from_prefix`/`to_prefix` -- is a residual predicate,
+ kept verbatim.
+
+ Raises `ValueError` (via `identifiers.split_column_ref`) on an ambiguous
+ column reference. The caller (`_relationship_from_join`) catches this per
+ relationship rather than letting it abort the whole conversion.
+ """
+ equality_pairs: list[tuple[str, str]] = []
+ residuals: list[str] = []
+ # Redundant wrapping parentheses are stripped before the split and again per
+ # part. `formula._scan` tracks paren depth -- correct for a general
+ # expression, and exactly wrong here: a condition written
+ # `( [A::x] = [B::y] and [A::p] = [B::q] )`, an ordinary TML spelling, puts
+ # every `and` at depth 1, so nothing split, no equality pair matched, and the
+ # whole relationship was demoted to an unrepresentable-join stash entry --
+ # leaving the Ossie datasets disconnected and `derive_keys` with no candidate.
+ for part in _split_top_level_and(_strip_wrapping_parens(on_expression)):
+ part = _strip_wrapping_parens(part)
+ match = _EQUALITY_PAIR_RE.match(part)
+ if match is None:
+ residuals.append(part)
+ continue
+ left_table, left_column = identifiers.split_column_ref(match.group(1))
+ right_table, right_column = identifiers.split_column_ref(match.group(2))
+ if left_table == from_prefix and right_table == to_prefix:
+ equality_pairs.append((left_column, right_column))
+ elif left_table == to_prefix and right_table == from_prefix:
+ equality_pairs.append((right_column, left_column))
+ else:
+ # An equality pair, but not one naming both sides of *this* join --
+ # cannot be expressed as one of its from_columns/to_columns.
+ residuals.append(part)
+ return equality_pairs, residuals
+
+
+def _unrepresentable_entry(
+ from_prefix: str,
+ to_prefix: str,
+ on_expression: str,
+ join_type: str | None,
+ cardinality: str | None,
+ join_shape: str,
+ referencing_join: str | None,
+) -> dict:
+ """One `unrepresentable_joins[]` entry -- everything schema-required, plus
+ whatever else about the join is known, verbatim."""
+ entry: dict = {
+ "from": from_prefix,
+ "to": to_prefix,
+ RELATIONSHIP_STASH_ON_EXPRESSION: on_expression,
+ RELATIONSHIP_STASH_JOIN_SHAPE: join_shape,
+ }
+ if join_type:
+ entry[RELATIONSHIP_STASH_TYPE] = join_type
+ if cardinality:
+ entry[RELATIONSHIP_STASH_CARDINALITY] = cardinality
+ if referencing_join:
+ entry[RELATIONSHIP_STASH_REFERENCING_JOIN] = referencing_join
+ return entry
+
+
+def _relationship_from_join(
+ *,
+ name: str,
+ from_prefix: str,
+ to_prefix: str,
+ on_expression: str | None,
+ join_type: str | None,
+ cardinality: str | None,
+ join_shape: str,
+ referencing_join: str | None,
+ log: IssueLog,
+) -> tuple[dict | None, dict | None, bool]:
+ """One join -> `(relationship, unrepresentable_entry, has_residual_predicates)`.
+
+ Exactly one of `relationship`/`unrepresentable_entry` is non-`None` (or
+ both `None` when there is no condition at all to report). Implements the
+ *Non-equality joins* table: at least one equality pair emits a
+ `Relationship`, with any residual predicates riding along in its own
+ `custom_extensions` rather than withholding the relationship; zero
+ equality pairs -- including when the condition could not be parsed at all
+ -- emits nothing, because Ossie's schema requires `from_columns`/
+ `to_columns` non-empty, and the condition goes to the model-scope
+ `unrepresentable_joins` stash instead.
+
+ A `ONE_TO_MANY` join additionally has its endpoints swapped once a
+ `Relationship` is built -- see the comment at the swap site for why. An
+ `unrepresentable_entry` is never swapped: it carries no live Ossie
+ Relationship object of its own for the spec's from/to convention to
+ apply to, so `from`/`to` there stay exactly TML's own, unswapped.
+ """
+ object_ref = f"relationship:{name}"
+ if not on_expression or not on_expression.strip():
+ log.add(
+ code="TS-JOIN-NO-CONDITION",
+ severity=Severity.WARNING,
+ message=(
+ f"join {name!r} from {from_prefix!r} to {to_prefix!r} has no "
+ f"condition; it cannot be represented as a relationship"
+ ),
+ object_ref=object_ref,
+ )
+ return None, None, False
+
+ try:
+ equality_pairs, residuals = _parse_join_condition(on_expression, from_prefix, to_prefix)
+ except ValueError as exc:
+ log.add(
+ code="TS-JOIN-MALFORMED",
+ severity=Severity.WARNING,
+ message=(
+ f"join {name!r} condition {on_expression!r} could not be parsed "
+ f"({exc}); it is preserved verbatim as an unrepresentable join "
+ f"rather than as a relationship"
+ ),
+ object_ref=object_ref,
+ )
+ entry = _unrepresentable_entry(
+ from_prefix, to_prefix, on_expression, join_type, cardinality,
+ join_shape, referencing_join,
+ )
+ return None, entry, False
+
+ if not equality_pairs:
+ log.add(
+ code="TS-JOIN-UNREPRESENTABLE",
+ severity=Severity.WARNING,
+ message=(
+ f"join {name!r} from {from_prefix!r} to {to_prefix!r} has no "
+ f"equality pair in its condition ({on_expression!r}); Ossie requires "
+ f"from_columns/to_columns to be non-empty, so no relationship is "
+ f"emitted for it"
+ ),
+ object_ref=object_ref,
+ )
+ entry = _unrepresentable_entry(
+ from_prefix, to_prefix, on_expression, join_type, cardinality,
+ join_shape, referencing_join,
+ )
+ return None, entry, False
+
+ relationship: dict = {
+ "name": name,
+ "from": from_prefix,
+ "to": to_prefix,
+ "from_columns": [pair[0] for pair in equality_pairs],
+ "to_columns": [pair[1] for pair in equality_pairs],
+ }
+
+ # core-spec/spec.yaml requires a Relationship's `from` to name the many
+ # side and `to` the one side. TML's own `from`/`to` -- the model_tables[]
+ # entry a join is declared under, and its `with`/`destination` target --
+ # do not themselves encode which side is which; `cardinality` does, and
+ # ONE_TO_MANY is the one value where TML's `from` names the one side and
+ # `to` names the many side: the wrong way around for Ossie's spec. So a
+ # ONE_TO_MANY join's endpoints are swapped here to compensate; MANY_TO_ONE
+ # and ONE_TO_ONE are already oriented correctly and are left alone.
+ endpoints_swapped = cardinality == "ONE_TO_MANY"
+ if endpoints_swapped:
+ relationship["from"], relationship["to"] = relationship["to"], relationship["from"]
+ relationship["from_columns"], relationship["to_columns"] = (
+ relationship["to_columns"], relationship["from_columns"]
+ )
+ if join_shape == "inline":
+ # An inline join's name is synthesized (TML's inline syntax has
+ # no name field), so it is re-derived from the swapped from/to --
+ # reading the same self-describing "{many}_to_{one}" way every
+ # other relationship's derived name already does. A `referencing`
+ # (or hybrid) shape's name is the Table joins_with[] entry's own
+ # identifier, unrelated to from/to naming, and is left as-is.
+ relationship["name"] = f"{relationship['from']}_to_{relationship['to']}"
+ name = relationship["name"]
+ object_ref = f"relationship:{name}"
+
+ rel_stash: dict = {RELATIONSHIP_STASH_JOIN_SHAPE: join_shape}
+ if join_type:
+ rel_stash[RELATIONSHIP_STASH_TYPE] = join_type
+ if cardinality:
+ rel_stash[RELATIONSHIP_STASH_CARDINALITY] = cardinality
+ if referencing_join:
+ rel_stash[RELATIONSHIP_STASH_REFERENCING_JOIN] = referencing_join
+ if endpoints_swapped:
+ rel_stash[RELATIONSHIP_STASH_ENDPOINTS_SWAPPED] = True
+ # The witness: from/to/from_columns/to_columns exactly as emitted
+ # above (i.e. already swapped), so the reverse direction can tell
+ # whether the relationship has been retargeted since this stash was
+ # written before undoing the swap to recover TML's original from/to.
+ rel_stash[RELATIONSHIP_STASH_ENDPOINTS_SWAPPED_WITNESS] = [
+ relationship["from"], relationship["to"],
+ relationship["from_columns"], relationship["to_columns"],
+ ]
+ has_residuals = bool(residuals)
+ if has_residuals:
+ # The residual predicates themselves are not stashed separately: they
+ # are already fully contained in the verbatim on_expression stashed
+ # below, and nothing reads them back on the way to TML.
+ rel_stash[RELATIONSHIP_STASH_ON_EXPRESSION] = on_expression
+ # The witness: from_columns/to_columns exactly as emitted above, so
+ # the reverse direction can tell whether the relationship has been
+ # retargeted since this stash was written before trusting the
+ # verbatim on_expression (and the residual narrowing riding with it).
+ rel_stash[RELATIONSHIP_STASH_ON_EXPRESSION_WITNESS] = [
+ relationship["from_columns"], relationship["to_columns"],
+ ]
+ log.add(
+ code="TS-JOIN-RESIDUAL-PREDICATES",
+ severity=Severity.WARNING,
+ message=(
+ f"relationship {name!r} carries residual predicate(s) beyond its "
+ f"equality pairs; a consumer that reads only from_columns/"
+ f"to_columns will join more rows than ThoughtSpot does"
+ ),
+ object_ref=object_ref,
+ )
+ relationship = _write_stash_safely(relationship, rel_stash, log, object_ref)
+ return relationship, None, has_residuals
+
+
+def _convert_join(
+ from_prefix: str, join: dict, from_table_body: dict, known_datasets: frozenset[str],
+ log: IssueLog,
+) -> tuple[dict | None, dict | None, keys.Relationship | None]:
+ """One `model_tables[].joins[]` entry -> `(relationship,
+ unrepresentable_entry, key_candidate)`.
+
+ Handles both TML join shapes: `inline` (fully defined here -- `with`/
+ `on`/`type`/`cardinality`) and `referencing` (`referencing_join` names an
+ entry in the *Table*'s own `joins_with[]`, which supplies `destination`/
+ `on`/`type`/`cardinality`; a `type`/`cardinality` also present on this
+ entry overrides the Table's and marks the shape
+ `referencing_with_inline_attrs`, the real hybrid the 2026-07-30 census
+ found on 12 of 493 joins).
+
+ `known_datasets` is checked against the resolved target the same way the
+ caller already checks the source before calling this at all: a target
+ naming a dataset this model never built -- a table document missing, a
+ duplicate alias, or simply a typo -- is dropped with a WARNING rather
+ than emitted. Upstream's own validator hard-fails a document with a
+ relationship pointing at an unknown dataset (`exit 1`, not a warning),
+ so emitting one anyway would make the *whole* document unusable by any
+ downstream tool that runs it; dropping the one broken relationship keeps
+ everything else in the model valid and usable, which is the more useful
+ failure of the two.
+
+ The cardinality-orientation rule itself is applied inside
+ `_relationship_from_join`, not here: a `ONE_TO_MANY` join's endpoints are
+ swapped there so the *emitted* relationship's `from`/`to` always lands on
+ the many-side/one-side arrangement core-spec/spec.yaml requires,
+ regardless of which side TML happened to declare the join from. Because
+ that swap already happened by the time `relationship` comes back here,
+ the key candidate handed to `keys.derive_keys` is read directly off the
+ (possibly-swapped) `relationship["to"]`/`relationship["to_columns"]` --
+ for a swapped `ONE_TO_MANY` join this is TML's original FROM side (now
+ the relationship's `to`), which is exactly the side a key belongs to;
+ for every other cardinality it is unchanged from before, since nothing
+ swapped. Only the cardinality label itself still needs translating:
+ `keys._qualifies` recognises `MANY_TO_ONE`/`ONE_TO_ONE`, never the TML
+ spelling `ONE_TO_MANY` a swapped relationship is still stashed under, so
+ `ONE_TO_MANY` is relabelled `MANY_TO_ONE` for the candidate. `MANY_TO_MANY`
+ needs no such handling -- it is excluded by `keys._qualifies` on either
+ side, which is already correct.
+ """
+ referencing_join = join.get("referencing_join")
+ if referencing_join:
+ candidates = from_table_body.get("joins_with") or []
+ matched = next((jw for jw in candidates if jw.get("name") == referencing_join), None)
+ if matched is None:
+ log.add(
+ code="TS-JOIN-REFERENCING-MISSING",
+ severity=Severity.WARNING,
+ message=(
+ f"model_tables[] entry {from_prefix!r} references joins_with "
+ f"{referencing_join!r}, which is not defined on its table; the "
+ f"join is skipped"
+ ),
+ object_ref=f"relationship:{referencing_join}",
+ )
+ return None, None, None
+ to_prefix = (matched.get("destination") or {}).get("name")
+ on_expression = matched.get("on")
+ join_type = join.get("type", matched.get("type"))
+ cardinality = join.get("cardinality", matched.get("cardinality"))
+ join_shape = (
+ "referencing_with_inline_attrs"
+ if ("type" in join or "cardinality" in join)
+ else "referencing"
+ )
+ name = referencing_join
+ else:
+ to_prefix = join.get("with")
+ on_expression = join.get("on")
+ join_type = join.get("type")
+ cardinality = join.get("cardinality")
+ join_shape = "inline"
+ name = f"{from_prefix}_to_{to_prefix}" if to_prefix else f"{from_prefix}_to_<unknown>"
+
+ if not to_prefix:
+ log.add(
+ code="TS-JOIN-NO-TARGET",
+ severity=Severity.WARNING,
+ message=f"join {name!r} from {from_prefix!r} names no target dataset; it is skipped",
+ object_ref=f"relationship:{name}",
+ )
+ return None, None, None
+
+ if to_prefix not in known_datasets:
+ log.add(
+ code="TS-JOIN-UNKNOWN-TARGET",
+ severity=Severity.WARNING,
+ message=(
+ f"join {name!r} from {from_prefix!r} targets {to_prefix!r}, which "
+ f"is not one of this model's datasets; the relationship is "
+ f"dropped rather than emitted pointing at a dataset that does not "
+ f"exist"
+ ),
+ object_ref=f"relationship:{name}",
+ )
+ return None, None, None
+
+ relationship, unrepresentable, has_residuals = _relationship_from_join(
+ name=name,
+ from_prefix=from_prefix,
+ to_prefix=to_prefix,
+ on_expression=on_expression,
+ join_type=join_type,
+ cardinality=cardinality,
+ join_shape=join_shape,
+ referencing_join=referencing_join,
+ log=log,
+ )
+
+ candidate = None
+ if relationship is not None:
+ candidate_cardinality = "MANY_TO_ONE" if cardinality == "ONE_TO_MANY" else (cardinality or "")
+ candidate = keys.Relationship(
+ name=relationship["name"],
+ to_dataset=relationship["to"],
+ to_columns=relationship["to_columns"],
+ cardinality=candidate_cardinality,
+ has_residual_predicates=has_residuals,
+ )
+ return relationship, unrepresentable, candidate
+
+
+@dataclass(frozen=True)
+class OssieConversion:
+ """The result of one TML -> Ossie conversion.
+
+ `model` is the full Ossie document -- `{"version": ..., "name": ...,
+ "datasets": [...], ...}`, one semantic model per document with no wrapper
+ -- ready to dump as YAML. `issues` is every declared loss and degradation
+ raised while building it: nothing in `model` is missing something TML held
+ without a matching entry here.
+ """
+
+ model: dict
+ issues: IssueLog
+
+
+def convert(document_set: DocumentSet) -> OssieConversion:
+ """Convert one ThoughtSpot TML document set into one Ossie semantic model.
+
+ Order matters and mirrors the module docstring above: datasets first (so
+ their names -- the model_tables[] alias-or-name, verbatim -- exist),
+ then the cross-model resolver (needs every dataset's name, and which
+ physical columns the model surfaces as ATTRIBUTE fields -- membership only,
+ no identifier value), then fields and metrics (need `resolve`),
+ then relationships (need nothing new, but key derivation needs every
+ relationship gathered first), then keys.
+
+ A malformed reference anywhere -- an ambiguous `column_id`, an ambiguous
+ reference inside a join condition -- is caught per object: that object is
+ skipped, an issue names it and why, and every other object still
+ converts. A field that could not be attributed to a dataset is either
+ entirely omitted (a physical column, or a formula-produced field with no
+ attribution -- there is nothing else to build) or, when it is a
+ formula-backed ATTRIBUTE column whose formula genuinely exists, preserved
+ verbatim in the model-scope `unattributed_formulas` stash rather than
+ dropped outright.
+ """
+ log = IssueLog()
+ model_body = document_set.model.body
+
+ model_display_name = model_body.get("name") or ""
+ if not model_display_name:
+ semantic_model_name = "model"
+ else:
+ try:
+ semantic_model_name = identifiers.normalise(model_display_name)
+ except ValueError:
+ # A model name with no ASCII alphanumerics at all (a CJK-only
+ # name, one that is punctuation-only) has nothing for `normalise`
+ # to fold onto. Falling back to a fixed placeholder identifier,
+ # reported, keeps the document convertible instead of aborting
+ # the whole model over one unfoldable name -- the exact text is
+ # still recovered via the STASH_TML_NAME stash just below, since
+ # the placeholder never equals the original display name.
+ semantic_model_name = "model"
+ log.add(
+ code="TS-MODEL-NAME-UNNORMALISABLE",
+ severity=Severity.WARNING,
+ message=(
+ f"model name {model_display_name!r} has no ASCII "
+ f"alphanumerics for normalise() to fold onto; the "
+ f"semantic model is named 'model' instead"
+ ),
+ object_ref=f"model:{model_display_name}",
+ )
+ semantic_model: dict = {"name": semantic_model_name, "datasets": []}
+ model_stash: dict = {}
+ # ThoughtSpot's portable object handle, so a re-import updates the model
+ # it came from rather than creating a duplicate beside it.
+ if document_set.model.obj_id:
+ model_stash[MODEL_STASH_OBJ_ID] = document_set.model.obj_id
+ if semantic_model_name != model_display_name:
+ model_stash[STASH_TML_NAME] = model_display_name
+
+ description = model_body.get("description")
+ if description:
+ semantic_model["description"] = description
+
+ # -- Phase 1: datasets --------------------------------------------------
+ dataset_order: list[str] = []
+ dataset_bodies: dict[str, dict] = {}
+ dataset_stashes: dict[str, dict] = {}
+ table_docs: dict[str, dict] = {}
+ physical_columns_by_prefix: dict[str, list[dict]] = {}
+ fields_by_dataset: dict[str, list] = {}
+ seen_prefixes: set[str] = set()
+
+ model_tables = model_body.get("model_tables") or []
+ for entry in model_tables:
+ table_ref = entry.get("name")
+ prefix = entry.get("alias") or table_ref
+ if not prefix:
+ log.add(
+ code="TS-DATASET-NO-NAME",
+ severity=Severity.WARNING,
+ message="a model_tables[] entry has no name and no alias; it cannot become a dataset",
+ object_ref="dataset:<unnamed>",
+ )
+ continue
+ object_ref = f"dataset:{prefix}"
+ if prefix in seen_prefixes:
+ log.add(
+ code="TS-DATASET-DUPLICATE-PREFIX",
+ severity=Severity.WARNING,
+ message=(
+ f"more than one model_tables[] entry resolves to the reference "
+ f"name {prefix!r}; only the first is converted"
+ ),
+ object_ref=object_ref,
+ )
+ continue
+ table_doc = document_set.table_by_name(table_ref) if table_ref else None
+ if table_doc is None:
+ log.add(
+ code="TS-DATASET-TABLE-MISSING",
+ severity=Severity.WARNING,
Review Comment:
Could this resolve the physical column to its Ossie field identifier before emitting the ANSI_SQL sibling?
For example, if the Ossie field is `total_revenue` but its warehouse column is `NET_AMOUNT`, this emits `orders.NET_AMOUNT`. Ossie's portable expression language resolves `dataset.field` against logical fields, so consumers may not be able to evaluate this reference.
We could emit the logical field name, or omit the sibling when that mapping cannot be made safely.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "jbonofre (via GitHub)" <gi...@apache.org>.
jbonofre commented on code in PR #364:
URL: https://github.com/apache/ossie/pull/364#discussion_r4085065319
##########
converters/thoughtspot/src/ossie_thoughtspot/cli.py:
##########
@@ -0,0 +1,241 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Command-line interface for the Apache Ossie <-> ThoughtSpot TML converter.
+
+ ossie-thoughtspot to-ossie <tml-file>... -o <out.yaml> [--issues <issues.json>]
+ ossie-thoughtspot to-tml <ossie.yaml> -o <out-dir> [--issues <issues.json>]
+
+`to-ossie` reads a Model TML document plus the Table/SQL View documents it references
+and writes one Apache Ossie semantic model. `to-tml` reads one Apache Ossie semantic
+model and writes the corresponding TML document set -- one file per document, tables
+before the model -- into an output directory.
+
+`-o`/`--output` is required in both directions: `to-ossie` writes exactly one file, and
+`to-tml` writes a set of files that has no single-file stdout representation, so unlike
+some sibling converters there is no "default: stdout" fallback here.
+
+Every declared loss or degradation the conversion records is written as a JSON array of
+issues -- to `--issues` when given, to stderr otherwise -- and never mixed into the
+document output. The process exits 1 when that issue log contains an ERROR-severity
+issue, 0 otherwise: a conversion that only warned or informed about a declared loss is
+still a successful conversion, and failing the exit code on a warning would just teach
+scripts to ignore it.
+
+Neither subcommand overwrites an existing output file unless `--force` is given; without
+it, a target that already exists refuses the run before anything is written.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+from . import _yaml, tml, tml_to_ossie, ossie_to_thoughtspot
+from .errors import ConversionError
+from .issues import IssueLog
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="ossie-thoughtspot",
+ description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ sub = parser.add_subparsers(dest="command")
+ sub.required = True # set as attribute (the add_subparsers kwarg is 3.7+)
+
+ to_ossie = sub.add_parser(
+ "to-ossie", help="ThoughtSpot TML documents -> one Apache Ossie semantic model"
+ )
+ to_ossie.add_argument(
+ "tml_files",
+ nargs="+",
+ metavar="TML_FILE",
+ help="the Model TML document plus every Table/SQL View document it references",
+ )
+ to_ossie.add_argument(
+ "-o", "--output", required=True, metavar="FILE", help="output Apache Ossie YAML file"
+ )
+ to_ossie.add_argument(
+ "--issues",
+ metavar="FILE",
+ help="write the issue log as JSON to this path (default: stderr)",
+ )
+ to_ossie.add_argument(
+ "--force",
+ action="store_true",
+ help="overwrite the output file (and --issues file) if either already exists; "
+ "without this flag, an existing target refuses the run before writing anything",
+ )
+
+ to_tml = sub.add_parser(
+ "to-tml", help="one Apache Ossie semantic model -> ThoughtSpot TML documents"
+ )
+ to_tml.add_argument("ossie_file", metavar="OSSIE_FILE", help="Apache Ossie YAML document")
+ to_tml.add_argument(
+ "-o",
+ "--output",
+ required=True,
+ metavar="DIR",
+ help="output directory; one TML file per document is written here (tables before "
+ "the model), created if it does not already exist",
+ )
+ to_tml.add_argument(
+ "--issues",
+ metavar="FILE",
+ help="write the issue log as JSON to this path (default: stderr)",
+ )
+ to_tml.add_argument(
+ "--force",
+ action="store_true",
+ help="overwrite output files (and --issues file) if any already exist; without "
+ "this flag, an existing target refuses the run before writing anything",
+ )
+ return parser
+
+
+def _safe_target_path(directory: Path, filename: str) -> Path:
+ """`directory / filename`, refusing to resolve outside `directory`.
+
+ `tml.dump_document_set` already sanitises `filename` (a document's `name` is
+ user-controlled TML content, and a hostile one -- `../../etc/passwd` -- previously
+ produced a path that escaped its output directory), so this should never trigger in
+ practice. It exists anyway because this module is the first caller that actually
+ writes these documents to disk, and a defence that lives only in the dumper is not
+ one this module can verify it still has. Both paths are resolved before comparing --
+ a naive string-prefix check is wrong on a filesystem with symlinks in play, e.g.
+ macOS where `/tmp` is a symlink to `/private/tmp`.
+ """
+ resolved_directory = directory.resolve()
+ target = (resolved_directory / filename).resolve()
+ # `==`, not `!=`: a filename resolving to the output directory ITSELF is the
+ # case this guard exists to catch, and the old `target != resolved_directory
+ # and ...` spelling made that case short-circuit to False and pass.
+ if target == resolved_directory or resolved_directory not in target.parents:
+ raise ConversionError(
+ f"refusing to write {filename!r}: it resolves outside the output directory "
+ f"{resolved_directory}"
+ )
+ return target
+
+
+def _existing(paths: list[Path]) -> list[Path]:
+ return [p for p in paths if p.exists()]
+
+
+def _refuse_overwrite(paths: list[Path]) -> str:
+ names = ", ".join(str(p) for p in paths)
+ return f"refusing to overwrite existing file(s): {names} (pass --force to overwrite)"
+
+
+def _write_issues(issues: IssueLog, issues_path: Path | None) -> None:
+ """Issues as a JSON array -- to `issues_path` when given, to stderr otherwise.
+
+ Always written, even when empty: a caller scripting against this output should be
+ able to rely on the shape (a JSON array) rather than on whether anything was said.
+ """
+ text = json.dumps(issues.as_dicts(), indent=2)
+ if issues_path is not None:
+ issues_path.parent.mkdir(parents=True, exist_ok=True)
+ issues_path.write_text(text + "\n", encoding="utf-8")
+ else:
+ print(text, file=sys.stderr)
+
+
+def _cmd_to_ossie(args: argparse.Namespace) -> int:
+ output_path = Path(args.output)
+ issues_path = Path(args.issues) if args.issues else None
+
+ try:
+ texts = [(path, Path(path).read_text(encoding="utf-8")) for path in args.tml_files]
+ document_set = tml.load_document_set(texts)
+ result = tml_to_ossie.convert(document_set)
+ except (ConversionError, OSError, UnicodeDecodeError) as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
+
+ targets = [output_path] + ([issues_path] if issues_path else [])
Review Comment:
If `--issues` and `-o` resolve to the same path on a fresh run, this check passes, then `_write_issues` replaces the converted document with JSON.
The same collision can happen between `--issues` and a generated `to-tml` file.
Could the CLI reject colliding paths before writing?
##########
converters/thoughtspot/src/ossie_thoughtspot/ossie_to_thoughtspot.py:
##########
@@ -0,0 +1,2216 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Build a ThoughtSpot Table or SQL View TML document from one Ossie dataset.
+
+An Ossie semantic model becomes 1+N TML documents: one Model document plus one
+Table (or SQL View) document per dataset. The Model references each table by
+name, so the tables have to exist first — this module builds the "N" half.
+Building the Model document itself (formulas, surfaced columns, joins) is a
+separate module, because deciding which of a dataset's fields become a
+physical column here versus a Model formula there needs the same test either
+way: a field whose expression is a single, unqualified column reference is
+physical; anything else — a function call, an operator, several references —
+is computed and has no physical column to hold it. Only the first kind is
+handled here.
+
+Two things are unrecoverable *from the field's own expression alone*, and
+both are handled by falling back to a documented default rather than
+guessing, with the fallback always reported:
+
+* **The warehouse column's own physical name, for a field that came from a
+ real ThoughtSpot table.** A round-tripped field's bracketed reference
+ (e.g. ``[ORDERS::Order Date]``) carries the table column's *display* name
+ only, not its own `db_column_name` — a Model's `column_id` is matched by
+ display name, never by warehouse name. When the two genuinely differed,
+ the forward direction now stashes the true warehouse name separately
+ (`FIELD_STASH_DB_COLUMN_NAME`, Table-backed columns only), and that value
+ is used whenever present. Only when it is genuinely absent — a
+ hand-authored bracket, or a document produced before this key existed —
+ does this fall back to assuming the display name and the warehouse name
+ agree, which is correct in the common case and is reported as an
+ assumption otherwise, because a wrong guess here names a column the
+ warehouse may not have. A hand-authored field instead carries a bare,
+ unqualified SQL identifier for its own physical column (e.g.
+ ``order_date``), which genuinely *is* its warehouse name, not a stand-in
+ for one, so no assumption or issue is needed there. Either way,
+ ``db_column_name`` is written — always, even when it is identical to the
+ column's display name, because some ThoughtSpot instances reject an import
+ that omits it.
+* **Whether an Ossie `Time` field's underlying warehouse column is really a
+ full timestamp.** The datatype map gives `Time` a conditional mapping —
+ `VARCHAR` normally, `DATE_TIME` when the column is timestamp-backed — but
+ that condition needs a fact this module has no way to observe. A `Time`
+ value can only ever reach this function from a hand-authored document in
+ the first place: the forward direction never emits `Time` at all (nothing
+ round-trips into it), so there is never a stashed ThoughtSpot column behind
+ it to inspect, and an Ossie `Field` carries no storage-format signal
+ besides `datatype` itself. There is nothing here to condition on, so the
+ unconditional default (`VARCHAR`) is what gets written, and the datatype's
+ declared loss is still reported so the choice is visible rather than silent.
+"""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Callable, Sequence
+
+from . import datatypes, formula, identifiers, stash
+from .constants import (
+ DATASET_STASH_ALIAS,
+ DATASET_STASH_CONNECTION_NAME,
+ DATASET_STASH_SOURCE_PARTS,
+ DATASET_STASH_SOURCE_PARTS_DB,
+ DATASET_STASH_SOURCE_PARTS_DB_TABLE,
+ DATASET_STASH_SOURCE_PARTS_SCHEMA,
+ DATASET_STASH_SQL_OUTPUT_COLUMNS,
+ DATASET_STASH_TABLE_NAME,
+ DATASET_STASH_TABLE_PROPERTIES,
+ DATASET_STASH_TML_OBJECT,
+ DATASET_STASH_TML_OBJECT_WITNESS,
+ DATASET_STASH_UNSURFACED_COLUMNS,
+ DIALECT,
+ FIELD_STASH_COLUMN_PROPERTIES,
+ FIELD_STASH_DATA_TYPE,
+ FIELD_STASH_DATA_TYPE_WITNESS,
+ FIELD_STASH_DB_COLUMN_NAME,
+ FIELD_STASH_DB_COLUMN_NAME_WITNESS,
+ METRIC_SHAPE_COLUMN_AGGREGATION,
+ METRIC_SHAPE_FORMULA,
+ METRIC_SHAPE_SCALAR_FORMULA_PLUS_AGGREGATION,
+ METRIC_STASH_SHAPE,
+ MODEL_STASH_ACTION_OBJECT_ASSOCIATIONS,
+ MODEL_STASH_COLUMN_GROUPS,
+ MODEL_STASH_CONSTRAINTS,
+ MODEL_STASH_FILTERS,
+ MODEL_STASH_LESSON_PLANS,
+ MODEL_STASH_MODEL_JOINS_WITH,
+ MODEL_STASH_MODEL_PROPERTIES,
+ MODEL_STASH_PARAMETERS,
+ MODEL_STASH_UNATTRIBUTED_FORMULAS,
+ MODEL_STASH_UNREPRESENTABLE_JOINS,
+ PORTABLE_DIALECT,
+ RELATIONSHIP_STASH_CARDINALITY,
+ RELATIONSHIP_STASH_ENDPOINTS_SWAPPED,
+ RELATIONSHIP_STASH_ENDPOINTS_SWAPPED_WITNESS,
+ RELATIONSHIP_STASH_JOIN_SHAPE,
+ RELATIONSHIP_STASH_ON_EXPRESSION,
+ RELATIONSHIP_STASH_ON_EXPRESSION_WITNESS,
+ RELATIONSHIP_STASH_REFERENCING_JOIN,
+ RELATIONSHIP_STASH_TYPE,
+ STASH_TML_NAME,
+)
+from .errors import ConversionError
+from .expressions import CATALOG, Classification, emit_direct, emit_passthrough, emit_unmappable
+from .issues import IssueLog, Severity
+from .tml import DocumentSet, TmlDocument, block_scalar
+
+#: A plain ANSI SQL regular identifier (unquoted) or a double-quoted one, per
+#: the specification's own identifier grammar — up to 128 characters, and a
+#: quoted identifier's content is the literal column name with the quotes
+#: stripped. This is what a hand-authored field's own physical-column
+#: expression looks like: no dataset qualifier (a field's expression runs
+#: against its own dataset's source), no operators, no function calls.
+_BARE_IDENTIFIER_RE = re.compile(r'^(?:[A-Za-z_][A-Za-z0-9_]{0,127}|"[^"]{1,128}")$')
+
+#: Any source string containing whitespace outside of a quoted identifier
+#: reads as a query rather than a `db.schema.table` reference — a real
+#: three-part identifier never contains one there, and any genuine SQL query
+#: does (at minimum a `SELECT` and a target). Whitespace *inside* a quoted
+#: identifier (`SALES.PUBLIC."ORDER TABLE"`) is a legitimate table name and
+#: must not trip this — see `_split_three_part_identifier`, which is always
+#: tried first for exactly that reason.
+_WHITESPACE_RE = re.compile(r"\s")
+
+#: A plain, unquoted ANSI SQL identifier segment.
+_PLAIN_IDENTIFIER_SEGMENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
+
+
+def _split_three_part_identifier(source: str) -> list[str] | None:
+ """`source` split on top-level `.` into its parts, or `None` when it does
+ not parse as a dotted identifier sequence at all.
+
+ Each part is either a plain unquoted identifier or a double-quoted one —
+ which may itself contain a `.`, whitespace, or any other character
+ except a literal quote, e.g. `"ORDER TABLE"`. Detecting the three-part
+ shape this way, before ever asking whether `source` merely *contains*
+ whitespace, is what keeps a quoted identifier with a space in it
+ (`SALES.PUBLIC."ORDER TABLE"`) from being misread as a query: the
+ quoted part's own whitespace is never inspected outside the quotes that
+ scope it. A genuine query fails this parse almost immediately -- its
+ first keyword is followed by a space, not a `.` or the end of the
+ string -- and falls through to the whitespace check instead.
+ """
+ parts: list[str] = []
+ i, n = 0, len(source)
+ if n == 0:
+ return None
+ while True:
+ if i >= n:
+ return None # a trailing '.' with nothing after it
+ if source[i] == '"':
+ end = source.find('"', i + 1)
+ if end == -1 or end == i + 1:
+ return None # unterminated or empty quoted identifier
+ parts.append(source[i + 1:end])
+ i = end + 1
+ else:
+ match = _PLAIN_IDENTIFIER_SEGMENT_RE.match(source, i)
+ if match is None:
+ return None
+ parts.append(match.group(0))
+ i = match.end()
+ if i == n:
+ return parts
+ if source[i] != ".":
+ return None
+ i += 1
+
+
+def _bare_sql_identifier(expression: str) -> str | None:
+ """The plain column name `expression` names, or `None` if it is not one
+ single unqualified identifier."""
+ text = expression.strip()
+ if _BARE_IDENTIFIER_RE.match(text) is None:
+ return None
+ if text.startswith('"') and text.endswith('"'):
+ return text[1:-1]
+ return text
+
+
+def _physical_identity(field: dict, log: IssueLog, *, object_ref: str) -> tuple[str, str] | None:
+ """`(display name, warehouse identifier)` for a physical field, or `None`
+ when the field is computed and has no single physical column to become.
+
+ A THOUGHTSPOT-dialect entry, when present, is authoritative and is
+ checked first: it is the verbatim expression a prior TML -> Ossie trip
+ preserved, so a bare `[TABLE::Column]` reference names the table's own
+ column exactly, and anything else in that dialect is unambiguously a
+ formula — no other dialect is worth consulting once a THOUGHTSPOT entry
+ says "computed". Only when there is no THOUGHTSPOT entry at all (a
+ hand-authored document) does a bare, unqualified SQL identifier in any
+ other dialect count as a physical reference instead.
+ """
+ dialects = ((field.get("expression") or {}).get("dialects")) or []
+ if not dialects:
+ log.add(
+ code="TS-FIELD-NO-EXPRESSION",
+ severity=Severity.WARNING,
+ message="field has no expression dialects; it cannot become a table column",
+ object_ref=object_ref,
+ )
+ return None
+
+ ts_entry = next((d for d in dialects if d.get("dialect") == DIALECT), None)
+ if ts_entry is not None:
+ ts_expr = ts_entry.get("expression", "")
+ try:
+ bare = formula.is_bare_column_ref(ts_expr)
+ except ValueError as exc:
+ # split_column_ref raises for two distinct reasons -- the
+ # bracket's table or column part itself contains "::", making the
+ # delimiter genuinely ambiguous, or the text inside the brackets
+ # never matched the [TABLE::Column] shape at all (e.g. an empty
+ # table part) -- and correctly refuses to guess in either case
+ # rather than silently mis-splitting. That refusal must not
+ # propagate as an uncaught exception out of a document
+ # conversion: it is reported and the field is treated the same
+ # as any other THOUGHTSPOT expression that is not a single
+ # column reference (see the `bare is None` case just below).
+ log.add(
+ code="TS-FIELD-COLUMN-REF-MALFORMED",
+ severity=Severity.ERROR,
+ message=(
+ f"expression {ts_expr!r} is not a usable ThoughtSpot column "
+ f"reference ({exc}); it cannot become a table column and is "
+ f"instead carried into the model as a formula, verbatim, "
+ f"which will fail to import until it is fixed"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+ if bare is None:
+ # A THOUGHTSPOT expression that is not a single column reference
+ # is a formula -- computed fields are the Model document's
+ # concern, not the table's.
+ return None
+ _table, column = bare
+ # The bracket's own column part is the table's *display* name (what
+ # a Model column_id must match), not necessarily its warehouse
+ # db_column_name -- a physical column is matched by display name
+ # only. When the forward direction saw the two differ, it stashes
+ # the true warehouse name on the field, witnessed against the
+ # display name it was recorded for: trustworthy only when the
+ # field still names the same physical column, since a user
+ # retargeting the bracket reference to a different column leaves a
+ # stash that now names the WRONG column's warehouse name -- one
+ # that would otherwise be silently applied to this one.
+ field_stash = stash.read_stash(field)
+ was_stashed = FIELD_STASH_DB_COLUMN_NAME in field_stash
+ stashed_db_column_name = stash.restore(
+ field_stash, FIELD_STASH_DB_COLUMN_NAME, None,
+ witness=column, witness_key=FIELD_STASH_DB_COLUMN_NAME_WITNESS,
+ )
+ if isinstance(stashed_db_column_name, str) and stashed_db_column_name:
+ return column, stashed_db_column_name
+ if was_stashed:
+ log.add(
+ code="TS-FIELD-DB-COLUMN-NAME-STALE",
+ severity=Severity.WARNING,
+ message=(
+ f"a stashed warehouse column name was recorded for a "
+ f"different physical column than this field's current "
+ f"{column!r}; the field was retargeted since the stash "
+ f"was written, so the stash is dropped and "
+ f"db_column_name is set equal to the display name "
+ f"instead, which will name a column the warehouse does "
+ f"not have if the two differ"
+ ),
+ object_ref=object_ref,
+ )
+ return column, column
+ # No stash to consult -- a hand-authored bracket, or a document
+ # produced before this key existed. Falling back to the display
+ # name is correct whenever the two originally agreed (the common
+ # case), but it is a genuine assumption, not a fact: a wrong guess
+ # here emits a Table column bound to a warehouse name that may not
+ # exist, so it is reported rather than made silently.
+ log.add(
+ code="TS-FIELD-DB-COLUMN-NAME-ASSUMED",
+ severity=Severity.WARNING,
+ message=(
+ f"no stashed warehouse column name was found for {column!r}; "
+ f"db_column_name is set equal to the display name, which will "
+ f"name a column the warehouse does not have if the two "
+ f"originally differed"
+ ),
+ object_ref=object_ref,
+ )
+ return column, column
+
+ display_name = field.get("label") or field.get("name")
+ for entry in dialects:
+ identifier = _bare_sql_identifier(entry.get("expression", ""))
+ if identifier is None:
+ continue
+ if not display_name:
+ log.add(
+ code="TS-FIELD-NO-NAME",
+ severity=Severity.WARNING,
+ message="field has neither a label nor a name; it cannot become a table column",
+ object_ref=object_ref,
+ )
+ return None
+ return display_name, identifier
+ return None
+
+
+def _field_datatype(field: dict, log: IssueLog, *, object_ref: str) -> str:
+ """The `db_column_properties.data_type` for one physical field.
+
+ A field with no declared `datatype` still gets one: ThoughtSpot treats
+ the whole `db_column_properties` block as compulsory, so an absent value
+ is inferred (`datatypes.to_tml(None)`) rather than the key being omitted.
+ """
+ datatype = field.get("datatype")
+ if datatype is not None:
+ loss = datatypes.declared_loss(datatype)
+ if loss is not None:
+ log.add(
+ code="TS-FIELD-DATATYPE-DECLARED-LOSS",
+ severity=Severity.WARNING,
+ message=f"datatype {datatype!r} does not round-trip exactly: {loss}",
+ object_ref=object_ref,
+ )
+
+ field_stash = stash.read_stash(field)
+ was_stashed = FIELD_STASH_DATA_TYPE in field_stash
+ # The exact ThoughtSpot spelling a prior TML -> Ossie trip recorded
+ # (BOOL vs BOOLEAN, FLOAT vs DOUBLE) wins over a freshly derived one only
+ # when the witness -- the Ossie datatype it was recorded against --
+ # still matches this field's current `datatype`. A field whose declared
+ # type was edited since (Boolean -> String, say) makes the stashed
+ # spelling stale: "BOOL" names a warehouse type for the datatype that
+ # *was* there, not the one that is there now.
+ stashed_spelling = stash.restore(
+ field_stash, FIELD_STASH_DATA_TYPE, None,
+ witness=datatype, witness_key=FIELD_STASH_DATA_TYPE_WITNESS,
+ )
+ if isinstance(stashed_spelling, str) and stashed_spelling:
+ return stashed_spelling
+ if was_stashed:
+ log.add(
+ code="TS-FIELD-DATA-TYPE-STASH-STALE",
+ severity=Severity.WARNING,
+ message=(
+ f"a warehouse spelling was stashed for a different datatype "
+ f"than this field's current {datatype!r}; the field was "
+ f"edited since the stash was written, so the stash is "
+ f"dropped and the canonical spelling is derived instead"
+ ),
+ object_ref=object_ref,
+ )
+
+ try:
+ return datatypes.to_tml(datatype)
+ except ValueError:
+ log.add(
+ code="TS-FIELD-DATATYPE-UNKNOWN",
+ severity=Severity.WARNING,
+ message=(
+ f"datatype {datatype!r} is not a recognised Ossie datatype; "
+ f"INT64 is inferred instead"
+ ),
+ object_ref=object_ref,
+ )
+ return datatypes.to_tml(None)
+
+
+def _field_object_ref(field: dict) -> str:
+ return f"field:{field.get('label') or field.get('name') or '<unnamed>'}"
+
+
+def _physical_table_column(field: dict, log: IssueLog) -> dict | None:
+ """One Table `columns[]` entry for `field`, or `None` when it is computed.
+
+ `description` is deliberately never copied here. Every field that
+ reaches this function is, by construction, also surfaced as a Model
+ `columns[]` ATTRIBUTE entry (`_build_field`, which writes the same
+ description there) -- a Table-only physical column never becomes a
+ `field` at all; it survives verbatim through
+ `DATASET_STASH_UNSURFACED_COLUMNS` instead. So the Model entry is the
+ only correct home for a Model-surfaced field's description; writing it
+ here too would assert something on the Table document its own source
+ never carried.
+ """
+ object_ref = _field_object_ref(field)
+ identity = _physical_identity(field, log, object_ref=object_ref)
+ if identity is None:
+ return None
+ name, db_column_name = identity
+ return {
+ "name": name,
+ # Always present, even equal to `name` -- some ThoughtSpot instances
+ # reject an import that omits it.
+ "db_column_name": db_column_name,
+ "db_column_properties": {"data_type": _field_datatype(field, log, object_ref=object_ref)},
+ }
+
+
+def _physical_sql_view_column(field: dict, output_aliases: dict, log: IssueLog) -> dict | None:
+ """One SQL View `sql_view_columns[]` entry for `field`, or `None` when it
+ is computed.
+
+ `output_aliases` is the dataset's stashed `field name -> sql_output_column`
+ map. It wins when present, because a query output alias is not something
+ the field's own expression can be relied on to reconstruct; the bare
+ identifier `_physical_identity` finds is only a fallback for a
+ hand-authored field with no such record.
+ """
+ object_ref = _field_object_ref(field)
+ identity = _physical_identity(field, log, object_ref=object_ref)
+ if identity is None:
+ return None
+ name, fallback_identifier = identity
+ sql_output_column = output_aliases.get(field.get("name")) or fallback_identifier
+ # `description` is never copied here -- see the matching note on
+ # _physical_table_column just above; the same reasoning applies
+ # unchanged to a SQL View's own physical column.
+ return {
+ "name": name,
+ "sql_output_column": sql_output_column,
+ "db_column_properties": {"data_type": _field_datatype(field, log, object_ref=object_ref)},
+ }
+
+
+def _derive_kind(source: str) -> tuple[str, bool]:
+ """`(kind, malformed)` guessed from `source` alone.
+
+ A genuine three-part dotted identifier — quoted parts included, so a
+ quoted identifier's own internal whitespace is never mistaken for a
+ query — reads as a table reference. Failing that, whitespace anywhere
+ else in `source` reads as a query: a real `db.schema.table` reference
+ never contains any outside a quoted part, and a real SQL query always
+ does. Anything else is neither shape clearly enough to guess, so it is
+ reported malformed and a table is still produced -- `_source_parts` is
+ what actually raises the issue for it, so the same root cause is never
+ reported twice.
+ """
+ parts = _split_three_part_identifier(source)
+ if parts is not None and len(parts) == 3 and all(parts):
+ return "table", False
+ if _WHITESPACE_RE.search(source):
+ return "sql_view", False
+ return "table", True
+
+
+def _decide_kind(dataset: dict, payload: dict, log: IssueLog, *, object_ref: str) -> str:
+ """Whether `dataset` becomes a `table:` or `sql_view:` document.
+
+ A stashed `tml_object` (written whenever this dataset came from a prior
+ TML -> Ossie trip) is authoritative -- it also determines which shape
+ `unsurfaced_columns` was captured in, so trusting it keeps that list
+ valid -- but only when its witness (the `source` it was stashed
+ alongside) still matches this dataset's CURRENT `source`. A user who
+ rewrites `source` from a table reference to a query (or back) since the
+ stash was written leaves a `tml_object` that now describes the wrong
+ shape; using it anyway would misread `source` under the old rules (a
+ query parsed as db/schema/table, or vice versa). A hand-authored dataset
+ has no stash at all, and falls through to `_derive_kind` either way.
+ """
+ source = dataset.get("source") or ""
+ was_stashed = DATASET_STASH_TML_OBJECT in payload
+ stashed_kind = stash.restore(
+ payload, DATASET_STASH_TML_OBJECT, None,
+ witness=source, witness_key=DATASET_STASH_TML_OBJECT_WITNESS,
+ )
+ if stashed_kind in ("table", "sql_view"):
+ return stashed_kind
+ if was_stashed:
+ log.add(
+ code="TS-DATASET-TML-OBJECT-STALE",
+ severity=Severity.WARNING,
+ message=(
+ "a stashed document kind (table/sql_view) no longer matches "
+ "this dataset's current source; the source was rewritten "
+ "since the stash was written, so the stash is dropped and "
+ "the kind is re-derived from the current source instead"
+ ),
+ object_ref=object_ref,
+ )
+ kind, _malformed = _derive_kind(source)
+ return kind
+
+
+def _source_parts(dataset: dict, payload: dict, log: IssueLog, *, object_ref: str) -> tuple[str, str, str]:
+ """`(db, schema, db_table)` for a Table document.
+
+ A stashed `source_parts` entry is used only when it still reconstructs
+ the dataset's current `source` exactly -- the dataset may have been
+ hand-edited since the stash was written, and a plain split of the live
+ `source` is the correct behaviour once that has happened, not a stale
+ three-way split nobody asked for any more.
+ """
+ source = dataset.get("source") or ""
+ stashed = payload.get(DATASET_STASH_SOURCE_PARTS)
+ if isinstance(stashed, dict):
+ db, schema, db_table = (
+ stashed.get(DATASET_STASH_SOURCE_PARTS_DB, ""),
+ stashed.get(DATASET_STASH_SOURCE_PARTS_SCHEMA, ""),
+ stashed.get(DATASET_STASH_SOURCE_PARTS_DB_TABLE, ""),
+ )
+ if ".".join((db, schema, db_table)) == source:
+ return db, schema, db_table
+ log.add(
+ code="TS-DATASET-SOURCE-PARTS-STALE",
+ severity=Severity.WARNING,
+ message=(
+ "the stashed source_parts no longer reconstruct this dataset's "
+ "current source; the source is re-split instead"
+ ),
+ object_ref=object_ref,
+ )
+
+ parts = _split_three_part_identifier(source)
+ if parts is not None and len(parts) == 3 and all(parts):
+ return parts[0], parts[1], parts[2]
+
+ log.add(
+ code="TS-DATASET-SOURCE-MALFORMED",
+ severity=Severity.WARNING,
+ message=(
+ f"source {source!r} does not split into three non-empty db/schema/table "
+ f"parts; it is kept verbatim as db_table with db and schema left blank"
+ ),
+ object_ref=object_ref,
+ )
+ return "", "", source
+
+
+def _connection_name(
+ payload: dict, connection_name: str | None, log: IssueLog, *, object_ref: str
+) -> str | None:
+ name = payload.get(DATASET_STASH_CONNECTION_NAME) or connection_name
+ if name:
+ return name
+ log.add(
+ code="TS-DATASET-CONNECTION-MISSING",
+ severity=Severity.WARNING,
+ message=(
+ "no connection name is available for this table -- none was stashed "
+ "and none was supplied by the caller; the connection is omitted from "
+ "the document and the import will fail until one is added"
+ ),
+ object_ref=object_ref,
+ remedy="Set the Table document's connection.name to a valid Connection display name before import.",
+ )
+ return None
+
+
+def _table_name(dataset: dict, payload: dict) -> str:
+ return (
+ payload.get(STASH_TML_NAME)
+ or payload.get(DATASET_STASH_TABLE_NAME)
+ or dataset.get("name")
+ or "<unnamed>"
+ )
+
+
+def _shared_body(dataset: dict, payload: dict, connection: str | None) -> dict:
+ body: dict = {}
+ if connection:
+ body["connection"] = {"name": connection}
+ description = dataset.get("description")
+ if description:
+ body["description"] = description
+ table_properties = payload.get(DATASET_STASH_TABLE_PROPERTIES)
+ if table_properties:
+ body["properties"] = table_properties
+ return body
+
+
+def _unsurfaced_columns_still_unsurfaced(
+ unsurfaced: list[dict] | None, live_column_names: set[str]
+) -> list[dict]:
+ """`unsurfaced` (the verbatim DATASET_STASH_UNSURFACED_COLUMNS entries),
+ with any entry now covered by a live field dropped.
+
+ DATASET_STASH_UNSURFACED_COLUMNS is INFORMATION_ONLY in its per-entry
+ *content* -- a physical column's own db_column_name/data_type has no
+ Ossie counterpart to check it against -- but its *membership* is a
+ different question with a different answer: whether a given entry is
+ still unsurfaced is exactly the complement of what the live document's
+ fields now cover, and that complement can change. A field added (or
+ retargeted onto) a column that was unsurfaced when the stash was
+ written makes that column surfaced now; blindly re-appending it here
+ would emit it a second time under the field-derived entry's own name --
+ a duplicate Table/SQL-View column name, which does not import. Filtering
+ here is silent by design: nothing was lost (the column is still present,
+ once, under the live field's own build), so there is nothing to name in
+ an issue -- see STASH_KEYS_WITH_DERIVABLE_MEMBERSHIP for why this is a
+ distinct question from the value-classification table above it.
+ """
+ if not unsurfaced:
+ return []
+ return [c for c in unsurfaced if c.get("name") not in live_column_names]
+
+
+def _build_table_body(
+ dataset: dict, payload: dict, connection: str | None, log: IssueLog, *, object_ref: str
+) -> dict:
+ db, schema, db_table = _source_parts(dataset, payload, log, object_ref=object_ref)
+ body: dict = {"name": _table_name(dataset, payload), "db": db, "schema": schema, "db_table": db_table}
+ body.update(_shared_body(dataset, payload, connection))
+
+ columns: list[dict] = []
+ for field in dataset.get("fields") or []:
+ column = _physical_table_column(field, log)
+ if column is not None:
+ columns.append(column)
+ unsurfaced = payload.get(DATASET_STASH_UNSURFACED_COLUMNS)
+ columns.extend(
+ _unsurfaced_columns_still_unsurfaced(unsurfaced, {c["name"] for c in columns})
+ )
+ body["columns"] = columns
+ return body
+
+
+def _build_sql_view_body(
+ dataset: dict, payload: dict, connection: str | None, log: IssueLog, *, object_ref: str
+) -> dict:
+ body: dict = {"name": _table_name(dataset, payload), "sql_query": dataset.get("source") or ""}
+ body.update(_shared_body(dataset, payload, connection))
+
+ output_aliases = payload.get(DATASET_STASH_SQL_OUTPUT_COLUMNS) or {}
+ columns: list[dict] = []
+ for field in dataset.get("fields") or []:
+ column = _physical_sql_view_column(field, output_aliases, log)
+ if column is not None:
+ columns.append(column)
+ unsurfaced = payload.get(DATASET_STASH_UNSURFACED_COLUMNS)
+ columns.extend(
+ _unsurfaced_columns_still_unsurfaced(unsurfaced, {c["name"] for c in columns})
+ )
+ body["sql_view_columns"] = columns
+ return body
+
+
+def build_table(dataset: dict, log: IssueLog, *, connection_name: str | None = None) -> TmlDocument:
+ """One Ossie dataset -> one ThoughtSpot `table:`/`sql_view:` TML document.
+
+ `connection_name` is the fallback used when the dataset carries no
+ stashed `connection_name` of its own -- Ossie has no connection concept,
+ so a hand-authored dataset has nowhere else to record which warehouse
+ Connection the table belongs to. When neither is available the
+ connection is omitted and an issue names the gap, rather than a
+ connection name being invented.
+
+ A `source` that is a query becomes a `sql_view:` document, its columns
+ under `sql_view_columns[]`; anything that at least looks like a
+ `db.schema.table` reference becomes a `table:` document. Only a field
+ whose own expression is a single physical column reference becomes a
+ column here -- a computed field has no single warehouse column to name,
+ and is left for the Model document to turn into a formula instead.
+ """
+ name = dataset.get("name") or "<unnamed>"
+ object_ref = f"dataset:{name}"
+ payload = stash.read_stash(dataset)
+ connection = _connection_name(payload, connection_name, log, object_ref=object_ref)
+
+ if dataset.get("ai_context"):
+ # Neither a Table nor a SQL View document has any synonym or
+ # instruction field at all -- there is nowhere in TML for this to
+ # go, in either direction, so the loss is unconditional rather than
+ # a fallback that might be avoided with more information.
+ log.add(
+ code="TS-DATASET-AI-CONTEXT-UNSUPPORTED",
+ severity=Severity.WARNING,
+ message=(
+ "dataset ai_context has no home in a Table or SQL View "
+ "document; it is not carried into the table"
+ ),
+ object_ref=object_ref,
+ )
+
+ if _decide_kind(dataset, payload, log, object_ref=object_ref) == "sql_view":
+ body = _build_sql_view_body(dataset, payload, connection, log, object_ref=object_ref)
+ return TmlDocument(kind="sql_view", body=body, guid=None)
+
+ body = _build_table_body(dataset, payload, connection, log, object_ref=object_ref)
+ return TmlDocument(kind="table", body=body, guid=None)
+
+
+# ---------------------------------------------------------------------------
+# build_model: the Model TML document.
+#
+# Everything below builds `model:` from one Ossie semantic model -- the
+# document root -- plus the Table/SQL-View documents `build_table` produced for its
+# datasets. Order of business: name/description/ai_context, then a resolver
+# any computed field or metric's portable (ANSI_SQL) expression needs
+# (`resolve_field`, built once from every dataset's physical fields), then
+# fields and metrics (which allocate model-wide unique display names),
+# then unattributed formulas, then relationships/unrepresentable
+# joins folded into each dataset's inline `joins[]`, then model-scope stash.
+# ---------------------------------------------------------------------------
+
+
+class _DisplayNameAllocator:
+ """Assigns unique TML display names across `columns[]` and `formulas[]`
+ combined, preserving each candidate's own text exactly whenever
+ it is not colliding with one already assigned.
+
+ `identifiers.Allocator` is not reused directly here: it folds every
+ candidate to a normalised (lowercase, underscore-joined) identifier even
+ on its very first use, which is correct for an *Ossie* identifier
+ (TML -> Ossie's own `field.name`) but wrong for a TML display name --
+ `Ossie -> TML` must use a field's `label` (or a metric's own
+ `name`, when there is no `label`) verbatim in the ordinary, non-colliding
+ case. This class reuses `identifiers.normalise` as the fold key -- the
+ exact case/punctuation-insensitive comparison Ossie identifier resolution
+ requires, and the same
+ one `identifiers.Allocator` computes internally -- and appends a numeric
+ suffix to the *original* text, never the folded one, only once a
+ collision is actually found.
+ """
+
+ def __init__(self) -> None:
+ self._taken: set[str] = set()
+
+ def allocate(self, display_name: str, log: IssueLog, *, object_ref: str) -> str:
+ try:
+ fold_base = identifiers.normalise(display_name)
+ except ValueError:
+ # A name with no ASCII alphanumerics at all -- normalise() raises
+ # rather than returning one. Falls back to a plain casefold so
+ # this allocator still has *some* fold key to dedupe against,
+ # rather than propagating the exception into a model build.
+ fold_base = display_name.strip().casefold() or "field"
+ fold, candidate, suffix = fold_base, display_name, 1
+ while fold in self._taken:
+ suffix += 1
+ candidate = f"{display_name}_{suffix}"
+ fold = f"{fold_base}_{suffix}"
+ self._taken.add(fold)
+ if candidate != display_name:
+ # The rename is correct -- uniqueness is required -- but
+ # it changes text the user chose and will see in the product, and
+ # silence here is exactly the kind of quiet difference this
+ # package otherwise always reports.
+ log.add(
+ code="TS-MODEL-DISPLAY-NAME-COLLISION",
+ severity=Severity.WARNING,
+ message=(
+ f"display name {display_name!r} collides with one already "
+ f"assigned in this model; it is emitted as {candidate!r} "
+ f"instead to satisfy ThoughtSpot's uniqueness requirement"
+ ),
+ object_ref=object_ref,
+ )
+ return candidate
+
+
+def _normalise_or_self(text: str) -> str:
+ """`identifiers.normalise(text)`, or `text` itself when it has no ASCII
+ alphanumerics for `normalise` to fold onto -- the same fallback
+ `_DisplayNameAllocator.allocate` and `_formula_id_from` already use, so
+ all three agree on what "the fold key" is for a piece of text with no
+ normal form."""
+ try:
+ return identifiers.normalise(text)
+ except ValueError:
+ return text
+
+
+def _restore_tml_name(
+ payload: dict, live_identifier: str, log: IssueLog, *, object_ref: str
+) -> str:
+ """The witness check for STASH_TML_NAME (metric and model scope): the exact ThoughtSpot
+ display name a prior TML -> Ossie trip stashed when identifier normalisation
+ changed it, restored only when it is still current.
+
+ Self-verifying rather than a separately stored witness (the same shape
+ `_source_parts` already uses for DATASET_STASH_SOURCE_PARTS): the
+ stashed name's own normalised form IS the check, since that is exactly
+ the fold the forward direction applied to produce `live_identifier` in
+ the first place. If they still agree, nobody has renamed the Ossie
+ identifier since the stash was written, and the exact display name is
+ restored; if they disagree, the identifier was renamed and the stash
+ describes a name that no longer belongs to this object, so it is
+ dropped and the live identifier is used instead.
+ """
+ stashed = payload.get(STASH_TML_NAME)
+ if not isinstance(stashed, str) or not stashed:
+ return live_identifier
+ if _normalise_or_self(stashed) == live_identifier:
+ return stashed
+ log.add(
+ code="TS-STASH-TML-NAME-STALE",
+ severity=Severity.WARNING,
+ message=(
+ f"a stashed display name {stashed!r} no longer matches this "
+ f"object's current identifier {live_identifier!r}; it was "
+ f"renamed since the stash was written, so the stashed name is "
+ f"dropped and the current identifier is used instead"
+ ),
+ object_ref=object_ref,
+ )
+ return live_identifier
+
+
+def _formula_id_from(display_name: str) -> str:
+ """`formulas[].id` for a formula surfaced under `display_name`.
+
+ Real ThoughtSpot display names carry spaces and mixed case
+ (``"Net Amount"``); ids do not (``formula_net_amount``). Deriving the id
+ from the *normalised* form of the display name -- the same fold
+ `_DisplayNameAllocator` already dedupes on -- rather than embedding the
+ display name verbatim is what lets a THOUGHTSPOT-verbatim cross-reference
+ elsewhere in the model (`[formula_net_amount]`) resolve
+ against a formula this converter itself is generating: the reference was
+ written against ThoughtSpot's own slug-shaped id convention, and a
+ verbatim, unnormalised id (``formula_Net Amount``) would silently break
+ it while still importing (a stray space in an id is otherwise legal).
+ Calls `_normalise_or_self` rather than repeating its try/except, so the
+ id-minting side and `_rewrite_formula_references`'s reference-matching
+ side cannot independently drift onto two different fold rules.
+ """
+ return f"{formula.FORMULA_REFERENCE_PREFIX}{_normalise_or_self(display_name)}"
+
+
+#: TML aggregation enum value -> the catalog `spec_name` whose DIRECT template
+#: is ThoughtSpot's own native rendering of it. Mirrors tml_to_ossie.py's own
+#: `_AGGREGATION_CATALOG_SPEC` (kept local rather than imported across modules
+#: for a private name) -- both derive `_CALL_NAME_TO_AGGREGATION` below from
+#: the same catalog rows, so "what native call names an aggregate" cannot
+#: silently drift between the read and write directions.
+_METRIC_AGGREGATION_CATALOG_SPEC = {
+ "SUM": "SUM(expr)", "COUNT": "COUNT(expr)", "AVERAGE": "AVG(expr)",
+ "MIN": "MIN(expr)", "MAX": "MAX(expr)", "COUNT_DISTINCT": "COUNT(DISTINCT expr)",
+ "STD_DEVIATION": "STDDEV(expr)", "VARIANCE": "VARIANCE(expr)",
+}
+
+#: The inverse: ThoughtSpot's own native aggregate call name (as rendered by
+#: `emit_direct`) -> the TML `aggregation` enum value it corresponds to.
+#: Derived, not hand-typed, for the same reason tml_to_ossie.py derives
+#: `_AGGREGATE_CALL_NAMES` from the catalog rather than listing native names
+#: by hand.
+_CALL_NAME_TO_AGGREGATION: dict[str, str] = {
+ formula.split_call(emit_direct(CATALOG[_spec], ["x"]))[0].lower(): _agg
+ for _agg, _spec in _METRIC_AGGREGATION_CATALOG_SPEC.items()
+}
+
+
+def _outer_aggregation_of(ts_expr: str) -> str | None:
+ """The TML `aggregation` enum value matching `ts_expr`'s own outer call,
+ or `None` when there is no outer call or it is not a recognised native
+ aggregate.
+
+ Used two ways: to decompose a `scalar_formula_plus_aggregation`-shaped
+ metric's composed expression back into its scalar inner expression plus
+ the aggregation that wraps it, and — for every other shape — to set the
+ surfacing column's `aggregation` as the documented convention the worked
+ shape shows (inert at query time when the formula's own expr already
+ aggregates, but present on real ThoughtSpot-authored documents).
+ """
+ call = formula.split_call(ts_expr)
+ if call is None:
+ return None
+ name, args = call
+ if len(args) != 1:
+ return None
+ return _CALL_NAME_TO_AGGREGATION.get(name.lower())
+
+
+def _decompose_scalar_aggregate(ts_expr: str) -> tuple[str, str] | None:
+ """`(aggregation, inner scalar expr)` for a composed aggregate call, or
+ `None` when `ts_expr`'s outer call is not a recognised native aggregate
+ over a single argument.
+
+ The scalar-formula-plus-aggregation pattern (`scalar_formula_plus_aggregation`): the Ossie metric's
+ THOUGHTSPOT-dialect entry already holds the *composed* text (e.g.
+ ``average ( [A::x] - [A::y] )``, built by tml_to_ossie's own
+ `_compose_aggregate_entries`) — this is the inverse, recovering the bare
+ scalar `[A::x] - [A::y]` and the `AVERAGE` that wraps it.
+ """
+ call = formula.split_call(ts_expr)
+ if call is None:
+ return None
+ name, args = call
+ if len(args) != 1:
+ return None
+ aggregation = _CALL_NAME_TO_AGGREGATION.get(name.lower())
+ if aggregation is None:
+ return None
+ return aggregation, args[0]
+
+
+def _maybe_block_scalar(expr: str) -> str:
+ """Wrap `expr` for `>-` emission whenever it contains a brace,
+ otherwise return it untouched."""
+ if "{" in expr or "}" in expr:
+ return block_scalar(expr)
+ return expr
+
+
+def _rewrite_formula_references(
+ expr: str,
+ formula_id_by_normalised_name: dict[str, str],
+ log: IssueLog,
+ *,
+ object_ref: str,
+) -> str:
+ """Rewrite every bare `[formula_X]` cross-reference in `expr` to the id
+ this build actually assigned the referenced formula.
+
+ `_formula_id_from` regenerates every formula's id from the *normalised*
+ form of its own display name -- real ThoughtSpot ids are slug-shaped,
+ display names are not. A cross-reference embedded in a verbatim
+ THOUGHTSPOT-dialect expression was written against the *source*
+ document's own id text, which need not match the id this build just
+ minted for the same formula (the source id could use different casing,
+ punctuation, or spacing than this converter's own convention) -- and
+ ThoughtSpot does not fail an unresolvable bracket reference at parse
+ time, it parses it as search tokens instead, so a stale reference is a
+ guaranteed import failure discovered only later, not a warning.
+
+ `formula_id_by_normalised_name` must be keyed by `_normalise_or_self`
+ applied to each formula's own final display name -- the exact same fold
+ `_formula_id_from` uses to mint the id in the first place, passed in by
+ the caller rather than recomputed here, so the two can never
+ independently drift the way two separately-typed normalisation steps
+ could (this package just finished centralising stash-key spellings for
+ the identical reason).
+
+ A reference matching nothing being built in this model is left in the
+ text untouched -- there is nothing safe to substitute -- and logged as
+ an ERROR: the emitted document will fail to import on this reference
+ until it is fixed, and that has to be visible, not silently shipped.
+ """
+ out: list[str] = []
+ cursor = 0
+ for start, end, body in formula._bracketed_spans(expr):
+ if "::" in body or not formula.is_formula_reference(body):
+ continue
+ referenced_name = body[len(formula.FORMULA_REFERENCE_PREFIX):]
+ target_id = formula_id_by_normalised_name.get(_normalise_or_self(referenced_name))
+ out.append(expr[cursor:start])
+ if target_id is None:
+ log.add(
+ code="TS-MODEL-FORMULA-REFERENCE-UNRESOLVED",
+ severity=Severity.ERROR,
+ message=(
+ f"expression references {body!r}, which does not match any "
+ f"formula this model emits; the reference is left as written "
+ f"and the resulting document will fail to import (ThoughtSpot "
+ f"parses an unresolvable bracket reference as search tokens, "
+ f"not a parse error) until it is fixed"
+ ),
+ object_ref=object_ref,
+ )
+ out.append(expr[start:end])
+ else:
+ out.append(f"[{target_id}]")
+ cursor = end
+ out.append(expr[cursor:])
+ return "".join(out)
+
+
+#: A bare `dataset.field` reference, per the specification's own dot-notation
+#: convention (`core-spec/expression_language.md:98`) -- the shape a
+#: hand-authored ANSI_SQL expression uses to name another Ossie field, e.g.
+#: `orders.amount`. Distinct from the warehouse-qualified dot form
+#: tml_to_ossie.py's own `resolve()` closure builds for a *round-tripped*
+#: document's portable sibling (`TABLE.db_column_name`) -- that form is never
+#: read back here: a round-tripped Ossie document always carries a THOUGHTSPOT
+#: entry too, which `to_thoughtspot_expression` prefers unconditionally, so
+#: this pattern is only ever exercised for a document with no such entry.
+_ANSI_DATASET_FIELD_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)\s*$")
+
+
+def _match_ansi_call(name: str, args: list[str]) -> tuple[str, list[str]] | None:
+ """The CATALOG key and (possibly rewritten) argument list matching a
+ single ANSI_SQL call `name(args)`, or `None` when nothing in the catalog
+ matches this call structurally.
+
+ Deliberately narrow: only the single-argument aggregate family
+ (`SUM(expr)`, `COUNT(expr)`, ..., and the `COUNT(DISTINCT expr)` special
+ case) is matched. This is the shape a metric's portable expression
+ realistically takes (the scalar-formula-plus-aggregation pattern's own
+ composed shape), and the catalog's other
+ families spell their placeholder differently per row (`ABS(x)`,
+ `LOWER(str)`, ...) — matching those too would need a full per-row arity
+ index this module does not build, so anything else falls through to "no
+ catalog construct matches structurally" rather than a guess.
+ """
+ upper = name.upper()
+ if upper == "COUNT" and len(args) == 1 and args[0].strip().upper().startswith("DISTINCT "):
+ inner = args[0].strip()[len("DISTINCT "):].strip()
+ if "COUNT(DISTINCT expr)" in CATALOG:
+ return "COUNT(DISTINCT expr)", [inner]
+ return None
+ if len(args) == 1:
+ key = f"{upper}(expr)"
+ if key in CATALOG:
+ return key, args
+ return None
+
+
+def _translate_ansi_sql(
+ expr: str,
+ resolve_field: Callable[[str], tuple[str, str] | None],
+ log: IssueLog,
+ *,
+ object_ref: str,
+) -> str | None:
+ """One ANSI_SQL expression -> a ThoughtSpot formula string, or `None`.
+
+ Handles exactly two structural shapes, recursively: a bare
+ `dataset.field` reference (rewritten via `resolve_field`), and a single
+ catalog-matched function call wrapping arguments of either shape. Anything
+ else raises an issue and returns `None` — the caller stashes rather than
+ this function guessing a rendering. Never re-renders one SQL dialect into
+ another: a construct the catalog does not structurally match is left
+ alone, not approximated.
+ """
+ match = _ANSI_DATASET_FIELD_RE.match(expr)
+ if match is not None:
+ key = f"{match.group(1)}.{match.group(2)}"
+ resolved = resolve_field(key)
+ if resolved is None:
+ log.add(
+ code="TS-EXPR-ANSI-UNRESOLVED",
+ severity=Severity.WARNING,
+ message=(
+ f"reference {key!r} does not resolve to a known field in this "
+ f"model; no ThoughtSpot expression is produced for it"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+ table, column = resolved
+ return identifiers.format_column_ref(table, column)
+
+ call = formula.split_call(expr)
+ if call is None:
+ log.add(
+ code="TS-EXPR-ANSI-UNSTRUCTURED",
+ severity=Severity.WARNING,
+ message=(
+ f"ANSI_SQL expression {expr!r} is neither a bare dataset.field "
+ f"reference nor a single function call this converter's catalog "
+ f"matches structurally; it is not re-rendered rather than guessed"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ name, args = call
+ matched = _match_ansi_call(name, args)
+ if matched is None:
+ log.add(
+ code="TS-EXPR-ANSI-UNMATCHED",
+ severity=Severity.WARNING,
+ message=(
+ f"{name}(...) in {expr!r} has no catalog construct this converter "
+ f"matches structurally; it is not re-rendered rather than guessed"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ spec_key, inner_args = matched
+ construct = CATALOG[spec_key]
+ translated: list[str] = []
+ for arg in inner_args:
+ piece = _translate_ansi_sql(arg, resolve_field, log, object_ref=object_ref)
+ if piece is None:
+ return None
+ translated.append(piece)
+
+ if construct.classification is Classification.DIRECT:
+ return emit_direct(construct, translated)
+ if construct.classification is Classification.PASSTHROUGH:
+ return emit_passthrough(construct, translated, log, object_ref=object_ref)
+ emit_unmappable(construct, log, object_ref=object_ref)
+ return None
+
+
+def to_thoughtspot_expression(
+ entries: Sequence[dict],
+ resolve_field: Callable[[str], tuple[str, str] | None],
+ log: IssueLog,
+ *,
+ object_ref: str,
+) -> str | None:
+ """One Ossie `expression.dialects[]` list -> a ThoughtSpot formula string,
+ or `None`.
+
+ Mirrors the reference converters' own `pick_expression`, and the same
+ dialect-selection order `tml_to_ossie.py`'s own `expression_entries` uses
+ in reverse: the THOUGHTSPOT entry, when present, is
+ authoritative and is returned **verbatim** — it is the exact `expr` text a
+ prior `TML -> Ossie` trip preserved untouched (tml_to_ossie.py's own
+ `expression_entries`), and every reference inside it already names this
+ document's own table/alias (a dataset's Ossie `name` is the
+ `model_tables[]` name-or-alias verbatim, so it round-trips unchanged) and
+ this document's own physical column display names (a Table document's
+ column `name` is copied from that same bracket text by `build_table`).
+ So nothing inside it needs rewriting for a document this converter
+ produced to return exactly, and none is attempted — `resolve_field` is
+ simply unused on this path.
+
+ Only when there is no THOUGHTSPOT entry at all — a hand-authored document,
+ or the worked-shape example in the construct-mapping document, both of
+ which carry only an ANSI_SQL sibling — does this fall through to
+ `_translate_ansi_sql`, which structurally matches a bare `dataset.field`
+ reference or a single catalog-recognised function call and rewrites via
+ `resolve_field`. Anything else raises an issue and returns `None` (the
+ caller stashes rather than guessing); one dialect is never re-rendered
+ into another.
+ """
+ by_dialect = {e.get("dialect"): e.get("expression") for e in entries if isinstance(e, dict)}
+
+ ts_expr = by_dialect.get(DIALECT)
+ if isinstance(ts_expr, str) and ts_expr:
+ return ts_expr
+
+ ansi_expr = by_dialect.get(PORTABLE_DIALECT)
+ if not isinstance(ansi_expr, str) or not ansi_expr:
+ log.add(
+ code="TS-EXPR-NO-USABLE-DIALECT",
+ severity=Severity.ERROR,
+ message=(
+ "expression carries no THOUGHTSPOT entry and no ANSI_SQL entry this "
+ "converter can translate; no ThoughtSpot expression can be produced for it"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ return _translate_ansi_sql(ansi_expr, resolve_field, log, object_ref=object_ref)
+
+
+def _field_physical_display_name(field: dict) -> str | None:
+ """The physical Table column's own display name `field` maps to, or
+ `None` when `field` is computed.
+
+ Mirrors `_physical_identity`'s own classification (THOUGHTSPOT-entry
+ priority, else any dialect's bare SQL identifier) without its logging or
+ its `db_column_name` lookup: this module's job here is only to classify
+ physical-vs-computed and to name the display column, and `build_table`
+ (called separately, on the same field, from the same log) already reports
+ any db_column_name assumption -- calling `_physical_identity` again here
+ would double-report the same finding under a second `object_ref`.
+
+ An ambiguous bracket (the table or column part itself contains "::") is
+ treated the same as "not a bare column reference" rather than left to
+ raise: `_physical_identity`, called on this same field from `build_table`
+ before this function ever runs, already reports the ambiguity once --
+ reporting it again here would be the same double report this function's
+ own docstring already rules out for db_column_name.
+ """
+ dialects = ((field.get("expression") or {}).get("dialects")) or []
+ ts_entry = next((d for d in dialects if d.get("dialect") == DIALECT), None)
+ if ts_entry is not None:
+ try:
+ bare = formula.is_bare_column_ref(ts_entry.get("expression", ""))
+ except ValueError:
+ return None
+ return bare[1] if bare is not None else None
+
+ display_name = field.get("label") or field.get("name")
+ for entry in dialects:
+ if _bare_sql_identifier(entry.get("expression", "")) is not None:
+ return display_name
+ return None
+
+
+def _physical_columns_of(table_doc: TmlDocument | None) -> list[dict]:
+ if table_doc is None:
+ return []
+ key = "sql_view_columns" if table_doc.kind == "sql_view" else "columns"
+ return table_doc.body.get(key) or []
+
+
+def _restore_ai_context(properties: dict, ai_context: object, log: IssueLog, *, object_ref: str) -> None:
+ """Fold an Ossie `ai_context` value (string or `{synonyms, instructions,
+ examples}`) into `properties`, mutating it in place (`synonyms` and
+ `synonym_type` live under `properties`, never at the column root).
+
+ `examples` has no TML equivalent and raises an issue rather than
+ being dropped silently.
+ """
+ if ai_context is None:
+ return
+ if isinstance(ai_context, str):
+ if ai_context:
+ properties["ai_context"] = ai_context
+ return
+ if not isinstance(ai_context, dict):
+ return
+
+ synonyms = ai_context.get("synonyms")
+ if synonyms:
+ properties["synonyms"] = list(synonyms)
+ # Only when the source did not state one. This runs AFTER the stashed
+ # column_properties are merged in, so an unconditional assignment
+ # overwrote a faithfully round-tripped `AUTO_GENERATED` with
+ # `USER_DEFINED` and logged nothing -- changing a column's synonym
+ # provenance, which the never-a-silent-loss contract forbids.
+ properties.setdefault("synonym_type", "USER_DEFINED")
+ instructions = ai_context.get("instructions")
+ if instructions:
+ properties["ai_context"] = instructions
+ if ai_context.get("examples"):
+ log.add(
+ code="TS-AI-CONTEXT-EXAMPLES-UNSUPPORTED",
+ severity=Severity.WARNING,
+ message=(
+ "ai_context.examples has no ThoughtSpot TML equivalent; it is "
+ "not carried into the model"
+ ),
+ object_ref=object_ref,
+ )
+
+
+#: Properties this converter must never write as `true` into a
+#: generated model, even when the stash carries the value verbatim. The
+#: stash is the Ossie document's own record of what the source TML held and
+#: is untouched by this filter (a forward conversion must still be able to
+#: recover the flag); only the *emitted* TML side ever drops it. A message
+#: per key, not one generic message, because the reasoning differs for
+#: each: a hidden column cannot be surfaced again without a manual edit on
+#: the target instance, and re-asserting was_auto_generated on a column this
+#: build did not itself generate would misrepresent its provenance.
+_NEVER_EMIT_TRUE_PROPERTY_MESSAGES = {
+ "is_hidden": (
+ "the source column had is_hidden=true, but a generated model must never "
+ "set it -- a hidden column cannot be surfaced again without a manual edit "
+ "on the target instance, so silently regenerating one would lock it there "
+ "again; it is dropped from the emitted column rather than written"
+ ),
+ "was_auto_generated": (
+ "the source column had was_auto_generated=true, but this build did not "
+ "auto-generate the regenerated column -- re-asserting the flag would "
+ "misrepresent its provenance; it is dropped from the emitted column "
+ "rather than written"
+ ),
+}
+
+
+def _drop_never_emit_true_properties(
+ extra_properties: dict, log: IssueLog, *, object_ref: str
+) -> dict:
+ """`extra_properties` (a restored `column_properties` stash) with
+ `is_hidden`/`was_auto_generated` removed before it is merged into the
+ emitted `properties` dict.
+
+ Only a `true` value is dropped-and-logged: it is the one value this
+ converter forbids the *generated* TML from carrying, and a generated model
+ silently losing a column's visibility (or misreporting its provenance)
+ is a real, actionable difference the model owner needs to see, not a
+ stylistic omission -- hence WARNING, matching this module's other
+ declared-loss codes (TS-MODEL-FIELD-DATATYPE-UNWRITABLE,
+ TS-MODEL-DATASET-KEY-UNUSED), rather than the INFO severity reserved for
+ a benign structural note. A stashed `false` is simply omitted, logging
+ nothing: `false` (or absent) is ThoughtSpot's own default for both
+ properties, so leaving the key out of the emitted document loses no
+ information at all.
+ """
+ filtered = dict(extra_properties)
+ for key, message in _NEVER_EMIT_TRUE_PROPERTY_MESSAGES.items():
+ if key not in filtered:
+ continue
+ value = filtered.pop(key)
+ if value is True:
+ log.add(
+ code="TS-MODEL-PROPERTY-NEVER-EMITTED",
+ severity=Severity.WARNING,
+ message=message,
+ object_ref=object_ref,
+ )
+ return filtered
+
+
+def _build_field(
+ field: dict,
+ dataset_prefix: str,
+ table_doc: TmlDocument | None,
+ allocator: _DisplayNameAllocator,
+ resolve_field: Callable[[str], tuple[str, str] | None],
+ log: IssueLog,
+) -> tuple[dict, dict | None] | None:
+ """One Ossie field -> `(columns[] entry, formulas[] entry or None)`, or
+ `None` when the field cannot be surfaced at all.
+
+ A physical field becomes a `column_id` entry, validated against the
+ dataset's own already-built Table document so a broken reference is
+ caught here rather than shipped as an import-time 404. A computed field
+ becomes a `formulas[]` + `formula_id` pair, never a bare `column_id`.
+ """
+ payload = stash.read_stash(field)
+ display_name = field.get("label") or field.get("name") or "<unnamed>"
+ object_ref = f"field:{display_name}"
+ name = allocator.allocate(display_name, log, object_ref=object_ref)
+ properties: dict = {"column_type": "ATTRIBUTE"}
+ formulas_entry: dict | None = None
+
+ physical_column_name = _field_physical_display_name(field)
+ if physical_column_name is not None:
+ exists = any(
+ c.get("name") == physical_column_name for c in _physical_columns_of(table_doc)
+ )
+ if not exists:
+ log.add(
+ code="TS-MODEL-COLUMN-ID-MISSING",
+ severity=Severity.ERROR,
+ message=(
+ f"field {display_name!r} maps to physical column "
+ f"{physical_column_name!r} on dataset {dataset_prefix!r}, but no "
+ f"such column exists on its Table document; the field is not "
+ f"surfaced in the model rather than referencing a column that "
+ f"does not exist"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+ columns_entry = {
+ "name": name,
+ "column_id": f"{dataset_prefix}::{physical_column_name}",
+ "properties": properties,
+ }
+ else:
+ expr = to_thoughtspot_expression(
+ (field.get("expression") or {}).get("dialects") or [],
+ resolve_field, log, object_ref=object_ref,
+ )
+ if expr is None:
+ log.add(
+ code="TS-MODEL-FIELD-UNTRANSLATABLE",
+ severity=Severity.ERROR,
+ message=(
+ f"field {display_name!r}'s expression could not be translated "
+ f"into any ThoughtSpot-importable form; it is not included in "
+ f"the model"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+ formula_id = _formula_id_from(name)
+ # `expr` is stored raw here -- not yet rewritten for cross-references
+ # to other formulas, and not yet block-scalar-wrapped. Both happen
+ # once, uniformly, in build_model's own final pass over the fully
+ # assembled formulas[] list, which is the earliest point every
+ # formula's final id is known (see _rewrite_formula_references).
+ formulas_entry = {"id": formula_id, "name": name, "expr": expr}
+ columns_entry = {"name": name, "formula_id": formula_id, "properties": properties}
+ if field.get("datatype") is not None:
+ log.add(
+ code="TS-MODEL-FIELD-DATATYPE-UNWRITABLE",
+ severity=Severity.WARNING,
+ message=(
+ f"field {display_name!r} is formula-backed and declares a "
+ f"datatype, but Model TML has no data_type key on a "
+ f"formula-backed columns[] entry; it is not carried into the "
+ f"model"
+ ),
+ object_ref=object_ref,
+ )
+
+ extra_properties = payload.get(FIELD_STASH_COLUMN_PROPERTIES) or {}
+ properties.update(_drop_never_emit_true_properties(extra_properties, log, object_ref=object_ref))
+ _restore_ai_context(properties, field.get("ai_context"), log, object_ref=object_ref)
+
+ description = field.get("description")
+ if description:
+ columns_entry["description"] = description
+
+ return columns_entry, formulas_entry
+
+
+#: Every `shape` value METRIC_STASH_SHAPE's own vocabulary defines (see
+#: constants.py) -- checked against, not enumerated a second time, so a
+#: future fourth shape only needs adding there for this set to pick it up.
+_KNOWN_METRIC_SHAPES = frozenset(
+ {METRIC_SHAPE_COLUMN_AGGREGATION, METRIC_SHAPE_SCALAR_FORMULA_PLUS_AGGREGATION, METRIC_SHAPE_FORMULA}
+)
+
+
+def _build_metric(
+ metric: dict,
+ allocator: _DisplayNameAllocator,
+ resolve_field: Callable[[str], tuple[str, str] | None],
+ log: IssueLog,
+) -> tuple[dict, dict] | None:
+ """One Ossie metric -> `(formulas[] entry, columns[] entry)`, or `None`
+ when it cannot be translated at all.
+
+ Always a formula, never `column_id` + `aggregation` -- Ossie's own
+ Metric schema has no `column_id` field regardless, so this is the only
+ shape available. The stash's `shape` (default METRIC_SHAPE_FORMULA, the
+ documented contract for an absent key) selects only between the two
+ formula-based emissions: `scalar_formula_plus_aggregation`
+ decomposes the composed expression back into a scalar `expr` plus a
+ load-bearing `properties.aggregation`; every other shape — the default,
+ and `column_aggregation`, whose Ossie-side THOUGHTSPOT text is *already*
+ the same aggregate-in-expr shape the default is — is emitted as-is, with
+ `properties.aggregation` set only as the inert convention real
+ ThoughtSpot-authored documents carry (see the worked shape example).
+ """
+ payload = stash.read_stash(metric)
+ live_name = metric.get("name") or "<unnamed>"
+ display_name = _restore_tml_name(payload, live_name, log, object_ref=f"metric:{live_name}")
+ object_ref = f"metric:{display_name}"
+ name = allocator.allocate(display_name, log, object_ref=object_ref)
+ formula_id = _formula_id_from(name)
+
+ ts_expr = to_thoughtspot_expression(
+ (metric.get("expression") or {}).get("dialects") or [],
+ resolve_field, log, object_ref=object_ref,
+ )
+ if ts_expr is None:
+ log.add(
+ code="TS-MODEL-METRIC-UNTRANSLATABLE",
+ severity=Severity.ERROR,
+ message=(
+ f"metric {display_name!r}'s expression could not be translated "
+ f"into any ThoughtSpot-importable form; it is not included in the "
+ f"model"
+ ),
+ object_ref=object_ref,
+ )
+ return None
+
+ shape = payload.get(METRIC_STASH_SHAPE, METRIC_SHAPE_FORMULA)
+ if shape not in _KNOWN_METRIC_SHAPES:
+ log.add(
+ code="TS-MODEL-METRIC-SHAPE-UNKNOWN",
+ severity=Severity.WARNING,
+ message=(
+ f"metric {display_name!r} is stashed with shape {shape!r}, which is "
+ f"not one of the shapes this converter recognises "
+ f"({sorted(_KNOWN_METRIC_SHAPES)!r}); treated as the default "
+ f"({METRIC_SHAPE_FORMULA!r}) rather than silently misapplied"
+ ),
+ object_ref=object_ref,
+ )
+ shape = METRIC_SHAPE_FORMULA
+ properties: dict = {"column_type": "MEASURE"}
+ formula_expr = ts_expr
+
+ if shape == METRIC_SHAPE_SCALAR_FORMULA_PLUS_AGGREGATION:
+ decomposed = _decompose_scalar_aggregate(ts_expr)
+ if decomposed is None:
+ log.add(
+ code="TS-MODEL-METRIC-SHAPE-MISMATCH",
+ severity=Severity.WARNING,
+ message=(
+ f"metric {display_name!r} is stashed as "
+ f"scalar_formula_plus_aggregation but its composed expression "
+ f"{ts_expr!r} has no recognised single-argument outer aggregate "
+ f"call; it is emitted as a plain formula instead"
+ ),
+ object_ref=object_ref,
+ )
+ else:
+ properties["aggregation"], formula_expr = decomposed
+
+ if "aggregation" not in properties:
+ conventional = _outer_aggregation_of(ts_expr)
+ if conventional is not None:
+ properties["aggregation"] = conventional
+ # Ossie's own Metric object has nowhere to record whether the
+ # *source* TML's surfacing column carried this property or
+ # omitted it -- both collapse identically on the way in, so this
+ # converter cannot tell them apart and always re-derives it. The
+ # value is a documented no-op here (the expr already aggregates,
+ # per the worked shape example and the domain-review note this
+ # module's docstrings already cite), so it changes no number --
+ # but it is still a difference a byte-for-byte reader would see,
+ # and a round trip whose whole point is fidelity should not make
+ # that judgment silently on the reader's behalf. INFO, not
+ # WARNING: nothing is wrong, this is FYI only, matching the
+ # severity expression_entries already uses for an equally benign
+ # structural note (TS-EXPR-THOUGHTSPOT-ONLY).
+ log.add(
+ code="TS-MODEL-METRIC-AGGREGATION-CONVENTION",
+ severity=Severity.INFO,
+ message=(
+ f"metric {display_name!r}'s formula already aggregates "
+ f"({conventional}); the surfacing column's aggregation is set "
+ f"to match, as the convention real ThoughtSpot-authored "
+ f"documents carry -- this is a no-op over an already-aggregate "
+ f"expression, not a change to the result, and Ossie has no way "
+ f"to record whether the source document set this property or "
+ f"omitted it"
+ ),
+ object_ref=object_ref,
+ )
+
+ if metric.get("datatype") is not None:
+ log.add(
+ code="TS-MODEL-METRIC-DATATYPE-UNWRITABLE",
+ severity=Severity.WARNING,
+ message=(
+ f"metric {display_name!r} declares a datatype, but Model TML has "
+ f"no data_type key anywhere for a formula-backed metric; it is not "
+ f"carried into the model"
+ ),
+ object_ref=object_ref,
+ )
+
+ extra_properties = payload.get(FIELD_STASH_COLUMN_PROPERTIES) or {}
+ properties.update(_drop_never_emit_true_properties(extra_properties, log, object_ref=object_ref))
+ _restore_ai_context(properties, metric.get("ai_context"), log, object_ref=object_ref)
+
+ # Raw, unwrapped `formula_expr` here -- see the matching comment in
+ # _build_field; both the cross-reference rewrite and the block-scalar
+ # wrap happen once, uniformly, in build_model's final pass.
+ formulas_entry = {"id": formula_id, "name": name, "expr": formula_expr}
+ columns_entry = {"name": name, "formula_id": formula_id, "properties": properties}
+ description = metric.get("description")
+ if description:
+ columns_entry["description"] = description
+
+ return formulas_entry, columns_entry
+
+
+def _build_field_index(
+ datasets: list[dict],
+) -> dict[str, tuple[str, str]]:
+ """`"dataset.field" -> (TABLE, physical column display name)`, for every
+ physical field in every dataset -- the data `resolve_field` (the
+ `to_thoughtspot_expression` parameter) is built from.
+
+ Deliberately not named `resolve` (see the module's Model-building
+ section and the task interfaces): `resolve` (tml_to_ossie.py) maps
+ `(TABLE, Column) -> "dataset.field"`; this is its inverse, same arity,
+ keyed the other way around, so a mixed-up argument would type-check and
+ produce silently wrong references.
+ """
+ index: dict[str, tuple[str, str]] = {}
+ for dataset in datasets:
+ dataset_prefix = dataset.get("name")
+ if not dataset_prefix:
+ continue
+ for field in dataset.get("fields") or []:
+ field_name = field.get("name")
+ if not field_name:
+ continue
+ physical_column_name = _field_physical_display_name(field)
+ if physical_column_name is None:
+ continue
+ index[f"{dataset_prefix}.{field_name}"] = (dataset_prefix, physical_column_name)
+ return index
+
+
+#: The two spellings a source join `type` can arrive as for what
+#: ThoughtSpot calls `OUTER` (its own full outer join). Matched
+#: case/whitespace-insensitively: the stash carries whatever spelling the
+#: source TML happened to use, and neither variant -- nor any casing of
+#: either -- is privileged.
+_FULL_OUTER_SPELLING = "FULL_OUTER"
+
+
+def _normalise_join_type(value: str) -> str:
+ """A source `FULL OUTER` / `FULL_OUTER` becomes `OUTER`, in every
+ context TML accepts a join `type` at all. ThoughtSpot accepts only
+ `INNER`, `LEFT_OUTER`, `RIGHT_OUTER`, `OUTER` and rejects both `FULL_OUTER`
+ spellings identically; `OUTER` *is* ThoughtSpot's own full outer join, so
+ this is a semantics-preserving rename, never a loss -- nothing is logged
+ for it, unlike every other rewrite in this module. Every other value
+ (already one of the four TML accepts, since it came from a real TML
+ export) passes through unchanged.
+ """
+ if value.strip().upper().replace(" ", "_") == _FULL_OUTER_SPELLING:
+ return "OUTER"
+ return value
+
+
+def _restore_relationship_condition(
+ from_prefix: str, to_prefix: str, from_columns: list[str], to_columns: list[str]
+) -> str:
+ """The equality-only `on:` condition for a relationship with no stashed
+ `on_expression` -- reconstructed from `from_columns`/`to_columns` alone,
+ which is all a hand-authored relationship (no stash) has to go on."""
+ from_columns = from_columns or []
+ to_columns = to_columns or []
+ # `zip` truncates to the shorter list, so a relationship whose two arrays
+ # disagree emitted a condition covering only the shorter one -- the extra
+ # predicates vanished with nothing logged, and the imported join then
+ # matched MORE rows than the Ossie document declared. Every other arity
+ # mismatch in this package raises rather than truncating; upstream
+ # apache/ossie#375 made equal arity a validation rule, so a document
+ # reaching here unequal is malformed.
+ if len(from_columns) != len(to_columns):
+ raise ConversionError(
+ f"relationship {from_prefix!r} -> {to_prefix!r} has "
+ f"{len(from_columns)} from_columns and {len(to_columns)} to_columns; "
+ f"they must have equal arity to form a join condition"
+ )
+ pairs = zip(from_columns, to_columns)
+ return " and ".join(
+ f"{identifiers.format_column_ref(from_prefix, fc)} = "
+ f"{identifiers.format_column_ref(to_prefix, tc)}"
+ for fc, tc in pairs
+ )
+
+
+def _join_entry_for_relationship(rel: dict, log: IssueLog) -> tuple[str, dict, dict | None]:
+ """One Ossie relationship -> `(from_prefix, model join entry,
+ Table joins_with[] entry or None)`.
+
+ A `"referencing"`- or `"referencing_with_inline_attrs"`-shaped join is
+ restored as such -- a `referencing_join` pointer on the Model entry plus
+ a matching `joins_with[]` entry for the caller to attach to the *Table*
+ document -- whenever the stashed `referencing_join` name still matches
+ this relationship's own current `name`. TML's inline join syntax has no
+ name field at all, so a relationship that instead falls through to the
+ inline branch below gets a fresh one synthesized from its own from/to
+ dataset names on the next TML -> Ossie pass; restoring the referencing
+ shape here is what avoids that rename. When the two names disagree --
+ the relationship was renamed since the stash was written -- the stash
+ is stale: it is dropped, an issue records it, and the join is emitted
+ inline instead, exactly as a hand-authored relationship with no stash
+ at all would be. The same is true when there is no stashed
+ `referencing_join` to begin with.
+
+ The witness-copy pattern governs `on_expression` here, named by example
+ elsewhere in this converter as the "verbatim on_expression" case. A plain
+ stash-if-present read would silently keep
+ serving the *old* condition (residual predicates included) after a user
+ retargets the relationship's `from_columns`/`to_columns` -- so the stash
+ is only trusted when the witness (a snapshot of those two arrays, taken
+ the moment the stash was written) still matches the live ones. A mismatch
+ means the relationship was edited since; the stash -- on_expression and
+ whatever residual narrowing it carried -- is dropped, an issue records
+ it, and the condition is re-derived from the current from_columns/
+ to_columns alone, exactly as a hand-authored relationship with no stash
+ at all would be.
+
+ The same witness pattern governs whether this relationship's endpoints
+ get un-swapped before any of the above runs. `TML -> Ossie` swaps a
+ `ONE_TO_MANY` join's `from`/`to`/`from_columns`/`to_columns` so the
+ emitted relationship satisfies core-spec/spec.yaml's many-side/one-side
+ convention (see `tml_to_ossie._relationship_from_join`) -- which means
+ recovering TML's own declared join direction here means undoing that
+ swap first, before `from_prefix`/`to_prefix`/`from_columns`/`to_columns`
+ are used for anything else in this function (the condition fallback, the
+ `with`/`destination` target, and the `from_prefix` the caller nests the
+ join under). The swap is undone only while
+ `RELATIONSHIP_STASH_ENDPOINTS_SWAPPED_WITNESS` still matches the
+ relationship's live from/to/from_columns/to_columns -- agreement means
+ nobody retargeted the relationship since the stash was written;
+ disagreement means it was, so the swap is left alone (the live shape is
+ trusted as-is, exactly as a hand-authored relationship with no stash at
+ all would be) and an issue records it.
+ """
+ payload = stash.read_stash(rel)
+ live_from = rel.get("from") or ""
+ live_to = rel.get("to") or ""
+ live_from_columns = rel.get("from_columns") or []
+ live_to_columns = rel.get("to_columns") or []
+
+ had_stashed_swap = RELATIONSHIP_STASH_ENDPOINTS_SWAPPED in payload
+ endpoints_swapped = stash.restore(
+ payload, RELATIONSHIP_STASH_ENDPOINTS_SWAPPED, False,
+ witness=[live_from, live_to, live_from_columns, live_to_columns],
+ witness_key=RELATIONSHIP_STASH_ENDPOINTS_SWAPPED_WITNESS,
+ )
+ if had_stashed_swap and not endpoints_swapped:
+ log.add(
+ code="TS-JOIN-ENDPOINTS-SWAP-STALE",
+ severity=Severity.WARNING,
+ message=(
+ f"relationship {rel.get('name')!r} has a stashed endpoint swap, "
+ f"but its from/to/from_columns/to_columns no longer match what "
+ f"that swap was recorded against -- the relationship was "
+ f"retargeted since the stash was written, so the swap is not "
+ f"undone; the join is emitted from this relationship's current "
+ f"from/to exactly as a hand-authored relationship with no stash "
+ f"at all would be"
+ ),
+ object_ref=f"relationship:{rel.get('name')}",
+ )
+
+ if endpoints_swapped:
+ from_prefix, to_prefix = live_to, live_from
+ from_columns, to_columns = live_to_columns, live_from_columns
+ else:
+ from_prefix, to_prefix = live_from, live_to
+ from_columns, to_columns = live_from_columns, live_to_columns
+
+ had_stashed_on_expression = RELATIONSHIP_STASH_ON_EXPRESSION in payload
+ on_expression = stash.restore(
+ payload, RELATIONSHIP_STASH_ON_EXPRESSION, None,
+ # Compared against the relationship's live from_columns/to_columns,
+ # never the un-swapped ones above: the stashed witness was written
+ # (tml_to_ossie.py) from the emitted -- i.e. already-swapped --
+ # from_columns/to_columns, which is exactly what "live" means here.
+ witness=[live_from_columns, live_to_columns],
+ witness_key=RELATIONSHIP_STASH_ON_EXPRESSION_WITNESS,
+ )
+ if not on_expression:
+ if had_stashed_on_expression:
+ log.add(
+ code="TS-JOIN-ON-EXPRESSION-STALE",
+ severity=Severity.WARNING,
+ message=(
+ f"relationship {rel.get('name')!r} has a stashed on_expression, "
+ f"but its from_columns/to_columns no longer match what that "
+ f"condition was derived from -- the relationship was retargeted "
+ f"since the stash was written, so the stashed condition (and any "
+ f"residual predicates it narrowed) is dropped; the plain equality "
+ f"condition is re-derived from the current from_columns/to_columns "
+ f"instead"
+ ),
+ object_ref=f"relationship:{rel.get('name')}",
+ )
+ on_expression = _restore_relationship_condition(from_prefix, to_prefix, from_columns, to_columns)
+ join_type = _normalise_join_type(payload.get(RELATIONSHIP_STASH_TYPE) or "INNER")
+ # Gated on the SAME witness as the endpoint swap, because it states the same
+ # fact. An Ossie relationship carries no cardinality field: `from` is the many
+ # side and `to` is the one side, so direction IS cardinality. When the witness
+ # is stale the swap above is deliberately not undone and the orientation is
+ # taken live -- and a stashed `ONE_TO_MANY` read alongside that live
+ # orientation declares the join backwards, which ThoughtSpot uses for
+ # fan-out, so the model returns multiplied rows.
+ #
+ # The stale-swap issue already promises the join is emitted "exactly as a
+ # hand-authored relationship with no stash at all would be". A hand-authored
+ # one gets the MANY_TO_ONE default; reading the stash here broke that promise.
+ # Gated on the stale-swap condition itself, not on the witness directly: a
+ # relationship that never carried a swap stash (hand-authored, never round
+ # -tripped) has a cardinality that stands on its own, and there is nothing
+ # stale about it. Only a swap that WAS stashed and has since gone stale
+ # invalidates it.
+ stashed_swap_is_stale = had_stashed_swap and not endpoints_swapped
+ cardinality = (
+ "MANY_TO_ONE" if stashed_swap_is_stale
+ else (payload.get(RELATIONSHIP_STASH_CARDINALITY) or "MANY_TO_ONE")
+ )
+
+ live_name = rel.get("name")
+ stashed_referencing_join = payload.get(RELATIONSHIP_STASH_REFERENCING_JOIN)
+ if stashed_referencing_join and stashed_referencing_join != live_name:
+ log.add(
+ code="TS-JOIN-REFERENCING-JOIN-STALE",
+ severity=Severity.WARNING,
+ message=(
+ f"relationship {live_name!r} has a stashed referencing_join "
+ f"{stashed_referencing_join!r}, but that no longer matches the "
+ f"relationship's own current name -- it was renamed since the "
+ f"stash was written, so the stashed Table joins_with[] reference "
+ f"is dropped; an inline join is emitted instead, named on the "
+ f"next TML -> Ossie pass from its from/to datasets like a "
+ f"hand-authored relationship would be"
+ ),
+ object_ref=f"relationship:{live_name}",
+ )
+ stashed_referencing_join = None
+
+ if stashed_referencing_join:
+ model_join_entry: dict = {"referencing_join": stashed_referencing_join}
+ if payload.get(RELATIONSHIP_STASH_JOIN_SHAPE) == "referencing_with_inline_attrs":
+ model_join_entry["type"] = join_type
+ model_join_entry["cardinality"] = cardinality
+ joins_with_entry = {
+ "name": stashed_referencing_join,
+ "destination": {"name": to_prefix},
+ "on": on_expression,
+ "type": join_type,
+ "cardinality": cardinality,
+ }
+ return from_prefix, model_join_entry, joins_with_entry
+
+ return from_prefix, {
+ "with": to_prefix, "on": on_expression, "type": join_type, "cardinality": cardinality,
+ }, None
+
+
+def _join_entry_for_unrepresentable(entry: dict) -> tuple[str, dict]:
+ """One `unrepresentable_joins[]` stash entry -> `(from_prefix, inline join
+ entry)` -- these carry the verbatim `on_expression` unconditionally (they
+ exist only because their condition has no equality pair at all), so the
+ join is restored exactly rather than approximated."""
+ from_prefix = entry.get("from") or ""
+ to_prefix = entry.get("to") or ""
+ on_expression = entry.get(RELATIONSHIP_STASH_ON_EXPRESSION) or ""
+ join_type = _normalise_join_type(entry.get(RELATIONSHIP_STASH_TYPE) or "INNER")
+ cardinality = entry.get(RELATIONSHIP_STASH_CARDINALITY) or "MANY_TO_ONE"
+ return from_prefix, {
+ "with": to_prefix, "on": on_expression, "type": join_type, "cardinality": cardinality,
+ }
+
+
+def build_model(semantic_model: dict, tables: Sequence[TmlDocument], log: IssueLog) -> TmlDocument:
+ """One Ossie semantic model -> one ThoughtSpot `model:` TML document.
+
+ `semantic_model` is the Ossie document root itself (apache/ossie#383): its
+ extra `version` key is simply never read here.
+
+ `tables` are the already-built Table/SQL-View documents for this model's
+ datasets (`build_table`, called once per dataset) -- consulted here, by
+ name, rather than re-derived, so a physical field's `column_id` always
+ references a column that genuinely exists on the document a Model import
+ would actually load (tables are emitted, and known, before the
+ model that references them).
+ """
+ model_payload = stash.read_stash(semantic_model)
+ live_model_name = semantic_model.get("name") or "<unnamed>"
+ model_name = _restore_tml_name(
+ model_payload, live_model_name, log, object_ref=f"model:{live_model_name}"
+ )
+ object_ref = f"model:{model_name}"
+ body: dict = {"name": model_name}
+
+ description = semantic_model.get("description")
+ if description:
+ body["description"] = description
+
+ if semantic_model.get("ai_context") is not None:
+ log.add(
+ code="TS-MODEL-AI-CONTEXT-UNSUPPORTED",
+ severity=Severity.WARNING,
+ message=(
+ "model-scope ai_context has no home in Model TML -- ThoughtSpot's "
+ "model-scope Spotter instructions are configured outside the TML "
+ "document; it is not carried into the model"
+ ),
+ object_ref=object_ref,
+ )
+
+ datasets = semantic_model.get("datasets") or []
+ tables_by_name = {t.body.get("name"): t for t in tables}
+
+ model_tables: list[dict] = []
+ model_tables_by_prefix: dict[str, dict] = {}
+ table_doc_by_prefix: dict[str, TmlDocument | None] = {}
+
+ for dataset in datasets:
+ dataset_prefix = dataset.get("name") or "<unnamed>"
+ ds_payload = stash.read_stash(dataset)
+ table_ref = _table_name(dataset, ds_payload)
+ alias = ds_payload.get(DATASET_STASH_ALIAS)
+ table_doc = tables_by_name.get(table_ref)
+ table_doc_by_prefix[dataset_prefix] = table_doc
+ if table_doc is None:
+ log.add(
+ code="TS-MODEL-TABLE-MISSING",
+ severity=Severity.ERROR,
+ message=(
+ f"dataset {dataset_prefix!r} references table {table_ref!r}, "
+ f"but no matching document was supplied in `tables`; the "
+ f"model_tables[] entry is still emitted by name, but none of "
+ f"this dataset's fields can be validated or surfaced"
+ ),
+ object_ref=f"dataset:{dataset_prefix}",
+ )
+
+ table_entry: dict = {"name": table_ref}
+ if alias:
+ table_entry["alias"] = alias
+ # Two entries naming one table must be told apart by their aliases --
+ # `model_tables[].joins[].with` and every `[PREFIX::Column]` reference
+ # resolve by alias-or-name, so an undistinguished pair is ambiguous on
+ # import. Found while fixing the duplicate-Table-document defect: the
+ # aliased case is the normal self-join and is fine; this is the case
+ # where the aliases did not survive.
+ clash = next(
+ (e for e in model_tables
+ if e["name"] == table_entry["name"]
+ and e.get("alias") == table_entry.get("alias")),
+ None,
+ )
+ if clash is not None:
+ log.add(
+ code="TS-MODEL-TABLE-ENTRY-AMBIGUOUS",
+ severity=Severity.ERROR,
+ message=(
+ f"datasets {dataset_prefix!r} and an earlier one both resolve "
+ f"to table {table_ref!r} with the same alias "
+ f"({table_entry.get('alias')!r}); model_tables[] entries are "
+ f"referenced by alias-or-name, so the two are indistinguishable "
+ f"and every reference to either is ambiguous on import"
+ ),
+ object_ref=f"dataset:{dataset_prefix}",
+ remedy=(
+ "Give the datasets distinct aliases, or a distinct source "
+ "table each if they are not a self-join."
+ ),
+ )
+ model_tables.append(table_entry)
+ model_tables_by_prefix[dataset_prefix] = table_entry
+
+ resolve_field = _build_field_index(datasets).get
+
+ allocator = _DisplayNameAllocator()
+ columns: list[dict] = []
+ formulas: list[dict] = []
+
+ for dataset in datasets:
+ dataset_prefix = dataset.get("name") or "<unnamed>"
+ table_doc = table_doc_by_prefix.get(dataset_prefix)
+ for field in dataset.get("fields") or []:
+ built = _build_field(field, dataset_prefix, table_doc, allocator, resolve_field, log)
+ if built is None:
+ continue
+ columns_entry, formulas_entry = built
+ columns.append(columns_entry)
+ if formulas_entry is not None:
+ formulas.append(formulas_entry)
+
+ for metric in semantic_model.get("metrics") or []:
+ built = _build_metric(metric, allocator, resolve_field, log)
+ if built is None:
+ continue
+ formulas_entry, columns_entry = built
+ formulas.append(formulas_entry)
+ columns.append(columns_entry)
+
+ for entry in model_payload.get(MODEL_STASH_UNATTRIBUTED_FORMULAS) or []:
+ # A formula spanning two or more Ossie datasets has no single
+ # dataset to belong to, which is exactly why the forward direction
+ # could not turn it into an ordinary Ossie field -- but a TML
+ # formula's surfacing columns[] entry was never tied to a dataset
+ # in the first place (`formula_id` + `properties`, no
+ # `column_id`), so nothing here actually stops the formula from
+ # being surfaced normally. An earlier revision re-emitted only the
+ # bare formulas[] entry with no surfacing columns[] entry at all --
+ # which, by ThoughtSpot's own visibility rule (a formulas[] entry
+ # with no columns[] entry referencing it is not surfaced), silently
+ # made a formula that WAS visible in the source unreachable in the
+ # rebuilt model, while the issue it raised said only that column
+ # properties were lost -- a materially smaller claim than what
+ # actually happened. Restoring the surfacing entry (using the
+ # stashed properties verbatim, filtered the same way every other
+ # surfaced field's properties are) fixes the cause rather than
+ # rewording the symptom, and needs no issue at all: nothing is lost
+ # once the formula is surfaced.
+ raw_name = entry.get("name") or "<unnamed>"
+ object_ref = f"formula:{raw_name}"
+ allocated_name = allocator.allocate(raw_name, log, object_ref=object_ref)
+ expr = entry.get("expr", "")
+ formula_id = _formula_id_from(allocated_name)
+ # Raw, unwrapped `expr` -- see the matching comment in _build_field.
+ formulas.append({"id": formula_id, "name": allocated_name, "expr": expr})
+ stashed_properties = entry.get(FIELD_STASH_COLUMN_PROPERTIES) or {}
+ properties = _drop_never_emit_true_properties(
+ dict(stashed_properties), log, object_ref=object_ref
+ )
+ properties.setdefault("column_type", "ATTRIBUTE")
+ columns.append({"name": allocated_name, "formula_id": formula_id, "properties": properties})
+
+ # Every formula's final id is only fully known once every field, metric
+ # and unattributed formula above has been assigned one -- a formula
+ # earlier in this list can be cross-referenced by one built later (or
+ # vice versa; declaration order inside model.formulas[] carries no
+ # ordering guarantee for this converter's own consumers). So the
+ # cross-reference rewrite and the block-scalar wrap
+ # both happen here, once, over the now-complete list, rather than
+ # per-formula while it was being built above.
+ formula_id_by_normalised_name = {
+ _normalise_or_self(entry["name"]): entry["id"] for entry in formulas
+ }
+ for entry in formulas:
+ rewritten = _rewrite_formula_references(
+ entry["expr"], formula_id_by_normalised_name, log,
+ object_ref=f"formula:{entry['name']}",
+ )
+ entry["expr"] = _maybe_block_scalar(rewritten)
+
+ covered_columns_by_dataset: dict[str, list[set]] = {}
+
+ for rel in semantic_model.get("relationships") or []:
+ from_prefix, join_entry, joins_with_entry = _join_entry_for_relationship(rel, log)
+ target = model_tables_by_prefix.get(from_prefix)
+ if target is None:
+ log.add(
+ code="TS-MODEL-RELATIONSHIP-UNKNOWN-FROM",
+ severity=Severity.ERROR,
+ message=(
+ f"relationship {rel.get('name')!r} names `from` dataset "
+ f"{from_prefix!r}, which is not one of this model's datasets; "
+ f"the join is dropped"
+ ),
+ object_ref=f"relationship:{rel.get('name')}",
+ )
+ continue
+ target.setdefault("joins", []).append(join_entry)
+ if joins_with_entry is not None:
+ # `table_doc_by_prefix` holds the same TmlDocument objects the
+ # caller's own `tables` sequence does -- `TmlDocument` is frozen,
+ # but its `body` dict is not, so appending here is visible in
+ # the final DocumentSet without build_model needing to return
+ # anything beyond the Model document it already does.
+ from_table_doc = table_doc_by_prefix.get(from_prefix)
+ if from_table_doc is not None:
+ from_table_doc.body.setdefault("joins_with", []).append(joins_with_entry)
+ to_prefix = rel.get("to")
+ to_columns = rel.get("to_columns")
+ if to_prefix and to_columns:
+ covered_columns_by_dataset.setdefault(to_prefix, []).append(set(to_columns))
+
+ for entry in model_payload.get(MODEL_STASH_UNREPRESENTABLE_JOINS) or []:
+ from_prefix, join_entry = _join_entry_for_unrepresentable(entry)
+ target = model_tables_by_prefix.get(from_prefix)
+ if target is None:
+ log.add(
+ code="TS-MODEL-RELATIONSHIP-UNKNOWN-FROM",
+ severity=Severity.ERROR,
+ message=(
+ f"an unrepresentable join names `from` dataset {from_prefix!r}, "
+ f"which is not one of this model's datasets; the join is dropped"
+ ),
+ object_ref=f"dataset:{from_prefix}",
+ )
+ continue
+ target.setdefault("joins", []).append(join_entry)
+
+ # Dataset-level mapping's `primary_key`/`unique_keys` rows: TML has no key
+ # declaration anywhere (neither Table nor Model), so a declared key's only
+ # possible home on the way back is a relationship whose `to_columns`
+ # cover it -- see the construct-mapping document's own worked example,
+ # where a single-dataset model's unused `primary_key` is exactly this
+ # loss. A key a relationship *does* cover needs no issue: the
+ # relationship (already restored above) carries the same fact.
+ for dataset in datasets:
+ dataset_prefix = dataset.get("name") or "<unnamed>"
+ covered = covered_columns_by_dataset.get(dataset_prefix, [])
+ declared_keys: list[tuple[str, list[str]]] = []
+ primary_key = dataset.get("primary_key")
+ if primary_key:
+ declared_keys.append(("primary_key", list(primary_key)))
+ for index, unique_key in enumerate(dataset.get("unique_keys") or []):
+ if unique_key:
+ declared_keys.append((f"unique_keys[{index}]", list(unique_key)))
+ for key_label, key_columns in declared_keys:
+ key_set = set(key_columns)
+ if any(key_set <= c for c in covered):
+ continue
+ log.add(
+ code="TS-MODEL-DATASET-KEY-UNUSED",
+ severity=Severity.WARNING,
+ message=(
+ f"dataset {dataset_prefix!r} declares {key_label} "
+ f"{key_columns!r}, but no relationship's to_columns cover it; "
+ f"TML has no key declaration anywhere, so this key has nowhere "
+ f"to go and is dropped"
+ ),
+ object_ref=f"dataset:{dataset_prefix}",
+ )
+
+ body["model_tables"] = model_tables
+ if columns:
+ body["columns"] = columns
+ if formulas:
+ body["formulas"] = formulas
+
+ model_properties = model_payload.get(MODEL_STASH_MODEL_PROPERTIES)
+ if model_properties:
+ body["properties"] = dict(model_properties)
+
+ for stash_key in (
+ MODEL_STASH_PARAMETERS, MODEL_STASH_FILTERS, MODEL_STASH_COLUMN_GROUPS,
+ MODEL_STASH_LESSON_PLANS, MODEL_STASH_ACTION_OBJECT_ASSOCIATIONS, MODEL_STASH_CONSTRAINTS,
+ ):
+ value = model_payload.get(stash_key)
+ if value:
+ body[stash_key] = value
+
+ model_joins_with = model_payload.get(MODEL_STASH_MODEL_JOINS_WITH)
+ if model_joins_with:
+ # Restored under the bare TML key `joins_with` -- `model_` in the
+ # stash key only disambiguates it from a *Table* document's own,
+ # differently-scoped `joins_with[]` inside the same payload namespace.
+ body["joins_with"] = model_joins_with
+
+ return TmlDocument(kind="model", body=body, guid=None)
+
+
+# ---------------------------------------------------------------------------
+# convert: the public Ossie -> TML entry point.
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class TmlConversion:
+ """The result of one Ossie -> TML conversion.
+
+ `documents` is the full TML document set -- one Model document plus one
+ Table/SQL-View document per dataset, ready to serialise via
+ `tml.dump_document_set`. `issues` is every declared loss and degradation
+ raised while building it, mirroring `tml_to_ossie.OssieConversion`'s own
+ shape in reverse.
+ """
+
+ documents: DocumentSet
+ issues: IssueLog
+
+
+def _deduplicate_table_documents(
+ tables: list[TmlDocument], log: IssueLog
+) -> list[TmlDocument]:
+ """One Table document per distinct table name, not one per dataset.
+
+ An aliased self-join is N Ossie datasets over ONE warehouse table -- a model
+ joining DATE_DIM twice as "Sold Date" and "Ship Date" is two datasets whose
+ stashed source table is the same. Building a document per dataset emitted
+ two Table documents both named `DATE_DIM`; `dump_document_set` then gave
+ them distinct FILEnames (`DATE_DIM.table.tml`, `DATE_DIM-2.table.tml`),
+ which hid the collision rather than surfacing it, and importing the set
+ created a duplicate ThoughtSpot Table object. The model side was already
+ correct: `model_tables[]` carries one entry per dataset with its own alias,
+ all pointing at the single table name.
+
+ Columns are unioned by name, first occurrence winning, because two aliases
+ of one table may surface different subsets of it and the Table document has
+ to hold every column any alias references. A body that differs beyond its
+ columns cannot be merged that way, so the first is kept and the difference
+ reported rather than silently resolved.
+ """
+ by_name: dict[str, TmlDocument] = {}
+ order: list[str] = []
+ for table in tables:
+ name = table.body.get("name")
+ first = by_name.get(name)
+ if first is None:
+ by_name[name] = table
+ order.append(name)
+ continue
+
+ seen_columns = {c.get("name") for c in first.body.get("columns") or []}
Review Comment:
This merge only read `columns`, but SQL View documents store output columns in `sql_view_columns`. When two aliases datasets share a SQL View and surface different fields, the second set is discarded.
`build_model` then checks both aliases against the first document, so fields only present in the second set are omitted with an error.
Could this merge and comparison use the column key for the document kind?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "djwaldo (via GitHub)" <gi...@apache.org>.
djwaldo commented on PR #364:
URL: https://github.com/apache/ossie/pull/364#issuecomment-5789292125
Your review prompted me to run a max-effort pass over the whole converter rather than just the
five rows. It found **nine more defects of the same character**, all now fixed in 7e7309b and
a05e3b4. 851 tests pass, up from 824.
The three worth your attention, because they produced wrong data rather than wrong syntax:
- **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 and two more `*_aggregate_op` variants. A MEASURE column
using any of them got its own `aggregation` composed on top: `sum ( group_sum ( ... ) )`, no
issue logged.
- **A parenthesised join condition lost its relationship.** `( [A::x] = [B::y] and [A::p] =
[B::q] )` put every `and` at paren-depth 1, so nothing split, no equality pair matched, and the
whole relationship became an unrepresentable-join stash entry — datasets disconnected, no key
candidates.
- **Moving-window frame bounds were inverted at one end.** `moving_average(m, 2, 3, d)` emitted
`BETWEEN 2 PRECEDING AND 3 PRECEDING` — valid SQL over the wrong five rows.
Plus: an aliased self-join emitted two Table documents of the same name; `zip` truncated a join
condition when the column arrays disagreed; `synonym_type` was overwritten after being faithfully
stashed; shared list objects put YAML anchors in every generated document; `_safe_target_path`'s
condition let through the one case it existed to catch; and two input shapes escaped the CLI as
bare tracebacks.
**Two things I'd flag as process rather than code.** Several of these were *asserted by existing
tests* — the frame-bound test pinned the defect and its comment recorded the misreading as fact,
so I settled the convention against the Tableau and Looker mappings instead and cited both in the
test. And your point about the catalog sweep generalised further than the five rows: it derived
each row's arity *from its own template*, so no template could ever disagree with itself. It now
asserts on the rendered output.
Also fixed the 11 passthrough rows that bake a caller-supplied value in as a literal —
`NTILE(4)` was being published as the mapping for `NTILE(n)`. They now declare it, the generated
doc marks them "example only", and construction refuses a new undeclared one.
CI needs approving again on the new head, if you would. PTAL.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "jbonofre (via GitHub)" <gi...@apache.org>.
jbonofre merged PR #364:
URL: https://github.com/apache/ossie/pull/364
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "djwaldo (via GitHub)" <gi...@apache.org>.
djwaldo commented on PR #364:
URL: https://github.com/apache/ossie/pull/364#issuecomment-5787692621
Thanks — all five reproduce, and all five are fixed in 4ace101. Your `CONCAT` instinct was
right: the `...` is not valid ThoughtSpot syntax, it's the spec's own ellipsis notation copied
into the template.
I fixed 1–3 as a class rather than row by row, which turned up a sixth you didn't hit:
**`NOT IN` has the same ellipsis defect** as `IN`.
`Construct` now declares `variadic=Variadic(style, min_args, tail_from)`. `JOIN` substitutes the
argument tail at a `{*}` marker; `FOLD` applies a binary template right-associatively, which is
what `COALESCE` needs since `ifnull` has no n-ary form:
```
CONCAT 2→ concat ( a , b ) 4→ concat ( a , b , c , d )
COALESCE 2→ ifnull ( a , b ) 3→ ifnull ( a , ifnull ( b , c ) )
IN 2→ a in { b } 4→ a in { b , c , d }
```
**On why the sweep missed it** — you're right that it only checked `emit_direct` doesn't raise,
and the deeper problem is that it derived each row's arity *from* its own template. Guard and
test read the same string, so no template could ever disagree with itself. It now sweeps
variadic rows across a range of arities and asserts on the **output**: no literal `...`, no
unfilled placeholder, balanced parens and braces. A literal `...` in any template is also
refused at construction now, so a new row can't reintroduce it. I mutation-checked both guards
against the original `CONCAT` template — each fails on it independently.
**Finding 4 — one judgment worth your eye.** I resolved collisions where the owning scope is
first known (per dataset for fields, the flat `metrics[]` for metrics) rather than inside
`_field_or_metric_identifier`. That function's allocator is deliberately model-wide, so routing
the common case through it would rename `orders.amount` and `customers.amount` to `amount` and
`amount_2` — legitimately distinct fields in different datasets. Renames are reported at
WARNING rather than applied quietly. Say if you'd rather they were an error.
**Finding 5** reproduced as real data loss on macOS — 3 documents emitted, 2 on disk. Comparison
now folds case; emitted names keep theirs.
824 tests pass. PTAL when you get a chance.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "djwaldo (via GitHub)" <gi...@apache.org>.
djwaldo commented on PR #364:
URL: https://github.com/apache/ossie/pull/364#issuecomment-5755295224
Rebased onto current `main` (now includes #396) and widened this converter's CI path filters to `core-spec/**` + `examples/**`, matching the fourteen siblings #396 updated — this one wasn't in that PR, so it was the only converter whose CI didn't re-run on a spec change.
823 tests pass locally. Apologies — the push reset the run you approved on the 17th, so it needs approving once more.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "jbonofre (via GitHub)" <gi...@apache.org>.
jbonofre commented on PR #364:
URL: https://github.com/apache/ossie/pull/364#issuecomment-5755417393
@djwaldo thanks! Let me do the review.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(thoughtspot): bidirectional ThoughtSpot TML <-> Ossie converter [ossie]
Posted by "djwaldo (via GitHub)" <gi...@apache.org>.
djwaldo commented on code in PR #364:
URL: https://github.com/apache/ossie/pull/364#discussion_r4099827763
##########
converters/thoughtspot/src/ossie_thoughtspot/cli.py:
##########
@@ -0,0 +1,283 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Command-line interface for the Apache Ossie <-> ThoughtSpot TML converter.
+
+ ossie-thoughtspot to-ossie <tml-file>... -o <out.yaml> [--issues <issues.json>]
+ ossie-thoughtspot to-tml <ossie.yaml> -o <out-dir> [--issues <issues.json>]
+
+`to-ossie` reads a Model TML document plus the Table/SQL View documents it references
+and writes one Apache Ossie semantic model. `to-tml` reads one Apache Ossie semantic
+model and writes the corresponding TML document set -- one file per document, tables
+before the model -- into an output directory.
+
+`-o`/`--output` is required in both directions: `to-ossie` writes exactly one file, and
+`to-tml` writes a set of files that has no single-file stdout representation, so unlike
+some sibling converters there is no "default: stdout" fallback here.
+
+Every declared loss or degradation the conversion records is written as a JSON array of
+issues -- to `--issues` when given, to stderr otherwise -- and never mixed into the
+document output. The process exits 1 when that issue log contains an ERROR-severity
+issue, 0 otherwise: a conversion that only warned or informed about a declared loss is
+still a successful conversion, and failing the exit code on a warning would just teach
+scripts to ignore it.
+
+Neither subcommand overwrites an existing output file unless `--force` is given; without
+it, a target that already exists refuses the run before anything is written.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+from . import _yaml, tml, tml_to_ossie, ossie_to_thoughtspot
+from .errors import ConversionError
+from .issues import IssueLog
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="ossie-thoughtspot",
+ description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ sub = parser.add_subparsers(dest="command")
+ sub.required = True # set as attribute (the add_subparsers kwarg is 3.7+)
+
+ to_ossie = sub.add_parser(
+ "to-ossie", help="ThoughtSpot TML documents -> one Apache Ossie semantic model"
+ )
+ to_ossie.add_argument(
+ "tml_files",
+ nargs="+",
+ metavar="TML_FILE",
+ help="the Model TML document plus every Table/SQL View document it references",
+ )
+ to_ossie.add_argument(
+ "-o", "--output", required=True, metavar="FILE", help="output Apache Ossie YAML file"
+ )
+ to_ossie.add_argument(
+ "--issues",
+ metavar="FILE",
+ help="write the issue log as JSON to this path (default: stderr)",
+ )
+ to_ossie.add_argument(
+ "--force",
+ action="store_true",
+ help="overwrite the output file (and --issues file) if either already exists; "
+ "without this flag, an existing target refuses the run before writing anything",
+ )
+
+ to_tml = sub.add_parser(
+ "to-tml", help="one Apache Ossie semantic model -> ThoughtSpot TML documents"
+ )
+ to_tml.add_argument("ossie_file", metavar="OSSIE_FILE", help="Apache Ossie YAML document")
+ to_tml.add_argument(
+ "-o",
+ "--output",
+ required=True,
+ metavar="DIR",
+ help="output directory; one TML file per document is written here (tables before "
+ "the model), created if it does not already exist",
+ )
+ to_tml.add_argument(
+ "--issues",
+ metavar="FILE",
+ help="write the issue log as JSON to this path (default: stderr)",
+ )
+ to_tml.add_argument(
+ "--force",
+ action="store_true",
+ help="overwrite output files (and --issues file) if any already exist; without "
+ "this flag, an existing target refuses the run before writing anything",
+ )
+ return parser
+
+
+def _safe_target_path(directory: Path, filename: str) -> Path:
+ """`directory / filename`, refusing to resolve outside `directory`.
+
+ `tml.dump_document_set` already sanitises `filename` (a document's `name` is
+ user-controlled TML content, and a hostile one -- `../../etc/passwd` -- previously
+ produced a path that escaped its output directory), so this should never trigger in
+ practice. It exists anyway because this module is the first caller that actually
+ writes these documents to disk, and a defence that lives only in the dumper is not
+ one this module can verify it still has. Both paths are resolved before comparing --
+ a naive string-prefix check is wrong on a filesystem with symlinks in play, e.g.
+ macOS where `/tmp` is a symlink to `/private/tmp`.
+ """
+ resolved_directory = directory.resolve()
+ target = (resolved_directory / filename).resolve()
+ # `==`, not `!=`: a filename resolving to the output directory ITSELF is the
+ # case this guard exists to catch, and the old `target != resolved_directory
+ # and ...` spelling made that case short-circuit to False and pass.
+ if target == resolved_directory or resolved_directory not in target.parents:
+ raise ConversionError(
+ f"refusing to write {filename!r}: it resolves outside the output directory "
+ f"{resolved_directory}"
+ )
+ return target
+
+
+def _colliding(paths: list[Path]) -> list[Path]:
+ """Any path this run would write more than once.
+
+ Compared after `resolve()`, because two spellings of one file (a relative
+ path and an absolute one, a symlinked directory) are the same file on disk.
+ Checked BEFORE any write: `--issues` pointing at `-o` passed the
+ does-it-already-exist check on a fresh run, and then `_write_issues`
+ replaced the converted document with the JSON issue log and the command
+ exited 0. `--force` does not license this -- it permits overwriting files
+ that were already there, not destroying one of this run's own outputs.
+ """
+ # Case-folded as well as resolved. `Path.resolve()` does not fold case, and
+ # the default filesystem on macOS and Windows does -- so `-o out.yaml
+ # --issues OUT.YAML` named one file, slipped past this very guard, and the
+ # issue log overwrote the converted document with exit 0. `tml.py` already
+ # folds for exactly this reason; this guard was written a day later and did
+ # not, which is how the defect it exists to prevent survived its own fix.
+ seen: dict[str, Path] = {}
+ counts: dict[str, int] = {}
+ for path in paths:
+ key = str(path.resolve()).casefold()
+ seen.setdefault(key, path.resolve())
+ counts[key] = counts.get(key, 0) + 1
+ return sorted(seen[key] for key, count in counts.items() if count > 1)
+
+
+def _refuse_collision(paths: list[Path]) -> str:
+ names = ", ".join(str(p) for p in paths)
+ return (
+ f"refusing to write the same path twice in one run: {names}. "
+ f"--issues must name a different file from the converted output."
+ )
+
+
+def _existing(paths: list[Path]) -> list[Path]:
+ return [p for p in paths if p.exists()]
+
+
+def _refuse_overwrite(paths: list[Path]) -> str:
+ names = ", ".join(str(p) for p in paths)
+ return f"refusing to overwrite existing file(s): {names} (pass --force to overwrite)"
+
+
+def _write_issues(issues: IssueLog, issues_path: Path | None) -> None:
+ """Issues as a JSON array -- to `issues_path` when given, to stderr otherwise.
+
+ Always written, even when empty: a caller scripting against this output should be
+ able to rely on the shape (a JSON array) rather than on whether anything was said.
+ """
+ text = json.dumps(issues.as_dicts(), indent=2)
+ if issues_path is not None:
+ issues_path.parent.mkdir(parents=True, exist_ok=True)
+ issues_path.write_text(text + "\n", encoding="utf-8")
+ else:
+ print(text, file=sys.stderr)
+
+
+def _cmd_to_ossie(args: argparse.Namespace) -> int:
+ output_path = Path(args.output)
+ issues_path = Path(args.issues) if args.issues else None
+
+ try:
+ texts = [(path, Path(path).read_text(encoding="utf-8")) for path in args.tml_files]
+ document_set = tml.load_document_set(texts)
+ result = tml_to_ossie.convert(document_set)
+ except (ConversionError, OSError, UnicodeDecodeError) as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
+
+ targets = [output_path] + ([issues_path] if issues_path else [])
+ collisions = _colliding(targets)
+ if collisions:
+ print(f"Error: {_refuse_collision(collisions)}", file=sys.stderr)
+ return 1
+ if not args.force:
+ existing = _existing(targets)
+ if existing:
+ print(f"Error: {_refuse_overwrite(existing)}", file=sys.stderr)
+ return 1
+
+ try:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(_yaml.dump(result.model), encoding="utf-8")
+ _write_issues(result.issues, issues_path)
+ except OSError as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
+
Review Comment:
Thanks for this one — I checked it against current `main` and it's already covered. That's my fault for not saying so when the fix landed.
Your comment on the same thing a day earlier (`cli.py:207`) prompted `fce4b92`, which added a collision check across *every* path a run will write — the converted output, each generated `to-tml` file, and `--issues` — evaluated before anything is written. Both shapes you describe refuse:
```
$ ossie-thoughtspot to-ossie tests/fixtures/minimal/*.tml -o x.yaml --issues x.yaml
Error: refusing to write the same path twice in one run: .../x.yaml.
--issues must name a different file from the converted output.
exit=1
$ ossie-thoughtspot to-tml m.yaml -o t/ --issues t/customers.table.tml
Error: refusing to write the same path twice in one run: .../t/customers.table.tml.
--issues must name a different file from the converted output.
exit=1
```
Nothing is written in either case — the output directory is left empty rather than half-populated — and `to-ossie` carries the same guard, so the symmetry you asked about holds.
I suspect the line you were reading is the separate pre-existing-target check, which does only what you describe; the collision check is a different guard that runs first. If those read as one thing, I'm glad to add a comment saying so.
Your other follow-up was real, though: the `ANSI_SQL` sibling naming warehouse columns is apache/ossie#459, with a fix in apache/ossie#460. It turned out to be 211 of 612 references across 20 of 31 real models — the qualifier was the Ossie dataset name while the column half was the warehouse column, so the pair resolved in neither namespace. Thanks for catching it, and for the review and merge.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org