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)
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.
-
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 schemaThis writes
schema/social.ladybug.cypherandschema/social.shacl.ttl. The shapes forPersonlook 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 ; ] . -
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:
p2has no email, andp3has nocreatedAt.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 -
Validate with the generated shapes
shacl2cypher validate schema/social.shacl.ttl --ladybug social.lbug --node-key idBoth 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:emailBut 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 -
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
KNOWSandOWNSinto classes (social:Knows,social:Owns) withknowsSubjectandknowsObjectlinks. 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 labelKnowseven collides with the rel tableKNOWS.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 idpassed 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 msNo errors now, and the edge properties are checked where they live. The exit code is
1because of the two real violations. Fix the data and it becomes0.
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 model | Generated SHACL | What shacl2cypher does |
|---|---|---|
required: true, keys | sh:minCount 1 | Checked. This is the main gap LadybugDB leaves open. |
| non-list property | sh:maxCount 1 | Checked; matters for Neo4j, where any property can hold a list. |
property type | sh:datatype | Guaranteed by schema on LadybugDB (no query); checked on Neo4j. |
min, max, minLength, maxLength, pattern | ranges, lengths, sh:pattern | Checked. |
enum | sh:in ( … ) | Checked. |
| closed types (the default) | sh:closed true | Checked: no undeclared properties or outgoing relationships. |
| edge endpoints | sh:class on the edge path | Guaranteed by the rel table on LadybugDB; checked on Neo4j. |
edge cardinality | sh:minCount/sh:maxCount, plus sh:inversePath for the source end | Checked in both directions. |
| named constraints | sh:lessThan, sh:or, sh:xone, sh:qualifiedValueShape | Checked. |
raw shacl: fragment | spliced verbatim | Checked if it uses supported SHACL. |
| edge with properties | reified class shape | Needs the overlay above. |
unique: true | comment only | Not 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 namedtypeand a relationshipTYPE. That is harmless unless your nodes really have atypeproperty 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.