shacl2cypher
← Shapes for Property Graphs

2. Turtle and your first shape

Shapes are written in Turtle, a compact text format for RDF. Ten minutes of syntax is all you need. Then we write a shape, compile it, and read the query it becomes.

Turtle in ten minutes

A Turtle file is a list of statements. Each statement is subject predicate object, ending with a full stop:

<http://example.org/hr#PersonShape> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/shacl#NodeShape> .

Nobody writes it like that. Five pieces of shorthand make it readable.

1. Prefixes

Declare a short name for a namespace once, then write prefix:localName:

@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.org/hr#> .

ex:PersonShape rdf:type sh:NodeShape .

The local name matters to shacl2cypher: ex:Person becomes the label Person, and ex:name becomes the property name.

2. a

a is short for rdf:type: ex:PersonShape a sh:NodeShape .

3. ; repeats the subject

ex:PersonShape a sh:NodeShape ;
    sh:targetClass ex:Person .

4. , repeats subject and predicate

ex:PersonShape sh:property ex:NameShape , ex:EmailShape .

5. [ … ] is an unnamed node, ( … ) is a list

Brackets create an anonymous thing inline, which is how most property shapes are written. Parentheses create an ordered list:

ex:PersonShape sh:property [ sh:path ex:name ; sh:minCount 1 ] .
ex:StatusShape sh:in ( "active" "on-leave" "left" ) .

Literals

Strings are quoted. Numbers and booleans can be bare. Anything else carries a datatype with ^^:

"Ann"     42     3.5     true     "2024-01-31"^^xsd:date

Comments start with #. That's the whole language, as far as this book needs it.

Your first shape

02-first-shape.ttl:

@prefix sh:  <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix ex:  <http://example.org/hr#> .

# Every node labelled :Person must have exactly one string name.
ex:PersonShape a sh:NodeShape ;
    sh:targetClass ex:Person ;
    sh:property [
        sh:path     ex:name ;
        sh:datatype xsd:string ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
    ] .

Read it top to bottom:

  • ex:PersonShape a sh:NodeShape: this is a shape about nodes.
  • sh:targetClass ex:Person: it applies to every :Person node. Each one becomes a focus node.
  • sh:property [ … ]: a property shape. From each focus node, follow sh:path ex:name to get the values.
  • Three constraints on those values: they are strings, there is at least one, and there is at most one.

Compile it

shacl2cypher compile doc/examples/book/02-first-shape.ttl --dialect neo4j --node-key id -o out/
compiled 3 rules (3 with queries, 0 static diagnostics) for neo4j into out/

Three constraints, three rules: PersonShape.name.datatype, PersonShape.name.maxCount and PersonShape.name.minCount. Here is the detail query for minCount from out/queries.cypher, exactly as generated:

// name: PersonShape.name.minCount
// ruleId: ex:PersonShape/ex:name/sh:minCount
MATCH (v0:`Person`)
WITH v0
WHERE NOT (size(reduce(x2 = [], x1 IN CASE WHEN v0.`name` IS NULL THEN [] WHEN v0.`name` IS :: LIST<ANY> THEN v0.`name` ELSE [v0.`name`] END | CASE WHEN x1 IS NULL OR x1 IN x2 THEN x2 ELSE x2 + [x1] END)) >= 1)
RETURN 'PersonShape.name.minCount' AS ruleId, 'ex:PersonShape' AS shape, 'ex:name' AS path, 'sh:minCount' AS `constraint`, 'Violation' AS severity,
       {label: head([l IN labels(v0) WHERE l IN ['Person']] + labels(v0)), key: 'id', keyValue: v0.`id`, elementId: elementId(v0)} AS focus, null AS value, 'ex:PersonShape violates sh:minCount on ex:name' AS message, [] AS details
LIMIT coalesce($limit, 9223372036854775807);

It is more than WHERE p.name IS NULL, and every part is there for a reason:

  • MATCH (v0:`Person`) is the target: every focus node.
  • The CASE turns name into a list of values: null becomes [], a list stays a list, and a single value becomes [value]. Neo4j lets any property hold a list, so SHACL's "number of values" has to handle both.
  • reduce(…) removes nulls and duplicates. SHACL counts distinct values.
  • WHERE NOT (… >= 1) keeps only focus nodes that fail.
  • RETURN builds the report row: rule, shape, path, constraint, severity, the focus node identified by --node-key and element id, and a message.

Next to it is a #summary query with the same WHERE. It returns one row with violationCount and a sample of focus nodes, even when the count is zero.

Run it

Against a database, validate compiles and runs in one step:

shacl2cypher validate doc/examples/book/02-first-shape.ttl --connect bolt://localhost:7687 --node-key id

Each failing node becomes a line under its rule, such as - Person id=p3: ex:PersonShape violates sh:minCount on ex:name. With --format json, the same violation is a structured row:

{
  "ruleId": "PersonShape.name.minCount",
  "shape": "ex:PersonShape",
  "path": "ex:name",
  "constraint": "sh:minCount",
  "severity": "Violation",
  "focus": { "label": "Person", "key": "id", "keyValue": "p3", "elementId": "4:…:12" },
  "value": null,
  "message": "ex:PersonShape violates sh:minCount on ex:name",
  "details": []
}
Try it

Add sh:minLength 2 to the property shape and compile again. You get a fourth rule, PersonShape.name.minLength, and a fourth query.

Takeaways

  • Turtle is statements, plus prefixes, a, ;, ,, [ ] and ( ).
  • A node shape has a target and property shapes. A property shape has a path and constraints.
  • Each constraint compiles into one named rule with a detail query and a summary query.