shacl2cypher
Guide

Using SHACL with shacl2cypher

SHACL talks about RDF: IRIs, predicates and literals. Your database has labels, properties and relationships. This guide covers how shacl2cypher bridges the two, and how to take control when its defaults guess wrong.

The mental model

You write ordinary SHACL, as if your graph were RDF. shacl2cypher translates every term the shapes mention into an element of your property graph:

In the shapesIn the property graphDefault name
Class ex:PersonLabel / node table:Person, the local name
Predicate with literal values ex:namePropertyname, the local name
Predicate with node values ex:worksForRelationship, outgoingWORKS_FOR, UPPER_SNAKE_CASE
A node (IRI)A node, identified by a key property--node-key or s2c:key
(no RDF equivalent)Properties on a relationships2c:targetRelationship

The resolution order is always the same: explicit s2c: annotations win, then structural hints and schema-snapshot evidence, then naming convention. --strict rejects anything that was decided by convention alone.

Labels and class hierarchies

A class target or sh:class becomes a label check. rdfs:subClassOf triples from the shapes files and from --ontology files are expanded transitively, so a shape targeting ex:Person also checks :Employee nodes when ex:Employee rdfs:subClassOf ex:Person.

  • Neo4j: with --neo4j-labels explicit (default) the query matches (n:Person|Employee). If your nodes already carry every superclass label, pass --neo4j-labels inherited and the query matches only :Person.
  • LadybugDB: a node lives in exactly one table, so expansion always happens.

Rename a label with s2c:label on the class or on a node shape with a single class target:

ex:Person s2c:label "Human" .

Property or relationship?

This is the one decision that matters most, because the same predicate could be either. A path step is a relationship when any of these holds:

  1. It is annotated with s2c:relationship or s2c:direction.
  2. Its position must yield nodes: an inverse step, a repeated step (*, +, ?), or any step before the last in a sequence.
  3. Its property shape has sh:class, sh:node, or sh:nodeKind of sh:IRI, sh:BlankNode or sh:BlankNodeOrIRI.
  4. The schema snapshot declares the conventional relationship type and no focus label declares a property with that name.

Otherwise it is a property. A property declared in the snapshot always beats a structural hint, so a hint can never turn a real property into an always-empty relationship.

The common surprise. sh:path ex:manager ; sh:minCount 1 with no hint and no schema checks a property called manager. If manager is a relationship in your graph, add sh:class ex:Person, sh:nodeKind sh:IRI, or ex:manager s2c:relationship "MANAGER". Or compile with a schema snapshot, and the evidence decides.

Direction

Relationships are outgoing by default. sh:inversePath flips a step. If your RDF vocabulary names the reverse direction, keep the vocabulary and annotate:

# "Company employs Person", stored as (:Person)-[:WORKS_FOR]->(:Company)
ex:employs s2c:relationship "WORKS_FOR" ;
           s2c:direction    "in" .

Schema snapshots

A snapshot is a JSON description of node types, relationship types with endpoints, and typed properties. Get one with shacl2cypher schema dump. It does three jobs:

  • Disambiguation. It settles whether a path is a property or a relationship (rule 4 above).
  • Static diagnostics. A label, property or relationship type the shapes mention but the snapshot lacks is reported as s2c:SchemaMismatch with its file and line. --fail-on-schema-mismatch turns these into a compile error for CI.
  • Guarantees. On LadybugDB the schema is enforced, so a sh:datatype xsd:string on a STRING column, or a sh:class on a relationship whose rel table only reaches that class, needs no query. Those rules get status: guaranteed-by-schema.

On Neo4j the snapshot is optional and only an observation of the data, so it never marks a rule as guaranteed. On LadybugDB it is required; validate --ladybug reads it from the file for you.

Node identity and IRI constants

Result rows identify the focus node by a key: the shape's s2c:key, else --node-key, always alongside the database element id.

ex:CarShape s2c:key "vin" .

