From a547403bba83c5f10e3437a711ab058a0b7848ef Mon Sep 17 00:00:00 2001 From: Tom Barber Date: Wed, 8 Jul 2026 21:52:38 -0400 Subject: [PATCH] feat: parse + emit the top-level ontology block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1. Adds four DTOs under bi.saiku.ossie.model.ontology mirroring the apache/ossie wire shape: - OntologyEntry { concept, relationships } - OntologyConcept { name, type, identify_by } - OntologyRelationship { name, roles, verbalizes, multiplicity, derived_by } - OntologyRole { concept } OssieDocument gains an ontology field with @JsonProperty("ontology") and @JsonPropertyOrder places it before semantic_model to match the flights.yaml shape. String not enum for type/multiplicity so unknown spec values round-trip without dragging library bumps into every consumer. Four tests (24 total across the module): - readsOntologyBlockFromYaml — deep read of concepts + relationships - writesOntologyBlockToYaml — wire fields survive emission including derived_by expressions - ontologyRoundTripsWithoutLoss — two passes produce structurally identical DTO trees - documentsWithoutOntologyBlockRoundTripCleanly — pre-ontology documents still work README gains a section showing doc.getOntology() traversal. --- README.md | 20 +++ .../bi/saiku/ossie/model/OssieDocument.java | 24 ++- .../ossie/model/ontology/OntologyConcept.java | 54 ++++++ .../ossie/model/ontology/OntologyEntry.java | 49 ++++++ .../model/ontology/OntologyRelationship.java | 81 +++++++++ .../ossie/model/ontology/OntologyRole.java | 25 +++ .../bi/saiku/ossie/OntologyBlockTest.java | 157 ++++++++++++++++++ 7 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 src/main/java/bi/saiku/ossie/model/ontology/OntologyConcept.java create mode 100644 src/main/java/bi/saiku/ossie/model/ontology/OntologyEntry.java create mode 100644 src/main/java/bi/saiku/ossie/model/ontology/OntologyRelationship.java create mode 100644 src/main/java/bi/saiku/ossie/model/ontology/OntologyRole.java create mode 100644 src/test/java/bi/saiku/ossie/OntologyBlockTest.java diff --git a/README.md b/README.md index c5c163a..c9fa53d 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,26 @@ String canonical = OssieSynonymIndex.resolve("revenue", metrics); // → "net_revenue" (assuming the metric declared that synonym) ``` +Walk the knowledge-graph half of the spec — concepts, their +attributes, and their associations to other concepts. Useful for +consumers that want an entity-level view of the model (an LLM +prompt-builder that names the entities before drilling into their +SQL representation, or a schema browser that surfaces the concept +graph alongside the tables): + +```java +for (var entry : doc.getOntology()) { + var concept = entry.getConcept(); + System.out.println("concept: " + concept.getName() + + " (id-by: " + concept.getIdentifyBy() + ")"); + for (var rel : entry.getRelationships()) { + System.out.println(" " + rel.getName() + " → " + + rel.getRoles().get(0).getConcept() + + " (" + rel.getMultiplicity() + ")"); + } +} +``` + Reject documents at unknown spec versions — useful in CI or when you want to fail fast on drift: diff --git a/src/main/java/bi/saiku/ossie/model/OssieDocument.java b/src/main/java/bi/saiku/ossie/model/OssieDocument.java index afe9c04..648e426 100644 --- a/src/main/java/bi/saiku/ossie/model/OssieDocument.java +++ b/src/main/java/bi/saiku/ossie/model/OssieDocument.java @@ -4,6 +4,7 @@ */ package bi.saiku.ossie.model; +import bi.saiku.ossie.model.ontology.OntologyEntry; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -19,7 +20,7 @@ * up the value from the emitted YAML to route to the right parser. */ @JsonInclude(JsonInclude.Include.NON_EMPTY) -@JsonPropertyOrder({"version", "semantic_model"}) +@JsonPropertyOrder({"version", "ontology", "semantic_model"}) public class OssieDocument { /** * Ossie spec version this document conforms to. Bump when the exporter starts emitting @@ -51,6 +52,19 @@ public class OssieDocument { @JsonProperty("ontology_mappings") private List ontologyMappings = new ArrayList<>(); + /** + * Top-level {@code ontology:} block from OSI v0.2.x. Each entry pairs a concept (named entity) + * with its out-bound relationships — attributes to primitive types, associations to other + * concepts, and derived relationships. Unlike {@code semantic_model:} (SQL-adjacent) and + * {@code ontology_mappings:} (bridging), this block is the pure knowledge-graph shape and + * can appear standalone in documents produced by ontology-first modelling tools. + * + *

