diff --git a/converters/salesforce/README.md b/converters/salesforce/README.md
index 3b11559c..77e81464 100644
--- a/converters/salesforce/README.md
+++ b/converters/salesforce/README.md
@@ -42,7 +42,8 @@ This produces a self-contained executable jar at `target/ossie-salesforce-conver
## Setup
-Both schemas must be obtained and placed under `src/main/resources/schemas/` before building, so they get bundled into the jar.
+Obtain the Salesforce schema before building so it is bundled into the jar.
+Maven copies the canonical Ossie schema from `../../core-spec/ossie-schema.json`.
### Salesforce Semantic Model Schema
@@ -50,11 +51,15 @@ Both schemas must be obtained and placed under `src/main/resources/schemas/` bef
2. Copy the JSON schema content from the page
3. Save it to `src/main/resources/schemas/salesforce-semantic-model-schema.json`
-### Apache Ossie Schema
+Run the complete suite, including Salesforce schema checks, with:
-1. Visit the [Ossie schema on GitHub](https://github.com/apache/ossie/blob/main/core-spec/ossie-schema.json)
-2. Copy the raw JSON contents
-3. Save it to `src/main/resources/schemas/ossie-schema.json`
+```bash
+mvn -DrequireSalesforceSchema=true clean verify
+```
+
+The property makes a missing Salesforce schema fail the test run. Without it,
+schema-dependent tests retain their existing skip behavior. `verify` also checks
+Apache license headers. Do not commit downloaded schemas.
## Usage
@@ -168,7 +173,7 @@ ossieToSf.convert(Paths.get("input/model.yaml"), Paths.get("output/"));
| Field `datatype` | Field `dataType` when a safe mapping exists |
| `relationships[]` | `semanticRelationships[]` |
| `from_columns` + `to_columns` | `criteria[]` |
-| `metrics[]` | Not currently exported |
+| `metrics[]` | Validated Tua expressions in `semanticCalculatedMeasurements[]` |
| `ai_context` | `businessPreferences` |
| `custom_extensions` (vendor: `SALESFORCE`) | Restored properties |
@@ -229,6 +234,127 @@ dimensions.
**Unsupported relationships** (containing Formula or SemanticField types) are stored in `custom_extensions` at the model level rather than being converted to Ossie relationships.
+### Metric expressions
+
+Metrics select `TABLEAU`, then `SNOWFLAKE`, then `ANSI_SQL`, independent of entry
+order. The selected expression is parsed and validated; an invalid preferred
+expression fails rather than falling back to another dialect. Duplicate selected
+dialect entries are errors. Successful conversion exports every declared metric.
+
+The target is the Salesforce/Tableau Next semantic model's
+[Tua calculation language](https://developer.salesforce.com/docs/data/semantic-layer/guide/query-api-in-depth-functions.html).
+Calculated measurements emit `syntax: Tua`, `dataType: Number`, and
+`aggregationType: UserAgg`, so an already aggregated formula is not aggregated
+again. See [calculated fields](https://developer.salesforce.com/docs/data/semantic-layer/guide/query-api-in-depth-calculated-fields.html)
+and [aggregation rules](https://developer.salesforce.com/docs/data/semantic-layer/guide/query-api-in-depth-aggregation.html).
+
+| SQL input | Tua output |
+|-----------|------------|
+| `SUM`, `AVG`, `MIN`, `MAX`, `COUNT(field)` | Same aggregate |
+| `COUNT(DISTINCT field)` | `COUNTD(field)` |
+| `+`, `-`, `*`, `/`, parentheses, numeric constants | Explicitly grouped arithmetic |
+| Searched `CASE WHEN` | `IF … THEN … ELSEIF … ELSE … END` |
+| Comparisons, `AND`, `OR`, `NOT` | Equivalent grouped operators |
+| `COALESCE(a, b, …)` | Nested `IFNULL` |
+| `NULLIF(a, b)` | `IF a = b THEN NULL ELSE a END` |
+| `IS NULL`, `IS NOT NULL` | `ISNULL`, `NOT ISNULL` |
+| `ABS`, `ROUND`, `CEIL`, `FLOOR` | `ABS`, `ROUND`, `CEILING`, `FLOOR` |
+
+These constructs compose. For example, with declared numeric fields `profit` and
+`revenue` in dataset `orders`:
+
+```yaml
+metrics:
+ - name: margin
+ datatype: Decimal
+ expression:
+ dialects:
+ - dialect: SNOWFLAKE
+ expression: SUM(orders.profit) / NULLIF(SUM(orders.revenue), 0)
+```
+
+The resulting expression is:
+
+```text
+(SUM([orders].[profit]) / (IF (SUM([orders].[revenue]) = 0) THEN NULL ELSE SUM([orders].[revenue]) END))
+```
+
+**Binding and types.** References resolve against declared dataset and field
+names, then against fields actually emitted to Salesforce. Physical column names
+and source paths are not aliases. Unqualified SQL fields must be unique. Regular
+SQL names normalize to uppercase; double-quoted names match the normalized
+declaration exactly, following the [expression specification](../../core-spec/expression_language.md).
+Thus `"ORDERS"."AMOUNT"` matches regular declarations `orders.amount`, while
+`"orders"."amount"` requires explicitly quoted lowercase declarations. Target
+API names are preserved; conversion does not rename fields or discover columns.
+Names containing brackets or control characters fail because their Tua escaping
+is not established. Referenced fields must also have a single, unqualified physical
+column binding in the exported model. This rejects derived expressions such as
+`profit+tax` that the existing field mapper can misclassify as physical columns.
+Derived-field compilation and normalization of qualified/quoted physical bindings
+belong to separate converter work.
+
+`TABLEAU` references use exact `[dataset].[field]` API names. Its supported formula
+subset is the Tua equivalents above, including `IF`, `IFNULL`, `ISNULL`, `COUNTD`
+and `CEILING`. Existing `ANSI_SQL` expressions using complete bracket notation
+retain that spelling as a compatibility case and receive the same validation.
+Bracket notation is not accepted as Snowflake SQL.
+
+Fields need a known compatible datatype, either declared in OSI or restored from
+an existing Salesforce field type. Arithmetic and `SUM`/`AVG` require numbers;
+`MIN`/`MAX` also permit text and temporal values inside numeric calculations.
+Comparisons and conditional/null-handling branches must have compatible types.
+Metrics must return numbers; a declared `Integer` result cannot conceal a
+fractional expression. Missing metric types are inferred. A formula must be
+aggregated or constant: mixed row/aggregate expressions, nested aggregates, and
+aggregates without a dataset field fail. A single aggregate cannot combine fields
+from multiple datasets. Separate aggregates can use datasets connected through
+explicitly enabled exported relationships. Each usable edge must match one source
+relationship by name, endpoints and ordered join-key pairs; join fields must resolve
+as direct fields. Missing or corrupted edges cannot establish connectivity. These
+checks protect metrics without changing the relationship mapper or proving join grain.
+
+**Limits and compatibility.** `COUNT`/`COUNTD` take a field. `ROUND` supports one
+argument or a second integer-literal precision; rounding-mode overloads are not
+supported. `CEIL`/`FLOOR` take one argument. They and `ROUND` at zero or negative
+precision infer integral values, so compatible `Integer` metrics are accepted. Simple `CASE`, date/time and string
+functions, casts, metric/calculated-field references, windows, LOD, `COUNT(*)`,
+SQL comments and backslash string escapes are outside this subset. String and
+Boolean literals are supported in predicates. No null-to-zero setting or implicit
+cast is added. Errors name the metric and explain the rejected construct or
+reference. Literal zero divisors fail; use `NULLIF` to make a zero denominator
+nullable. Expressions have bounded size, nesting and generated output.
+
+This replaces the unvalidated SQL fallback introduced in
+[#402](https://github.com/apache/ossie/pull/402). Previously accepted invalid,
+unsupported or untyped formulas now fail, including invalid `TABLEAU` input.
+Metric/model extension preservation remains owned by #402. CLI conversion errors
+are printed to stderr with exit code 3; this small change overlaps the conversion
+error handling in [#286](https://github.com/apache/ossie/pull/286).
+
+**Implementation choice.** The existing mapping pipeline calls a focused Java
+metric compiler. JSqlParser 5.3 parses SQL; a bounded Tua frontend handles native
+formulas. Both produce a small immutable metric AST. Type/aggregation checks finish
+before the private emitter writes Tua. No Python runtime is needed. JSqlParser is
+used under its Apache-2.0 option; its unused JMH benchmark dependency is excluded.
+
+The implementation has five components: `SqlMetricExpressionParser`,
+`TuaMetricExpressionParser`, `MetricExpression`, `MetricExpressionTranslator` and
+`MetricFieldResolver`. The resolver caches successful bindings and verified graph
+reachability within one model. To add supported syntax, update the relevant
+frontend and metric rule with composition and semantic tests. There is no generic
+model-planning or deployment framework in this change.
+
+**Validation boundary.** Unit tests cover parsing, binding and failure behavior.
+Independent local Tua evaluation tests use synthetic rows with nulls, duplicates,
+empty inputs and zero denominators. Those tests model the intended semantics;
+they are not native Tableau Next execution. The published Salesforce **output**
+schema checks structure, not formula syntax, catalog bindings or authoring API
+acceptance. Before deployment, validate authoring and native queries in a test
+org, including numeric precision, rounding ties, empty groups, null behavior and
+unguarded dynamic division and multi-dataset grain. This converter does not provision Data 360 bindings or enrich
+missing fields.
+
## Architecture
```
diff --git a/converters/salesforce/pom.xml b/converters/salesforce/pom.xml
index 396c9bdc..2bb0d20b 100644
--- a/converters/salesforce/pom.xml
+++ b/converters/salesforce/pom.xml
@@ -52,9 +52,23 @@
1.5.93.6.23.5.0
+ 5.3
+
+ com.github.jsqlparser
+ jsqlparser
+ ${jsqlparser.version}
+
+
+
+ org.openjdk.jmh
+ jmh-core
+
+
+ com.fasterxml.jackson.corejackson-databind
diff --git a/converters/salesforce/src/main/java/org/apache/ossie/app/OssieSalesforceConverter.java b/converters/salesforce/src/main/java/org/apache/ossie/app/OssieSalesforceConverter.java
index ee44b27f..c6e59090 100644
--- a/converters/salesforce/src/main/java/org/apache/ossie/app/OssieSalesforceConverter.java
+++ b/converters/salesforce/src/main/java/org/apache/ossie/app/OssieSalesforceConverter.java
@@ -52,6 +52,7 @@ public static void main(String[] args) {
} catch (InvalidInputException e) {
System.exit(2);
} catch (ConversionException e) {
+ System.err.println("Error: " + e.getMessage());
System.exit(3);
}
}
diff --git a/converters/salesforce/src/main/java/org/apache/ossie/converter/ConverterConstants.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/ConverterConstants.java
index 044dfe4c..02c0bb26 100644
--- a/converters/salesforce/src/main/java/org/apache/ossie/converter/ConverterConstants.java
+++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/ConverterConstants.java
@@ -89,6 +89,7 @@ public enum Level {
public static final String DIALECTS = "dialects";
public static final String DIALECT = "dialect";
public static final String DIALECT_TABLEAU = "TABLEAU";
+ public static final String DIALECT_ANSI_SQL = "ANSI_SQL";
// Relationship properties
public static final String CRITERIA = "criteria";
diff --git a/converters/salesforce/src/main/java/org/apache/ossie/converter/MetricExpression.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/MetricExpression.java
new file mode 100644
index 00000000..9d84ca9b
--- /dev/null
+++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/MetricExpression.java
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+
+package org.apache.ossie.converter;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Set;
+
+/** Immutable compiler nodes. Neither source parsing nor type checking emits target text. */
+final class MetricExpression {
+ private MetricExpression() {}
+ sealed interface Node permits Literal, Field, Unary, Binary, Call, Conditional {}
+ record Literal(Object value) implements Node {}
+ record Field(List parts, boolean tableau) implements Node {
+ Field { parts = List.copyOf(parts); }
+ }
+ record Unary(String operator, Node operand) implements Node {}
+ record Binary(String operator, Node left, Node right) implements Node {}
+ record Call(String name, List arguments, boolean distinct) implements Node {
+ Call { arguments = List.copyOf(arguments); }
+ }
+ /** Alternating predicate/result pairs, followed by a separate ELSE expression. */
+ record Conditional(List branches, Node otherwise) implements Node {
+ Conditional { branches = List.copyOf(branches); }
+ }
+ enum Level { CONSTANT, ROW, AGGREGATE }
+ enum Type {
+ INTEGER("Integer"), DECIMAL("Decimal"), FLOAT("Float"), STRING("String"),
+ BOOLEAN("Boolean"), DATE("Date"), DATETIME("DateTime"), DATETIME_TZ("DateTimeTz"),
+ NULL(null), UNKNOWN(null);
+ final String datatype;
+ Type(String datatype) { this.datatype = datatype; }
+ boolean numeric() { return this == INTEGER || this == DECIMAL || this == FLOAT; }
+ static Type of(String datatype) {
+ if (datatype == null) return UNKNOWN;
+ for (Type type : values()) if (datatype.equals(type.datatype)) return type;
+ return UNKNOWN;
+ }
+ }
+ record Typed(Node node, Type type, Level level, Set datasets,
+ List children, MetricFieldResolver.ResolvedField binding, BigDecimal number) {
+ Typed { datasets = Set.copyOf(datasets); children = List.copyOf(children); }
+ }
+}
diff --git a/converters/salesforce/src/main/java/org/apache/ossie/converter/MetricExpressionTranslator.java b/converters/salesforce/src/main/java/org/apache/ossie/converter/MetricExpressionTranslator.java
new file mode 100644
index 00000000..33160177
--- /dev/null
+++ b/converters/salesforce/src/main/java/org/apache/ossie/converter/MetricExpressionTranslator.java
@@ -0,0 +1,329 @@
+/*
+ * 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.
+ */
+
+package org.apache.ossie.converter;
+
+import static org.apache.ossie.converter.MetricExpression.*;
+import static org.apache.ossie.util.DataStructureUtils.*;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.ossie.exception.ConversionException;
+
+/** Compiles the documented metric subset: parse, bind/check, then emit bounded Tua text. */
+final class MetricExpressionTranslator {
+ private static final List DIALECTS = List.of("TABLEAU", "SNOWFLAKE", "ANSI_SQL");
+ private static final Set AGGREGATES = Set.of("SUM", "AVG", "MIN", "MAX", "COUNT", "COUNTD");
+ record Result(String expression, String dataType) {}
+ private MetricExpressionTranslator() {}
+
+ static Result translate(Map metric, Map sourceModel,
+ Map targetModel) {
+ return translate(metric, new MetricFieldResolver(sourceModel, targetModel));
+ }
+
+ static Result translate(Map metric, MetricFieldResolver resolver) {
+ String name = getString(metric, "name");
+ try {
+ Map expression = getMap(metric, "expression");
+ List