shacl2cypher
Guide

Model it, generate it, then check the data

LPG Modeler turns one YAML model into database DDL and SHACL shapes. shacl2cypher runs those shapes against the database. Together they close the loop between the schema you designed and the data you actually have.

Why the two belong together

A model says what the data must look like, but no single database enforces all of it. LadybugDB has typed tables but no NOT NULL outside the primary key. Neo4j Community cannot enforce existence constraints. Neither can express "a booking ends after it starts".

LPG Modeler already knows this. Every target reports what it cannot enforce, and the SHACL target carries the constraints the database drops:

social.lpg.yaml:28:7 warning [ladybug] downgrade-required: Property 'Person.email' is required,
which LadybugDB cannot enforce: it has no NOT NULL and only the primary key is non-null.

That SHACL file is only a promise until something runs it. shacl2cypher is that something: it compiles the generated shapes into Cypher and runs them against the live database, read-only.

             lpg-modeler-cli emit
 social.lpg.yaml ──────────────┬──▶ social.ladybug.cypher ──▶ database (enforces what it can)
                               └──▶ social.shacl.ttl ─────┐
                                                          ▼
                                        shacl2cypher validate ──▶ report + exit code
                                          (checks the rest against live data)
The model is the only thing you edit. The DDL creates the database; the shapes check it.

Walkthrough

This uses social.lpg.yaml, the modeler's starter example. It has an abstract Party with key id, Person and Company extending it, a Timestamped mixin that adds a required createdAt, and the edges OWNS, KNOWS (both with a since property) and LIKES. All files are in doc/examples/modeler.

  1. Generate DDL and shapes

    npx lpg-modeler-cli check social.lpg.yaml
    npx lpg-modeler-cli emit  social.lpg.yaml --target ladybug --target shacl --out schema

    This writes schema/social.ladybug.cypher and schema/social.shacl.ttl. The shapes for Person look like this (abridged):

    social:PersonShape a sh:NodeShape ;
      sh:targetClass social:Person ;
      sh:closed true ;
      sh:ignoredProperties ( rdf:type ) ;
      sh:property [
        sh:path social:email ;
        sh:datatype xsd:string ;
        sh:maxCount 1 ;
        sh:minCount 1 ;
      ] ;
      # … born, createdAt, id …
      sh:property [
        sh:path social:knows ;
        # (:Person)-[:KNOWS]->(:Person)
        sh:class social:Person ;
      ] .
  2. Create the database and load data

    Run the generated DDL, then load data, with a LadybugDB client. Its version must match the LadybugDB engine inside your shacl2cypher build; a file written by an older client fails to open. The sample data has two deliberate mistakes: p2 has no email, and p3 has no createdAt.

    CREATE (:Person {id: 'p1', email: 'ann@example.org', born: date('1990-04-01'), createdAt: timestamp('2024-01-01T10:00:00+00:00')});
    CREATE (:Person {id: 'p2', born: date('1985-02-11'), createdAt: timestamp('2024-02-01T10:00:00+00:00')});
    CREATE (:Person {id: 'p3', email: 'cy@example.org'});
    // … a company, a car, and KNOWS / OWNS / LIKES edges
  3. Validate with the generated shapes

    shacl2cypher validate schema/social.shacl.ttl --ladybug social.lbug --node-key id

    Both mistakes are found:

    failed      Violation           1         5  PersonShape.createdAt.minCount
                - Person id=p3: social:PersonShape violates sh:minCount on social:createdAt
    failed      Violation           1         2  PersonShape.email.minCount
                - Person id=p2: social:PersonShape violates sh:minCount on social:email

    But the run also prints eight schema warnings and 16 query errors:

    warning: social.shacl.ttl:96: s2c:SchemaMismatch: social:Knows maps to label `Knows`, which the schema snapshot does not declare
    warning: social.shacl.ttl:98: s2c:SchemaMismatch: relationship type `KNOWS_SUBJECT` is not declared in the schema snapshot
    …
    error       Violation           -         2  KnowsShape.knowsObject.class
                (Query execution failed: Binder exception: Cannot bind KNOWS as a node pattern label.)
    …
    44 rules: 14 passed, 2 failed, 0 timed out, 16 errors, 0 skipped, 12 guaranteed by schema; 2 violations in 46 ms
  4. Add an overlay for edges with properties

    The errors come from how RDF represents an edge that carries properties. RDF has no properties on edges, so the modeler reifies KNOWS and OWNS into classes (social:Knows, social:Owns) with knowsSubject and knowsObject links. That is correct for RDF, but in the property graph those classes don't exist: they are relationships. On LadybugDB, whose table names are case-insensitive, the label Knows even collides with the rel table KNOWS.

    shacl2cypher unions all shapes files, so you can fix this without editing generated output. Keep a small overlay next to the model:

    @prefix sh:     <http://www.w3.org/ns/shacl#> .
    @prefix xsd:    <http://www.w3.org/2001/XMLSchema#> .
    @prefix s2c:    <https://w3id.org/shacl2cypher#> .
    @prefix social: <https://example.org/vocab/social#> .
    
    # The generated KnowsShape and OwnsShape describe RDF reification classes.
    # In the property graph those are relationships, so switch the RDF form off...
    social:KnowsShape sh:deactivated true .
    social:OwnsShape  sh:deactivated true .
    
    # ...and check the relationship properties where they actually live.
    social:KnowsEdgeShape a sh:NodeShape ;
        s2c:targetRelationship "KNOWS" ;
        sh:property [ sh:path social:since ; sh:datatype xsd:date ; sh:maxCount 1 ] .
    
    social:OwnsEdgeShape a sh:NodeShape ;
        s2c:targetRelationship "OWNS" ;
        sh:property [ sh:path social:since ; sh:datatype xsd:date ; sh:maxCount 1 ] .
    
    # Rows name each node by its model key instead of --node-key.
    social:CarShape s2c:key "vin" .
    shacl2cypher validate schema/social.shacl.ttl social.lpg-overlay.ttl --ladybug social.lbug --node-key id
    passed      Violation           0         2  KnowsEdgeShape.since.datatype
    passed      Violation           0         2  KnowsEdgeShape.since.maxCount
    skipped     Violation           -         0  KnowsShape.knowsObject.class
                (deactivated)
    …
    failed      Violation           1         4  PersonShape.createdAt.minCount
                - Person id=p3: social:PersonShape violates sh:minCount on social:createdAt
    failed      Violation           1         2  PersonShape.email.minCount
                - Person id=p2: social:PersonShape violates sh:minCount on social:email
    …
    48 rules: 18 passed, 2 failed, 0 timed out, 0 errors, 16 skipped, 12 guaranteed by schema; 2 violations in 46 ms

    No errors now, and the edge properties are checked where they live. The exit code is 1 because of the two real violations. Fix the data and it becomes 0.