Empty for documents that don't declare an ontology — Jackson's {@code NON_EMPTY} + * inclusion drops the field on serialisation in that case. + */ + @JsonProperty("ontology") + private List ontology = new ArrayList<>(); + public String getVersion() { return version; } @@ -75,6 +89,14 @@ public void setOntologyMappings(List v) { this.ontologyMappings = v == null ? new ArrayList<>() : v; } + public List getOntology() { + return ontology; + } + + public void setOntology(List v) { + this.ontology = v == null ? new ArrayList<>() : v; + } + /** * Return every semantic model the document publishes — the top-level {@code semantic_model:} * entries plus any nested inside {@code ontology_mappings[*].semantic_model:}. Consumers diff --git a/src/main/java/bi/saiku/ossie/model/ontology/OntologyConcept.java b/src/main/java/bi/saiku/ossie/model/ontology/OntologyConcept.java new file mode 100644 index 0000000..27a8557 --- /dev/null +++ b/src/main/java/bi/saiku/ossie/model/ontology/OntologyConcept.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Spicule Ltd + * Apache License, Version 2.0. + */ +package bi.saiku.ossie.model.ontology; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.ArrayList; +import java.util.List; + +/** + * A named entity in the OSI ontology. + * + *

{@code type} is a string in the wire format (typically {@code EntityType}, but the OSI draft + * leaves room for other kinds — {@code AttributeType} etc. — so the model round-trips whatever + * value is present rather than pinning an enum). + * + *

{@code identify_by} lists the relationship names on this concept that together identify a + * unique instance (analogous to a primary key on a table). Empty when the concept is a value type + * without an intrinsic identifier. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class OntologyConcept { + private String name; + private String type; + + @JsonProperty("identify_by") + private List identifyBy = new ArrayList<>(); + + public String getName() { + return name; + } + + public void setName(String v) { + this.name = v; + } + + public String getType() { + return type; + } + + public void setType(String v) { + this.type = v; + } + + public List getIdentifyBy() { + return identifyBy; + } + + public void setIdentifyBy(List v) { + this.identifyBy = v == null ? new ArrayList<>() : v; + } +} diff --git a/src/main/java/bi/saiku/ossie/model/ontology/OntologyEntry.java b/src/main/java/bi/saiku/ossie/model/ontology/OntologyEntry.java new file mode 100644 index 0000000..9c98119 --- /dev/null +++ b/src/main/java/bi/saiku/ossie/model/ontology/OntologyEntry.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Spicule Ltd + * Apache License, Version 2.0. + */ +package bi.saiku.ossie.model.ontology; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.ArrayList; +import java.util.List; + +/** + * One entry in the top-level {@code ontology:} list. Each entry pairs a {@link OntologyConcept} + * with the relationships out-bound from it — attributes to primitive concepts (String, Float), + * associations to other named entities, or derived relationships. Both members are optional so + * partial documents round-trip; a well-formed OSI ontology entry declares both. + * + *

The nested shape follows the apache/ossie flights example verbatim: + * + *

{@code
+ * ontology:
+ *   - concept: { name: Example_Runway, type: EntityType, identify_by: [id] }
+ *     relationships:
+ *       - name: id
+ *         roles: [ { concept: String } ]
+ *         verbalizes: [ '{Example_Runway} id {String}' ]
+ *         multiplicity: ManyToOne
+ * }
+ */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class OntologyEntry { + private OntologyConcept concept; + private List relationships = new ArrayList<>(); + + public OntologyConcept getConcept() { + return concept; + } + + public void setConcept(OntologyConcept v) { + this.concept = v; + } + + public List getRelationships() { + return relationships; + } + + public void setRelationships(List v) { + this.relationships = v == null ? new ArrayList<>() : v; + } +} diff --git a/src/main/java/bi/saiku/ossie/model/ontology/OntologyRelationship.java b/src/main/java/bi/saiku/ossie/model/ontology/OntologyRelationship.java new file mode 100644 index 0000000..8ef7ac4 --- /dev/null +++ b/src/main/java/bi/saiku/ossie/model/ontology/OntologyRelationship.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Spicule Ltd + * Apache License, Version 2.0. + */ +package bi.saiku.ossie.model.ontology; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.ArrayList; +import java.util.List; + +/** + * An out-bound relationship from one {@link OntologyConcept} to one or more targets. + * + *

Covers three shapes that all sit under the same wire structure in OSI: + * + *

    + *
  • Attributes — relationships whose {@link OntologyRole#getConcept() role concept} + * is a primitive type ({@code String}, {@code Float}, ...). One role, one verbalize line, + * usually {@code ManyToOne} multiplicity. + *
  • Associations — relationships whose role concept is another named entity. Same + * shape, but the role concept is the target's {@code name}. + *
  • Derived — relationships that declare a {@code derived_by} expression pointing at + * already-existing attribute values. The expression is a plain string (typically an equality + * like {@code Runway.airportid == Airport.airportid}); OSI leaves parsing to the consumer. + *
+ * + *

{@code multiplicity} is a string — the spec allows {@code ManyToOne}, {@code OneToMany}, + * {@code ManyToMany}, {@code OneToOne}, but future values would silently round-trip if we pinned + * an enum, so a string is safer. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class OntologyRelationship { + private String name; + private List roles = new ArrayList<>(); + private List verbalizes = new ArrayList<>(); + private String multiplicity; + + @JsonProperty("derived_by") + private List derivedBy = new ArrayList<>(); + + public String getName() { + return name; + } + + public void setName(String v) { + this.name = v; + } + + public List getRoles() { + return roles; + } + + public void setRoles(List v) { + this.roles = v == null ? new ArrayList<>() : v; + } + + public List getVerbalizes() { + return verbalizes; + } + + public void setVerbalizes(List v) { + this.verbalizes = v == null ? new ArrayList<>() : v; + } + + public String getMultiplicity() { + return multiplicity; + } + + public void setMultiplicity(String v) { + this.multiplicity = v; + } + + public List getDerivedBy() { + return derivedBy; + } + + public void setDerivedBy(List v) { + this.derivedBy = v == null ? new ArrayList<>() : v; + } +} diff --git a/src/main/java/bi/saiku/ossie/model/ontology/OntologyRole.java b/src/main/java/bi/saiku/ossie/model/ontology/OntologyRole.java new file mode 100644 index 0000000..26f690c --- /dev/null +++ b/src/main/java/bi/saiku/ossie/model/ontology/OntologyRole.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Spicule Ltd + * Apache License, Version 2.0. + */ +package bi.saiku.ossie.model.ontology; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * The target of an {@link OntologyRelationship}. Almost always contains a single {@code concept} + * field naming either a primitive type ({@code String}, {@code Float}, ...) or another + * {@link OntologyConcept#getName() named concept} in the same ontology. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class OntologyRole { + private String concept; + + public String getConcept() { + return concept; + } + + public void setConcept(String v) { + this.concept = v; + } +} diff --git a/src/test/java/bi/saiku/ossie/OntologyBlockTest.java b/src/test/java/bi/saiku/ossie/OntologyBlockTest.java new file mode 100644 index 0000000..83b8034 --- /dev/null +++ b/src/test/java/bi/saiku/ossie/OntologyBlockTest.java @@ -0,0 +1,157 @@ +/* + * 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.assertTrue; + +import bi.saiku.ossie.model.OssieDocument; +import bi.saiku.ossie.model.ontology.OntologyEntry; +import org.junit.jupiter.api.Test; + +/** + * Round-trips a real ontology block through the reader + writer to prove the DTO shape matches the + * apache/ossie wire format. Anchor fixture is the runway → airport shape from the flights example. + */ +class OntologyBlockTest { + + private static final String YAML = + """ + version: 0.2.0.dev0 + ontology: + - concept: + name: Example_Runway + type: EntityType + identify_by: [id] + relationships: + - name: id + roles: + - concept: String + verbalizes: ['{Example_Runway} id {String}'] + multiplicity: ManyToOne + - name: length + roles: + - concept: Float + verbalizes: ['{Example_Runway} length {Float}'] + multiplicity: ManyToOne + - name: airports + roles: + - concept: Example_Airport + verbalizes: ['{Example_Runway} airports {Example_Airport}'] + multiplicity: ManyToOne + derived_by: ['Example_Runway.airportid == Example_Airport.airportid'] + - concept: + name: Example_Airport + type: EntityType + identify_by: [airportid] + relationships: + - name: airportid + roles: + - concept: String + verbalizes: ['{Example_Airport} airportid {String}'] + multiplicity: ManyToOne + semantic_model: [] + """; + + private final OssieYamlReader reader = new OssieYamlReader(); + private final OssieYamlWriter writer = new OssieYamlWriter(); + + @Test + void readsOntologyBlockFromYaml() throws Exception { + OssieDocument doc = reader.readString(YAML); + assertEquals(2, doc.getOntology().size()); + + OntologyEntry runway = doc.getOntology().get(0); + assertEquals("Example_Runway", runway.getConcept().getName()); + assertEquals("EntityType", runway.getConcept().getType()); + assertEquals(1, runway.getConcept().getIdentifyBy().size()); + assertEquals("id", runway.getConcept().getIdentifyBy().get(0)); + assertEquals(3, runway.getRelationships().size()); + + var idRel = runway.getRelationships().get(0); + assertEquals("id", idRel.getName()); + assertEquals(1, idRel.getRoles().size()); + assertEquals("String", idRel.getRoles().get(0).getConcept()); + assertEquals("ManyToOne", idRel.getMultiplicity()); + assertEquals(1, idRel.getVerbalizes().size()); + assertTrue(idRel.getVerbalizes().get(0).contains("{Example_Runway}")); + assertTrue(idRel.getDerivedBy().isEmpty()); + + var airportsRel = runway.getRelationships().get(2); + assertEquals("airports", airportsRel.getName()); + assertEquals("Example_Airport", airportsRel.getRoles().get(0).getConcept()); + assertEquals(1, airportsRel.getDerivedBy().size()); + assertTrue(airportsRel.getDerivedBy().get(0).contains("airportid")); + } + + @Test + void writesOntologyBlockToYaml() throws Exception { + OssieDocument doc = reader.readString(YAML); + String emitted = writer.writeAsString(doc); + // Structural anchors — the wire fields we care about survive round-trip. + assertTrue(emitted.contains("ontology:"), "top-level ontology key"); + assertTrue(emitted.contains("Example_Runway")); + assertTrue(emitted.contains("Example_Airport")); + assertTrue(emitted.contains("identify_by:")); + assertTrue(emitted.contains("verbalizes:")); + assertTrue(emitted.contains("multiplicity: ManyToOne")); + assertTrue(emitted.contains("derived_by:")); + assertTrue(emitted.contains("Example_Runway.airportid == Example_Airport.airportid")); + } + + @Test + void ontologyRoundTripsWithoutLoss() throws Exception { + OssieDocument first = reader.readString(YAML); + String emitted = writer.writeAsString(first); + OssieDocument second = reader.readString(emitted); + + // Every OntologyEntry and its content survives the second read. + assertEquals(first.getOntology().size(), second.getOntology().size()); + for (int i = 0; i < first.getOntology().size(); i++) { + var a = first.getOntology().get(i); + var b = second.getOntology().get(i); + assertEquals(a.getConcept().getName(), b.getConcept().getName()); + assertEquals(a.getConcept().getType(), b.getConcept().getType()); + assertEquals(a.getConcept().getIdentifyBy(), b.getConcept().getIdentifyBy()); + assertEquals(a.getRelationships().size(), b.getRelationships().size()); + for (int j = 0; j < a.getRelationships().size(); j++) { + var ra = a.getRelationships().get(j); + var rb = b.getRelationships().get(j); + assertEquals(ra.getName(), rb.getName()); + assertEquals(ra.getMultiplicity(), rb.getMultiplicity()); + assertEquals(ra.getVerbalizes(), rb.getVerbalizes()); + assertEquals(ra.getDerivedBy(), rb.getDerivedBy()); + assertEquals(ra.getRoles().size(), rb.getRoles().size()); + for (int k = 0; k < ra.getRoles().size(); k++) { + assertEquals( + ra.getRoles().get(k).getConcept(), + rb.getRoles().get(k).getConcept()); + } + } + } + } + + @Test + void documentsWithoutOntologyBlockRoundTripCleanly() throws Exception { + // A pre-ontology-support document must still parse + emit without dragging the (empty) + // ontology field into the output. NON_EMPTY inclusion handles it, this locks the behaviour. + String pre = + """ + version: 0.2.0.dev0 + semantic_model: + - name: Sales + datasets: + - name: orders + source: public.orders + """; + OssieDocument doc = reader.readString(pre); + assertNotNull(doc.getOntology()); + assertTrue(doc.getOntology().isEmpty()); + String emitted = writer.writeAsString(doc); + assertTrue(!emitted.contains("ontology:") || emitted.contains("ontology: []")); + // Safe outcome either way — some downstreams accept ontology: [], others expect it absent. + } +}