Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 132 additions & 6 deletions converters/salesforce/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,24 @@ 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

1. Visit the [Salesforce Semantic Model Schema documentation](https://developer.salesforce.com/docs/data/semantic-layer/guide/salesforce-semantic-model-schema.html)
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

Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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

```
Expand Down
14 changes: 14 additions & 0 deletions converters/salesforce/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,23 @@
<json-schema-validator.version>1.5.9</json-schema-validator.version>
<maven-shade-plugin.version>3.6.2</maven-shade-plugin.version>
<maven-resources-plugin.version>3.5.0</maven-resources-plugin.version>
<jsqlparser.version>5.3</jsqlparser.version>
</properties>

<dependencies>
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>${jsqlparser.version}</version>
<exclusions>
<!-- JSqlParser declares its benchmark harness as a runtime dependency.
The parser classes do not depend on JMH. -->
<exclusion>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MetricFieldResolver.Identifier> 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<Node> arguments, boolean distinct) implements Node {
Call { arguments = List.copyOf(arguments); }
}
/** Alternating predicate/result pairs, followed by a separate ELSE expression. */
record Conditional(List<Node> 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<String> datasets,
List<Typed> children, MetricFieldResolver.ResolvedField binding, BigDecimal number) {
Typed { datasets = Set.copyOf(datasets); children = List.copyOf(children); }
}
}
Loading