Examples: 06-relationships.ttl.
A relationship with an endpoint class
ex:PersonShape a sh:NodeShape ;
sh:targetClass ex:Person ;
sh:property [
sh:path ex:worksFor ;
sh:class ex:Company ;
sh:minCount 1 ;
sh:maxCount 1 ;
] .
"Every person works for exactly one thing, and that thing is a :Company." sh:class checks each value's label. It is also the hint that tells shacl2cypher ex:worksFor is a relationship, so it becomes WORKS_FOR:
// name: PersonShape.worksFor.class
MATCH (v0:`Person`)
MATCH (v0)-[:`WORKS_FOR`]->(v1)
WITH DISTINCT v0, v1
WHERE NOT (v1:`Company`)
RETURN …, {label: head(labels(v1)), keyValue: v1.`id`, elementId: elementId(v1)} AS value, …
The offending value in the report is the end node itself: its label, key and element id.
When is a path a relationship?
Property or relationship is the most important mapping decision, and the one to understand before writing relationship shapes. A step is a relationship if any of these apply, checked in this order:
- It is annotated:
ex:employer s2c:relationship "WORKS_FOR"(ors2c:direction). - It must yield nodes: an inverse step, a repeated step, or any step before the last one in a sequence.
- Its property shape says the values are nodes:
sh:class,sh:node, orsh:nodeKind sh:IRI. - A schema snapshot declares the relationship type and no such property.
Otherwise it's a property. Names follow convention: ex:worksFor → WORKS_FOR. When your relationship type differs from the predicate's name, annotate the predicate:
ex:employer s2c:relationship "WORKS_FOR" .
ex:ContractorShape a sh:NodeShape ;
sh:targetClass ex:Contractor ;
sh:property [ sh:path ex:employer ; sh:maxCount 1 ] .
Incoming relationships
ex:CompanyShape a sh:NodeShape ;
sh:targetClass ex:Company ;
sh:property [
sh:path [ sh:inversePath ex:worksFor ] ;
sh:minCount 1 ;
] .
sh:inversePath walks a relationship backwards: from a company to the people who work for it. "Every company has at least one employee." The rule is named CompanyShape.inv_worksFor.minCount.
Sequences: several hops
sh:property [
sh:path ( ex:worksFor ex:locatedIn ) ;
sh:class ex:City ;
] ;
A list as a path means "follow each step in turn": (person)-[:WORKS_FOR]->()-[:LOCATED_IN]->(x), and every x must be a :City. Rule name: PersonShape.worksFor.locatedIn.class.
Alternatives
sh:property [
sh:path [ sh:alternativePath ( ex:email ex:phone ) ] ;
sh:minCount 1 ;
] .
The values are the union of both paths: "at least one email or phone". The steps of an alternative must all be properties or all be relationships. On LadybugDB, alternatives across different relationship routes are not supported.
Transitive paths
sh:property [
sh:path [ sh:zeroOrMorePath ex:manages ] ;
sh:class ex:Person ;
] ;
"Everyone reachable down the management chain, including yourself, is a :Person." sh:oneOrMorePath excludes the start node, and sh:zeroOrOnePath is at most one hop. Unbounded repeats are capped by --max-path-depth (default 10), and the cap is recorded on the rule:
// name: PersonShape.manages_star.class
MATCH (v0:`Person`)
MATCH (v0)-[:`MANAGES`*0..10]->(v1)
WITH DISTINCT v0, v1
WHERE NOT (v1:`Person`)
These rules get the cost class unbounded-path, the most expensive kind. LadybugDB allows at most 30 hops.
Direction for a reverse-named predicate
If your vocabulary says ex:employs (company → person) but the graph stores (:Person)-[:WORKS_FOR]->(:Company), keep the vocabulary:
ex:employs s2c:relationship "WORKS_FOR" ; s2c:direction "in" .
Remove sh:class ex:Company from the worksFor shape and compile. Without the hint and without a schema, worksFor is checked as a property. Now compile again with a schema snapshot that declares WORKS_FOR: the evidence brings the relationship back.
Takeaways
- A relationship is a path whose values are nodes;
sh:classchecks their labels. - Make relationships explicit with hints, a schema snapshot, or
s2c:relationship. sh:inversePath, sequences, alternatives and repeated paths cover incoming, multi-hop, either-or and transitive rules.