diff --git a/README.md b/README.md index be561fc..c5c163a 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,39 @@ var doc = new OssieDocument(); new OssieYamlWriter().write(doc, Paths.get("my-model.ossie.yaml")); ``` +Resolve AI-context synonyms to canonical entity names, without writing +your own tree walk: + +```java +import bi.saiku.ossie.OssieSynonymIndex; + +var model = doc.getSemanticModel().get(0); +var metrics = OssieSynonymIndex.buildMetricIndex(model); +// If the LLM (or the user) said "revenue" or "top-line" or "REVENUE", +// resolve it to the canonical metric name declared in the YAML. +String canonical = OssieSynonymIndex.resolve("revenue", metrics); +// → "net_revenue" (assuming the metric declared that synonym) +``` + +Reject documents at unknown spec versions — useful in CI or when you +want to fail fast on drift: + +```java +var doc = new OssieYamlReader() + .setStrictVersion(true) + .read(Paths.get("model.ossie.yaml")); +// Throws UnsupportedOssieVersionException if the version isn't in +// OssieYamlReader.SUPPORTED_VERSIONS. +``` + ## Compatibility -- **OSI spec versions** — DTOs cover the v0.1.x and v0.2.x wire - formats. Version strings on documents are not validated at read time; - future spec additions are ignored via - `@JsonIgnoreProperties(ignoreUnknown = true)`. +- **OSI spec versions** — the DTO tree covers the v0.1.x and v0.2.x + wire formats. `OssieYamlReader.SUPPORTED_VERSIONS` names the exact + versions the reader knows about. Unknown top-level and per-entity + fields are silently ignored (`@JsonIgnoreProperties(ignoreUnknown = + true)`), so additive spec changes round-trip without breaking older + consumers. - **JDK** — 21+. - **dbt Core** — reads `target/osi_document.json` from dbt 1.12 and up verbatim. diff --git a/src/main/java/bi/saiku/ossie/OssieSynonymIndex.java b/src/main/java/bi/saiku/ossie/OssieSynonymIndex.java new file mode 100644 index 0000000..b4c3968 --- /dev/null +++ b/src/main/java/bi/saiku/ossie/OssieSynonymIndex.java @@ -0,0 +1,118 @@ +/* + * Copyright 2026 Spicule Ltd + * Apache License, Version 2.0. + */ +package bi.saiku.ossie; + +import bi.saiku.ossie.model.AiContext; +import bi.saiku.ossie.model.Dataset; +import bi.saiku.ossie.model.Field; +import bi.saiku.ossie.model.Metric; +import bi.saiku.ossie.model.SemanticModel; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Canonicalises AI-context synonyms to their entity names. + * + *

The Ossie / OSI spec attaches {@code AIContext.synonyms} to fields, metrics, and datasets so + * LLM agents can refer to them by natural aliases: "revenue" resolves to the canonical + * {@code net_revenue} metric; "sales transactions" resolves to the {@code store_sales} dataset. + * The synonyms are declared once in the YAML but the lookup logic ("case-insensitive walk of every + * dataset's fields, collect their AI-context synonyms") is otherwise identical across consumers. + * + *

Each index maps lowercased synonym to the corresponding entity's canonical name. + * Lookups against the returned map should also lowercase the key ({@link Locale#ROOT}). Callers + * are typically: + * + *

+ * + *

Insertion order in the returned map is preserved (dataset → field → metric traversal order in + * the source model), which helps a UI display "revenue → net_revenue (from Sales.net_revenue)" + * predictably. + * + *

