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: + * + *

    + *
  1. 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. + *
  2. 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. + *
  3. Both scans are from the same {@link OssieSchema} instance. + *
  4. 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: + * + *

+ */ +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 + + + + + +