diff --git a/README.md b/README.md
index c9fa53d..400721b 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,30 @@
-# ossie-core
+# ossie
-JVM library for reading, writing, and round-tripping [Apache
-Ossie](https://github.com/apache/ossie) / [Open Semantic
-Interchange](https://open-semantic-interchange.org/) semantic model
-documents. Zero dependencies beyond Jackson.
+JVM libraries for [Apache Ossie](https://github.com/apache/ossie) /
+[Open Semantic Interchange](https://open-semantic-interchange.org/).
-## What it does
+Two artifacts:
+
+- **[ossie-core](#ossie-core)** — DTOs + YAML/JSON reader / writer.
+ Zero dependencies beyond Jackson. Use this when you need to read,
+ emit, or round-trip OSI semantic model documents on the JVM.
+- **[ossie-sql](#ossie-sql)** — Apache Calcite adapter + shelf-state
+ query engine. Point it at an OSI YAML plus a JDBC URL for any
+ relational warehouse and get a query surface (typed shelf-state
+ builder OR raw SQL passthrough) with cross-dataset joins,
+ metric composition, and dialect handling.
+
+Both are Apache 2.0, JDK 21+, and designed to be used outside the
+Saiku project.
+
+---
+
+## ossie-core
+
+Read, write, and round-trip Ossie semantic model documents. Zero
+dependencies beyond Jackson.
+
+### What it does
- **Read** OSI YAML *or* JSON documents into a typed DTO tree. Jackson's
YAMLMapper accepts both formats, so you can point it at dbt Core
@@ -19,7 +38,7 @@ Designed to be used outside the Saiku project — no Spring, no JAX-RS,
no Mondrian, no Calcite. If your JVM tool needs to understand or emit
OSI semantic models, this is the smallest thing that will do it.
-## Quick start
+### Quick start
Add the dependency (Maven Central release is pending; snapshots are
published to GitHub Packages under `spiculedata/ossie` in the meantime):
@@ -115,7 +134,7 @@ var doc = new OssieYamlReader()
// OssieYamlReader.SUPPORTED_VERSIONS.
```
-## Compatibility
+### Compatibility
- **OSI spec versions** — the DTO tree covers the v0.1.x and v0.2.x
wire formats. `OssieYamlReader.SUPPORTED_VERSIONS` names the exact
@@ -127,6 +146,128 @@ var doc = new OssieYamlReader()
- **dbt Core** — reads `target/osi_document.json` from dbt 1.12 and up
verbatim.
+---
+
+## ossie-sql
+
+Execute queries against an OSI semantic model, backed by any JDBC
+warehouse. Apache Calcite adapter + shelf-state query engine.
+
+Depends on `ossie-core` (for the DTOs) plus Calcite. Compiles the
+semantic model into virtual Calcite tables at runtime; user queries
+authored either as typed shelf-state (`OssieQuery`) or as raw SQL get
+planned against those virtual tables, auto-joined via the
+relationship declarations, and executed on the underlying warehouse.
+
+### Quick start
+
+```xml
+
+ bi.saiku.ossie
+ ossie-sql
+ 0.1.0
+
+```
+
+### End-to-end example
+
+Point the engine at your Ossie YAML plus the warehouse where the
+underlying tables live. Build a shelf-state query and execute:
+
+```java
+import bi.saiku.ossie.sql.OssieEngine;
+import bi.saiku.ossie.sql.OssieQuery;
+import bi.saiku.ossie.sql.OssieResult;
+
+try (var engine = OssieEngine.builder()
+ .semanticModel(Path.of("orders.ossie.yaml"))
+ .jdbcUrl("jdbc:postgresql://warehouse:5432/analytics")
+ .credentials("saiku_reader", "***")
+ .build()) {
+
+ var query = OssieQuery.builder()
+ .model("Orders")
+ .factDataset("orders")
+ .rows("customers", "customer_country")
+ .values("total_revenue")
+ .sortByMetric("total_revenue", "DESC")
+ .limit(10)
+ .build();
+
+ OssieResult result = engine.execute(query);
+ for (var row : result.getRecords()) {
+ System.out.println(row);
+ }
+ System.out.println("Generated SQL: " + result.getGeneratedSql());
+}
+```
+
+### Preview SQL without executing
+
+```java
+String sql = engine.compile(query);
+// SELECT "customers"."COUNTRY" AS "customers.customer_country",
+// SUM("orders"."ORDER_TOTAL") AS "total_revenue"
+// FROM "orders", "customers"
+// GROUP BY "customers"."COUNTRY"
+// ORDER BY "total_revenue" DESC
+// LIMIT 10
+```
+
+### Raw SQL over the semantic model
+
+The engine also exposes `openConnection()` — a plain JDBC connection
+whose datasets appear as virtual tables. Any BI tool, ORM, or LLM
+code path that speaks JDBC gets a semantic-aware query surface:
+
+```java
+try (var engine = OssieEngine.builder()
+ .semanticModel(doc)
+ .jdbcUrl("jdbc:postgresql://warehouse/analytics")
+ .credentials("reader", "***")
+ .build();
+ var conn = engine.openConnection();
+ var stmt = conn.createStatement();
+ var rs = stmt.executeQuery(
+ "SELECT \"customers\".\"COUNTRY\", COUNT(*) " +
+ "FROM \"customers\", \"orders\" " +
+ "GROUP BY \"customers\".\"COUNTRY\"")) {
+ // ... iterate ...
+}
+```
+
+`customers` and `orders` are Ossie datasets, not warehouse tables.
+Calcite's auto-join rule wires the join between them from the
+`relationships` block in the YAML — you don't write the ON clause.
+
+### What's supported
+
+- Dimensions on Rows and Columns shelves, mapped to arbitrary column
+ expressions via `field.expression.dialects.ANSI_SQL`
+- Metrics with inline SQL expressions (aggregations, ratios,
+ arbitrary scalar arithmetic — TPC-DS's `SUM(sales) / COUNT(DISTINCT
+ customer)` shape works verbatim)
+- Aggregation override at query time (`.values("net_revenue", "AVG")`)
+- Filters: EQ, NEQ, LT, LTE, GT, GTE, IN, BETWEEN, IS_NULL,
+ IS_NOT_NULL
+- Sorts by metric alias or by dimension
+- Row limits
+- Cross-dataset joins driven by the `relationships` block, injected
+ by Calcite's planner via `OssieAutoJoinRule`
+- Metric views (`SELECT * FROM "total_revenue"` where metric names
+ are addressable as views — useful for tools that want the
+ aggregated columns as first-class relations)
+
+### Warehouse compatibility
+
+Anything with a JDBC driver + a Calcite dialect: Postgres, MySQL /
+MariaDB, Oracle, MSSQL, DuckDB, H2, HSQLDB, Snowflake, BigQuery,
+ClickHouse. Dialect resolution flows through Calcite's
+`SqlDialectFactoryImpl`; unknown warehouses fall back to the
+ANSI-SQL dialect with a warning.
+
+---
+
## Related
- [Apache Ossie](https://github.com/apache/ossie) — the OSI
diff --git a/ossie-core/pom.xml b/ossie-core/pom.xml
new file mode 100644
index 0000000..92cccab
--- /dev/null
+++ b/ossie-core/pom.xml
@@ -0,0 +1,73 @@
+
+
+ 4.0.0
+
+
+ bi.saiku.ossie
+ ossie-parent
+ 0.1.0-SNAPSHOT
+
+
+ ossie-core
+ jar
+
+ ossie-core
+ Zero-dependency-beyond-Jackson library for reading, writing, and round-tripping
+ Apache Ossie (Open Semantic Interchange) semantic model documents on the JVM.
+
+ Ships DTOs for the OSI v0.1.x / 0.2.x wire format, an OssieYamlReader that accepts YAML
+ *or* JSON (Jackson YAMLMapper handles both, so it consumes dbt Core 1.12's
+ target/osi_document.json directly), an OssieYamlWriter, an OssieSynonymIndex helper, and
+ optional strict version-check enforcement.
+
+ No Spring, no JAX-RS, no Mondrian, no Calcite.
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-yaml
+
+
+
+ com.networknt
+ json-schema-validator
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+
+
+
+
diff --git a/src/main/java/bi/saiku/ossie/OssieSynonymIndex.java b/ossie-core/src/main/java/bi/saiku/ossie/OssieSynonymIndex.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/OssieSynonymIndex.java
rename to ossie-core/src/main/java/bi/saiku/ossie/OssieSynonymIndex.java
diff --git a/src/main/java/bi/saiku/ossie/OssieYamlReader.java b/ossie-core/src/main/java/bi/saiku/ossie/OssieYamlReader.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/OssieYamlReader.java
rename to ossie-core/src/main/java/bi/saiku/ossie/OssieYamlReader.java
diff --git a/src/main/java/bi/saiku/ossie/OssieYamlWriter.java b/ossie-core/src/main/java/bi/saiku/ossie/OssieYamlWriter.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/OssieYamlWriter.java
rename to ossie-core/src/main/java/bi/saiku/ossie/OssieYamlWriter.java
diff --git a/src/main/java/bi/saiku/ossie/UnsupportedOssieVersionException.java b/ossie-core/src/main/java/bi/saiku/ossie/UnsupportedOssieVersionException.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/UnsupportedOssieVersionException.java
rename to ossie-core/src/main/java/bi/saiku/ossie/UnsupportedOssieVersionException.java
diff --git a/src/main/java/bi/saiku/ossie/model/AiContext.java b/ossie-core/src/main/java/bi/saiku/ossie/model/AiContext.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/AiContext.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/AiContext.java
diff --git a/src/main/java/bi/saiku/ossie/model/CustomExtension.java b/ossie-core/src/main/java/bi/saiku/ossie/model/CustomExtension.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/CustomExtension.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/CustomExtension.java
diff --git a/src/main/java/bi/saiku/ossie/model/Dataset.java b/ossie-core/src/main/java/bi/saiku/ossie/model/Dataset.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/Dataset.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/Dataset.java
diff --git a/src/main/java/bi/saiku/ossie/model/DialectExpression.java b/ossie-core/src/main/java/bi/saiku/ossie/model/DialectExpression.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/DialectExpression.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/DialectExpression.java
diff --git a/src/main/java/bi/saiku/ossie/model/DimensionMeta.java b/ossie-core/src/main/java/bi/saiku/ossie/model/DimensionMeta.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/DimensionMeta.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/DimensionMeta.java
diff --git a/src/main/java/bi/saiku/ossie/model/Expression.java b/ossie-core/src/main/java/bi/saiku/ossie/model/Expression.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/Expression.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/Expression.java
diff --git a/src/main/java/bi/saiku/ossie/model/Field.java b/ossie-core/src/main/java/bi/saiku/ossie/model/Field.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/Field.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/Field.java
diff --git a/src/main/java/bi/saiku/ossie/model/Metric.java b/ossie-core/src/main/java/bi/saiku/ossie/model/Metric.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/Metric.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/Metric.java
diff --git a/src/main/java/bi/saiku/ossie/model/OssieDocument.java b/ossie-core/src/main/java/bi/saiku/ossie/model/OssieDocument.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/OssieDocument.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/OssieDocument.java
diff --git a/src/main/java/bi/saiku/ossie/model/Relationship.java b/ossie-core/src/main/java/bi/saiku/ossie/model/Relationship.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/Relationship.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/Relationship.java
diff --git a/src/main/java/bi/saiku/ossie/model/SemanticModel.java b/ossie-core/src/main/java/bi/saiku/ossie/model/SemanticModel.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/SemanticModel.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/SemanticModel.java
diff --git a/src/main/java/bi/saiku/ossie/model/ontology/OntologyConcept.java b/ossie-core/src/main/java/bi/saiku/ossie/model/ontology/OntologyConcept.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/ontology/OntologyConcept.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/ontology/OntologyConcept.java
diff --git a/src/main/java/bi/saiku/ossie/model/ontology/OntologyEntry.java b/ossie-core/src/main/java/bi/saiku/ossie/model/ontology/OntologyEntry.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/ontology/OntologyEntry.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/ontology/OntologyEntry.java
diff --git a/src/main/java/bi/saiku/ossie/model/ontology/OntologyRelationship.java b/ossie-core/src/main/java/bi/saiku/ossie/model/ontology/OntologyRelationship.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/ontology/OntologyRelationship.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/ontology/OntologyRelationship.java
diff --git a/src/main/java/bi/saiku/ossie/model/ontology/OntologyRole.java b/ossie-core/src/main/java/bi/saiku/ossie/model/ontology/OntologyRole.java
similarity index 100%
rename from src/main/java/bi/saiku/ossie/model/ontology/OntologyRole.java
rename to ossie-core/src/main/java/bi/saiku/ossie/model/ontology/OntologyRole.java
diff --git a/src/test/java/bi/saiku/ossie/OntologyBlockTest.java b/ossie-core/src/test/java/bi/saiku/ossie/OntologyBlockTest.java
similarity index 100%
rename from src/test/java/bi/saiku/ossie/OntologyBlockTest.java
rename to ossie-core/src/test/java/bi/saiku/ossie/OntologyBlockTest.java
diff --git a/src/test/java/bi/saiku/ossie/OssieSynonymIndexTest.java b/ossie-core/src/test/java/bi/saiku/ossie/OssieSynonymIndexTest.java
similarity index 100%
rename from src/test/java/bi/saiku/ossie/OssieSynonymIndexTest.java
rename to ossie-core/src/test/java/bi/saiku/ossie/OssieSynonymIndexTest.java
diff --git a/src/test/java/bi/saiku/ossie/OssieYamlReaderTest.java b/ossie-core/src/test/java/bi/saiku/ossie/OssieYamlReaderTest.java
similarity index 100%
rename from src/test/java/bi/saiku/ossie/OssieYamlReaderTest.java
rename to ossie-core/src/test/java/bi/saiku/ossie/OssieYamlReaderTest.java
diff --git a/src/test/java/bi/saiku/ossie/StrictVersionTest.java b/ossie-core/src/test/java/bi/saiku/ossie/StrictVersionTest.java
similarity index 100%
rename from src/test/java/bi/saiku/ossie/StrictVersionTest.java
rename to ossie-core/src/test/java/bi/saiku/ossie/StrictVersionTest.java
diff --git a/src/test/resources/ossie/osi-schema.json b/ossie-core/src/test/resources/ossie/osi-schema.json
similarity index 100%
rename from src/test/resources/ossie/osi-schema.json
rename to ossie-core/src/test/resources/ossie/osi-schema.json
diff --git a/ossie-sql/pom.xml b/ossie-sql/pom.xml
new file mode 100644
index 0000000..ce7d05b
--- /dev/null
+++ b/ossie-sql/pom.xml
@@ -0,0 +1,91 @@
+
+
+ 4.0.0
+
+
+ bi.saiku.ossie
+ ossie-parent
+ 0.1.0-SNAPSHOT
+
+
+ ossie-sql
+ jar
+
+ ossie-sql
+ Apache Calcite adapter + shelf-state query engine over Open Semantic Interchange
+ semantic models.
+
+ Takes an ossie-core OssieDocument + a JDBC URL for any relational warehouse (Postgres,
+ Snowflake, BigQuery, DuckDB, H2, MySQL, ClickHouse, ...) and gives you:
+
+ - A JDBC connection you can run raw SQL against — the OSI datasets appear as virtual
+ Calcite schemas / tables backed by the warehouse.
+ - A typed shelf-state query builder (OssieQuery) that composes rows, columns, values,
+ filters, sorts, and limits against the semantic model and executes them via the
+ same Calcite planner.
+
+ The point: agents, BI tools, and apps that want to query an Ossie semantic model against
+ any warehouse can depend on this library instead of reimplementing the translator + join
+ rule + Calcite plumbing.
+
+
+
+ bi.saiku.ossie
+ ossie-core
+
+
+ org.apache.calcite
+ calcite-core
+
+
+ com.google.guava
+ guava
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+ com.h2database
+ h2
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+
+
+
+
diff --git a/ossie-sql/src/main/java/bi/saiku/ossie/sql/OssieEngine.java b/ossie-sql/src/main/java/bi/saiku/ossie/sql/OssieEngine.java
new file mode 100644
index 0000000..30a4359
--- /dev/null
+++ b/ossie-sql/src/main/java/bi/saiku/ossie/sql/OssieEngine.java
@@ -0,0 +1,315 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql;
+
+import bi.saiku.ossie.OssieYamlReader;
+import bi.saiku.ossie.model.OssieDocument;
+import bi.saiku.ossie.model.SemanticModel;
+import bi.saiku.ossie.sql.internal.OssieShelfSqlTranslator;
+import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Properties;
+
+/**
+ * Execute {@link OssieQuery} shelf-state queries — or raw SQL — against an OSI semantic model
+ * hosted on any JDBC warehouse. Thin, opinionated wrapper around the ossie-sql Calcite adapter.
+ *
+ *
Typical use:
+ *
+ *
{@code
+ * try (var engine = OssieEngine.builder()
+ * .semanticModel(Path.of("orders.ossie.yaml"))
+ * .jdbcUrl("jdbc:postgresql://warehouse:5432/analytics")
+ * .credentials("saiku_reader", "***")
+ * .build()) {
+ *
+ * var query = OssieQuery.builder()
+ * .model("Orders")
+ * .factDataset("orders")
+ * .rows("customers", "customer_country")
+ * .values("total_revenue")
+ * .sortByMetric("total_revenue", "DESC")
+ * .limit(10)
+ * .build();
+ *
+ * var result = engine.execute(query);
+ * result.getRecords().forEach(System.out::println);
+ * }
+ * }
+ *
+ * The engine also exposes {@link #openConnection()} — a {@link Connection} that speaks JDBC
+ * with the Ossie datasets registered as virtual Calcite schemas / tables. That lets any tool that
+ * already speaks JDBC (a BI editor, an ORM, an LLM code path) query the same semantic model
+ * without going through the shelf-state builder.
+ *
+ *
The engine is safe to reuse across threads for {@link #execute} and {@link #compile}. Each
+ * call to {@link #openConnection()} returns a fresh short-lived JDBC connection.
+ */
+public final class OssieEngine implements AutoCloseable {
+
+ private final OssieDocument document;
+ private final SemanticModel semantic;
+ private final String jdbcUrl;
+ private final Properties jdbcProperties;
+ private final OssieShelfSqlTranslator translator;
+ private final Path calciteModelFile;
+
+ private OssieEngine(Builder b) {
+ this.document = Objects.requireNonNull(b.document, "semanticModel is required");
+ SemanticModel resolved = null;
+ for (SemanticModel m : document.getEffectiveSemanticModels()) {
+ if (b.modelName == null || b.modelName.equalsIgnoreCase(m.getName())) {
+ resolved = m;
+ break;
+ }
+ }
+ if (resolved == null) {
+ throw new IllegalArgumentException(
+ "No semantic model named '" + b.modelName + "' in the Ossie document. Available: "
+ + document.getEffectiveSemanticModels().stream()
+ .map(SemanticModel::getName)
+ .toList());
+ }
+ this.semantic = resolved;
+ this.jdbcUrl = Objects.requireNonNull(b.jdbcUrl, "jdbcUrl is required");
+ this.jdbcProperties = new Properties();
+ if (b.jdbcUsername != null) this.jdbcProperties.setProperty("user", b.jdbcUsername);
+ if (b.jdbcPassword != null) this.jdbcProperties.setProperty("password", b.jdbcPassword);
+ if (b.extraJdbcProperties != null) this.jdbcProperties.putAll(b.extraJdbcProperties);
+ this.translator = new OssieShelfSqlTranslator();
+
+ try {
+ this.calciteModelFile = writeCalciteModelFile();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to materialise Calcite model JSON: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Compile a shelf-state query to SQL without executing it. Useful for previews / auditing —
+ * the returned string is the exact SQL {@link #execute(OssieQuery)} would dispatch to Calcite.
+ */
+ public String compile(OssieQuery query) {
+ return translator.translate(query, semantic);
+ }
+
+ /**
+ * Execute a shelf-state query and return typed records. Opens a fresh JDBC connection for
+ * the duration of the call; closes it before returning.
+ */
+ public OssieResult execute(OssieQuery query) throws SQLException {
+ String sql = compile(query);
+ long start = System.currentTimeMillis();
+ try (Connection conn = openConnection();
+ PreparedStatement stmt = conn.prepareStatement(sql);
+ ResultSet rs = stmt.executeQuery()) {
+ return materialise(sql, rs, System.currentTimeMillis() - start);
+ }
+ }
+
+ /**
+ * Execute an arbitrary SQL string against the same Calcite connection. Useful for tools that
+ * want to author their own SQL (agent LLMs that write joins directly, BI query editors,
+ * dbt-style ELT that treats the semantic model as a source).
+ */
+ public OssieResult executeSql(String sql) throws SQLException {
+ long start = System.currentTimeMillis();
+ try (Connection conn = openConnection();
+ PreparedStatement stmt = conn.prepareStatement(sql);
+ ResultSet rs = stmt.executeQuery()) {
+ return materialise(sql, rs, System.currentTimeMillis() - start);
+ }
+ }
+
+ /**
+ * Open a fresh JDBC connection over the Ossie model. The returned connection speaks
+ * Calcite JDBC — datasets appear as virtual tables under the default schema, metrics as
+ * virtual view tables, and relationships get auto-joined at plan time. Suitable for any tool
+ * that speaks JDBC.
+ *
+ *
Caller closes the connection.
+ */
+ public Connection openConnection() throws SQLException {
+ try {
+ Class.forName("org.apache.calcite.jdbc.Driver");
+ } catch (ClassNotFoundException e) {
+ throw new SQLException(
+ "Calcite JDBC driver not on the classpath. Add org.apache.calcite:calcite-core to your build.", e);
+ }
+ Properties props = new Properties();
+ props.putAll(jdbcProperties);
+ // caseSensitive=false lets our double-quoted identifiers resolve to warehouse columns
+ // regardless of the operator's chosen case in the YAML.
+ props.setProperty("caseSensitive", "false");
+ // schemaFactory-provided models get discovered via `model` connection property.
+ String calciteUrl = "jdbc:calcite:model=" + calciteModelFile.toAbsolutePath();
+ return DriverManager.getConnection(calciteUrl, props);
+ }
+
+ public OssieDocument getDocument() {
+ return document;
+ }
+
+ public SemanticModel getSemanticModel() {
+ return semantic;
+ }
+
+ @Override
+ public void close() {
+ try {
+ Files.deleteIfExists(calciteModelFile);
+ } catch (IOException ignore) {
+ // Best-effort cleanup; the temp file will get GC'd by the OS eventually.
+ }
+ }
+
+ /**
+ * Write the Calcite {@code model.json} descriptor that points at our SchemaFactory + carries
+ * the warehouse JDBC URL through as an operand. Reused for every connection this engine
+ * hands out.
+ */
+ private Path writeCalciteModelFile() throws IOException {
+ ObjectMapper json = new ObjectMapper();
+ Map root = new LinkedHashMap<>();
+ root.put("version", "1.0");
+ root.put("defaultSchema", semantic.getName());
+
+ Map schema = new LinkedHashMap<>();
+ schema.put("name", semantic.getName());
+ schema.put("type", "custom");
+ schema.put("factory", "bi.saiku.ossie.sql.internal.OssieSchemaFactory");
+
+ Map operand = new LinkedHashMap<>();
+ operand.put("jdbcUrl", jdbcUrl);
+ if (jdbcProperties.getProperty("user") != null) {
+ operand.put("jdbcUser", jdbcProperties.getProperty("user"));
+ }
+ if (jdbcProperties.getProperty("password") != null) {
+ operand.put("jdbcPassword", jdbcProperties.getProperty("password"));
+ }
+ operand.put("modelName", semantic.getName());
+ // Inline the Ossie YAML — the factory reads it back through OssieYamlReader on schema
+ // registration. Keeps the engine usable when the source YAML came from an in-memory string
+ // rather than a filesystem path.
+ operand.put("ossieDocumentInline", new bi.saiku.ossie.OssieYamlWriter().writeAsString(document));
+
+ schema.put("operand", operand);
+ root.put("schemas", List.of(schema));
+
+ Path tmp = Files.createTempFile("ossie-calcite-model-", ".json");
+ try (var out = Files.newBufferedWriter(tmp)) {
+ json.writer(new DefaultPrettyPrinter()).writeValue(out, root);
+ }
+ return tmp;
+ }
+
+ private OssieResult materialise(String sql, ResultSet rs, long runtimeMs) throws SQLException {
+ ResultSetMetaData md = rs.getMetaData();
+ int n = md.getColumnCount();
+ List columns = new ArrayList<>();
+ String[] keys = new String[n];
+ for (int i = 1; i <= n; i++) {
+ String key = md.getColumnLabel(i);
+ keys[i - 1] = key;
+ String type = key.contains(".") ? "dimension" : "metric";
+ columns.add(new OssieResult.Column(key, type, md.getColumnTypeName(i)));
+ }
+ List> records = new ArrayList<>();
+ while (rs.next()) {
+ Map row = new LinkedHashMap<>();
+ for (int i = 1; i <= n; i++) row.put(keys[i - 1], rs.getObject(i));
+ records.add(row);
+ }
+ return new OssieResult(sql, columns, records, runtimeMs);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Fluent construction. At minimum you must supply a semantic model (as an
+ * {@link OssieDocument}, a path to a YAML file, or an inline YAML string) and a JDBC URL for
+ * the warehouse the Ossie datasets are backed by. Credentials are optional and pass through
+ * to the JDBC driver via standard {@code user} / {@code password} properties.
+ */
+ public static final class Builder {
+ private OssieDocument document;
+ private String modelName;
+ private String jdbcUrl;
+ private String jdbcUsername;
+ private String jdbcPassword;
+ private Properties extraJdbcProperties;
+
+ public Builder semanticModel(OssieDocument doc) {
+ this.document = doc;
+ return this;
+ }
+
+ public Builder semanticModel(Path yamlOrJson) throws IOException {
+ this.document = new OssieYamlReader().read(yamlOrJson);
+ return this;
+ }
+
+ public Builder semanticModel(Reader reader) throws IOException {
+ this.document = new OssieYamlReader().read(reader);
+ return this;
+ }
+
+ public Builder semanticModelYaml(String yamlText) throws IOException {
+ this.document = new OssieYamlReader().readString(yamlText);
+ return this;
+ }
+
+ /**
+ * Select a specific semantic model by name when the OSI document declares more than one.
+ * When omitted, the first model is used.
+ */
+ public Builder model(String name) {
+ this.modelName = name == null ? null : name.toLowerCase(Locale.ROOT);
+ return this;
+ }
+
+ public Builder jdbcUrl(String url) {
+ this.jdbcUrl = url;
+ return this;
+ }
+
+ public Builder credentials(String user, String password) {
+ this.jdbcUsername = user;
+ this.jdbcPassword = password;
+ return this;
+ }
+
+ /**
+ * Extra JDBC properties passed to the warehouse driver. Merged with any credentials set
+ * via {@link #credentials}.
+ */
+ public Builder jdbcProperties(Properties props) {
+ this.extraJdbcProperties = props;
+ return this;
+ }
+
+ public OssieEngine build() {
+ return new OssieEngine(this);
+ }
+ }
+}
diff --git a/ossie-sql/src/main/java/bi/saiku/ossie/sql/OssieQuery.java b/ossie-sql/src/main/java/bi/saiku/ossie/sql/OssieQuery.java
new file mode 100644
index 0000000..caaabd2
--- /dev/null
+++ b/ossie-sql/src/main/java/bi/saiku/ossie/sql/OssieQuery.java
@@ -0,0 +1,298 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Immutable shelf-state query against an OSI semantic model. Composed via {@link Builder} — the
+ * request is typed the same way the workbench UI shelves are typed: {@code rows}, {@code columns},
+ * {@code values}, {@code filters}, {@code sorts}, {@code limit}. The engine walks the semantic
+ * model at compile time and produces SQL against the underlying warehouse.
+ *
+ * All references (dataset name, field name, metric name) are looked up against the
+ * {@link bi.saiku.ossie.model.OssieDocument} the engine was built with. Unknown names throw at
+ * compile-time with a message naming the offending identifier — same shape errors take across
+ * agent, IDE, and workbench consumers.
+ */
+public final class OssieQuery {
+
+ private final String modelName;
+ private final String factDataset;
+ private final List rows;
+ private final List columns;
+ private final List values;
+ private final List filters;
+ private final List sorts;
+ private final Integer limit;
+
+ private OssieQuery(Builder b) {
+ this.modelName = b.modelName;
+ this.factDataset = b.factDataset;
+ this.rows = List.copyOf(b.rows);
+ this.columns = List.copyOf(b.columns);
+ this.values = List.copyOf(b.values);
+ this.filters = List.copyOf(b.filters);
+ this.sorts = List.copyOf(b.sorts);
+ this.limit = b.limit;
+ }
+
+ public String getModelName() {
+ return modelName;
+ }
+
+ public String getFactDataset() {
+ return factDataset;
+ }
+
+ public List getRows() {
+ return rows;
+ }
+
+ public List getColumns() {
+ return columns;
+ }
+
+ public List getValues() {
+ return values;
+ }
+
+ public List getFilters() {
+ return filters;
+ }
+
+ public List getSorts() {
+ return sorts;
+ }
+
+ public Integer getLimit() {
+ return limit;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Reference to a field on a dataset. Used on Rows and Columns shelves.
+ */
+ public static final class FieldRef {
+ private final String dataset;
+ private final String field;
+
+ public FieldRef(String dataset, String field) {
+ this.dataset = dataset;
+ this.field = field;
+ }
+
+ public String getDataset() {
+ return dataset;
+ }
+
+ public String getField() {
+ return field;
+ }
+ }
+
+ /**
+ * Reference to a metric. The metric expression comes from the semantic model. An optional
+ * {@code aggregationOverride} rewrites the outer aggregation function ({@code SUM} → {@code AVG}
+ * etc.) at compile time; only wraps if the metric's declared expression matches
+ * {@code AGG(...)} exactly.
+ */
+ public static final class MetricRef {
+ private final String metric;
+ private final String aggregationOverride;
+
+ public MetricRef(String metric) {
+ this(metric, null);
+ }
+
+ public MetricRef(String metric, String aggregationOverride) {
+ this.metric = metric;
+ this.aggregationOverride = aggregationOverride;
+ }
+
+ public String getMetric() {
+ return metric;
+ }
+
+ public String getAggregationOverride() {
+ return aggregationOverride;
+ }
+ }
+
+ /**
+ * Predicate over a single field. Operators: EQ, NEQ, LT, LTE, GT, GTE, IN, BETWEEN, IS_NULL,
+ * IS_NOT_NULL. Single-value ops use {@link #getValue()}; IN and BETWEEN use
+ * {@link #getValues()}. IS_NULL and IS_NOT_NULL take neither.
+ */
+ public static final class FilterExpr {
+ private final String dataset;
+ private final String field;
+ private final String op;
+ private final String value;
+ private final List values;
+
+ public FilterExpr(String dataset, String field, String op, String value) {
+ this(dataset, field, op, value, null);
+ }
+
+ public FilterExpr(String dataset, String field, String op, List values) {
+ this(dataset, field, op, null, values);
+ }
+
+ public FilterExpr(String dataset, String field, String op) {
+ this(dataset, field, op, null, null);
+ }
+
+ private FilterExpr(String dataset, String field, String op, String value, List values) {
+ this.dataset = dataset;
+ this.field = field;
+ this.op = op;
+ this.value = value;
+ this.values = values == null ? Collections.emptyList() : List.copyOf(values);
+ }
+
+ public String getDataset() {
+ return dataset;
+ }
+
+ public String getField() {
+ return field;
+ }
+
+ public String getOp() {
+ return op;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public List getValues() {
+ return values;
+ }
+ }
+
+ /**
+ * Sort key. Either a metric alias (aggregated column) or a field on a dataset. Direction is
+ * "ASC" or "DESC".
+ */
+ public static final class SortRef {
+ private final String metric;
+ private final String dataset;
+ private final String field;
+ private final String direction;
+
+ public static SortRef byMetric(String metric, String direction) {
+ return new SortRef(metric, null, null, direction);
+ }
+
+ public static SortRef byField(String dataset, String field, String direction) {
+ return new SortRef(null, dataset, field, direction);
+ }
+
+ private SortRef(String metric, String dataset, String field, String direction) {
+ this.metric = metric;
+ this.dataset = dataset;
+ this.field = field;
+ this.direction = direction;
+ }
+
+ public String getMetric() {
+ return metric;
+ }
+
+ public String getDataset() {
+ return dataset;
+ }
+
+ public String getField() {
+ return field;
+ }
+
+ public String getDirection() {
+ return direction;
+ }
+ }
+
+ public static final class Builder {
+ private String modelName;
+ private String factDataset;
+ private final List rows = new ArrayList<>();
+ private final List columns = new ArrayList<>();
+ private final List values = new ArrayList<>();
+ private final List filters = new ArrayList<>();
+ private final List sorts = new ArrayList<>();
+ private Integer limit;
+
+ public Builder model(String name) {
+ this.modelName = name;
+ return this;
+ }
+
+ public Builder factDataset(String name) {
+ this.factDataset = name;
+ return this;
+ }
+
+ public Builder rows(String dataset, String field) {
+ rows.add(new FieldRef(dataset, field));
+ return this;
+ }
+
+ public Builder columns(String dataset, String field) {
+ columns.add(new FieldRef(dataset, field));
+ return this;
+ }
+
+ public Builder values(String metric) {
+ values.add(new MetricRef(metric));
+ return this;
+ }
+
+ public Builder values(String metric, String aggregationOverride) {
+ values.add(new MetricRef(metric, aggregationOverride));
+ return this;
+ }
+
+ public Builder filter(String dataset, String field, String op, String value) {
+ filters.add(new FilterExpr(dataset, field, op, value));
+ return this;
+ }
+
+ public Builder filter(String dataset, String field, String op, List values) {
+ filters.add(new FilterExpr(dataset, field, op, values));
+ return this;
+ }
+
+ public Builder filter(String dataset, String field, String op) {
+ filters.add(new FilterExpr(dataset, field, op));
+ return this;
+ }
+
+ public Builder sortByMetric(String metric, String direction) {
+ sorts.add(SortRef.byMetric(metric, direction));
+ return this;
+ }
+
+ public Builder sortByField(String dataset, String field, String direction) {
+ sorts.add(SortRef.byField(dataset, field, direction));
+ return this;
+ }
+
+ public Builder limit(int n) {
+ this.limit = n;
+ return this;
+ }
+
+ public OssieQuery build() {
+ return new OssieQuery(this);
+ }
+ }
+}
diff --git a/ossie-sql/src/main/java/bi/saiku/ossie/sql/OssieResult.java b/ossie-sql/src/main/java/bi/saiku/ossie/sql/OssieResult.java
new file mode 100644
index 0000000..88c43e4
--- /dev/null
+++ b/ossie-sql/src/main/java/bi/saiku/ossie/sql/OssieResult.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Result of executing an {@link OssieQuery} through {@link OssieEngine#execute(OssieQuery)}.
+ *
+ * Records-shaped by default. Each {@code Map} in {@link #getRecords()} is one
+ * row; keys are the same aliases produced in the emitted SQL ({@code "."} for
+ * dimensions, the metric name for aggregates).
+ *
+ * {@link #getGeneratedSql()} exposes the SQL string the engine compiled — useful for auditing
+ * what actually hit the warehouse, or for pointing an external tool (BI query editor, LLM code
+ * path) at the same generated query.
+ */
+public final class OssieResult {
+
+ private final String generatedSql;
+ private final List columns;
+ private final List> records;
+ private final long runtimeMs;
+
+ public OssieResult(String generatedSql, List columns, List> records, long runtimeMs) {
+ this.generatedSql = generatedSql;
+ this.columns = List.copyOf(columns);
+ this.records = List.copyOf(records);
+ this.runtimeMs = runtimeMs;
+ }
+
+ public String getGeneratedSql() {
+ return generatedSql;
+ }
+
+ public List getColumns() {
+ return columns;
+ }
+
+ public List> getRecords() {
+ return records;
+ }
+
+ public long getRuntimeMs() {
+ return runtimeMs;
+ }
+
+ public int getRowCount() {
+ return records.size();
+ }
+
+ /**
+ * Column descriptor. {@code type} is one of "dimension" (a shelved field) or "metric" (an
+ * aggregated column). {@code sqlType} is the JDBC type name reported by the warehouse.
+ */
+ public static final class Column {
+ private final String key;
+ private final String type;
+ private final String sqlType;
+
+ public Column(String key, String type, String sqlType) {
+ this.key = key;
+ this.type = type;
+ this.sqlType = sqlType;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getSqlType() {
+ return sqlType;
+ }
+ }
+}
diff --git a/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieAutoJoinRule.java b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieAutoJoinRule.java
new file mode 100644
index 0000000..5495afc
--- /dev/null
+++ b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieAutoJoinRule.java
@@ -0,0 +1,503 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql.internal;
+
+import bi.saiku.ossie.model.Relationship;
+import bi.saiku.ossie.model.SemanticModel;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.calcite.plan.RelOptRule;
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelOptTable;
+import org.apache.calcite.plan.volcano.RelSubset;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.TableScan;
+import org.apache.calcite.rel.logical.LogicalJoin;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeField;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
+import org.apache.calcite.tools.RelBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Calcite planner rule that auto-injects an Ossie {@code relationship}'s ON predicate into any
+ * Cartesian join between two datasets in the same {@link OssieSchema}.
+ *
+ * Rewrites this shape:
+ *
+ *
{@code
+ * -- User writes:
+ * SELECT c.REGION, SUM(o.AMOUNT)
+ * FROM SALES.ORDERS o, SALES.CUSTOMERS c
+ * GROUP BY c.REGION;
+ *
+ * -- Which Calcite parses as: LogicalJoin(condition=[true])(ORDERS, CUSTOMERS)
+ * -- This rule rewrites to: LogicalJoin(condition=[o.CUSTOMER_ID = c.ID])(ORDERS, CUSTOMERS)
+ * -- where the columns come from the Ossie relationship on those two datasets.
+ * }
+ *
+ * Fires only when:
+ *
+ *
+ * The join condition is trivially {@code true} (i.e. the user genuinely wrote a Cartesian
+ * join — {@code FROM A, B} with no {@code WHERE} join predicate). We never overwrite an
+ * explicit user-provided condition.
+ * Both sides walk down to a {@link TableScan} whose schema name matches an OssieSchema
+ * registered in {@link OssieSchema#lookupRegistered}. We identify Ossie-backed tables by
+ * schema name rather than {@code RelOptTable#unwrap} because our datasets surface as
+ * {@code JdbcTable}s (for JDBC pushdown), so the unwrap chain lands on JdbcSchema.
+ * Both scans are from the same {@link OssieSchema} instance.
+ * Exactly one Ossie {@link Relationship} links the two datasets. Multiple candidates raise
+ * {@link AmbiguousJoinException} so silent-wrong-results never happens.
+ *
+ *
+ * N-way joins ({@code FROM A, B, C, …}) — the rule handles these by walking down through
+ * nested Joins to reach every raw {@link TableScan} in the outer subtree, then rebuilding a
+ * left-deep join chain against them using Ossie relationships to derive each pair's predicate.
+ * Requires exactly one relationship for each successive link — ambiguity between the same pair
+ * of datasets bails.
+ *
+ *
Non-goals for this first slice, tracked as follow-ups:
+ *
+ *
+ * Cross-schema joins (Ossie + non-Ossie tables).
+ * Self-joins ({@code FROM A a, A b}) — the rule bails when both sides resolve to the same
+ * dataset.
+ *
+ */
+public class OssieAutoJoinRule extends RelOptRule {
+
+ private static final Logger log = LoggerFactory.getLogger(OssieAutoJoinRule.class);
+
+ /**
+ * Singleton rule instance. Uses the older RelOptRule base class (rather than RelRule +
+ * Config) because Calcite 1.41's Config machinery relies on the Immutables annotation
+ * processor to synthesise the {@code Config.EMPTY} constant; we don't want that dependency
+ * for one rule. RelOptRule remains fully supported and is what the JDBC adapter's own rules
+ * still use in 1.41.
+ */
+ public static final OssieAutoJoinRule INSTANCE = new OssieAutoJoinRule();
+
+ private OssieAutoJoinRule() {
+ super(operand(LogicalJoin.class, any()), "OssieAutoJoinRule");
+ }
+
+ @Override
+ public void onMatch(RelOptRuleCall call) {
+ LogicalJoin join = call.rel(0);
+ // Guard 1: only rewrite Cartesian joins.
+ if (!join.getCondition().isAlwaysTrue()) return;
+
+ // Guard 2: both sides must land on an Ossie-backed TableScan.
+ OssieTableRef left = findOssieTable(join.getLeft());
+ OssieTableRef right = findOssieTable(join.getRight());
+ if (left == null || right == null) {
+ // N-way case: at least one side is a nested Join. Handle via the compound-rebuild
+ // path — collect every raw TableScan reachable in either subtree and build a fresh
+ // left-deep join chain against them. Same tree-rebuild pattern as the two-way case,
+ // just extended to N tables and N-1 relationships.
+ tryCompoundRewrite(call, join);
+ return;
+ }
+ if (left.schema != right.schema) return;
+ if (left.datasetName.equals(right.datasetName)) return; // self-join — bail
+
+ // Guard 3: find exactly one Ossie relationship linking the two datasets.
+ Relationship relationship = pickRelationship(left.schema.model(), left.datasetName, right.datasetName);
+ if (relationship == null) return;
+
+ // Build the rewrite. Calcite's Volcano planner has usually pushed projections down onto
+ // each side of the join before this rule fires, so `join.getLeft().getRowType()` might
+ // only expose a subset of columns — often NOT including the join key. We can't simply
+ // update the join condition against the current row types; we need to reach back to the
+ // raw TableScans (which have every column) and rebuild the join around them.
+ //
+ // Structure of the rewrite:
+ // Project([])
+ // LogicalJoin(left. = right.)
+ // TableScan(left dataset)
+ // TableScan(right dataset)
+ //
+ // The outer Project restricts back to the columns the current join was producing, so the
+ // rewrite is a drop-in substitution for the LogicalJoin node.
+ RelBuilder builder = call.builder();
+ RexBuilder rex = builder.getRexBuilder();
+ TableScan leftScan = left.scan;
+ TableScan rightScan = right.scan;
+ RelDataType leftScanRow = leftScan.getRowType();
+ RelDataType rightScanRow = rightScan.getRowType();
+
+ // Build ON predicate using column indices from the raw TableScan row types.
+ boolean sameDirection = relationship.getFrom().equals(left.datasetName);
+ List leftKeyCols = sameDirection ? relationship.getFromColumns() : relationship.getToColumns();
+ List rightKeyCols = sameDirection ? relationship.getToColumns() : relationship.getFromColumns();
+ if (leftKeyCols.size() != rightKeyCols.size() || leftKeyCols.isEmpty()) return;
+
+ int leftFieldCount = leftScanRow.getFieldCount();
+ List conjuncts = new ArrayList<>();
+ for (int i = 0; i < leftKeyCols.size(); i++) {
+ Integer leftIdx = fieldOrdinal(leftScanRow, leftKeyCols.get(i));
+ Integer rightIdx = fieldOrdinal(rightScanRow, rightKeyCols.get(i));
+ if (leftIdx == null || rightIdx == null) return;
+ RelDataTypeField lf = leftScanRow.getFieldList().get(leftIdx);
+ RelDataTypeField rf = rightScanRow.getFieldList().get(rightIdx);
+ RexNode l = rex.makeInputRef(lf.getType(), leftIdx);
+ RexNode r = rex.makeInputRef(rf.getType(), leftFieldCount + rightIdx);
+ conjuncts.add(rex.makeCall(SqlStdOperatorTable.EQUALS, l, r));
+ }
+ RexNode condition = conjuncts.size() == 1 ? conjuncts.get(0) : rex.makeCall(SqlStdOperatorTable.AND, conjuncts);
+
+ // Compose using RelBuilder. .push(leftScan).push(rightScan).join(INNER, condition) leaves
+ // the joined tables on the stack; we then Project to keep exactly the columns the
+ // original join was producing (found by matching column NAMES from the original row
+ // type against the joined row type, which has all columns from both TableScans).
+ builder.push(leftScan).push(rightScan).join(org.apache.calcite.rel.core.JoinRelType.INNER, condition);
+ RelDataType originalRow = join.getRowType();
+ RelDataType joinedRow = builder.peek().getRowType();
+ List projections = new ArrayList<>();
+ List projectionNames = new ArrayList<>();
+ for (RelDataTypeField original : originalRow.getFieldList()) {
+ // Find the column in the joined row type by name. First hit wins — deterministic
+ // because RelBuilder preserves left-then-right order.
+ int foundIdx = -1;
+ for (int i = 0; i < joinedRow.getFieldCount(); i++) {
+ if (joinedRow.getFieldList().get(i).getName().equalsIgnoreCase(original.getName())) {
+ foundIdx = i;
+ break;
+ }
+ }
+ if (foundIdx < 0) {
+ // Column not found by name — Calcite's projection pushdown has stripped the
+ // original columns and replaced them with synthetic ones (typically "DUMMY"
+ // when the outer query is COUNT(*) with no column references). Substitute a
+ // zero literal of the expected type so downstream shape-matches; the value is
+ // never actually read for aggregate-only queries. Zero (not NULL) because
+ // Calcite's DUMMY columns are declared NOT NULL and transformTo rejects
+ // nullability mismatches.
+ projections.add(rex.makeZeroLiteral(original.getType()));
+ } else {
+ projections.add(
+ rex.makeInputRef(joinedRow.getFieldList().get(foundIdx).getType(), foundIdx));
+ }
+ projectionNames.add(original.getName());
+ }
+ builder.project(projections, projectionNames);
+ RelNode rewritten = builder.build();
+ log.debug(
+ "OssieAutoJoinRule: injecting relationship '{}' predicate into Cartesian join {}↔{}",
+ relationship.getName(),
+ left.datasetName,
+ right.datasetName);
+ call.transformTo(rewritten);
+ }
+
+ /**
+ * Rewrite path for N-way Cartesian joins. Calcite parses {@code FROM A, B, C} as
+ * {@code Join(Join(A, B), C)} with both joins having {@code condition=true}. The two-way
+ * rebuild path handles the inner one. This path handles the outer by walking down to
+ * all raw TableScans in the subtree (traversing nested Joins) and building a fresh
+ * left-deep join chain against them, using Ossie relationships to derive each pair's ON
+ * predicate. The outer Project restricts to the original output columns.
+ *
+ * Ambiguity guard: if the collected TableScans include tables with multiple Ossie
+ * relationships between the same pair, we don't guess — the rewrite bails and the user
+ * gets an explicit-ON error. Same policy as the two-way {@link AmbiguousJoinException}
+ * case.
+ */
+ private void tryCompoundRewrite(RelOptRuleCall call, LogicalJoin join) {
+ // Collect all raw TableScans reachable in either subtree — walking past LogicalProject,
+ // RelSubset, and nested Joins.
+ List scans = new ArrayList<>();
+ List datasetNames = new ArrayList<>();
+ OssieSchema[] schemaHolder = new OssieSchema[1];
+ boolean ok = collectAllTableScans(join.getLeft(), scans, datasetNames, schemaHolder);
+ if (!ok || !collectAllTableScans(join.getRight(), scans, datasetNames, schemaHolder)) return;
+ if (schemaHolder[0] == null || scans.size() < 3) return;
+ // Deduplicate — same TableScan can appear multiple times if the plan is exploring
+ // alternative shapes. Preserve first-seen order for deterministic joining.
+ java.util.LinkedHashSet uniqueNames = new java.util.LinkedHashSet<>();
+ List uniqueScans = new ArrayList<>();
+ for (int i = 0; i < scans.size(); i++) {
+ if (uniqueNames.add(datasetNames.get(i))) {
+ uniqueScans.add(scans.get(i));
+ }
+ }
+ if (uniqueScans.size() < 3) return;
+ SemanticModel model = schemaHolder[0].model();
+
+ // Build the join chain. Start with the first scan; for each subsequent scan, find a
+ // relationship linking it to some already-joined dataset, then join with that predicate.
+ RelBuilder builder = call.builder();
+ RexBuilder rex = builder.getRexBuilder();
+ List joinedNames = new ArrayList<>();
+ List baseOffsets = new ArrayList<>(); // starting column position of each joined table
+
+ TableScan firstScan = uniqueScans.get(0);
+ String firstName = uniqueNames.iterator().next();
+ builder.push(firstScan);
+ joinedNames.add(firstName);
+ baseOffsets.add(0);
+ int totalFields = firstScan.getRowType().getFieldCount();
+
+ for (int i = 1; i < uniqueScans.size(); i++) {
+ TableScan next = uniqueScans.get(i);
+ String nextName = List.copyOf(uniqueNames).get(i);
+ Relationship relationship = null;
+ String linkedName = null;
+ for (String candidate : joinedNames) {
+ Relationship r = pickRelationshipSafe(model, candidate, nextName);
+ if (r != null) {
+ if (relationship != null) {
+ // Multiple candidates linking the next table into the joined set —
+ // ambiguous, bail.
+ return;
+ }
+ relationship = r;
+ linkedName = candidate;
+ }
+ }
+ if (relationship == null) return; // no relationship — can't extend the chain
+ int linkedOffset = baseOffsets.get(joinedNames.indexOf(linkedName));
+ TableScan linkedScan = uniqueScans.get(joinedNames.indexOf(linkedName));
+ RelDataType linkedRow = linkedScan.getRowType();
+ RelDataType nextRow = next.getRowType();
+ boolean sameDirection = relationship.getFrom().equals(linkedName);
+ List linkedCols = sameDirection ? relationship.getFromColumns() : relationship.getToColumns();
+ List nextCols = sameDirection ? relationship.getToColumns() : relationship.getFromColumns();
+ if (linkedCols.isEmpty() || linkedCols.size() != nextCols.size()) return;
+ List conjuncts = new ArrayList<>();
+ for (int k = 0; k < linkedCols.size(); k++) {
+ Integer li = fieldOrdinal(linkedRow, linkedCols.get(k));
+ Integer ni = fieldOrdinal(nextRow, nextCols.get(k));
+ if (li == null || ni == null) return;
+ RexNode l = rex.makeInputRef(linkedRow.getFieldList().get(li).getType(), linkedOffset + li);
+ RexNode r = rex.makeInputRef(nextRow.getFieldList().get(ni).getType(), totalFields + ni);
+ conjuncts.add(rex.makeCall(SqlStdOperatorTable.EQUALS, l, r));
+ }
+ RexNode condition =
+ conjuncts.size() == 1 ? conjuncts.get(0) : rex.makeCall(SqlStdOperatorTable.AND, conjuncts);
+ builder.push(next);
+ builder.join(org.apache.calcite.rel.core.JoinRelType.INNER, condition);
+ baseOffsets.add(totalFields);
+ totalFields += nextRow.getFieldCount();
+ joinedNames.add(nextName);
+ }
+
+ // Outer Project restricting to the columns the current outer Join was producing.
+ RelDataType originalRow = join.getRowType();
+ RelDataType joinedRow = builder.peek().getRowType();
+ List projections = new ArrayList<>();
+ List projectionNames = new ArrayList<>();
+ for (RelDataTypeField original : originalRow.getFieldList()) {
+ int foundIdx = -1;
+ for (int j = 0; j < joinedRow.getFieldCount(); j++) {
+ if (joinedRow.getFieldList().get(j).getName().equalsIgnoreCase(original.getName())) {
+ foundIdx = j;
+ break;
+ }
+ }
+ if (foundIdx < 0) {
+ // Same fix as the two-way path: substitute a zero literal so downstream shape
+ // matches. See two-way rewrite for the DUMMY-column rationale.
+ projections.add(rex.makeZeroLiteral(original.getType()));
+ } else {
+ projections.add(
+ rex.makeInputRef(joinedRow.getFieldList().get(foundIdx).getType(), foundIdx));
+ }
+ projectionNames.add(original.getName());
+ }
+ builder.project(projections, projectionNames);
+ RelNode rewritten = builder.build();
+
+ log.debug("OssieAutoJoinRule: n-way rebuild over datasets {} — outer Cartesian → chained Joins", joinedNames);
+ call.transformTo(rewritten);
+ }
+
+ /**
+ * Wraps {@link #pickRelationship} to return null instead of throwing on ambiguity. The
+ * n-way builder handles ambiguity by bailing at the caller level with more context (which
+ * links to which).
+ */
+ private Relationship pickRelationshipSafe(SemanticModel model, String a, String b) {
+ try {
+ return pickRelationship(model, a, b);
+ } catch (AmbiguousJoinException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Walk a RelNode subtree collecting every reachable Ossie-backed TableScan. Recognises
+ * TableScan, LogicalProject, Project, and Join (both sides). Returns false if any scan
+ * isn't Ossie-backed or if multiple schemas appear.
+ */
+ private boolean collectAllTableScans(
+ RelNode rel, List outScans, List outNames, OssieSchema[] schemaHolder) {
+ RelNode cursor = unwrapSubset(rel);
+ if (cursor instanceof org.apache.calcite.rel.core.Project) {
+ return collectAllTableScans(
+ ((org.apache.calcite.rel.core.Project) cursor).getInput(), outScans, outNames, schemaHolder);
+ }
+ if (cursor instanceof org.apache.calcite.rel.core.Filter) {
+ // Calcite pushes WHERE predicates down into per-arm Filter nodes ahead of the join.
+ // Walk past them the same way we walk past Projects — the underlying TableScan still
+ // reflects the raw dataset shape; the Filter's predicate (which our rebuild
+ // preserves via projection pushdown re-running after transformTo) is orthogonal to
+ // the join-key rewrite.
+ return collectAllTableScans(
+ ((org.apache.calcite.rel.core.Filter) cursor).getInput(), outScans, outNames, schemaHolder);
+ }
+ if (cursor instanceof org.apache.calcite.rel.core.Join) {
+ org.apache.calcite.rel.core.Join innerJoin = (org.apache.calcite.rel.core.Join) cursor;
+ return collectAllTableScans(innerJoin.getLeft(), outScans, outNames, schemaHolder)
+ && collectAllTableScans(innerJoin.getRight(), outScans, outNames, schemaHolder);
+ }
+ if (cursor instanceof TableScan) {
+ TableScan scan = (TableScan) cursor;
+ RelOptTable table = scan.getTable();
+ List qualifiedName = table.getQualifiedName();
+ if (qualifiedName.isEmpty()) return false;
+ OssieSchema schema = unwrapOssieSchema(table);
+ if (schema == null) return false;
+ if (schemaHolder[0] == null) schemaHolder[0] = schema;
+ else if (schemaHolder[0] != schema) return false;
+ outScans.add(scan);
+ outNames.add(qualifiedName.get(qualifiedName.size() - 1));
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Walk a RelNode subtree looking for a {@link TableScan} whose backing table is an Ossie
+ * dataset. Recognises a direct scan or one wrapped in a {@code LogicalProject} (Calcite
+ * often introduces one for column projection). Returns null when neither shape matches.
+ */
+ private OssieTableRef findOssieTable(RelNode rel) {
+ RelNode cursor = unwrapSubset(rel);
+ // Peel off wrapping layers Calcite introduces during optimisation: LogicalProject
+ // (column pruning) and RelSubset (Volcano equivalence class). We iterate to unwrap
+ // arbitrary chains — TableScan can sit under Project → Project → TableScan in some
+ // planner states. Bounded loop to avoid runaway if the input tree is unusual.
+ for (int depth = 0; depth < 8 && !(cursor instanceof TableScan); depth++) {
+ RelNode next;
+ if (cursor instanceof org.apache.calcite.rel.logical.LogicalProject) {
+ next = ((org.apache.calcite.rel.logical.LogicalProject) cursor).getInput();
+ } else if (cursor instanceof org.apache.calcite.rel.core.Project) {
+ next = ((org.apache.calcite.rel.core.Project) cursor).getInput();
+ } else {
+ return null;
+ }
+ cursor = unwrapSubset(next);
+ }
+ if (!(cursor instanceof TableScan)) return null;
+ TableScan scan = (TableScan) cursor;
+ RelOptTable relOptTable = scan.getTable();
+ List qualifiedName = relOptTable.getQualifiedName();
+ if (qualifiedName.isEmpty()) return null;
+ String datasetName = qualifiedName.get(qualifiedName.size() - 1);
+ OssieSchema schema = unwrapOssieSchema(relOptTable);
+ if (schema == null) return null;
+ return new OssieTableRef(schema, datasetName, scan);
+ }
+
+ /**
+ * If {@code rel} is a Volcano {@link RelSubset}, return its best (or original) member so we
+ * can inspect the shape. Otherwise return {@code rel} unchanged. Called at every layer of
+ * the walk in {@link #findOssieTable}.
+ */
+ private static RelNode unwrapSubset(RelNode rel) {
+ if (rel instanceof RelSubset) {
+ RelSubset subset = (RelSubset) rel;
+ RelNode best = subset.getBest();
+ if (best != null) return best;
+ // No best plan chosen yet — use the original (the RelNode Volcano was constructed
+ // around). Correct for the equivalence class since all members produce the same
+ // rowType.
+ RelNode original = subset.getOriginal();
+ if (original != null) return original;
+ }
+ return rel;
+ }
+
+ /**
+ * Look up the {@link OssieSchema} that owns a {@link RelOptTable}. Our datasets surface as
+ * {@link org.apache.calcite.adapter.jdbc.JdbcSchema}-owned JdbcTables (so Calcite's planner
+ * can push down JDBC-native SQL), which means {@code table.unwrap(OssieSchema.class)}
+ * returns null. Instead we consult {@link OssieSchema#lookupRegistered} using the qualified
+ * table name's first segment (the schema name Calcite gave us at factory time).
+ */
+ private OssieSchema unwrapOssieSchema(RelOptTable table) {
+ List qualifiedName = table.getQualifiedName();
+ if (qualifiedName.size() < 2) return null;
+ return OssieSchema.lookupRegistered(qualifiedName.get(0));
+ }
+
+ /**
+ * Return the single Ossie relationship linking two datasets, or null when none exists.
+ * Directional-agnostic: matches (from=A, to=B) OR (from=B, to=A). Throws {@link
+ * AmbiguousJoinException} when more than one matches — better than silent wrong results.
+ */
+ private Relationship pickRelationship(SemanticModel model, String left, String right) {
+ List matches = new ArrayList<>();
+ for (Relationship r : model.getRelationships()) {
+ if (r.getFrom() == null || r.getTo() == null) continue;
+ if ((r.getFrom().equals(left) && r.getTo().equals(right))
+ || (r.getFrom().equals(right) && r.getTo().equals(left))) {
+ matches.add(r);
+ }
+ }
+ if (matches.isEmpty()) return null;
+ if (matches.size() > 1) {
+ List names = new ArrayList<>();
+ for (Relationship r : matches) names.add(r.getName());
+ throw new AmbiguousJoinException(left, right, names);
+ }
+ return matches.get(0);
+ }
+
+ private Integer fieldOrdinal(RelDataType row, String columnName) {
+ for (int i = 0; i < row.getFieldCount(); i++) {
+ if (row.getFieldList().get(i).getName().equalsIgnoreCase(columnName)) return i;
+ }
+ return null;
+ }
+
+ /** Tuple carrying the identity of an Ossie dataset behind a TableScan. */
+ private static final class OssieTableRef {
+ final OssieSchema schema;
+ final String datasetName;
+
+ @SuppressWarnings("unused") // scan retained for future extensions (e.g. re-alias)
+ final TableScan scan;
+
+ OssieTableRef(OssieSchema schema, String datasetName, TableScan scan) {
+ this.schema = schema;
+ this.datasetName = datasetName;
+ this.scan = scan;
+ }
+
+ @Override
+ public String toString() {
+ return "OssieTableRef{schema=" + (schema == null ? "null" : "ok") + ", dataset=" + datasetName + "}";
+ }
+ }
+
+ /**
+ * Raised when a Cartesian join sits between two datasets that have MULTIPLE Ossie
+ * relationships. The user must add an explicit ON clause to pick one; the rule refuses to
+ * guess.
+ */
+ public static class AmbiguousJoinException extends RuntimeException {
+ public AmbiguousJoinException(String left, String right, List candidates) {
+ super("OssieAutoJoinRule: multiple Ossie relationships link '" + left + "' and '"
+ + right + "': " + candidates
+ + ". Add an explicit ON clause to disambiguate.");
+ }
+ }
+}
diff --git a/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieDatasetTable.java b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieDatasetTable.java
new file mode 100644
index 0000000..c013e5d
--- /dev/null
+++ b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieDatasetTable.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql.internal;
+
+import bi.saiku.ossie.model.Dataset;
+import bi.saiku.ossie.model.Field;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.calcite.DataContext;
+import org.apache.calcite.adapter.jdbc.JdbcSchema;
+import org.apache.calcite.linq4j.Enumerable;
+import org.apache.calcite.linq4j.Linq4j;
+import org.apache.calcite.plan.RelOptTable;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.schema.ScannableTable;
+import org.apache.calcite.schema.Schema;
+import org.apache.calcite.schema.Table;
+import org.apache.calcite.schema.TranslatableTable;
+import org.apache.calcite.schema.impl.AbstractTable;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+/**
+ * Calcite {@link Table} projection of a single Ossie {@link Dataset}.
+ *
+ * When a JDBC-backed {@link JdbcSchema} is available, this class delegates rowType + query
+ * planning to the underlying JDBC table so Calcite pushes SELECT/WHERE/GROUP BY down to the
+ * warehouse as native SQL. Without JDBC (schema-only mode), it synthesises a rowType from the
+ * Ossie {@code fields} array with every column typed as VARCHAR and returns zero rows on scan —
+ * enough for BI tools to introspect the schema.
+ *
+ *
Ossie's dataset {@code source} is parsed as {@code schema.table} (or bare {@code table}).
+ * When the JDBC warehouse uses a different schema layout, the Ossie exporter needs to be
+ * corrected upstream; this table doesn't try to invent aliases.
+ */
+public class OssieDatasetTable extends AbstractTable implements ScannableTable, TranslatableTable {
+
+ private final Dataset dataset;
+ private final JdbcSchema jdbcSchema;
+
+ /** Cached delegate resolved once against the JDBC schema; null in schema-only mode. */
+ private Table jdbcDelegate;
+
+ public OssieDatasetTable(Dataset dataset, JdbcSchema jdbcSchema) {
+ this.dataset = dataset;
+ this.jdbcSchema = jdbcSchema;
+ }
+
+ public Dataset dataset() {
+ return dataset;
+ }
+
+ @Override
+ public RelDataType getRowType(RelDataTypeFactory typeFactory) {
+ Table delegate = resolveDelegate();
+ if (delegate != null) {
+ return delegate.getRowType(typeFactory);
+ }
+ // Schema-only mode. Build a rowType from Ossie fields; every column defaults to VARCHAR
+ // because Ossie fields don't yet carry a type hint (would be nice to model in a v2 spec
+ // pass — the exporter has the info at hand from Mondrian's ).
+ RelDataTypeFactory.Builder b = typeFactory.builder();
+ for (Field f : dataset.getFields()) {
+ b.add(f.getName(), typeFactory.createSqlType(SqlTypeName.VARCHAR)).nullable(true);
+ }
+ return b.build();
+ }
+
+ @Override
+ public Enumerable scan(DataContext root) {
+ Table delegate = resolveDelegate();
+ if (delegate instanceof ScannableTable scannable) {
+ return scannable.scan(root);
+ }
+ // No JDBC, no delegate → empty result set. Keeps schema introspection queries happy.
+ return Linq4j.emptyEnumerable();
+ }
+
+ @Override
+ public RelNode toRel(RelOptTable.ToRelContext context, RelOptTable relOptTable) {
+ Table delegate = resolveDelegate();
+ if (delegate instanceof TranslatableTable translatable) {
+ return translatable.toRel(context, relOptTable);
+ }
+ // Fallback: schema-only mode. Use Calcite's LogicalTableScan so the planner has a valid
+ // relational node to work with even if it can't push down to a warehouse.
+ return org.apache.calcite.rel.logical.LogicalTableScan.create(context.getCluster(), relOptTable, List.of());
+ }
+
+ /**
+ * Look up the JDBC-backed physical table for this dataset. Split-name form: everything after
+ * the last "." is the table; anything before it is the schema qualifier and is currently
+ * IGNORED (JdbcSchema only sees the tables in its default catalog/schema — a future pass will
+ * resolve fully-qualified names). Cache once resolved.
+ */
+ private Table resolveDelegate() {
+ if (jdbcSchema == null) return null;
+ if (jdbcDelegate != null) return jdbcDelegate;
+ String source = dataset.getSource();
+ String tableName = source == null ? dataset.getName() : lastDot(source);
+ jdbcDelegate = jdbcSchema.getTable(tableName);
+ if (jdbcDelegate == null) {
+ // Try case-insensitive fallback — some warehouses lowercase, some uppercase.
+ String upper = tableName.toUpperCase();
+ String lower = tableName.toLowerCase();
+ for (String candidate : new String[] {upper, lower}) {
+ jdbcDelegate = jdbcSchema.getTable(candidate);
+ if (jdbcDelegate != null) break;
+ }
+ }
+ return jdbcDelegate;
+ }
+
+ private static String lastDot(String s) {
+ int idx = s.lastIndexOf('.');
+ return idx < 0 ? s : s.substring(idx + 1);
+ }
+
+ @Override
+ public Schema.TableType getJdbcTableType() {
+ Table delegate = resolveDelegate();
+ return delegate == null ? Schema.TableType.TABLE : delegate.getJdbcTableType();
+ }
+
+ /** For debugging and error messages. */
+ @Override
+ public String toString() {
+ List columns = new ArrayList<>();
+ for (Field f : dataset.getFields()) columns.add(f.getName());
+ return "OssieDatasetTable{name=" + dataset.getName() + ", source=" + dataset.getSource() + ", columns="
+ + columns + "}";
+ }
+}
diff --git a/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieMetricViewTable.java b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieMetricViewTable.java
new file mode 100644
index 0000000..e9bdc91
--- /dev/null
+++ b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieMetricViewTable.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql.internal;
+
+import bi.saiku.ossie.model.Metric;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.calcite.adapter.jdbc.JdbcSchema;
+import org.apache.calcite.plan.RelOptTable;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.rel.type.RelDataTypeField;
+import org.apache.calcite.rel.type.RelDataTypeSystem;
+import org.apache.calcite.schema.Table;
+import org.apache.calcite.schema.TranslatableTable;
+import org.apache.calcite.schema.impl.AbstractTable;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+/**
+ * Calcite {@link TranslatableTable} that expands an Ossie {@link Metric} into a scalar SELECT
+ * against its home dataset.
+ *
+ * Users query it like:
+ *
+ *
{@code
+ * SELECT * FROM SALES.TOTAL_REVENUE;
+ * }
+ *
+ * which returns a single row with the aggregate value. The class exists because {@code
+ * ViewTable.viewMacro(SchemaPlus, …)} — Calcite's usual view-registration path — requires a
+ * {@code SchemaPlus} at construction time, which we can't obtain from inside {@link
+ * OssieSchemaFactory#create} (the factory returns a {@link org.apache.calcite.schema.Schema}
+ * BEFORE Calcite has wrapped it in a {@code SchemaPlus}). By using {@link
+ * org.apache.calcite.plan.RelOptTable.ToRelContext#expandView} from within {@link #toRel}, we
+ * shift SQL parsing to query time when Calcite has full schema context.
+ *
+ *
Return type inference is limited to the common aggregate functions produced by our
+ * Mondrian→Ossie exporter ({@code SUM}, {@code COUNT}, {@code AVG}, {@code MIN}, {@code MAX},
+ * {@code COUNT(DISTINCT …)}). Anything else falls back to {@link SqlTypeName#ANY} so Calcite
+ * resolves the type at expand time — safer than guessing wrong. A future slice can improve this
+ * by parsing the expression once at construction time and caching the resolved type.
+ */
+public class OssieMetricViewTable extends AbstractTable implements TranslatableTable {
+
+ /** Parses out {@code AGG(TABLE.COLUMN)} or {@code AGG(DISTINCT TABLE.COLUMN)} — the shape our
+ * Mondrian→Ossie exporter emits. Falls through to {@link SqlTypeName#ANY} for anything
+ * more exotic; per-dialect return-type inference is a follow-up. */
+ private static final Pattern AGG_PATTERN = Pattern.compile(
+ "^\\s*(SUM|COUNT|MIN|MAX|AVG)\\s*\\(\\s*(DISTINCT\\s+)?(?:([\\w\"]+)\\.)?([\\w\"]+)\\s*\\)\\s*$",
+ Pattern.CASE_INSENSITIVE);
+
+ private final String metricName;
+ private final String viewSql;
+ private final List schemaPath;
+ private final Metric metric;
+ private final JdbcSchema jdbcSchema;
+ private final String homeDatasetSourceTable;
+
+ public OssieMetricViewTable(
+ Metric metric,
+ String viewSql,
+ List schemaPath,
+ JdbcSchema jdbcSchema,
+ String homeDatasetSourceTable) {
+ this.metricName = metric.getName();
+ this.viewSql = viewSql;
+ this.schemaPath = schemaPath;
+ this.metric = metric;
+ this.jdbcSchema = jdbcSchema;
+ this.homeDatasetSourceTable = homeDatasetSourceTable;
+ }
+
+ @Override
+ public RelDataType getRowType(RelDataTypeFactory typeFactory) {
+ // Calcite's checkConvertedType compares getRowType (declared here) against the type of
+ // the RelNode expandView produces from viewSql. They MUST match exactly, else the
+ // planner throws "Conversion to relational algebra failed to preserve datatypes". So
+ // rather than guess a coarse SqlTypeName (DOUBLE/BIGINT/etc), we look up the underlying
+ // column's exact type from the JDBC-backed home dataset, then apply Calcite's default
+ // aggregate return-type derivation via RelDataTypeSystem. For the aggregators our
+ // Mondrian exporter emits (SUM/COUNT/MIN/MAX/AVG/COUNT-DISTINCT) that's an exact match;
+ // anything more exotic falls back to ANY (Calcite's wildcard) which is permissive
+ // enough to keep the planner happy.
+ RelDataType returnType = deriveReturnType(typeFactory);
+ return typeFactory.builder().add(metricName, returnType).build();
+ }
+
+ /**
+ * Derive the metric's return type by combining Calcite's default aggregate rules with the
+ * column type looked up on the home dataset's underlying JdbcTable. Returns ANY when we
+ * can't parse the expression or don't have a JDBC schema (schema-only mode) — safe, and the
+ * planner handles ANY without a type-conversion error.
+ */
+ private RelDataType deriveReturnType(RelDataTypeFactory typeFactory) {
+ String ansi = metric.getExpression() == null
+ ? ""
+ : metric.getExpression().getDialects().stream()
+ .filter(d -> "ANSI_SQL".equalsIgnoreCase(d.getDialect()))
+ .map(d -> d.getExpression())
+ .findFirst()
+ .orElse("");
+ Matcher m = AGG_PATTERN.matcher(ansi);
+ if (!m.matches()) return typeFactory.createSqlType(SqlTypeName.ANY);
+ String aggregator = m.group(1).toUpperCase(Locale.ROOT);
+ String columnName = stripQuotes(m.group(4));
+ RelDataType columnType = lookupColumnType(typeFactory, columnName);
+ if (columnType == null) return typeFactory.createSqlType(SqlTypeName.ANY);
+ RelDataTypeSystem system = RelDataTypeSystem.DEFAULT;
+ RelDataType derived;
+ boolean nullable;
+ switch (aggregator) {
+ case "SUM":
+ derived = system.deriveSumType(typeFactory, columnType);
+ nullable = true; // SUM over empty set → NULL
+ break;
+ case "COUNT":
+ // COUNT never returns NULL — even over an empty set it's 0.
+ derived = typeFactory.createSqlType(SqlTypeName.BIGINT);
+ nullable = false;
+ break;
+ case "AVG":
+ derived = system.deriveAvgAggType(typeFactory, columnType);
+ nullable = true;
+ break;
+ case "MIN":
+ case "MAX":
+ // MIN/MAX preserve the input type. NULL over empty set.
+ derived = columnType;
+ nullable = true;
+ break;
+ default:
+ return typeFactory.createSqlType(SqlTypeName.ANY);
+ }
+ return typeFactory.createTypeWithNullability(derived, nullable);
+ }
+
+ /**
+ * Resolve a column name against the JDBC-backed home dataset's rowType. Returns null when
+ * no JDBC schema is wired, the home dataset isn't found in it, or the column name doesn't
+ * appear on the resolved table.
+ */
+ private RelDataType lookupColumnType(RelDataTypeFactory typeFactory, String columnName) {
+ if (jdbcSchema == null || homeDatasetSourceTable == null) return null;
+ Table underlying = firstNonNull(
+ jdbcSchema.getTable(homeDatasetSourceTable),
+ jdbcSchema.getTable(homeDatasetSourceTable.toUpperCase(Locale.ROOT)),
+ jdbcSchema.getTable(homeDatasetSourceTable.toLowerCase(Locale.ROOT)));
+ if (underlying == null) return null;
+ RelDataType rowType = underlying.getRowType(typeFactory);
+ for (RelDataTypeField f : rowType.getFieldList()) {
+ if (f.getName().equalsIgnoreCase(columnName)) return f.getType();
+ }
+ return null;
+ }
+
+ private static String stripQuotes(String s) {
+ if (s == null) return null;
+ return s.replace("\"", "");
+ }
+
+ @SafeVarargs
+ private static T firstNonNull(T... values) {
+ for (T v : values) if (v != null) return v;
+ return null;
+ }
+
+ @Override
+ public RelNode toRel(RelOptTable.ToRelContext context, RelOptTable relOptTable) {
+ // ToRelContext.expandView parses the viewSql and resolves identifiers against the
+ // supplied schemaPath — everything Calcite needs is available on this hook. The rowType
+ // returned by relOptTable comes from getRowType above, which Calcite already validated
+ // against the parsed SELECT list.
+ return context.expandView(relOptTable.getRowType(), viewSql, schemaPath, List.of(metricName)).rel;
+ }
+
+ @Override
+ public String toString() {
+ return "OssieMetricViewTable{name=" + metricName + ", sql=" + viewSql + "}";
+ }
+}
diff --git a/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieRelationshipViewTable.java b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieRelationshipViewTable.java
new file mode 100644
index 0000000..fe3bcff
--- /dev/null
+++ b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieRelationshipViewTable.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql.internal;
+
+import java.util.List;
+import java.util.Locale;
+import org.apache.calcite.adapter.jdbc.JdbcSchema;
+import org.apache.calcite.plan.RelOptTable;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.rel.type.RelDataTypeField;
+import org.apache.calcite.schema.Table;
+import org.apache.calcite.schema.TranslatableTable;
+import org.apache.calcite.schema.impl.AbstractTable;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+/**
+ * Calcite {@link TranslatableTable} that materialises an Ossie {@code relationship} as a
+ * pre-joined view. Users query it like:
+ *
+ * {@code
+ * SELECT REGION, SUM(AMOUNT) FROM SALES.ORDERS_JOIN_CUSTOMERS GROUP BY REGION;
+ * }
+ *
+ * The point: the JOIN predicate lives in the Ossie YAML, not the query. Users who don't want to
+ * remember which columns link ORDERS to CUSTOMERS can SELECT from the pre-joined view and get
+ * both tables' columns as a single flat rowtype. Calcite pushes the whole thing down to the
+ * warehouse as a single JOIN query, so there's no runtime overhead compared to hand-rolling
+ * {@code JOIN ... ON ...}.
+ *
+ *
Naming convention: {@code _JOIN_} where {@code } and {@code } come from
+ * the Ossie {@code relationship}'s {@code from} and {@code to} fields (usually fact then
+ * dimension for Mondrian-exported schemas).
+ *
+ * Follows the same expandView-at-toRel pattern as {@link OssieMetricViewTable} so we don't
+ * need a {@link org.apache.calcite.schema.SchemaPlus} at construction time.
+ */
+public class OssieRelationshipViewTable extends AbstractTable implements TranslatableTable {
+
+ private final String viewName;
+ private final String viewSql;
+ private final List schemaPath;
+ private final JdbcSchema jdbcSchema;
+ private final String fromSourceTable;
+ private final String toSourceTable;
+
+ public OssieRelationshipViewTable(
+ String viewName,
+ String viewSql,
+ List schemaPath,
+ JdbcSchema jdbcSchema,
+ String fromSourceTable,
+ String toSourceTable) {
+ this.viewName = viewName;
+ this.viewSql = viewSql;
+ this.schemaPath = schemaPath;
+ this.jdbcSchema = jdbcSchema;
+ this.fromSourceTable = fromSourceTable;
+ this.toSourceTable = toSourceTable;
+ }
+
+ @Override
+ public RelDataType getRowType(RelDataTypeFactory typeFactory) {
+ // Row type = union of both underlying dataset rowtypes. When names collide (both sides
+ // have `id`, for instance), Calcite's builder appends numeric suffixes (id, id0). That's
+ // acceptable — the point of this view is for users to reach into either side, not for
+ // stable column naming. Users who want cleaner projection can wrap it in their own
+ // SELECT.
+ RelDataTypeFactory.Builder b = typeFactory.builder();
+ if (jdbcSchema != null) {
+ addColumnsFromSourceTable(typeFactory, b, fromSourceTable);
+ addColumnsFromSourceTable(typeFactory, b, toSourceTable);
+ }
+ if (b.getFieldCount() == 0) {
+ // Neither side resolvable — schema-only mode or the JDBC lookup missed. Return a
+ // one-column ANY row so the table registers without blowing up the schema.
+ b.add(viewName, typeFactory.createSqlType(SqlTypeName.ANY));
+ }
+ return b.build();
+ }
+
+ @Override
+ public RelNode toRel(RelOptTable.ToRelContext context, RelOptTable relOptTable) {
+ // Defer SQL parsing to query time — expandView has full schema context. Mirrors the
+ // pattern in OssieMetricViewTable; see that class for the why (SchemaPlus is not
+ // available inside SchemaFactory.create).
+ return context.expandView(relOptTable.getRowType(), viewSql, schemaPath, List.of(viewName)).rel;
+ }
+
+ private void addColumnsFromSourceTable(
+ RelDataTypeFactory typeFactory, RelDataTypeFactory.Builder builder, String sourceTable) {
+ if (sourceTable == null) return;
+ Table underlying = firstNonNull(
+ jdbcSchema.getTable(sourceTable),
+ jdbcSchema.getTable(sourceTable.toUpperCase(Locale.ROOT)),
+ jdbcSchema.getTable(sourceTable.toLowerCase(Locale.ROOT)));
+ if (underlying == null) return;
+ RelDataType rowType = underlying.getRowType(typeFactory);
+ for (RelDataTypeField f : rowType.getFieldList()) {
+ builder.add(f.getName(), f.getType());
+ }
+ }
+
+ @SafeVarargs
+ private static T firstNonNull(T... values) {
+ for (T v : values) if (v != null) return v;
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return "OssieRelationshipViewTable{name=" + viewName + ", sql=" + viewSql + "}";
+ }
+}
diff --git a/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieSchema.java b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieSchema.java
new file mode 100644
index 0000000..0cd3604
--- /dev/null
+++ b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieSchema.java
@@ -0,0 +1,286 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql.internal;
+
+import bi.saiku.ossie.model.Dataset;
+import bi.saiku.ossie.model.DialectExpression;
+import bi.saiku.ossie.model.Metric;
+import bi.saiku.ossie.model.Relationship;
+import bi.saiku.ossie.model.SemanticModel;
+import com.google.common.collect.ImmutableMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import org.apache.calcite.adapter.jdbc.JdbcSchema;
+import org.apache.calcite.schema.Schema;
+import org.apache.calcite.schema.SchemaPlus;
+import org.apache.calcite.schema.Table;
+import org.apache.calcite.schema.impl.AbstractSchema;
+
+/**
+ * Calcite {@link Schema} projection of one Ossie {@link SemanticModel}.
+ *
+ * Three kinds of tables land here:
+ *
+ *
+ * Datasets — one Calcite {@link Table} per Ossie dataset. When JDBC is wired, this
+ * delegates directly to the underlying {@link org.apache.calcite.adapter.jdbc.JdbcSchema}
+ * so SELECT / WHERE / GROUP BY / JOIN push down to the warehouse as native SQL. Without
+ * JDBC (schema-only mode), a placeholder {@link OssieDatasetTable} keeps introspection
+ * working but scans return zero rows.
+ * Metrics — one {@link OssieMetricViewTable} per Ossie metric that carries an
+ * ANSI_SQL dialect. Users write {@code SELECT * FROM SALES.TOTAL_REVENUE} to get the
+ * scalar aggregate over its home dataset. MDX-only metrics (calculated members) skip;
+ * they live in Mondrian, not on the SQL surface.
+ * Join views — one {@link OssieRelationshipViewTable} per Ossie {@code
+ * relationship}, named {@code _JOIN_}. Materialises the JOIN predicate from
+ * the YAML so users can query pre-joined data with a single {@code SELECT}.
+ *
+ *
+ * Auto-injected joins via a proper Calcite planner rule (so users can write {@code SELECT c.x,
+ * SUM(o.y) FROM ORDERS o, CUSTOMERS c} without any JOIN clause and have the predicate injected
+ * from Ossie relationships) is the next follow-up on the parent epic.
+ */
+public class OssieSchema extends AbstractSchema {
+
+ private final SemanticModel model;
+ private final JdbcSchema jdbcSchema;
+ /** Name of the hidden sub-schema attached to the root that holds our JdbcSchema — needed to
+ * qualify the SELECT emitted for each Ossie dataset view. Null when no JDBC is wired. */
+ private final String jdbcSubSchemaName;
+
+ /** Schema name Calcite hands us at registration time (from the connect model). Used to
+ * qualify identifiers in metric-view SQL so the parser resolves them across schemas.
+ * Falls back to the Ossie model name (usually the same). */
+ private volatile String selfSchemaName;
+
+ /**
+ * Registry of live OssieSchema instances keyed by the name Calcite registered them under.
+ * Populated by {@link OssieSchemaFactory#create} via {@link #register}. Consulted by
+ * {@link OssieAutoJoinRule} when it needs to identify whether a TableScan is Ossie-backed —
+ * {@code RelOptTable.unwrap(OssieSchema.class)} doesn't work because our datasets surface as
+ * {@link JdbcSchema}-owned tables, so the direct unwrap path finds JdbcSchema not OssieSchema.
+ * A name-based registry is the pragmatic fallback.
+ *
+ *
Static state is unfortunate but acceptable: Calcite's own JdbcSchema uses similar
+ * process-wide caches. Multiple factories creating a schema with the same name → last-wins
+ * (config error the user needs to fix upstream).
+ */
+ private static final java.util.concurrent.ConcurrentMap REGISTRY =
+ new java.util.concurrent.ConcurrentHashMap<>();
+
+ public OssieSchema(SemanticModel model, JdbcSchema jdbcSchema, String jdbcSubSchemaName) {
+ this.model = model;
+ this.jdbcSchema = jdbcSchema;
+ this.jdbcSubSchemaName = jdbcSubSchemaName;
+ }
+
+ /** Called by {@link OssieSchemaFactory} immediately after construction. Also registers this
+ * schema in the process-wide registry so {@link OssieAutoJoinRule} can look it up by name. */
+ void bindSchemaName(String name) {
+ this.selfSchemaName = name;
+ REGISTRY.put(name, this);
+ }
+
+ /** Look up a registered OssieSchema by the name it was registered under. Used by
+ * {@link OssieAutoJoinRule} to identify Ossie-backed TableScans without unwrapping through
+ * {@link JdbcSchema}. Returns null when no OssieSchema is registered under {@code name}. */
+ static OssieSchema lookupRegistered(String name) {
+ return REGISTRY.get(name);
+ }
+
+ public SemanticModel model() {
+ return model;
+ }
+
+ @Override
+ protected Map getTableMap() {
+ // Deterministic linked map so SHOW TABLES / information_schema output is stable across
+ // restarts (BI tools cache introspection results by dataset name).
+ //
+ // Structure of what we register:
+ // 1. One table per Ossie dataset — either the underlying JdbcTable when a warehouse is
+ // wired, or an OssieDatasetTable placeholder in schema-only mode.
+ // 2. One view per Ossie metric — an OssieMetricViewTable whose SQL is
+ // "SELECT AS FROM .".
+ // Users write 'SELECT * FROM .' to get the aggregate over the whole
+ // home dataset; downstream slices add per-dimension grouping via relationship-aware
+ // rewrites. Metrics with only an MDX dialect are skipped — those live in Mondrian,
+ // not in this SQL surface.
+ //
+ // Name collisions between metrics and datasets are broken in favour of the dataset (the
+ // Mondrian exporter's conventions keep them distinct — this is a safety net rather than
+ // an expected case).
+ Map tables = new LinkedHashMap<>();
+ for (Dataset dataset : model.getDatasets()) {
+ Table table = null;
+ if (jdbcSchema != null) {
+ String sourceTable = lastDot(dataset.getSource() == null ? dataset.getName() : dataset.getSource());
+ table = firstNonNull(
+ jdbcSchema.getTable(sourceTable),
+ jdbcSchema.getTable(sourceTable.toUpperCase()),
+ jdbcSchema.getTable(sourceTable.toLowerCase()));
+ }
+ if (table == null) {
+ // Schema-only fallback — no JDBC, or the underlying table wasn't found. Register
+ // a placeholder table synthesised from the Ossie fields so introspection works.
+ table = new OssieDatasetTable(dataset, null);
+ }
+ tables.put(dataset.getName(), table);
+ }
+ for (Metric metric : model.getMetrics()) {
+ if (tables.containsKey(metric.getName())) continue;
+ OssieMetricViewTable view = buildMetricView(metric);
+ if (view != null) tables.put(metric.getName(), view);
+ }
+ // Register one pre-joined view per Ossie relationship. Named `_JOIN_`.
+ // Users get a flat rowtype of both underlying tables' columns and Calcite pushes the
+ // JOIN down to the warehouse — no runtime overhead over writing JOIN ... ON ... by hand.
+ // Skips relationships whose from/to don't resolve to registered datasets (defensive,
+ // should never happen for a well-formed Ossie doc).
+ for (Relationship rel : model.getRelationships()) {
+ String viewName = joinViewName(rel);
+ if (tables.containsKey(viewName)) continue;
+ OssieRelationshipViewTable view = buildJoinView(rel);
+ if (view != null) tables.put(viewName, view);
+ }
+ return ImmutableMap.copyOf(tables);
+ }
+
+ /** Public for the schema-only mode too — kept short so it never collides with a dataset. */
+ static String joinViewName(Relationship rel) {
+ return rel.getFrom() + "_JOIN_" + rel.getTo();
+ }
+
+ /**
+ * Build a pre-joined view for an Ossie relationship. Returns null when either side doesn't
+ * resolve to a registered dataset or the relationship has zero join columns.
+ */
+ private OssieRelationshipViewTable buildJoinView(Relationship rel) {
+ if (rel.getFrom() == null || rel.getTo() == null) return null;
+ if (rel.getFromColumns().isEmpty() || rel.getToColumns().isEmpty()) return null;
+ if (rel.getFromColumns().size() != rel.getToColumns().size()) return null;
+ Dataset fromDs = findDataset(rel.getFrom());
+ Dataset toDs = findDataset(rel.getTo());
+ if (fromDs == null || toDs == null) return null;
+ String effectiveSchemaName = selfSchemaName != null ? selfSchemaName : model.getName();
+ // Build "a. = b. AND ..." predicate.
+ StringBuilder predicate = new StringBuilder();
+ for (int i = 0; i < rel.getFromColumns().size(); i++) {
+ if (i > 0) predicate.append(" AND ");
+ predicate.append("a.\"").append(rel.getFromColumns().get(i)).append("\" = ");
+ predicate.append("b.\"").append(rel.getToColumns().get(i)).append("\"");
+ }
+ String viewSql = "SELECT * FROM \"" + effectiveSchemaName + "\".\"" + fromDs.getName() + "\" a "
+ + "JOIN \"" + effectiveSchemaName + "\".\"" + toDs.getName() + "\" b "
+ + "ON " + predicate;
+ String fromSource = lastDot(fromDs.getSource() == null ? fromDs.getName() : fromDs.getSource());
+ String toSource = lastDot(toDs.getSource() == null ? toDs.getName() : toDs.getSource());
+ return new OssieRelationshipViewTable(
+ joinViewName(rel), viewSql, List.of(effectiveSchemaName), jdbcSchema, fromSource, toSource);
+ }
+
+ private Dataset findDataset(String name) {
+ for (Dataset d : model.getDatasets()) {
+ if (d.getName().equals(name)) return d;
+ }
+ return null;
+ }
+
+ /**
+ * Build an {@link OssieMetricViewTable} for a metric. Returns null when the metric has no
+ * ANSI SQL dialect (MDX-only calculated members — they live in Mondrian, not here) or the
+ * model has zero datasets (nowhere to aggregate against).
+ */
+ private OssieMetricViewTable buildMetricView(Metric metric) {
+ String ansiSql = pickAnsiSql(metric);
+ if (ansiSql == null) return null;
+ String homeDataset = pickHomeDataset(ansiSql);
+ if (homeDataset == null) return null;
+ String effectiveSchemaName = selfSchemaName != null ? selfSchemaName : model.getName();
+ // Quote identifiers so mixed-case names (Pharma Rx, TOTAL_REVENUE) survive Calcite's
+ // parser without being lower-cased to unresolvable names.
+ String viewSql = "SELECT " + ansiSql + " AS \"" + metric.getName() + "\" " + "FROM \"" + effectiveSchemaName
+ + "\".\"" + homeDataset + "\"";
+ // Look up the home dataset's underlying table name so the metric view can resolve
+ // column types via the JdbcSchema's rowType. Falls back to the dataset name itself.
+ String homeSource = null;
+ for (Dataset d : model.getDatasets()) {
+ if (d.getName().equals(homeDataset)) {
+ homeSource = lastDot(d.getSource() == null ? d.getName() : d.getSource());
+ break;
+ }
+ }
+ return new OssieMetricViewTable(metric, viewSql, List.of(effectiveSchemaName), jdbcSchema, homeSource);
+ }
+
+ /**
+ * Return the ANSI SQL dialect expression from a metric, or null if the metric only has an
+ * MDX dialect (e.g. calculated members). Ossie's spec lets metrics carry multiple dialects;
+ * this adapter only understands ANSI SQL at query time.
+ */
+ private static String pickAnsiSql(Metric metric) {
+ if (metric.getExpression() == null) return null;
+ for (DialectExpression d : metric.getExpression().getDialects()) {
+ if ("ANSI_SQL".equalsIgnoreCase(d.getDialect())) return d.getExpression();
+ }
+ return null;
+ }
+
+ /**
+ * Best-effort resolution of a metric's home dataset from its ANSI SQL text. Scans for a
+ * dataset name appearing as {@code .column}, {@code FROM }, or the bare dataset
+ * name; returns the first match. Falls back to the first dataset in the model when the
+ * expression is opaque (e.g. a literal or a scalar function with no column reference).
+ * Returns null only if the model has zero datasets — in which case there's nothing to
+ * aggregate over and the caller should skip the metric.
+ */
+ private String pickHomeDataset(String ansiSql) {
+ String lower = ansiSql.toLowerCase(Locale.ROOT);
+ for (Dataset d : model.getDatasets()) {
+ String needle = d.getName().toLowerCase(Locale.ROOT);
+ if (lower.contains(needle + ".") || lower.contains("from " + needle) || lower.equals(needle)) {
+ return d.getName();
+ }
+ }
+ return model.getDatasets().isEmpty() ? null : model.getDatasets().get(0).getName();
+ }
+
+ private static String lastDot(String s) {
+ int idx = s.lastIndexOf('.');
+ return idx < 0 ? s : s.substring(idx + 1);
+ }
+
+ @SafeVarargs
+ private static T firstNonNull(T... values) {
+ for (T v : values) if (v != null) return v;
+ return null;
+ }
+
+ /**
+ * Sub-schemas: we don't publish any today. Reserved for a future world where a single Ossie
+ * document produces one Calcite sub-schema per semantic model, addressable by connect
+ * operand.
+ */
+ @Override
+ protected Map getSubSchemaMap() {
+ return ImmutableMap.of();
+ }
+
+ /** Signal to Calcite that this schema is safe to expose via {@code SchemaPlus.add(…)}. */
+ @Override
+ public boolean isMutable() {
+ return false;
+ }
+
+ /**
+ * Helper for tests that want the built schema without going through the JDBC connect path.
+ * Wraps the schema in a Calcite {@link SchemaPlus} rooted under a caller-provided parent.
+ */
+ public SchemaPlus attachTo(SchemaPlus parent, String name) {
+ return parent.add(name, this);
+ }
+}
diff --git a/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieSchemaFactory.java b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieSchemaFactory.java
new file mode 100644
index 0000000..99818d3
--- /dev/null
+++ b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieSchemaFactory.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql.internal;
+
+import bi.saiku.ossie.OssieYamlReader;
+import bi.saiku.ossie.model.OssieDocument;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+import javax.sql.DataSource;
+import org.apache.calcite.adapter.jdbc.JdbcSchema;
+import org.apache.calcite.schema.Schema;
+import org.apache.calcite.schema.SchemaFactory;
+import org.apache.calcite.schema.SchemaPlus;
+
+/**
+ * Calcite entry point for the Ossie semantic layer.
+ *
+ * Instantiated by Calcite when a JDBC connect URL references this class through a JSON model
+ * file — see the Calcite adapter docs. Typical connect model:
+ *
+ *
{@code
+ * {
+ * "version": "1.0",
+ * "defaultSchema": "SALES",
+ * "schemas": [
+ * {
+ * "name": "SALES",
+ * "type": "custom",
+ * "factory": "org.saiku.sql.adapter.OssieSchemaFactory",
+ * "operand": {
+ * "ossieYaml": "/path/to/schema.ossie.yaml",
+ * "modelName": "Sales",
+ * "jdbcUrl": "jdbc:postgresql://localhost:5432/warehouse",
+ * "jdbcUser": "app",
+ * "jdbcPassword": "..."
+ * }
+ * }
+ * ]
+ * }
+ * }
+ *
+ * Operand keys:
+ *
+ *
+ * {@code ossieYaml} (required) — path to the Ossie YAML file.
+ * {@code modelName} (optional) — which {@code semantic_model[]} entry to expose when the
+ * document carries multiple; defaults to the first one.
+ * {@code jdbcUrl} / {@code jdbcUser} / {@code jdbcPassword} (optional but strongly
+ * encouraged) — where the actual data lives. Without them, the Ossie datasets surface as
+ * queryable virtual tables with no rows — useful only for schema-introspection tests.
+ *
+ */
+public class OssieSchemaFactory implements SchemaFactory {
+
+ static {
+ // Register OssieAutoJoinRule GLOBALLY with every Calcite planner. Fires each time
+ // Calcite instantiates a query planner (typically once per JDBC statement); our rule
+ // becomes part of the standard rule set from then on. The rule's onMatch method has
+ // enough guards (Ossie-schema check + Cartesian-condition check + relationship lookup)
+ // that it's a no-op for any query touching non-Ossie tables — safe to register
+ // globally.
+ //
+ // Timing: this static block runs when Calcite first loads OssieSchemaFactory (via
+ // Class.forName reflection driven by the connect model's "factory" operand), which
+ // happens BEFORE the planner for the first query is instantiated. So the hook is
+ // already installed by the time the first query needs it.
+ org.apache.calcite.runtime.Hook.PLANNER.add((java.util.function.Consumer) planner -> {
+ if (planner instanceof org.apache.calcite.plan.RelOptPlanner) {
+ ((org.apache.calcite.plan.RelOptPlanner) planner).addRule(OssieAutoJoinRule.INSTANCE);
+ }
+ });
+ }
+
+ public static final String OP_OSSIE_YAML = "ossieYaml";
+
+ /**
+ * Inline OSI YAML string. Alternative to {@link #OP_OSSIE_YAML} when the document is already
+ * in memory — used by {@link bi.saiku.ossie.sql.OssieEngine} so callers don't have to write a
+ * temp YAML file when they pass an {@link OssieDocument} straight to the engine builder.
+ */
+ public static final String OP_OSSIE_YAML_INLINE = "ossieDocumentInline";
+
+ public static final String OP_MODEL_NAME = "modelName";
+ public static final String OP_JDBC_URL = "jdbcUrl";
+ public static final String OP_JDBC_USER = "jdbcUser";
+ public static final String OP_JDBC_PASSWORD = "jdbcPassword";
+
+ @Override
+ public Schema create(SchemaPlus parentSchema, String name, Map operand) {
+ String ossieYaml = (String) operand.get(OP_OSSIE_YAML);
+ String ossieYamlInline = (String) operand.get(OP_OSSIE_YAML_INLINE);
+ if (ossieYaml == null && ossieYamlInline == null) {
+ throw new IllegalArgumentException("OssieSchemaFactory: one of '" + OP_OSSIE_YAML + "' (file path) or '"
+ + OP_OSSIE_YAML_INLINE + "' (inline YAML) is required");
+ }
+ String modelName = (String) operand.get(OP_MODEL_NAME);
+ String jdbcUrl = (String) operand.get(OP_JDBC_URL);
+ String jdbcUser = (String) operand.get(OP_JDBC_USER);
+ String jdbcPassword = (String) operand.get(OP_JDBC_PASSWORD);
+
+ OssieDocument doc;
+ try {
+ doc = ossieYamlInline != null
+ ? new OssieYamlReader().readString(ossieYamlInline)
+ : new OssieYamlReader().read(Path.of(ossieYaml));
+ } catch (IOException e) {
+ String source = ossieYamlInline != null ? "(inline document)" : ossieYaml;
+ throw new RuntimeException(
+ "OssieSchemaFactory: failed to read Ossie YAML at " + source + ": " + e.getMessage(), e);
+ }
+
+ var models = doc.getEffectiveSemanticModels();
+ String source = ossieYamlInline != null ? "(inline document)" : ossieYaml;
+ if (models.isEmpty()) {
+ throw new IllegalStateException("OssieSchemaFactory: Ossie document at " + source
+ + " has zero semantic models — check the exporter didn't skip every cube");
+ }
+ var chosen = models.get(0);
+ if (modelName != null) {
+ chosen = models.stream()
+ .filter(m -> modelName.equals(m.getName()))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("OssieSchemaFactory: no semantic model named '"
+ + modelName + "' in " + source + "; available: "
+ + models.stream().map(m -> m.getName()).toList()));
+ }
+ // Attach the JDBC warehouse as a hidden sub-schema of parentSchema. Calcite's JdbcSchema
+ // constructor requires a SchemaPlus with a real parent (it walks up via
+ // getParentSchema() during query planning); passing null causes NPE the moment the
+ // planner touches the schema, hence the sub-schema attach trick used by other adapters.
+ JdbcSchema jdbc = null;
+ String hiddenName = null;
+ if (jdbcUrl != null && !jdbcUrl.isBlank()) {
+ DataSource ds = JdbcSchema.dataSource(jdbcUrl, null, jdbcUser, jdbcPassword);
+ hiddenName = "__" + name + "_jdbc";
+ // Register the JdbcSchema directly under the root using SchemaPlus.add so Calcite's
+ // planner can find it by name when resolving ViewTable SQL that references it. Note
+ // the JdbcSchema is registered with parent=null via SchemaPlus.add — Calcite fills
+ // in the parent reference when it installs the sub-schema.
+ jdbc = JdbcSchema.create(parentSchema, hiddenName, ds, null, null);
+ parentSchema.add(hiddenName, jdbc);
+ }
+ OssieSchema schema = new OssieSchema(chosen, jdbc, hiddenName);
+ // Metric-view SQL qualifies dataset references as ""."" so Calcite's
+ // parser resolves them across schemas. The factory's own name arg is the authoritative
+ // source — Calcite hasn't installed the sub-schema yet at this point, so we can't read
+ // it from parentSchema.
+ schema.bindSchemaName(name);
+ return schema;
+ }
+}
diff --git a/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieShelfSqlTranslator.java b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieShelfSqlTranslator.java
new file mode 100644
index 0000000..eba2b80
--- /dev/null
+++ b/ossie-sql/src/main/java/bi/saiku/ossie/sql/internal/OssieShelfSqlTranslator.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql.internal;
+
+import bi.saiku.ossie.model.DialectExpression;
+import bi.saiku.ossie.model.Field;
+import bi.saiku.ossie.model.Metric;
+import bi.saiku.ossie.model.SemanticModel;
+import bi.saiku.ossie.sql.OssieQuery;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Compile an {@link OssieQuery} to the SQL string the ossie-sql Calcite adapter executes.
+ *
+ * The generated shape (illustrative):
+ *
+ *
{@code
+ * SELECT
+ * "customers"."region" AS "customers.region",
+ * SUM("orders"."amount") AS "revenue"
+ * FROM "orders", "customers"
+ * WHERE "customers"."region" = 'NA'
+ * GROUP BY "customers"."region"
+ * ORDER BY "customers"."region" ASC
+ * LIMIT 100
+ * }
+ *
+ * Datasets are named unqualified in the FROM clause — the Calcite connection's
+ * defaultSchema resolves them. Cross-dataset joins come from the {@link OssieAutoJoinRule} at
+ * plan time, driven by the semantic model's relationships block; the emitted SQL only names the
+ * datasets.
+ *
+ *
Field references walk the DTO tree for their ANSI_SQL expression: {@code field.name} is the
+ * agent-facing alias, {@code field.expression.dialects[ANSI_SQL]} is the raw column. This
+ * separation lets converters (dbt/MetricFlow → OSI) name fields as friendly aliases mapped to
+ * arbitrary warehouse column names. If a field declares no expression the translator falls
+ * back to the field name — the shape our reference demo YAMLs use where the name IS the raw
+ * column.
+ */
+public final class OssieShelfSqlTranslator {
+
+ private static final Set KNOWN_AGGS = Set.of("SUM", "AVG", "MIN", "MAX", "COUNT");
+ private static final Pattern OUTER_AGG = Pattern.compile("^\\s*(SUM|AVG|MIN|MAX|COUNT)\\s*\\((.*)\\)\\s*$");
+
+ public String translate(OssieQuery query, SemanticModel semantic) {
+ if (semantic == null) throw new IllegalStateException("Ossie semantic model is required");
+ if (query.getFactDataset() == null || query.getFactDataset().isBlank()) {
+ throw new IllegalArgumentException("OssieQuery.factDataset is required");
+ }
+
+ // --- collect referenced datasets ---
+ Set datasets = new LinkedHashSet<>();
+ datasets.add(query.getFactDataset());
+ for (OssieQuery.FieldRef f : query.getRows()) datasets.add(f.getDataset());
+ for (OssieQuery.FieldRef f : query.getColumns()) datasets.add(f.getDataset());
+ for (OssieQuery.FilterExpr f : query.getFilters()) {
+ if (f.getDataset() != null) datasets.add(f.getDataset());
+ }
+ for (OssieQuery.SortRef s : query.getSorts()) {
+ if (s.getDataset() != null) datasets.add(s.getDataset());
+ }
+
+ // --- SELECT + GROUP BY ---
+ List selectCols = new ArrayList<>();
+ List groupByCols = new ArrayList<>();
+ for (OssieQuery.FieldRef f : query.getRows()) {
+ String qref = qualifiedField(f, semantic);
+ selectCols.add(qref + " AS " + quoteAlias(f.getDataset() + "." + f.getField()));
+ groupByCols.add(qref);
+ }
+ for (OssieQuery.FieldRef f : query.getColumns()) {
+ String qref = qualifiedField(f, semantic);
+ selectCols.add(qref + " AS " + quoteAlias(f.getDataset() + "." + f.getField()));
+ groupByCols.add(qref);
+ }
+ for (OssieQuery.MetricRef v : query.getValues()) {
+ String expr = lookupMetricExpression(semantic, v.getMetric());
+ if (v.getAggregationOverride() != null
+ && !v.getAggregationOverride().isBlank()) {
+ expr = swapAggregation(expr, v.getAggregationOverride().toUpperCase());
+ }
+ selectCols.add(expr + " AS " + quoteAlias(v.getMetric()));
+ }
+ if (selectCols.isEmpty()) {
+ throw new IllegalArgumentException("OssieQuery has no columns to select — add rows, columns, or values");
+ }
+
+ StringBuilder sql = new StringBuilder("SELECT ").append(String.join(", ", selectCols));
+
+ // --- FROM ---
+ List fromRefs = new ArrayList<>();
+ for (String ds : datasets) fromRefs.add(quoteRef(ds));
+ sql.append(" FROM ").append(String.join(", ", fromRefs));
+
+ // --- WHERE ---
+ List whereClauses = new ArrayList<>();
+ for (OssieQuery.FilterExpr f : query.getFilters()) whereClauses.add(filterToSql(f, semantic));
+ if (!whereClauses.isEmpty()) sql.append(" WHERE ").append(String.join(" AND ", whereClauses));
+
+ // --- GROUP BY (only when the query has values — otherwise it's a rowset) ---
+ if (!query.getValues().isEmpty() && !groupByCols.isEmpty()) {
+ sql.append(" GROUP BY ").append(String.join(", ", groupByCols));
+ }
+
+ // --- ORDER BY ---
+ if (!query.getSorts().isEmpty()) {
+ List orderCols = new ArrayList<>();
+ for (OssieQuery.SortRef s : query.getSorts()) {
+ String ref;
+ if (s.getMetric() != null && !s.getMetric().isBlank()) {
+ ref = quoteRef(s.getMetric());
+ } else {
+ String colExpr = lookupFieldExpression(semantic, s.getDataset(), s.getField());
+ String col = (colExpr != null && !colExpr.isBlank()) ? quoteRef(colExpr) : quoteRef(s.getField());
+ ref = quoteRef(s.getDataset()) + "." + col;
+ }
+ orderCols.add(ref + " " + normalizedDirection(s.getDirection()));
+ }
+ sql.append(" ORDER BY ").append(String.join(", ", orderCols));
+ }
+
+ if (query.getLimit() != null && query.getLimit() > 0)
+ sql.append(" LIMIT ").append(query.getLimit());
+ return sql.toString();
+ }
+
+ /**
+ * Rewrite the outer aggregation function on a metric expression. Only fires when the expression
+ * looks like {@code AGG(...)} at the top level AND the override is known; otherwise
+ * pass-through. Preserves declared {@code COUNT(*)} as-is when the override isn't COUNT —
+ * {@code SUM(*)} is a parse error in every ANSI dialect.
+ */
+ static String swapAggregation(String expr, String override) {
+ if (expr == null || override == null || !KNOWN_AGGS.contains(override)) return expr;
+ Matcher m = OUTER_AGG.matcher(expr);
+ if (!m.matches()) return expr;
+ String inner = m.group(2);
+ int depth = 0;
+ for (int i = 0; i < inner.length(); i++) {
+ char c = inner.charAt(i);
+ if (c == '(') depth++;
+ else if (c == ')') {
+ depth--;
+ if (depth < 0) return expr;
+ }
+ }
+ if (depth != 0) return expr;
+ if ("*".equals(inner.trim()) && !"COUNT".equals(override)) return expr;
+ return override + "(" + inner + ")";
+ }
+
+ private String qualifiedField(OssieQuery.FieldRef f, SemanticModel semantic) {
+ if (f.getDataset() == null || f.getField() == null) {
+ throw new IllegalArgumentException("FieldRef requires both dataset and field");
+ }
+ String columnExpr = lookupFieldExpression(semantic, f.getDataset(), f.getField());
+ String col = (columnExpr != null && !columnExpr.isBlank()) ? quoteRef(columnExpr) : quoteRef(f.getField());
+ return quoteRef(f.getDataset()) + "." + col;
+ }
+
+ /**
+ * Look up a field's ANSI_SQL expression in the semantic model. Returns null if the dataset or
+ * field isn't declared. Null triggers the fallback-to-field-name behaviour above.
+ */
+ private String lookupFieldExpression(SemanticModel semantic, String datasetName, String fieldName) {
+ if (semantic == null || datasetName == null || fieldName == null) return null;
+ for (bi.saiku.ossie.model.Dataset ds : semantic.getDatasets()) {
+ if (!datasetName.equalsIgnoreCase(ds.getName())) continue;
+ for (Field field : ds.getFields()) {
+ if (fieldName.equalsIgnoreCase(field.getName())) {
+ return firstAnsiDialect(field.getExpression());
+ }
+ }
+ }
+ return null;
+ }
+
+ private String lookupMetricExpression(SemanticModel semantic, String metricName) {
+ for (Metric m : semantic.getMetrics()) {
+ if (metricName.equals(m.getName())) {
+ String expr = firstAnsiDialect(m.getExpression());
+ if (expr != null && !expr.isBlank()) return expr;
+ throw new IllegalStateException(
+ "Ossie metric '" + metricName + "' has no ANSI SQL expression declared in the model");
+ }
+ }
+ throw new IllegalArgumentException(
+ "Ossie metric '" + metricName + "' not found in semantic model '" + semantic.getName() + "'");
+ }
+
+ /**
+ * Walk an {@link bi.saiku.ossie.model.Expression} for its ANSI_SQL dialect. First-declared wins
+ * if multiple ANSI entries are present. Returns null when the expression or dialects list is
+ * empty — the caller decides how to fall back.
+ */
+ private String firstAnsiDialect(bi.saiku.ossie.model.Expression expr) {
+ if (expr == null || expr.getDialects() == null) return null;
+ for (DialectExpression de : expr.getDialects()) {
+ if (de == null || de.getDialect() == null) continue;
+ if ("ANSI_SQL".equalsIgnoreCase(de.getDialect())) return de.getExpression();
+ }
+ return null;
+ }
+
+ private String filterToSql(OssieQuery.FilterExpr f, SemanticModel semantic) {
+ String col;
+ if (f.getDataset() != null && !f.getDataset().isBlank()) {
+ String colExpr = lookupFieldExpression(semantic, f.getDataset(), f.getField());
+ String colName = (colExpr != null && !colExpr.isBlank()) ? colExpr : f.getField();
+ col = quoteRef(f.getDataset()) + "." + quoteRef(colName);
+ } else {
+ col = quoteRef(f.getField());
+ }
+ String op = f.getOp() == null ? "EQ" : f.getOp().toUpperCase();
+ switch (op) {
+ case "EQ":
+ return col + " = " + literal(f.getValue());
+ case "NEQ":
+ return col + " <> " + literal(f.getValue());
+ case "LT":
+ return col + " < " + literal(f.getValue());
+ case "LTE":
+ return col + " <= " + literal(f.getValue());
+ case "GT":
+ return col + " > " + literal(f.getValue());
+ case "GTE":
+ return col + " >= " + literal(f.getValue());
+ case "IN":
+ if (f.getValues().isEmpty()) return "1 = 0";
+ List lits = new ArrayList<>();
+ for (String v : f.getValues()) lits.add(literal(v));
+ return col + " IN (" + String.join(", ", lits) + ")";
+ case "BETWEEN":
+ if (f.getValues().size() < 2) throw new IllegalArgumentException("BETWEEN filter requires two values");
+ return col + " BETWEEN " + literal(f.getValues().get(0)) + " AND "
+ + literal(f.getValues().get(1));
+ case "IS_NULL":
+ return col + " IS NULL";
+ case "IS_NOT_NULL":
+ return col + " IS NOT NULL";
+ default:
+ throw new IllegalArgumentException("Unsupported filter op: " + f.getOp());
+ }
+ }
+
+ private String literal(String v) {
+ if (v == null) return "NULL";
+ if (v.matches("-?\\d+(\\.\\d+)?")) return v;
+ return "'" + v.replace("'", "''") + "'";
+ }
+
+ private String normalizedDirection(String dir) {
+ if (dir != null && dir.equalsIgnoreCase("DESC")) return "DESC";
+ return "ASC";
+ }
+
+ private String quoteRef(String ident) {
+ if (ident == null) throw new IllegalArgumentException("identifier is null");
+ return "\"" + ident.replace("\"", "\"\"") + "\"";
+ }
+
+ private String quoteAlias(String alias) {
+ return quoteRef(alias);
+ }
+}
diff --git a/ossie-sql/src/test/java/bi/saiku/ossie/sql/OssieEngineTest.java b/ossie-sql/src/test/java/bi/saiku/ossie/sql/OssieEngineTest.java
new file mode 100644
index 0000000..8839357
--- /dev/null
+++ b/ossie-sql/src/test/java/bi/saiku/ossie/sql/OssieEngineTest.java
@@ -0,0 +1,291 @@
+/*
+ * Copyright 2026 Spicule Ltd
+ * Apache License, Version 2.0.
+ */
+package bi.saiku.ossie.sql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.Statement;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * End-to-end integration test — runs an OssieEngine over an in-process H2 warehouse seeded with
+ * the orders fixture. Proves the whole stack (YAML → Calcite adapter → auto-join rule → JDBC
+ * result) is wired correctly against a real database with real data.
+ */
+class OssieEngineTest {
+
+ private static Path warehouseDir;
+ private static String jdbcUrl;
+
+ private static final String ORDERS_YAML =
+ """
+ version: 0.2.0.dev0
+ semantic_model:
+ - name: Orders
+ description: "Orders demo — three datasets joined by customer_id."
+ datasets:
+ - name: orders
+ source: FCT_ORDERS
+ primary_key: [ID]
+ fields:
+ - name: id
+ expression:
+ dialects:
+ - dialect: ANSI_SQL
+ expression: ID
+ - name: customer_id
+ expression:
+ dialects:
+ - dialect: ANSI_SQL
+ expression: CUSTOMER_ID
+ - name: ordered_at
+ expression:
+ dialects:
+ - dialect: ANSI_SQL
+ expression: ORDERED_AT
+ - name: customers
+ source: DIM_CUSTOMERS
+ primary_key: [ID]
+ fields:
+ - name: id
+ expression:
+ dialects:
+ - dialect: ANSI_SQL
+ expression: ID
+ - name: customer_country
+ label: Country
+ expression:
+ dialects:
+ - dialect: ANSI_SQL
+ expression: COUNTRY
+ metrics:
+ - name: total_revenue
+ aggregation_kind: sum
+ expression:
+ dialects:
+ - dialect: ANSI_SQL
+ expression: SUM("orders"."ORDER_TOTAL")
+ - name: order_count
+ aggregation_kind: count
+ expression:
+ dialects:
+ - dialect: ANSI_SQL
+ expression: COUNT(*)
+ - name: avg_order_value
+ aggregation_kind: ratio
+ expression:
+ dialects:
+ - dialect: ANSI_SQL
+ expression: (SUM("orders"."ORDER_TOTAL")) / NULLIF(COUNT(*), 0)
+ relationships:
+ - name: orders_to_customers
+ from: orders
+ to: customers
+ from_columns: [CUSTOMER_ID]
+ to_columns: [ID]
+ """;
+
+ @BeforeAll
+ static void seedH2() throws Exception {
+ warehouseDir = Files.createTempDirectory("ossie-engine-it-");
+ jdbcUrl = "jdbc:h2:" + warehouseDir.resolve("orders").toAbsolutePath() + ";MODE=PostgreSQL;AUTO_SERVER=TRUE";
+ try (Connection conn = java.sql.DriverManager.getConnection(jdbcUrl, "sa", "");
+ Statement st = conn.createStatement()) {
+ st.execute(
+ "CREATE TABLE DIM_CUSTOMERS (ID INT PRIMARY KEY, COUNTRY VARCHAR(2), CUSTOMER_TYPE VARCHAR(16))");
+ st.execute(
+ "CREATE TABLE FCT_ORDERS (ID INT PRIMARY KEY, CUSTOMER_ID INT, ORDERED_AT VARCHAR(10), ORDER_TOTAL DECIMAL(10,2))");
+ st.execute(
+ "INSERT INTO DIM_CUSTOMERS VALUES (1,'US','individual'),(2,'US','business'),(3,'GB','individual'),(4,'DE','business')");
+ st.execute(
+ "INSERT INTO FCT_ORDERS VALUES (1,1,'2024-07-05',42.50),(2,2,'2024-07-12',155.00),(3,3,'2024-07-19',18.75),(4,4,'2024-07-26',220.00),(5,1,'2024-08-02',36.00),(6,2,'2024-08-16',175.75),(7,3,'2024-09-01',51.00),(8,4,'2024-09-08',95.00)");
+ }
+ }
+
+ @AfterAll
+ static void teardown() throws IOException {
+ try (var walk = Files.walk(warehouseDir)) {
+ walk.sorted(java.util.Comparator.reverseOrder()).map(Path::toFile).forEach(java.io.File::delete);
+ }
+ }
+
+ private OssieEngine openEngine() throws IOException {
+ return OssieEngine.builder()
+ .semanticModelYaml(ORDERS_YAML)
+ .model("Orders")
+ .jdbcUrl(jdbcUrl)
+ .credentials("sa", "")
+ .build();
+ }
+
+ @Test
+ void simpleAggregationExecutes() throws Exception {
+ try (var engine = openEngine()) {
+ var q = OssieQuery.builder()
+ .model("Orders")
+ .factDataset("orders")
+ .values("total_revenue")
+ .values("order_count")
+ .build();
+ OssieResult r = engine.execute(q);
+ assertEquals(1, r.getRowCount(), "one aggregate row");
+ var row = r.getRecords().get(0);
+ assertNotNull(row.get("total_revenue"));
+ assertNotNull(row.get("order_count"));
+ // 42.50 + 155.00 + 18.75 + 220.00 + 36.00 + 175.75 + 51.00 + 95.00 = 794.00
+ assertEquals(
+ 0,
+ java.math.BigDecimal.valueOf(794.00)
+ .compareTo(new java.math.BigDecimal(
+ row.get("total_revenue").toString())));
+ assertEquals(8L, ((Number) row.get("order_count")).longValue());
+ }
+ }
+
+ @Test
+ void crossDatasetJoinAutoResolves() throws Exception {
+ try (var engine = openEngine()) {
+ var q = OssieQuery.builder()
+ .model("Orders")
+ .factDataset("orders")
+ .rows("customers", "customer_country")
+ .values("total_revenue")
+ .sortByMetric("total_revenue", "DESC")
+ .build();
+ OssieResult r = engine.execute(q);
+ assertEquals(3, r.getRowCount(), "3 distinct countries in the fixture");
+ // First row is highest-revenue country. From the seed: DE=$315, US=$409.25, GB=$69.75.
+ // US has the largest total revenue.
+ var top = r.getRecords().get(0);
+ assertEquals("US", top.get("customers.customer_country"));
+ }
+ }
+
+ @Test
+ void ratioMetricComposesInline() throws Exception {
+ try (var engine = openEngine()) {
+ var q = OssieQuery.builder()
+ .model("Orders")
+ .factDataset("orders")
+ .rows("customers", "customer_country")
+ .values("avg_order_value")
+ .build();
+ OssieResult r = engine.execute(q);
+ assertEquals(3, r.getRowCount());
+ // avg_order_value expression is (SUM/NULLIF(COUNT,0)), non-null for every country.
+ for (var row : r.getRecords()) assertNotNull(row.get("avg_order_value"));
+ }
+ }
+
+ @Test
+ void filterFieldRewriteHandlesLabelledColumn() throws Exception {
+ try (var engine = openEngine()) {
+ var q = OssieQuery.builder()
+ .model("Orders")
+ .factDataset("orders")
+ .rows("customers", "customer_country")
+ .values("total_revenue")
+ .filter("customers", "customer_country", "IN", java.util.List.of("US", "GB"))
+ .build();
+ OssieResult r = engine.execute(q);
+ assertEquals(2, r.getRowCount());
+ // DE excluded by IN filter — sanity check.
+ for (var row : r.getRecords()) {
+ String country = (String) row.get("customers.customer_country");
+ assertTrue(country.equals("US") || country.equals("GB"), "IN filter should have excluded " + country);
+ }
+ }
+ }
+
+ @Test
+ void compilePreviewsSqlWithoutHittingWarehouse() throws Exception {
+ try (var engine = openEngine()) {
+ String sql = engine.compile(OssieQuery.builder()
+ .model("Orders")
+ .factDataset("orders")
+ .rows("customers", "customer_country")
+ .values("total_revenue")
+ .build());
+ assertTrue(sql.contains("SELECT"));
+ assertTrue(sql.contains("\"customers\".\"COUNTRY\""));
+ assertTrue(sql.contains("SUM"));
+ }
+ }
+
+ @Test
+ void rawSqlPassthroughWorks() throws Exception {
+ // The engine's connection speaks Calcite JDBC — any SQL that references the Ossie
+ // datasets works. This is the "any BI tool" surface.
+ try (var engine = openEngine()) {
+ OssieResult r = engine.executeSql("SELECT \"customers\".\"COUNTRY\", COUNT(*) AS n "
+ + "FROM \"customers\", \"orders\" GROUP BY \"customers\".\"COUNTRY\" ORDER BY n DESC");
+ assertEquals(3, r.getRowCount());
+ }
+ }
+
+ @Test
+ void openConnectionYieldsUsableJdbcHandle() throws Exception {
+ try (var engine = openEngine();
+ Connection conn = engine.openConnection();
+ Statement stmt = conn.createStatement();
+ var rs = stmt.executeQuery("SELECT COUNT(*) FROM \"orders\"")) {
+ assertTrue(rs.next());
+ assertEquals(8, rs.getInt(1));
+ }
+ }
+
+ @Test
+ void unknownMetricNamesThrowClearly() throws Exception {
+ try (var engine = openEngine()) {
+ var q = OssieQuery.builder()
+ .model("Orders")
+ .factDataset("orders")
+ .values("nonexistent_metric")
+ .build();
+ IllegalArgumentException ex = org.junit.jupiter.api.Assertions.assertThrows(
+ IllegalArgumentException.class, () -> engine.compile(q));
+ assertTrue(ex.getMessage().contains("nonexistent_metric"), "message should name the offending metric");
+ assertTrue(ex.getMessage().contains("Orders"), "message should name the semantic model");
+ }
+ }
+
+ @Test
+ void queryResultCarriesGeneratedSql() throws Exception {
+ try (var engine = openEngine()) {
+ OssieResult r = engine.execute(OssieQuery.builder()
+ .model("Orders")
+ .factDataset("orders")
+ .values("order_count")
+ .build());
+ assertNotNull(r.getGeneratedSql());
+ assertTrue(r.getGeneratedSql().contains("COUNT(*)"));
+ }
+ }
+
+ @Test
+ void wellFormedColumnDescriptors() throws Exception {
+ try (var engine = openEngine()) {
+ OssieResult r = engine.execute(OssieQuery.builder()
+ .model("Orders")
+ .factDataset("orders")
+ .rows("customers", "customer_country")
+ .values("total_revenue")
+ .build());
+ assertEquals(2, r.getColumns().size());
+ assertEquals("customers.customer_country", r.getColumns().get(0).getKey());
+ assertEquals("dimension", r.getColumns().get(0).getType());
+ assertEquals("total_revenue", r.getColumns().get(1).getKey());
+ assertEquals("metric", r.getColumns().get(1).getType());
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index 0d24ec2..b36b83d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,26 +5,26 @@
4.0.0
bi.saiku.ossie
- ossie-core
+ ossie-parent
0.1.0-SNAPSHOT
- jar
-
- ossie-core
- Zero-dependency-beyond-Jackson library for reading, writing, and round-tripping
- Apache Ossie (Open Semantic Interchange) semantic model documents on the JVM.
-
- Ships:
- - Immutable-ish DTO tree (SemanticModel, Dataset, Field, Metric, Relationship,
- Expression, AiContext, CustomExtension, OssieDocument) modelling the OSI
- v0.1.x / 0.2.x wire format.
- - OssieYamlReader — reads YAML *or* JSON (Jackson YAMLMapper accepts both). Consumes
- dbt Core 1.12's target/osi_document.json directly.
- - OssieYamlWriter — emits Ossie YAML.
-
- Designed for use outside Saiku: no Spring, no JAX-RS, no Mondrian, no Calcite.
- Any JVM tool that needs to read or emit OSI semantic models can depend on it.
+ pom
+
+ ossie (parent)
+ JVM libraries for Apache Ossie / Open Semantic Interchange.
+
+ - ossie-core: DTOs + YAML/JSON reader / writer. Zero deps beyond Jackson.
+ - ossie-sql: Calcite adapter + shelf-state query engine. Executes queries authored against
+ the OSI semantic model against any JDBC warehouse.
+
+ Both artifacts are intended to be usable outside Saiku — no Spring, no JAX-RS, no
+ Mondrian.
https://github.com/spiculedata/ossie
+
+ ossie-core
+ ossie-sql
+
+
Apache License, Version 2.0
@@ -62,107 +62,146 @@
UTF-8
21
+
2.18.6
5.11.3
1.5.9
+ 1.41.0
+ 1.27.0
+ 33.4.8-jre
+ 2.3.232
+ 2.0.16
-
-
- com.fasterxml.jackson.core
- jackson-databind
- ${jackson.version}
-
-
- com.fasterxml.jackson.dataformat
- jackson-dataformat-yaml
- ${jackson.version}
-
-
-
- com.networknt
- json-schema-validator
- ${json-schema-validator.version}
- test
-
-
- org.junit.jupiter
- junit-jupiter
- ${junit.version}
- test
-
-
+
+
+
+
+ bi.saiku.ossie
+ ossie-core
+ ${project.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-yaml
+ ${jackson.version}
+
+
+ com.networknt
+ json-schema-validator
+ ${json-schema-validator.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+
+
+ org.apache.calcite
+ calcite-core
+ ${calcite.version}
+
+
+ org.apache.calcite.avatica
+ avatica-core
+ ${avatica.version}
+
+
+ com.google.guava
+ guava
+ ${guava.version}
+
+
+ com.h2database
+ h2
+ ${h2.version}
+
+
+ org.slf4j
+ slf4j-api
+ ${slf4j.version}
+
+
+
-
-
- org.apache.maven.plugins
- maven-compiler-plugin
- 3.13.0
-
- ${maven.compiler.release}
- UTF-8
-
-
-
- org.apache.maven.plugins
- maven-surefire-plugin
- 3.5.4
-
-
- org.apache.maven.plugins
- maven-source-plugin
- 3.3.1
-
-
- attach-sources
-
- jar-no-fork
-
-
-
-
-
- org.apache.maven.plugins
- maven-javadoc-plugin
- 3.11.2
-
- none
- true
-
-
-
- attach-javadocs
-
- jar
-
-
-
-
-
- com.diffplug.spotless
- spotless-maven-plugin
- 3.7.0
-
-
-
- 2.66.0
-
-
-
-
-
-
-
- spotless-check
- verify
-
- check
-
-
-
-
-
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ ${maven.compiler.release}
+ UTF-8
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.4
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.3.1
+
+
+ attach-sources
+
+ jar-no-fork
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ 3.11.2
+
+ none
+ true
+
+
+
+ attach-javadocs
+
+ jar
+
+
+
+
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+ 3.7.0
+
+
+
+ 2.66.0
+
+
+
+
+
+
+
+ spotless-check
+ verify
+
+ check
+
+
+
+
+
+