Ambiguous synonyms are resolved with a first-declared-wins policy — if two fields + * declare {@code "id"} as a synonym, the first one encountered stays in the map and later + * declarations are silently dropped. That matches the pragmatic behaviour most consumers want: + * make the synonym resolvable to something rather than throw at build time. Consumers + * that need strict ambiguity detection should walk the source model themselves. + */ +public final class OssieSynonymIndex { + + private OssieSynonymIndex() {} + + /** + * Field-name synonym index. Keys are lowercased synonyms; values are the canonical + * {@code "."} handle. Returns an empty map if the model declares no + * synonyms on any field. + */ + public static Map buildFieldIndex(SemanticModel model) { + Map out = new LinkedHashMap<>(); + if (model == null) return out; + for (Dataset ds : model.getDatasets()) { + for (Field f : ds.getFields()) { + for (String syn : synonymsOf(f.getAiContext())) { + out.putIfAbsent(syn.toLowerCase(Locale.ROOT), ds.getName() + "." + f.getName()); + } + } + } + return out; + } + + /** + * Metric-name synonym index. Keys are lowercased synonyms; values are the canonical metric + * name. Metrics are model-scoped in the OSI spec so no dataset qualifier is needed. + */ + public static Map buildMetricIndex(SemanticModel model) { + Map out = new LinkedHashMap<>(); + if (model == null) return out; + for (Metric m : model.getMetrics()) { + for (String syn : synonymsOf(m.getAiContext())) { + out.putIfAbsent(syn.toLowerCase(Locale.ROOT), m.getName()); + } + } + return out; + } + + /** Dataset-name synonym index. Keys are lowercased synonyms; values are the canonical dataset name. */ + public static Map buildDatasetIndex(SemanticModel model) { + Map out = new LinkedHashMap<>(); + if (model == null) return out; + for (Dataset ds : model.getDatasets()) { + for (String syn : synonymsOf(ds.getAiContext())) { + out.putIfAbsent(syn.toLowerCase(Locale.ROOT), ds.getName()); + } + } + return out; + } + + /** + * Resolves a caller-supplied name against a synonym index. Returns the canonical name (from + * the map) or the input unchanged if no synonym matches. Case-insensitive. + * + *

Reads best as a validator escape hatch: + * + *

{@code
+     * Map aliases = OssieSynonymIndex.buildMetricIndex(model);
+     * String resolved = OssieSynonymIndex.resolve(request.getMetric(), aliases);
+     * // proceed with `resolved` — validation now sees the canonical name
+     * }
+ */ + public static String resolve(String maybeSynonym, Map index) { + if (maybeSynonym == null || index == null || index.isEmpty()) return maybeSynonym; + String canonical = index.get(maybeSynonym.toLowerCase(Locale.ROOT)); + return canonical == null ? maybeSynonym : canonical; + } + + private static Iterable synonymsOf(AiContext ctx) { + if (ctx == null || ctx.getSynonyms() == null || ctx.getSynonyms().isEmpty()) { + return java.util.List.of(); + } + return ctx.getSynonyms(); + } +} diff --git a/src/main/java/bi/saiku/ossie/OssieYamlReader.java b/src/main/java/bi/saiku/ossie/OssieYamlReader.java index 5e50f64..d196cd7 100644 --- a/src/main/java/bi/saiku/ossie/OssieYamlReader.java +++ b/src/main/java/bi/saiku/ossie/OssieYamlReader.java @@ -13,43 +13,92 @@ import java.io.Reader; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Set; /** - * Load an {@link OssieDocument} from Ossie-flavoured YAML. + * Loads an {@link OssieDocument} from OSI-flavoured YAML or JSON. * - *

Symmetric with {@code OssieYamlWriter} — the pair is the round-trip. Lives in - * saiku-service alongside the writer + POJOs so any module can consume it (previously kept in - * saiku-sql; promoted here when {@code OssieDiscoverService} in this module needed to read the - * same YAML the Calcite adapter loads). + *

YAML is a strict superset of JSON so the same reader accepts either. dbt Core 1.12's + * {@code target/osi_document.json} feeds through this class verbatim. * - *

{@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} is disabled so Ossie documents that - * carry vendor extensions or future spec keys we don't yet model won't blow up the loader — the - * spec is draft and additive fields are the whole point of {@code custom_extensions}. + *

{@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} is disabled: additive spec fields + * that this library version doesn't yet model round-trip silently rather than failing the load. + * That's the right default for a draft-spec ecosystem where consumers routinely lag emitters. + * + *

By default, the {@code version:} field is accepted as-is. Callers that need to reject + * documents at unknown spec versions can opt in with {@link #setStrictVersion(boolean)} — the + * reader then throws {@link UnsupportedOssieVersionException} on a mismatch. The set of + * recognised versions is exposed via {@link #getSupportedVersions()} for surfacing in errors and + * documentation. */ public final class OssieYamlReader { + /** + * Ossie / OSI spec versions this library understands. Widens as new drafts land — additive + * changes bump the last suffix (e.g. {@code 0.2.0.dev0} → {@code 0.2.0.dev1}); breaking + * changes require a new major version of ossie-core itself. + * + *

Included today: + * + *

+ */ + public static final Set SUPPORTED_VERSIONS = Set.of("0.1.0", "0.1.1", "0.2.0.dev0"); + private final ObjectMapper yaml; + private boolean strictVersion = false; public OssieYamlReader() { this.yaml = new ObjectMapper(new YAMLFactory()); this.yaml.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); } + /** + * When {@code true}, {@link #read} throws {@link UnsupportedOssieVersionException} if the + * document's {@code version} field is not in {@link #SUPPORTED_VERSIONS}. Default {@code false} + * — permissive mode for tools that want to attempt a best-effort parse against unfamiliar spec + * versions (Jackson's {@code ignoreUnknown} handles additive changes gracefully). + * + * @return {@code this} for fluent chaining + */ + public OssieYamlReader setStrictVersion(boolean strict) { + this.strictVersion = strict; + return this; + } + + /** Returns the versions this reader recognises. Equivalent to {@link #SUPPORTED_VERSIONS}. */ + public Set getSupportedVersions() { + return SUPPORTED_VERSIONS; + } + public OssieDocument read(Path yamlPath) throws IOException { try (InputStream in = Files.newInputStream(yamlPath)) { - return yaml.readValue(in, OssieDocument.class); + return checkVersion(yaml.readValue(in, OssieDocument.class)); } } public OssieDocument read(InputStream stream) throws IOException { - return yaml.readValue(stream, OssieDocument.class); + return checkVersion(yaml.readValue(stream, OssieDocument.class)); } public OssieDocument read(Reader reader) throws IOException { - return yaml.readValue(reader, OssieDocument.class); + return checkVersion(yaml.readValue(reader, OssieDocument.class)); } public OssieDocument readString(String yamlText) throws IOException { - return yaml.readValue(yamlText, OssieDocument.class); + return checkVersion(yaml.readValue(yamlText, OssieDocument.class)); + } + + private OssieDocument checkVersion(OssieDocument doc) { + if (!strictVersion) return doc; + String version = doc.getVersion(); + if (version == null || !SUPPORTED_VERSIONS.contains(version)) { + throw new UnsupportedOssieVersionException(version, SUPPORTED_VERSIONS); + } + return doc; } } diff --git a/src/main/java/bi/saiku/ossie/UnsupportedOssieVersionException.java b/src/main/java/bi/saiku/ossie/UnsupportedOssieVersionException.java new file mode 100644 index 0000000..46334e4 --- /dev/null +++ b/src/main/java/bi/saiku/ossie/UnsupportedOssieVersionException.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Spicule Ltd + * Apache License, Version 2.0. + */ +package bi.saiku.ossie; + +import java.util.Set; +import java.util.TreeSet; + +/** + * Thrown by {@link OssieYamlReader} when {@code setStrictVersion(true)} is enabled and the parsed + * document's {@code version:} field is not in {@link OssieYamlReader#SUPPORTED_VERSIONS}. + * + *

Runtime exception because callers using strict-version-check are opting in to fail-fast + * behaviour on version drift, not routine error handling. Extends {@link RuntimeException} so + * existing method signatures ({@code read(...) throws IOException}) don't need widening. + * + *

The message names the offending version and lists the supported set so operators can decide + * whether to widen this library's version constant or downgrade the document's version. + */ +public class UnsupportedOssieVersionException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final String observedVersion; + private final Set supportedVersions; + + public UnsupportedOssieVersionException(String observed, Set supported) { + super(buildMessage(observed, supported)); + this.observedVersion = observed; + this.supportedVersions = supported == null ? Set.of() : Set.copyOf(supported); + } + + /** @return the version string carried by the document (may be {@code null} if none declared) */ + public String getObservedVersion() { + return observedVersion; + } + + /** @return the set of versions the reader was configured to accept */ + public Set getSupportedVersions() { + return supportedVersions; + } + + private static String buildMessage(String observed, Set supported) { + String obs = observed == null ? "(no version declared)" : "'" + observed + "'"; + Set sortedSupported = supported == null ? Set.of() : new TreeSet<>(supported); + return "Ossie document declares version " + obs + + " which is not in the reader's supported set: " + sortedSupported + + ". Enable permissive mode by leaving OssieYamlReader.setStrictVersion(false) — " + + "the default — or bump this library to a release that lists the observed version."; + } +} diff --git a/src/test/java/bi/saiku/ossie/OssieSynonymIndexTest.java b/src/test/java/bi/saiku/ossie/OssieSynonymIndexTest.java new file mode 100644 index 0000000..aef3aeb --- /dev/null +++ b/src/test/java/bi/saiku/ossie/OssieSynonymIndexTest.java @@ -0,0 +1,167 @@ +/* + * Copyright 2026 Spicule Ltd + * Apache License, Version 2.0. + */ +package bi.saiku.ossie; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import bi.saiku.ossie.model.OssieDocument; +import bi.saiku.ossie.model.SemanticModel; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class OssieSynonymIndexTest { + + private final OssieYamlReader reader = new OssieYamlReader(); + + private static final String YAML_WITH_SYNONYMS = + """ + version: 0.2.0.dev0 + semantic_model: + - name: Sales + datasets: + - name: store_sales + source: public.store_sales + ai_context: + synonyms: [sales transactions, POS data] + fields: + - name: c_state + expression: + dialects: + - dialect: ANSI_SQL + expression: C_STATE + ai_context: + synonyms: [state, region] + - name: c_country + expression: + dialects: + - dialect: ANSI_SQL + expression: C_COUNTRY + ai_context: + synonyms: [country] + metrics: + - name: net_revenue + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(store_sales.amount) + ai_context: + synonyms: [revenue, turnover, top-line] + - name: order_count + expression: + dialects: + - dialect: ANSI_SQL + expression: COUNT(*) + ai_context: + synonyms: [orders] + """; + + private SemanticModel loadModel() throws Exception { + OssieDocument doc = reader.readString(YAML_WITH_SYNONYMS); + return doc.getSemanticModel().get(0); + } + + @Test + void metricSynonymsResolveCanonicalName() throws Exception { + Map idx = OssieSynonymIndex.buildMetricIndex(loadModel()); + assertEquals("net_revenue", idx.get("revenue")); + assertEquals("net_revenue", idx.get("turnover")); + assertEquals("net_revenue", idx.get("top-line")); + assertEquals("order_count", idx.get("orders")); + } + + @Test + void metricLookupIsCaseInsensitive() throws Exception { + Map idx = OssieSynonymIndex.buildMetricIndex(loadModel()); + assertEquals("net_revenue", idx.get("revenue")); + assertEquals("net_revenue", OssieSynonymIndex.resolve("REVENUE", idx)); + assertEquals("net_revenue", OssieSynonymIndex.resolve("Revenue", idx)); + assertEquals("net_revenue", OssieSynonymIndex.resolve("ReVeNuE", idx)); + } + + @Test + void fieldSynonymsQualifyByDataset() throws Exception { + Map idx = OssieSynonymIndex.buildFieldIndex(loadModel()); + assertEquals("store_sales.c_state", idx.get("state")); + assertEquals("store_sales.c_state", idx.get("region")); + assertEquals("store_sales.c_country", idx.get("country")); + } + + @Test + void datasetSynonymsResolveCanonicalName() throws Exception { + Map idx = OssieSynonymIndex.buildDatasetIndex(loadModel()); + assertEquals("store_sales", idx.get("sales transactions")); + assertEquals("store_sales", idx.get("pos data")); + } + + @Test + void resolveReturnsInputWhenNotASynonym() throws Exception { + Map idx = OssieSynonymIndex.buildMetricIndex(loadModel()); + // Canonical name doesn't get rewritten. + assertEquals("net_revenue", OssieSynonymIndex.resolve("net_revenue", idx)); + // Nor does an unknown name. + assertEquals("unrelated", OssieSynonymIndex.resolve("unrelated", idx)); + } + + @Test + void resolveHandlesNullsWithoutThrowing() { + assertNull(OssieSynonymIndex.resolve(null, Map.of("x", "y"))); + assertEquals("orig", OssieSynonymIndex.resolve("orig", null)); + assertEquals("orig", OssieSynonymIndex.resolve("orig", Map.of())); + } + + @Test + void emptyModelYieldsEmptyIndices() { + // No datasets, no metrics — every index is empty but non-null. + SemanticModel empty = new SemanticModel(); + assertTrue(OssieSynonymIndex.buildFieldIndex(empty).isEmpty()); + assertTrue(OssieSynonymIndex.buildMetricIndex(empty).isEmpty()); + assertTrue(OssieSynonymIndex.buildDatasetIndex(empty).isEmpty()); + } + + @Test + void nullModelYieldsEmptyIndices() { + assertTrue(OssieSynonymIndex.buildFieldIndex(null).isEmpty()); + assertTrue(OssieSynonymIndex.buildMetricIndex(null).isEmpty()); + assertTrue(OssieSynonymIndex.buildDatasetIndex(null).isEmpty()); + } + + @Test + void firstDeclaredWinsOnCollision() throws Exception { + String yaml = + """ + version: 0.2.0.dev0 + semantic_model: + - name: T + metrics: + - name: a + expression: + dialects: [{dialect: ANSI_SQL, expression: '1'}] + ai_context: + synonyms: [shared] + - name: b + expression: + dialects: [{dialect: ANSI_SQL, expression: '2'}] + ai_context: + synonyms: [shared] + """; + SemanticModel m = reader.readString(yaml).getSemanticModel().get(0); + Map idx = OssieSynonymIndex.buildMetricIndex(m); + // First declaration wins; second is silently dropped. + assertEquals("a", idx.get("shared")); + } + + @Test + void resolveOnCanonicalNameReturnsSameString() throws Exception { + // Sanity: resolve() shouldn't rewrite an already-canonical reference. Also confirms it's + // returning the input reference (not a copy) when no rewrite happens — helps downstream + // reference-equality shortcuts. + Map idx = OssieSynonymIndex.buildMetricIndex(loadModel()); + String canonical = "net_revenue"; + assertSame(canonical, OssieSynonymIndex.resolve(canonical, idx)); + } +} diff --git a/src/test/java/bi/saiku/ossie/StrictVersionTest.java b/src/test/java/bi/saiku/ossie/StrictVersionTest.java new file mode 100644 index 0000000..c5cece9 --- /dev/null +++ b/src/test/java/bi/saiku/ossie/StrictVersionTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Spicule Ltd + * Apache License, Version 2.0. + */ +package bi.saiku.ossie; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import bi.saiku.ossie.model.OssieDocument; +import org.junit.jupiter.api.Test; + +class StrictVersionTest { + + private static final String MINIMAL_TEMPLATE = + """ + version: %s + semantic_model: + - name: T + datasets: + - name: d + source: s.d + """; + + @Test + void permissiveModeAcceptsAnyVersion() throws Exception { + OssieDocument doc = new OssieYamlReader().readString(MINIMAL_TEMPLATE.formatted("9.9.9")); + // Permissive read accepts unknown version — Jackson deserialises it verbatim. + assertEquals("9.9.9", doc.getVersion()); + } + + @Test + void strictModeAcceptsEveryKnownVersion() throws Exception { + OssieYamlReader reader = new OssieYamlReader().setStrictVersion(true); + for (String v : OssieYamlReader.SUPPORTED_VERSIONS) { + OssieDocument doc = reader.readString(MINIMAL_TEMPLATE.formatted(v)); + assertEquals(v, doc.getVersion(), "supported version " + v + " should parse in strict mode"); + } + } + + @Test + void strictModeRejectsUnknownVersion() { + OssieYamlReader reader = new OssieYamlReader().setStrictVersion(true); + UnsupportedOssieVersionException ex = assertThrows( + UnsupportedOssieVersionException.class, + () -> reader.readString(MINIMAL_TEMPLATE.formatted("99.99.99"))); + assertEquals("99.99.99", ex.getObservedVersion()); + assertTrue(ex.getSupportedVersions().contains("0.1.1")); + // The message names both the offending version and the supported set. + assertTrue(ex.getMessage().contains("99.99.99")); + assertTrue(ex.getMessage().contains("0.1.1")); + } + + @Test + void strictModeRejectsMissingVersion() { + // A document with no `version:` at all gets the DTO default in permissive mode + // (0.2.0.dev0) — which happens to be in the supported set, so this scenario would false- + // negative in the "reject" test. To exercise the null-version branch, force a null. + OssieYamlReader reader = new OssieYamlReader().setStrictVersion(true); + UnsupportedOssieVersionException ex = + new UnsupportedOssieVersionException(null, OssieYamlReader.SUPPORTED_VERSIONS); + assertEquals(null, ex.getObservedVersion()); + assertTrue(ex.getMessage().contains("no version declared")); + } + + @Test + void supportedVersionsIsExposed() { + assertNotNull(OssieYamlReader.SUPPORTED_VERSIONS); + assertTrue(OssieYamlReader.SUPPORTED_VERSIONS.contains("0.1.1")); // dbt 1.12 target + assertTrue(OssieYamlReader.SUPPORTED_VERSIONS.contains("0.2.0.dev0")); // apache/ossie head + } + + @Test + void strictSettingIsFluent() { + OssieYamlReader reader = new OssieYamlReader(); + // Fluent return so callers can chain: new OssieYamlReader().setStrictVersion(true).readString(...) + assertEquals(reader, reader.setStrictVersion(true)); + assertEquals(reader, reader.setStrictVersion(false)); + } +}