IRIs used as constants in sh:in and sh:hasValue are compared by local name against properties (ex:Active matches the string "Active"), or as full IRIs with s2c:iriAsString "full". Against relationships, they match the end node's key: sh:hasValue ex:acme on ex:worksFor means "works for the node whose key is acme". This needs --node-key.

Lists and missing values

Every path yields a set of values:

  • A scalar property is one value. A list property is one value per element, and nulls inside lists are dropped.
  • null, an absent property and an empty list are all the empty set.
  • Value constraints (sh:datatype, ranges, lengths, patterns, sh:in, sh:class) are satisfied by an empty set. Only sh:minCount and sh:hasValue notice that something is missing.

To treat a list as one value, for example to check sh:datatype against a LIST<STRING> column, use s2c:collection "scalar" together with s2c:datatype "LIST<STRING>".

Recipes

Validate properties on relationships

RDF has no properties on edges, so SHACL has no target for them. s2c:targetRelationship makes every relationship of a type a focus:

ex:KnowsShape s2c:targetRelationship "KNOWS" ;
    sh:property [ sh:path ex:since ; sh:datatype xsd:date ; sh:maxCount 1 ] .

Only value constraints on single-property paths apply there. Rows carry a relationship focus {type, startKey, endKey, elementId}.

The RDF name and the graph name differ

ex:fullName s2c:property     "name" .
ex:employer s2c:relationship "WORKS_FOR" .
ex:Human    s2c:label        "Person" .

Put the annotation on the predicate to apply it everywhere, or on one property shape to apply it only there. Shape-level annotations work for simple paths only.

Exact temporal types

xsd:dateTime accepts both zoned and local date-times. To demand one:

[ sh:path ex:createdAt ; sh:datatype xsd:dateTime ; s2c:datatype "ZONED_DATETIME" ]

Layer project-specific hints over shared shapes

All input files are unioned into one shapes graph. You can keep a clean, portable shapes file and add a second file with the s2c: annotations, deactivations and extra shapes that only your property graph needs:

shacl2cypher validate domain.shacl.ttl domain.lpg-overlay.ttl --connect bolt://localhost:7687

Settings that may only have one value, such as two different sh:severity values on one shape, are compile errors that list both locations. The LPG Modeler guide uses this pattern for generated shapes.

Roll out a rule gradually

Start new constraints at sh:severity sh:Warning and validate with the default --fail-on violation: warnings show up in the report but do not fail the build. Raise the severity once the data is clean. Use sh:deactivated true to park a rule; it stays visible in the manifest as deactivated.

Make every mapping reviewable

Compile with --strict and a schema snapshot. Every name must then be annotated or confirmed by the schema, so a typo like ex:emial is a compile error instead of a rule that silently finds nothing.

Where results differ from an RDF SHACL engine

  • sh:closed also forbids undeclared outgoing relationships, and always allows the node key. Incoming relationships are not constrained.
  • sh:minLength and sh:maxLength apply to strings and integers only.
  • Node values are IRIs and property values are literals for sh:nodeKind; blank nodes never occur.
  • Rows for nested shapes include details: the inner rule ids that failed. That goes beyond the standard report.

Not supported

These are rejected at compile time, or recorded as unsupported rules with --lenient:

  • sh:targetNode: there are no IRIs to name.
  • SHACL-SPARQL and SPARQL-based constraint components.
  • sh:languageIn, sh:uniqueLang and rdf:langString: property graphs have no language tags.
  • Recursive shapes, because nested shapes are inlined into the query.
  • Property shapes nested inside property shapes (not yet).

Dialect notes

  • LadybugDB table names are case-insensitive. ex:Address (label Address) and ex:address (relationship ADDRESS) collide. Rename one with s2c:label or s2c:relationship.
  • LadybugDB rejects regex back-references and caps variable-length paths at 30 hops, so --max-path-depth above 30 is an error there.
  • Neo4j decodes \uXXXX even inside backticks, so an identifier containing that text cannot be rendered and fails compilation instead of matching the wrong name.
  • Patterns are parsed as XSD regular expressions and rewritten for each engine. They are never passed through verbatim, and an invalid pattern is a compile error at its source line.

Where to go next