Known caveat. The deactivated shapes still print their schema-mismatch warnings, so combining the overlay with --fail-on-schema-mismatch still fails compilation. Leave that flag off for modeler-generated shapes that include edges with properties.

What each model feature becomes

LPG Modeler's naming lines up with shacl2cypher's conventions. A node type Person is emitted as social:Person, which resolves back to :Person. An edge OWNS is emitted as the predicate social:owns, which resolves back to OWNS. Edge shapes carry sh:class for every endpoint type in the model, so they are recognized as relationships even without a schema snapshot.

In the modelGenerated SHACLWhat shacl2cypher does
required: true, keyssh:minCount 1Checked. This is the main gap LadybugDB leaves open.
non-list propertysh:maxCount 1Checked; matters for Neo4j, where any property can hold a list.
property typesh:datatypeGuaranteed by schema on LadybugDB (no query); checked on Neo4j.
min, max, minLength, maxLength, patternranges, lengths, sh:patternChecked.
enumsh:in ( … )Checked.
closed types (the default)sh:closed trueChecked: no undeclared properties or outgoing relationships.
edge endpointssh:class on the edge pathGuaranteed by the rel table on LadybugDB; checked on Neo4j.
edge cardinalitysh:minCount/sh:maxCount, plus sh:inversePath for the source endChecked in both directions.
named constraintssh:lessThan, sh:or, sh:xone, sh:qualifiedValueShapeChecked.
raw shacl: fragmentspliced verbatimChecked if it uses supported SHACL.
edge with propertiesreified class shapeNeeds the overlay above.
unique: truecomment onlyNot checked: core SHACL cannot express uniqueness. Rely on the database's unique or key constraint.

Two smaller details:

  • sh:ignoredProperties ( rdf:type ) is read as permission for a property named type and a relationship TYPE. That is harmless unless your nodes really have a type property that closure should reject.
  • If an edge type name isn't UPPER_SNAKE_CASE, the modeler's lowerCamel predicate may not convert back to the same relationship name. Compile with a schema snapshot and read the static diagnostics; they name any relationship type that doesn't exist.

On Neo4j

The same shapes compile for Neo4j without a schema snapshot. social.shacl.ttl produces 44 rules, all with queries:

npx lpg-modeler-cli emit social.lpg.yaml --target neo4j --target shacl --out schema
shacl2cypher compile schema/social.shacl.ttl social.lpg-overlay.ttl --dialect neo4j --node-key id -o checks
NEO4J_PASSWORD=secret shacl2cypher validate --manifest checks/manifest.json --connect bolt://localhost:7687

On Neo4j, nothing is guaranteed by schema, so every datatype and endpoint rule runs as a query. That is the point on a schema-optional database: the generated constraint script covers keys and uniqueness, and the shapes cover everything else.

In continuous integration

A pipeline that fails when the model is invalid, when generated artifacts drift, or when staging data breaks a rule:

name: graph-schema
on: [push, pull_request]

jobs:
  model:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npx lpg-modeler-cli check model/social.lpg.yaml
      - run: npx lpg-modeler-cli emit model/social.lpg.yaml --target neo4j --target shacl --out schema
      # Compile the shapes; the manifest's queries and fingerprints show in review.
      - run: cargo install --locked shacl2cypher
      - run: shacl2cypher compile schema/social.shacl.ttl model/social.lpg-overlay.ttl --dialect neo4j --node-key id -o schema/checks
      - run: git diff --exit-code schema

  data:
    needs: model
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cargo install --locked shacl2cypher --features neo4j
      - run: shacl2cypher validate --manifest schema/checks/manifest.json --connect ${{ secrets.STAGING_BOLT_URI }} --timeout 120 --format sarif -o report.sarif
        env:
          NEO4J_PASSWORD: ${{ secrets.STAGING_NEO4J_PASSWORD }}
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with: { sarif_file: report.sarif }

The SARIF report points each violation at its line in social.shacl.ttl. Because that file is generated from the model, the model is where you fix the rule.

Where to go next