How to keep OWL ontologies, SHACL shapes and ShEx schemas next to your data in one RDF dataset, say which schema governs which data, and validate data graphs against them.
Every example below is runnable: the files are in examples/hr, and the outputs are the ones Ontobug prints.
- 1. Concepts
- 2. Install and first run
- 3. The registry (Ladybug)
- 4. Adding an OWL ontology
- 5. Imports between ontologies
- 6. Adding SHACL shapes
- 7. Mapping schemas to data graphs
- 8. Validating a graph with SHACL
- 9. Validating with ShEx
- 10. Checking the registry
- 11. Lifecycle: versions, drift, deactivation, removal
- 12. Cypher recipes
- 13. Using another SHACL processor
- 14. Rust API
- 15. Database tour: tables and traversal
- 16. Compatibility and limits
A dataset is a set of named graphs. Ontobug gives each graph a job:
| Kind of graph | Holds | Example |
|---|---|---|
| Data graph | Instance data | <https://example.org/data/staff> |
| Ontology graph | RDFS / OWL axioms: classes, properties, hierarchies | <https://example.org/onto/hr> |
| Shapes graph | SHACL shapes: constraints on the data | <https://example.org/shapes/hr> |
| ShEx graph | A ShEx schema | <https://example.org/shex/hr> |
| Registry | One SchemaEntry node per schema graph, in Ladybug |
MATCH (e:SchemaEntry) RETURN e.graph, e.role |
The registry lives in Ladybug. It records, for each schema graph:
- its role: ontology, SHACL or ShEx;
- the data graphs it applies to, or all graphs;
- whether it is active;
- the ontology IRI, a pinned version, the SHA-256 of the file it came from, imports, and when it was loaded.
Because the registry is a property graph, you query and change it with Cypher — ontobug cypher runs it, or use any Ladybug client against the .lbdb file.
┌────────────────── Ladybug registry ──────────────────┐
│ (:SchemaEntry {graph: onto/core, role: ontology, │
│ appliesTo: [data/other]}) │
│ (:SchemaEntry {graph: onto/hr, role: ontology, │
│ appliesTo: [data/staff], │
│ ontologyIri: hr#}) │
│ (:SchemaEntry {graph: shapes/hr, role: shacl, │
│ appliesTo: [data/staff]}) │
└─────────────────────────────────────────────────────┘
│ scope of data/staff = hr + core (import) + shapes/hr
▼
data/staff ──validate──▶ SHACL engine (rudof) ──▶ report
Registering a graph never moves, copies or rewrites its triples. The registry only describes it.
git clone <this repo> ontobug && cd ontobug
cargo install --path . # installs the `ontobug` binaryEvery command works on one dataset, and the dataset is the Ladybug database, given with --ladybug (-l) or ONTOBUG_LADYBUG (default: the dataset stem with .lbdb, so hr.trig goes with hr.lbdb). It holds every triple losslessly as RdfNode/Statement nodes and edges, plus the registry and the projected schemas. --dataset (-d, ONTOBUG_DATASET, default dataset.trig) is the TriG/N-Quads snapshot: each command that changes content rewrites it atomically, and opening with an empty database seeds from it.
The whole HR walkthrough in one go:
cd examples/hr
export ONTOBUG_DATASET=hr.trig
# …which puts the registry in hr.lbdb next to it.
ontobug add ontology https://example.org/onto/core --file core.ttl \
--applies-to https://example.org/data/other
ontobug add ontology https://example.org/onto/hr --file hr.ttl \
-t https://example.org/data/staff -t https://example.org/data/staff-broken
ontobug add shacl https://example.org/shapes/hr --file hr-shapes.ttl \
-t https://example.org/data/staff -t https://example.org/data/staff-broken
ontobug load https://example.org/data/staff --file staff.ttl
ontobug load https://example.org/data/staff-broken --file staff-broken.ttl
ontobug list
ontobug validate https://example.org/data/staff # conforms: yes, exit 0
ontobug validate https://example.org/data/staff-broken # 10 violations, exit 1Graph arguments take an IRI, or DEFAULT for the default graph. In --applies-to and map --to, ALL means every graph.
After the walkthrough, the registry holds one SchemaEntry node per registration:
$ ontobug list
ontology active <https://example.org/onto/hr> -> <https://example.org/data/staff> <https://example.org/data/staff-broken> version=https://example.org/hr/2.1.0 ontology=<https://example.org/hr#>
shacl active <https://example.org/shapes/hr> -> <https://example.org/data/staff> <https://example.org/data/staff-broken>
$ ontobug cypher "MATCH (e:SchemaEntry) RETURN e.graph, e.role, e.appliesTo, e.version, e.active ORDER BY e.graph, e.role"
https://example.org/onto/core\tontology\t["https://example.org/data/other"]\t1.0.0\tTrue
https://example.org/onto/hr\tontology\t["https://example.org/data/staff","https://example.org/data/staff-broken"]\thttps://example.org/hr/2.1.0\tTrue
https://example.org/shapes/hr\tshacl\t["https://example.org/data/staff","https://example.org/data/staff-broken"]\t\tTrueEach node carries the registration: graph, role (ontology, shacl or shex),
ontologyIri, version, sha256, active, loadedAt, and the appliesTo and
imports lists as JSON arrays. An empty appliesTo means every graph.
The schema's content stays in its RDF graph; the registry only describes it:
<https://example.org/onto/hr> {
<https://example.org/hr#> a owl:Ontology ; owl:imports <https://example.org/core#> .
hr:Employee a owl:Class ; rdfs:subClassOf core:Person .
# …
}
One graph, several roles. An ontology that carries its own SHACL shapes gets one node
per role, so scoping counts it once per role. ontobug add writes one role; register the
same graph again with another role to add the second:
ontobug add ontology https://example.org/onto/hr --file hr.ttl -t https://example.org/data/staff
ontobug add shacl https://example.org/onto/hr --file hr-shapes.ttl -t https://example.org/data/staffThe registry says which graphs hold schemas. The schemas themselves are projected
into Ladybug as a property graph — add does this automatically for ontology and shapes
graphs, and ontobug project GRAPH rebuilds a graph's projection:
$ ontobug project https://example.org/onto/hr
projected <https://example.org/onto/hr>: 5 classes, 6 properties, 10 edges, 0 shapes, 0 constraints, 8 links(The 5 classes include stubs for core:Person and core:Organization, which this
graph references but only the core graph defines — project it too and the stubs give
way to shared nodes.)
- Ontology classes and properties become
OntologyClass/OntologyPropertynodes (IRI, label, comment, owning graph), withSUBCLASS_OF,EQUIVALENT_CLASS,DOMAINandRANGEedges. References to classes no graph defines (anowl:equivalentClassto an external ontology) become labelled-empty stubs, so edges stay connected. Datatype ranges (xsd:string, …) are kept as arangeDatatypeslist on the property. - SHACL shapes become
ShaclShapenodes withShaclPropertyconstraint nodes (path, datatype, class, counts, pattern, message, …) overHAS_CONSTRAINTedges. TARGETSlinks shapes to theOntologyClassnodes of their target classes (whatever graph defined them), andON_PROPERTYlinks constraints to theirOntologyPropertynodes — rebuilt over all shapes after every projection, since rebuilding a graph deletes the class nodes its shapes pointed at.
So the class hierarchy and the shape-to-property paths are queryable (see §12).
Unregistering keeps a graph's projection with its triples; remove --drop deletes both.
Namespaces partition the projection the way they partition RDF: every content node
carries its namespace (the term IRI up to its last # or /), and Namespace nodes
inventory each one with its prefix, term count and source graphs:
$ ontobug namespaces
core https://example.org/core# 5 terms https://example.org/onto/core https://example.org/shapes/hr
hr https://example.org/hr# 14 terms https://example.org/onto/hr https://example.org/shapes/hr
hrsh https://example.org/hr/shapes# 3 terms https://example.org/shapes/hrPrefixes come from the documents' @prefix declarations when present
(@prefix core: above; the shapes' own @prefix hrsh: similarly wins over a derived
name), and are derived from the IRI otherwise. Query a namespace as a subgraph
(§12). Databases written before namespaces existed are migrated
automatically on open.
Any RDFS or OWL document works: Turtle, N-Triples, RDF/XML (.rdf, .owl), N3. Give it an owl:Ontology header. Ontobug reads the IRI and version from it. examples/hr/core.ttl:
@prefix core: <https://example.org/core#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
<https://example.org/core#> a owl:Ontology ;
rdfs:label "Core ontology" ;
owl:versionInfo "1.0.0" .
core:Agent a owl:Class ; rdfs:label "Agent" .
core:Person a owl:Class ; rdfs:subClassOf core:Agent ; rdfs:label "Person" .
core:Organization a owl:Class ; rdfs:subClassOf core:Agent ; rdfs:label "Organization" .
core:Person owl:disjointWith core:Organization .
core:name a owl:DatatypeProperty ;
rdfs:domain core:Agent ;
rdfs:range xsd:string .examples/hr/hr.ttl builds on it and imports it:
@prefix hr: <https://example.org/hr#> .
@prefix core: <https://example.org/core#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
<https://example.org/hr#> a owl:Ontology ;
owl:versionIRI <https://example.org/hr/2.1.0> ;
owl:versionInfo "2.1.0" ;
owl:imports <https://example.org/core#> .
hr:Employee a owl:Class ; rdfs:subClassOf core:Person .
hr:Manager a owl:Class ; rdfs:subClassOf hr:Employee .
hr:Contractor a owl:Class ; rdfs:subClassOf core:Person ; owl:disjointWith hr:Employee .
hr:worksFor a owl:ObjectProperty , owl:FunctionalProperty ;
rdfs:domain hr:Employee ; rdfs:range core:Organization .
hr:manages a owl:ObjectProperty ;
rdfs:domain hr:Manager ; rdfs:range hr:Employee ; owl:inverseOf hr:managedBy .
hr:email a owl:DatatypeProperty ; rdfs:domain core:Person ; rdfs:range xsd:string .
hr:salary a owl:DatatypeProperty , owl:FunctionalProperty ;
rdfs:domain hr:Employee ; rdfs:range xsd:decimal .
hr:hireDate a owl:DatatypeProperty , owl:FunctionalProperty ;
rdfs:domain hr:Employee ; rdfs:range xsd:date .Keep ontology and data apart. Put axioms (rdfs:subClassOf, domains, ranges) in the ontology graph and instances in data graphs. Then several datasets can share one ontology, and you can update the ontology without touching the data.
$ ontobug add ontology https://example.org/onto/hr --file hr.ttl \
-t https://example.org/data/staff -t https://example.org/data/staff-broken
registered <https://example.org/onto/hr> (41 triples)
ontology active <https://example.org/onto/hr> -> <https://example.org/data/staff-broken> <https://example.org/data/staff> version=https://example.org/hr/2.1.0 ontology=<https://example.org/hr#>add does four things in one go:
- Clears the graph and loads the file into it.
- Reads the header. If the graph declares exactly one
owl:Ontologywhose IRI differs from the graph name, it records it asontologyIri. If you give no--version, it pinsowl:versionIRI, or elseowl:versionInfo. - Records the file's SHA-256 as
sha256, for drift detection (see §11). - Writes the registration, replacing any earlier description of the graph.
Options:
| Option | Effect |
|---|---|
-t, --applies-to G |
A data graph it governs; repeatable. ALL, or no option at all, means every graph |
--version V |
Pin a version instead of the one read from the header |
--import IRI |
Record an import in the registry (imports); repeatable |
--inactive |
Register it switched off |
Naming the graph. The graph name and the ontology IRI can be the same (ontobug add ontology https://example.org/hr# …). A separate graph name such as …/onto/hr lets you load two versions of one ontology side by side, as …/onto/hr/2.0 and …/onto/hr/2.1.
During SHACL validation, the ontologies in scope for a data graph join the data graph. Ontobug also writes out the rdf:type triples they entail through rdfs:subClassOf and owl:equivalentClass. That is what makes sh:targetClass core:Person reach an hr:Manager (see §8.3).
Ontobug does not run a full OWL reasoner and does not check OWL consistency. To use the ontologies for query-time reasoning, export the graphs (ontobug export) to an OWL reasoner.
An ontology's scope includes the active, registered ontologies it imports, transitively. Import cycles are safe.
Where imports come from:
owl:importsasserted inside the ontology's own graph, as inhr.ttlabove;importsrecorded in the registry (--import);owl:importsrecorded in the registry. Older registries of vocabulary 2 used it, and it is still read.
What an import matches: a registered ontology's graph name, or its ontologyIri. hr.ttl imports <https://example.org/core#>. That is the ontologyIri of the graph <https://example.org/onto/core>, so core joins the scope:
$ ontobug scope https://example.org/data/staff
ontology <https://example.org/onto/hr>
ontology <https://example.org/onto/core>
shacl <https://example.org/shapes/hr>Core itself applies only to data/other. It reaches data/staff only through the import.
What an import does not do: fetch anything from the web. An import that matches no registered ontology is recorded and ignored. Load and register the imported ontology yourself:
ontobug add ontology http://xmlns.com/foaf/0.1/ --file foaf.rdf -t https://example.org/data/otherAn inactive ontology is never imported:
$ ontobug deactivate https://example.org/onto/core
$ ontobug scope https://example.org/data/staff
ontology <https://example.org/onto/hr>
shacl <https://example.org/shapes/hr>Why the registry uses its own imports and not owl:imports. The domain of owl:imports is owl:Ontology, so in an RDF registry every registration would read as an ontology. Ladybug nodes carry no such entailment, but the split is kept: owl:imports asserted inside an ontology's graph, imports recorded about it in the registry.
A shapes graph holds sh:NodeShapes. Each shape has targets, which select focus nodes, and constraints, usually on property paths. examples/hr/hr-shapes.ttl:
@prefix hrsh: <https://example.org/hr/shapes#> .
@prefix hr: <https://example.org/hr#> .
@prefix core: <https://example.org/core#> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
# Every person has exactly one name and well-formed e-mail addresses.
hrsh:PersonShape a sh:NodeShape ;
sh:targetClass core:Person ;
sh:property [
sh:path core:name ;
sh:datatype xsd:string ;
sh:minCount 1 ;
sh:maxCount 1 ;
sh:message "A person has exactly one name (xsd:string)."
] ;
sh:property [
sh:path hr:email ;
sh:datatype xsd:string ;
sh:pattern "^[^@\\s]+@[^@\\s]+\\.[a-z]{2,}$" ;
sh:message "E-mail addresses look like name@domain.tld."
] .
# Every employee works for exactly one organization and has a non-negative salary.
hrsh:EmployeeShape a sh:NodeShape ;
sh:targetClass hr:Employee ;
sh:property [
sh:path hr:worksFor ;
sh:class core:Organization ;
sh:minCount 1 ;
sh:maxCount 1 ;
sh:message "An employee works for exactly one organization."
] ;
sh:property [
sh:path hr:salary ;
sh:datatype xsd:decimal ;
sh:maxCount 1 ;
sh:minInclusive 0 ;
sh:message "Salary is one non-negative xsd:decimal."
] ;
sh:property [ sh:path hr:hireDate ; sh:datatype xsd:date ; sh:maxCount 1 ] .
# A manager manages at least one employee.
hrsh:ManagerShape a sh:NodeShape ;
sh:targetClass hr:Manager ;
sh:property [
sh:path hr:manages ;
sh:class hr:Employee ;
sh:minCount 1 ;
sh:message "A manager manages at least one employee."
] .Targets say which nodes a shape checks:
| Target | Focus nodes |
|---|---|
sh:targetClass C |
Instances of C and of its subclasses (through rdfs:subClassOf in the data graph) |
sh:targetNode n |
The node n |
sh:targetSubjectsOf p |
Every subject of a p triple |
sh:targetObjectsOf p |
Every object of a p triple |
A shape that is also an rdfs:Class |
Its own instances (an implicit class target) |
Constraints say what must hold:
| Constraint | Example |
|---|---|
| Cardinality | sh:minCount 1 ; sh:maxCount 1 |
| Value type | sh:datatype xsd:date, sh:class core:Organization, sh:nodeKind sh:IRI |
| Value range | sh:minInclusive 0, sh:maxExclusive 100 |
| Strings | sh:pattern "^[A-Z]{2}$", sh:minLength 2, sh:languageIn ("en" "de") |
| Enumerations | sh:in ( "draft" "final" ), sh:hasValue hr:Active |
| Nested shapes | sh:node hrsh:AddressShape |
| Logic | sh:or ( [ sh:datatype xsd:date ] [ sh:datatype xsd:dateTime ] ), sh:not, sh:and, sh:xone |
| Pairs | sh:equals, sh:disjoint, sh:lessThan |
| Closed shapes | sh:closed true ; sh:ignoredProperties ( rdf:type ) |
| Paths | sh:path ( hr:worksFor core:name ), sh:path [ sh:inversePath hr:manages ] |
Reporting:
sh:messagesets the text of a result.sh:severity sh:Warning(orsh:Info) turns a violation into a warning. Only results of severitysh:Violationmake a graph non-conforming.sh:deactivated trueswitches a single shape off.
Gotcha: sh:datatype is exact. In Turtle, -10 is an xsd:integer, and 120000.00 is an xsd:decimal. SHACL compares datatype IRIs and does not follow XSD derivation, so sh:datatype xsd:decimal rejects an integer. Write -10.0, or use sh:or over both datatypes.
$ ontobug add shacl https://example.org/shapes/hr --file hr-shapes.ttl \
-t https://example.org/data/staff -t https://example.org/data/staff-broken
registered <https://example.org/shapes/hr> (38 triples)
shacl active <https://example.org/shapes/hr> -> <https://example.org/data/staff-broken> <https://example.org/data/staff>Several shapes graphs can apply to one data graph: Ontobug validates against their union. A common setup is one organisation-wide shapes graph applied to every graph, plus a stricter one per dataset:
ontobug add shacl https://example.org/shapes/common --file common.ttl # every graph
ontobug add shacl https://example.org/shapes/payroll --file payroll.ttl -t https://example.org/data/payrollappliesTo says which data a schema describes:
- An empty
appliesTolist: every graph (ALLon the CLI). - One or more graph IRIs, or
DEFAULT: only those graphs.
Change only the targets, keeping roles, version and everything else:
ontobug map https://example.org/shapes/hr --to https://example.org/data/staff DEFAULT
ontobug map https://example.org/shapes/hr --to ALLPer-graph mapping lets conflicting schemas live side by side:
MATCH (e:SchemaEntry)
WHERE e.graph IN ['https://ex.org/onto/zoo', 'https://ex.org/onto/garden',
'https://ex.org/onto/common']
RETURN e.graph, e.appliesTo;
// https://ex.org/onto/zoo ["https://ex.org/data/zoo"]
// https://ex.org/onto/garden ["https://ex.org/data/garden"]
// https://ex.org/onto/common [] <- every graphHere a ex:Dog in the zoo graph is read with the zoo and common ontologies. A ex:Dog in the garden graph is read with the garden and common ones.
$ ontobug validate https://example.org/data/staff
data: <https://example.org/data/staff>
shapes: <https://example.org/shapes/hr>
ontologies: <https://example.org/onto/hr> <https://example.org/onto/core>
conforms: yesstaff-broken.ttl has deliberate mistakes:
ex:acme a core:Organization ; core:name "ACME Corp" .
# A manager with no explicit hr:Employee type: the ontology makes it one.
ex:carol a hr:Manager ;
core:name "Carol" ;
hr:email "carol(at)acme" ;
hr:salary -10 .
ex:dave a hr:Employee ;
hr:worksFor ex:acme , ex:globex ;
hr:salary "a lot" .
ex:globex core:name "Globex" .$ ontobug validate https://example.org/data/staff-broken
data: <https://example.org/data/staff-broken>
shapes: <https://example.org/shapes/hr>
ontologies: <https://example.org/onto/hr> <https://example.org/onto/core>
conforms: no (10 results)
- Violation https://example.org/people/carol https://example.org/hr#email = "carol(at)acme"
sh:PatternConstraintComponent
E-mail addresses look like name@domain.tld.
- Violation https://example.org/people/carol https://example.org/hr#manages
sh:MinCountConstraintComponent
A manager manages at least one employee.
- Violation https://example.org/people/carol https://example.org/hr#salary = "-10"^^xsd:integer
sh:DatatypeConstraintComponent
Salary is one non-negative xsd:decimal.
- Violation https://example.org/people/carol https://example.org/hr#salary = "-10"^^xsd:integer
sh:MinInclusiveConstraintComponent
Salary is one non-negative xsd:decimal.
- Violation https://example.org/people/carol https://example.org/hr#worksFor
sh:MinCountConstraintComponent
An employee works for exactly one organization.
- Violation https://example.org/people/dave https://example.org/core#name
sh:MinCountConstraintComponent
A person has exactly one name (xsd:string).
- Violation https://example.org/people/dave https://example.org/hr#salary = "a lot"
sh:DatatypeConstraintComponent
Salary is one non-negative xsd:decimal.
- Violation https://example.org/people/dave https://example.org/hr#salary = "a lot"
sh:MinInclusiveConstraintComponent
Salary is one non-negative xsd:decimal.
- Violation https://example.org/people/dave https://example.org/hr#worksFor = https://example.org/people/globex
sh:ClassConstraintComponent
An employee works for exactly one organization.
- Violation https://example.org/people/dave https://example.org/hr#worksFor
sh:MaxCountConstraintComponent
An employee works for exactly one organization.Each line is one SHACL validation result: severity, focus node, path, the offending value, the constraint component, and the shape's sh:message.
Exit codes: 0 the graph conforms, 1 it does not, 2 an error (bad arguments, unreadable shapes, no shapes in scope). That makes validate usable as a CI gate:
ontobug -d release.trig validate https://example.org/data/staff --output json > report.json{
"conforms": false,
"dataGraph": "https://example.org/data/staff-broken",
"ontologyGraphs": ["https://example.org/onto/hr", "https://example.org/onto/core"],
"results": [
{
"component": "http://www.w3.org/ns/shacl#PatternConstraintComponent",
"focusNode": "https://example.org/people/carol",
"message": "E-mail addresses look like name@domain.tld.",
"path": "https://example.org/hr#email",
"severity": "Violation",
"sourceShape": "_:ea317d33824c3ab49b6453e8975b0fe2",
"value": "\"carol(at)acme\""
}
],
"shapesGraphs": ["https://example.org/shapes/hr"]
}For a data graph D:
- Shapes: every active shapes graph whose
appliesToincludesD, or is empty. Their union is the shapes graph. Pass--shapes G(repeatable) to use named shapes graphs instead, registered or not. If no shapes apply,validatefails with exit code 2. It never guesses. - Ontologies: every active ontology that applies to
D, plus the active ontologies they import, transitively.--no-ontologiesleaves them out. - Data graph:
D's triples, plus the ontologies' triples, plus therdf:typetriples they entail (see §8.3). - Engine: rudof's native SHACL engine.
ontobug scope D prints steps 1 and 2 without validating.
The SHACL spec defines "instance of C" through rdf:type/rdfs:subClassOf* in the data graph. A shape with sh:targetClass core:Person must therefore check every hr:Manager, but only if the data graph knows that hr:Manager rdfs:subClassOf hr:Employee and hr:Employee rdfs:subClassOf core:Person. Those axioms live in the ontology graphs, not in the data.
So Ontobug adds the ontologies in scope to the data graph. It also writes out the entailed types: for every ?x a ?c with ?c (rdfs:subClassOf|owl:equivalentClass|^owl:equivalentClass)+ ?super, it adds ?x a ?super. Every engine then agrees, whatever its own class index does.
Compare the two runs on staff-broken:
| With ontologies (default) | --no-ontologies |
|
|---|---|---|
Carol (a hr:Manager) checked by ManagerShape |
yes | yes |
Carol checked by EmployeeShape (needs hr:worksFor) |
yes, through Manager ⊑ Employee |
no |
Carol checked by PersonShape (e-mail pattern) |
yes, through Employee ⊑ Person |
no |
Dave (a hr:Employee) checked by PersonShape (needs a name) |
yes | no |
| Results | 10 | 5 |
Only class membership is entailed. Domains and ranges are not applied. If they were, hr:salary's domain would silently turn any node with a salary into an hr:Employee, and a missing a hr:Employee would go unnoticed instead of being reported.
use ontobug::{Catalog, ValidateOptions, parse_graph};
let catalog = Catalog::open("hr.trig")?;
let v = catalog.validate(
parse_graph("https://example.org/data/staff-broken")?.as_ref(),
&ValidateOptions::default(),
)?;
if !v.report.conforms {
for r in &v.report.results {
eprintln!("{} {} {:?}: {:?}", r.severity, r.focus, r.path, r.message);
}
}validate::shacl(data_ntriples, shapes, format, mode) validates arbitrary RDF text without a catalog.
ShEx validates chosen nodes against chosen shapes, given by a shape map. examples/hr/person.shex:
PREFIX core: <https://example.org/core#>
PREFIX hr: <https://example.org/hr#>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
PREFIX : <https://example.org/hr/shex#>
:Employee {
core:name xsd:string ;
hr:email xsd:string * ;
hr:worksFor @:Organization ;
hr:salary xsd:decimal ?
}
:Organization {
core:name xsd:string
}
$ ontobug shex https://example.org/data/staff --schema person.shex \
--map '<https://example.org/people/bob>@<https://example.org/hr/shex#Employee>,<https://example.org/people/acme>@<https://example.org/hr/shex#Employee>'
FAIL https://example.org/people/acme@https://example.org/hr/shex#Employee
Shape :Employee failed for node <https://example.org/people/acme>: no candidates matched the expression
└── Candidate [<https://example.org/core#name> "ACME Corp"] rejected: predicate <https://example.org/hr#worksFor> required cardinality {1, 1} but got 0
ok https://example.org/people/bob@https://example.org/hr/shex#EmployeeThe exit code is 1 when any node fails. To keep a ShEx schema in the dataset, register its RDF form (ShExR, in Turtle for example) with ontobug add shex …. It is then recorded and mapped like the other roles. The shex command reads the ShExC text from --schema.
A registration written out of band can be wrong: a digest that is not 64 lowercase
hex digits, or a graph registered twice in one role. ontobug check runs the
structural checks over the Ladybug registry and exits 1 on problems:
$ ontobug check
registry ok (3 registrations)Break one on purpose through Cypher (Ladybug has no constraints of its own, so
anything goes until check runs):
$ ontobug cypher "MATCH (e:SchemaEntry) WHERE e.graph = 'https://example.org/onto/hr' SET e.sha256 = 'ABC' RETURN e.graph"
$ ontobug check
<https://example.org/onto/hr>: sha256 ABC is not 64 lowercase hex digitsFix it by re-adding the graph (add rewrites the registration and re-projects the
content), then check is green again. Run check after any hand edit.
Drift detection. add records the file's SHA-256. verify compares the file you have now with that digest:
$ ontobug verify https://example.org/onto/hr --file hr.ttl
unchanged
$ ontobug verify https://example.org/onto/hr --file hr-edited.ttl
changed: recorded 1aae19e3…, file 658695ac… # exit 1To pick up the change, run add again: it reloads the graph and rewrites the description.
Upgrading an ontology. Load the new version into its own graph, switched off, so both versions stay in the dataset. Then swap them, validate, and swap back if the data does not fit:
ontobug add ontology https://example.org/onto/hr/2.2 --file hr-2.2.ttl --inactive \
-t https://example.org/data/staff
ontobug deactivate https://example.org/onto/hr # still registered, contributes nothing
ontobug activate https://example.org/onto/hr/2.2
ontobug validate https://example.org/data/staff \
|| { ontobug deactivate https://example.org/onto/hr/2.2; ontobug activate https://example.org/onto/hr; }Deactivate or remove:
| Command | Registration | Graph's triples |
|---|---|---|
deactivate G |
Kept, active false. Out of every scope and never imported |
Kept |
activate G |
active true |
Kept |
remove G |
Deleted | Kept, as an ordinary graph |
remove G --drop |
Deleted | Deleted |
map, activate, deactivate and remove fail with exit code 2 on a graph that is not registered.
The registry and the projected schemas are plain Ladybug nodes and edges. Run these
with ontobug cypher, or any Ladybug client against the .lbdb file.
Registrations: the active shapes graphs and what they govern:
MATCH (e:SchemaEntry)
WHERE e.role = 'shacl' AND e.active = true
RETURN e.graph, e.appliesTo ORDER BY e.graph;Class hierarchy: superclasses of a class, transitively:
MATCH (c:OntologyClass)-[:SUBCLASS_OF*1..5]->(s:OntologyClass)
WHERE c.iri = 'https://example.org/hr#Manager'
RETURN s.iri;Properties of a class: everything with a domain (or range) on it:
MATCH (p:OntologyProperty)-[:DOMAIN]->(c:OntologyClass)
WHERE c.iri = 'https://example.org/hr#Employee'
RETURN p.iri, p.kind;Shapes to data, through the ontology: which shapes target a class, and which properties their constraints check:
MATCH (s:ShaclShape)-[:TARGETS]->(c:OntologyClass)<-[:DOMAIN]-(p:OntologyProperty)
WHERE c.iri = 'http://schema.org/Person'
RETURN s.shape, p.iri;Constraint details for one path:
MATCH (p:ShaclProperty) WHERE p.path = 'https://example.org/hr#salary'
RETURN p.datatype, p.minCount, p.maxCount, p.minInclusive, p.message;Stubs: classes referenced but defined nowhere (external imports worth registering):
MATCH (c:OntologyClass) WHERE c.label = '' RETURN c.graph, c.iri;Namespaces: terms of one vocabulary — the subgraph view:
MATCH (c:OntologyClass) WHERE c.namespace = 'http://schema.org/' RETURN count(c);Namespace inventory, same as ontobug namespaces:
MATCH (n:Namespace) RETURN n.prefix, n.iri, n.termCount ORDER BY n.prefix;The dataset is standard RDF, so any SHACL processor can validate it. Export the graphs that ontobug scope names:
ontobug export https://example.org/data/staff-broken > data.nt
ontobug export https://example.org/shapes/hr > shapes.nt
ontobug export https://example.org/onto/hr > hr.nt
ontobug export https://example.org/onto/core > core.ntpySHACL. -e adds an ontology to the data graph, and -i rdfs turns on RDFS inference:
cat hr.nt core.nt > ontologies.nt
pyshacl -s shapes.nt -e ontologies.nt -i rdfs -f human data.nt[dependencies]
ontobug = { git = "…", default-features = false } # no CLI dependenciesuse ontobug::oxigraph::io::RdfFormat;
use ontobug::{AddOptions, Catalog, SchemaRole, ValidateOptions, parse_graph, parse_targets};
let c = Catalog::open("hr.trig", "hr.lbdb")?; // or Catalog::new() for empty in-memory state
let staff = parse_graph("https://example.org/data/staff")?;
c.add(
SchemaRole::Ontology,
parse_graph("https://example.org/onto/hr")?.as_ref(),
&std::fs::read("hr.ttl")?,
RdfFormat::Turtle,
&AddOptions { applies_to: vec![staff.clone()], ..Default::default() },
)?;
c.load(staff.as_ref(), &std::fs::read("staff.ttl")?, RdfFormat::Turtle, true)?;
for e in c.schemas_for(staff.as_ref(), SchemaRole::Ontology)? {
println!("{} {:?}", e.graph, e.version);
}
let v = c.validate(staff.as_ref(), &ValidateOptions::default())?;
assert!(v.report.conforms, "{}", v.report);
c.save("hr.trig")?;Catalog method |
Does |
|---|---|
new, open(data, registry), save(data), registry() |
Create the dataset in Ladybug; seed an empty one from a snapshot; write snapshots; reach the database underneath |
default_registry_path(dataset) |
The .lbdb path for a dataset file |
add(role, graph, bytes, format, &AddOptions) |
Load a schema document as triples, register it, and project it as nodes and edges |
register(&SchemaGraph) |
Write a registration as given |
project_graph_content(g) |
(Re)build a graph's nodes and edges; returns counts |
load(graph, bytes, format, replace) |
Load triples (data or schema) |
graph_ntriples(g), graph_len(g) |
A graph's triples / count, reconstructed from its rows |
list(), is_registered(g), schemas_for(g, role) |
Read the registry; resolve scopes, imports included |
map(g, &targets), set_active(g, bool), unregister(g), drop_graph(g) |
Change registrations (drop_graph also removes the projection and the triples) |
problems() |
Check the registry structurally |
verify(g, bytes) |
Detect drift |
validate(g, &ValidateOptions), validate_shex(g, shexc, base, map) |
Validate from the triple rows |
cypher(cypher), namespaces() |
Run Cypher against the database; list the namespace subgraphs |
Everything lives in one .lbdb: the registry, the projected schemas, every triple,
and the namespace inventory. show_graphs() lists each table as a STANDARD graph —
Ladybug exposes tables that way; query them with plain MATCH:
MATCH (e:SchemaEntry) RETURN e.graph, e.role; -- the registry
MATCH (c:OntologyClass) WHERE c.iri CONTAINS 'Person' RETURN c.iri, c.label;
MATCH (a:RdfNode)-[r:Statement]->(b:RdfNode) -- any triple
WHERE a.id = '<https://example.org/people/alice>'
RETURN r.predicate, b.id;How the tables connect:
SchemaEntry registry: graph, role, targets, version, sha256 …
OntologyClass <--SUBCLASS_OF--> OntologyClass class hierarchy (all graphs,
OntologyClass <--EQUIVALENT_CLASS--> OntologyClass shared IRIs stay connected)
OntologyProperty --DOMAIN/RANGE--> OntologyClass
ShaclShape --HAS_CONSTRAINT--> ShaclProperty --ON_PROPERTY--> OntologyProperty
\--TARGETS--> OntologyClass
RdfNode --Statement--> RdfNode every triple of every graph:
predicate/graph/namespace/ord on the rel
Namespace inventory of the namespaces below
| Table | Kind | Columns / meaning |
|---|---|---|
SchemaEntry |
node | one per registration (id is graph plus role): graph, role (ontology, shacl, shex), ontologyIri, version, sha256, active, loadedAt, appliesTo / imports (JSON; empty appliesTo is every graph) |
OntologyClass |
node | iri, label, comment, owning graph; empty label is a stub: referenced, defined nowhere |
OntologyProperty |
node | iri, kind (object, datatype, annotation, property), label, comment, rangeDatatypes (JSON), graph |
ShaclShape |
node | shape, targetClasses / targetNodes / targetSubjectsOf / targetObjectsOf (JSON), message, severity, deactivated, graph |
ShaclProperty |
node | shapeId, path, datatype, class, nodeKind, minCount, maxCount, pattern, minInclusive, maxInclusive, message, severity, deactivated, graph |
SUBCLASS_OF, EQUIVALENT_CLASS |
rel | class to class |
DOMAIN, RANGE |
rel | property to class |
HAS_CONSTRAINT |
rel | shape to its property constraints |
TARGETS |
rel | shape to the classes of its target classes, whatever graph defined them |
ON_PROPERTY |
rel | constraint to the property of its path |
RdfNode |
node | one per distinct term: id is the N-Triples token (<iri>, _:label, "lex"…), kind (iri, blank, literal), value (bare IRI, blank label, or literal lexicon), datatype, lang, namespace |
Statement |
rel | one per distinct triple: predicate (bare IRI), graph (token or DEFAULT), namespace (subject's), ord (per-graph order, for deterministic dumps) |
Start from the registry to find what governs a data graph, then walk:
-- class hierarchy, transitively, across graphs
MATCH (c:OntologyClass)-[:SUBCLASS_OF*1..5]->(s:OntologyClass)
WHERE c.iri = 'https://example.org/hr#Manager' RETURN s.iri;
-- shape to the properties it checks, through the ontology
MATCH (s:ShaclShape)-[:TARGETS]->(c:OntologyClass)<-[:DOMAIN]-(p:OntologyProperty)
WHERE c.iri = 'http://schema.org/Person' RETURN s.shape, p.iri;
-- triple walks: outgoing, incoming, and multi-hop over Statement
MATCH (a:RdfNode)-[r:Statement]->(b:RdfNode)
WHERE a.id = '<https://example.org/people/alice>' RETURN r.predicate, b.id;
MATCH (b:RdfNode)<-[r:Statement]-(a:RdfNode)
WHERE b.id = '<https://example.org/people/acme>' RETURN a.id, r.predicate;
MATCH (a:RdfNode)-[r:Statement*1..2]->(b:RdfNode)
WHERE a.id = '<https://example.org/people/alice>' RETURN DISTINCT b.id LIMIT 10;
-- literal values live on the object node
MATCH (a:RdfNode)-[r:Statement]->(b:RdfNode)
WHERE r.predicate = 'http://schema.org/email' RETURN b.value;RDF namespaces partition names, not data: the namespace of a term is its IRI up to
and including the last # or / (http://schema.org/Person → http://schema.org/).
Every content node and every statement carries its subject's namespace, so each
namespace is a queryable subgraph of the one connected graph — cross-namespace edges
(a shape targeting a foreign class, a subclass in another vocabulary) stay traversable,
which physically separate graphs would break.
Namespace nodes inventory them: iri, prefix, sourceGraphs (JSON), termCount
(projected nodes), triples (statements). Prefixes come from the documents' @prefix
declarations when present, and are derived from the IRI otherwise (…/hr# → hr,
http://schema.org/ → schema); blank-node subjects have no namespace and are not
inventoried. ontobug namespaces prints the table; scope any traversal with
WHERE n.namespace = '…' (nodes) or WHERE r.namespace = '…' (statements):
MATCH (n:Namespace) RETURN n.prefix, n.iri, n.termCount ORDER BY n.prefix;
MATCH (c:OntologyClass) WHERE c.namespace = 'http://schema.org/' RETURN count(c);Origin. Ontobug began as the schema registry of oxilite
(SPARQL on SQLite). The registry has since moved to Ladybug:
registrations are SchemaEntry nodes queried with Cypher, and there is no oxilite wire
compatibility anymore. RDF content stays standard TriG/N-Quads, so any SHACL processor
still validates it (§13).
Limits:
- Storage. Everything lives in a Ladybug
.lbdbdatabase:RdfNode/Statementtriple graph, registry, projections and namespace inventory. TriG/N-Quads files are snapshots — written by save/export, read only to seed an empty database. - Projection is one-way. Ladybug mirrors the RDF graphs for querying; edit the
documents,
addthem again, and the projection rebuilds. - No OWL reasoner. Ontologies take part in validation through class membership only (§8.3). Ontobug checks neither OWL consistency nor property characteristics.
- No fetching. Imports resolve only against registered ontologies.
- No blank-node graphs. A graph named by a blank node cannot be registered, because no other graph can name it.
- Projection limits. Complex SHACL paths (
sh:inversePathaside) are stored as written, andsh:in/sh:nodereferences are not followed. - SHACL coverage is rudof's: SHACL Core. Check rudof's documentation for SHACL-SPARQL and newer features.