The Semantic Layer, From ScratchChapter 11
Chapter 11

SHACL: when you do want rules to reject data

The closed-world checks a database person expects (NOT NULL, CHECK, foreign keys), written for a graph, run on demand, and reported, not enforced.

Try an experiment in your head. You INSERT a customer into Postgres with two email addresses, one of them "not-an-email", and an address id that doesn't exist. Postgres won't have it: a single email column can't hold two values, a CHECK rejects the malformed one, and the foreign key rejects the unknown address. Insert the same customer into Stardog as triples and it says "OK," then and later, reasoning or not. Which brings us to your question: what exactly does SHACL mean, and why do people use it? SHACL stands for Shapes Constraint Language, a W3C standard (a Recommendation since July 2017) for writing down what acceptable data looks like and checking real data against it. RDFS and OWL are designed never to reject anything. SHACL is the part of the RDF world that says "no, that's wrong," and tells you which node, which property and which rule.

11.1Why the ontology never said no

Two ideas from earlier chapters explain it. The open-world assumption (Chapter 5): a missing fact is unknown, not false, so a customer with no :email triple is not an error, where a closed-world database would reject the NULL. Axioms infer rather than reject (Chapter 6): if the model said :address rdfs:range :Address and an :address pointed at a product, RDFS would infer the product is an Address, which is why the kit uses the documentation-only so:rangeIncludes. Neither ever checks anything.

A tiny example of the gap. These two triples are fine to OWL:

<urn:stardog:demos:c360:customer:9999> :email "bad.customer@example.com" , "not-an-email" .

Two emails? A customer can have several. A malformed one? OWL only sees a string. If you come from Postgres, you want email varchar NOT NULL UNIQUE CHECK (email ~ '@'). SHACL is how you say that about a graph.

11.2SHACL's vocabulary, broken down

SHACL is itself written in RDF (Turtle), using the namespace sh: = http://www.w3.org/ns/shacl#.

TermWhat it isWhy it existsTiny example
ShapeA named set of conditions that some nodes must meet.A reusable spec for a kind of record, like a table's constraints.:CustomerShape a sh:NodeShape
Node shapeA shape about a node as a whole: mostly "which nodes to check" plus a list of property shapes.The container: "these checks apply to customers.":CustomerShape a sh:NodeShape ; sh:targetClass :Customer
Property shapeConditions on the values reached through one property (a path).Most rules are about a column-like property: email, address.sh:property [ sh:path :email ; sh:minCount 1 ]
TargetThe declaration of which nodes a shape applies to.SHACL checks only the nodes you point it at.sh:targetClass :Customer means every node with a :Customer.
Focus nodeOne node being checked right now.Reports name the exact offender.customer:9999
Constraint componentOne kind of check: count, datatype, class, pattern, range…The building blocks, like NOT NULL or CHECK.sh:maxCount 1, sh:datatype xsd:string
Validation reportThe RDF result of a check: sh:conforms true/false plus one sh:result per violation.Failures come back as queryable data.[ a sh:ValidationReport ; sh:conforms true ]
Severitysh:Violation (default), sh:Warning or sh:Info.Not every rule is fatal.sh:severity sh:Warning

The smallest complete shape, "every Customer must have an email":

:EmailRequired a sh:NodeShape ;
  sh:targetClass :Customer ;
  sh:property [ sh:path :email ; sh:minCount 1 ] .

Against the data from Section 11.1, customer:9999 passes (it has at least one email). Add sh:maxCount 1 and it fails with "more than 1 value." Add sh:pattern "^[^@ ]+@[^@ ]+[.][^@ ]+$" and "not-an-email" fails too. That's the entire idea; the rest is detail.

11.3SHACL for a database person

The translation table to keep open:

You'd write in SQLSHACL constraintNotes
email NOT NULLsh:minCount 1"At least one value via this path."
one value per row (a scalar column)sh:maxCount 1RDF allows many values per property; this forbids it.
email varcharsh:datatype xsd:stringWould have caught Chapter 9's float-for-money mistake: sh:datatype xsd:decimal on :purchasePrice.
FOREIGN KEY (location) REFERENCES addresssh:class :AddressThe value must be a node typed :Address.
CHECK (email ~ '…')sh:pattern "…"Regular expression on the value's text.
CHECK (price > 0)sh:minExclusive 0Also sh:minInclusive, sh:maxInclusive…
CHECK (card_type IN (…))sh:in ( "Visa" "Mastercard" … )An enumerated list.
no undeclared columnssh:closed trueNo properties except the ones listed.
:CustomerShape target: every :Customer email: exactly 1, string, looks like x@y.z address: at least 1, must be an :Address customer:1 (Jolynn) jbithany0@multiply.com address → a :Address customer:225 (Carolyn) cinworth68@g.co address → a :Address customer:9999 (bad) 2 emails, one "not-an-email" address → untyped node ✓ conforms ✓ conforms ✗ 3 results MaxCount on :email Pattern on "not-an-email" Class on :address

Report: sh:conforms false. Jolynn and Carolyn pass silently. Customer 9999 produces three results: too many emails, a malformed email, and an address that isn't an Address.

Figure 11.1: A shape is a stencil laid over every node it targets. Each failed condition becomes one result in the report.

11.4The CustomerShape, line by line

The exercise's shape: every Customer has exactly one well-formed email and at least one address that is an Address:

:CustomerShape a sh:NodeShape ;
  sh:targetClass :Customer ;
  sh:property [
    sh:path :email ;
    sh:minCount 1 ;
    sh:maxCount 1 ;
    sh:datatype xsd:string ;
    sh:pattern "^[^@ ]+@[^@ ]+[.][^@ ]+$" ;
    sh:message "A customer needs exactly one well-formed email."
  ] ;
  sh:property [
    sh:path :address ;
    sh:minCount 1 ;
    sh:class :Address ;
    sh:message "A customer's address must be an Address."
  ] .

Top to bottom: a node shape that targets every :Customer (in Stardog including subclass instances, because shacl.targetClass.simple is false). Each sh:property [ … ] is a property shape, an unnamed (blank) node holding the checks for one path. minCount 1 ; maxCount 1 means exactly one value: NOT NULL plus single-valued. A plain "…" literal is an xsd:string, so sh:datatype passes for normal text. The regex uses [.] instead of \. to avoid escaping inside Turtle strings. sh:class :Address requires the value to have rdf:type :Address in the data being validated: the foreign-key check. sh:message is optional text copied into each result.

11.5Running validation in Stardog 12

Stardog calls its SHACL engine ICV (Integrity Constraint Validation), a name you'll see in options, the CLI and the HTTP API. Four ways to run it, all read-only for your data:

WhereHowReturns
Studio / any SPARQL clientThe VALIDATE query form (a Stardog SPARQL extension since Stardog 9)A validation report graph
Studio / SPARQLSERVICE icv:validate { … } inside a SELECTA table, one row per violation
CLIstardog icv report, stardog icv validateReport / pass-fail
HTTPthe database's icv/validate endpoint (icv/{tx}/validate inside a transaction), which the CLI and pystardog's report() call.As above

The VALIDATE grammar from the docs:

VALIDATE (ALL | [<IRI>+] [GRAPH <IRI>+])
  [USING SHAPES (<IRI>+ | GRAPH <IRI>+ | <QuadData>)]
  [LIMIT <int>] [LIMIT PER SHAPE <int>]

In words: say what data to check (all, specific nodes, or named graphs) and which shapes to use (shape IRIs, shapes stored in named graphs, or shapes inline in curly braces). LIMIT caps the number of results.

Where shapes live, and why you name them. Shapes are ordinary triples, stored in a named graph like anything else. In the 11.7 exercise you'll put yours in a scratch graph, <urn:book:ch11:shapes>. The question is which shapes a validation uses. There are two ways to run it:

You runShapes used
VALIDATE ALLEvery shape found in any graph stored in Stardog. Today that means training-c360, c360_extended_rules and, after the 11.7 exercise, your scratch shapes graph. (A database setting, shacl.shape.graphs, controls this; on kit-c360 it is left at the default, tag:stardog:api:context:local, "all stored graphs".)
VALIDATE GRAPH <urn:book:ch11>
USING SHAPES GRAPH <urn:book:ch11:shapes>
Only the shapes in the graph you name.

It works like dbt test, which runs every test file it finds in the project. Forget a scratch test file and it runs too, every time. Same here: if you forget to drop <urn:book:ch11:shapes>, next month's VALIDATE ALL still checks every customer against this chapter's practice :CustomerShape, and reports violations nobody asked for. So the 11.7 exercise always names its shapes graph with USING SHAPES GRAPH, and drops the scratch graphs at the end.

11.6Where SHACL fits in a federated setup

In our architecture the data isn't in Stardog; it's SQL rows seen through mappings. What could go wrong, and where?

Aurora rows customer, address Mapping templates, casts virtual graph triples at query time SHACL report conforms? results not in the main docs <urn:book:ch11> materialized sample our exercise: documented path →
Figure 11.2: SHACL checks what the mappings produce. Validating a materialized graph is documented. Validating a virtual graph directly is possible per Stardog staff, but not covered in the main docs.

Can Stardog validate a virtual graph directly?

The main documentation page, Data Quality Constraints (docs.stardog.com/data-quality-constraints), doesn't mention virtual graphs. On the Stardog Community forum, Stardog staff have answered twice:

So it's supported in principle, not officially documented, and at least one user saw false "all clear" results. We'll do the documented thing: validate a small materialized sample in the scratch graph <urn:book:ch11>. An optional read-only stretch at the end tries the virtual graph with a built-in lie detector.

11.7Try it: validate a sample, break it, fix it

Two scratch graphs, deleted at the end: <urn:book:ch11> for the data and <urn:book:ch11:shapes> for the shapes. The data uses real values for Jolynn (customer 1) and Carolyn (customer 225), plus a deliberately bad customer 9999, who doesn't exist in Aurora.

11.8Reading a validation report

The kind of report step 3 returns, trimmed to one result:

[ a sh:ValidationReport ;
  sh:conforms false ;
  sh:result [
    a sh:ValidationResult ;
    sh:resultSeverity sh:Violation ;
    sh:sourceShape _:b0 ;                                  # the email property shape
    sh:sourceConstraintComponent sh:PatternConstraintComponent ;
    sh:focusNode <urn:stardog:demos:c360:customer:9999> ;
    sh:resultPath :email ;
    sh:value "not-an-email" ;
    sh:resultMessage "A customer needs exactly one well-formed email."
  ] ] .

Read it like a failed test: sh:focusNode is the bad row's primary key, sh:resultPath is the column, sh:value is the bad cell (when there is one), sh:sourceConstraintComponent says which check failed, and sh:resultSeverity says how much to care.

sh:conforms is the verdict, not a setting. Every report starts with it, and what follows depends on it:

Before the fix (step 3)After the fix
[ a sh:ValidationReport ;
  sh:conforms false ;
  sh:result [ … pattern, customer:9999 … ] ;
  sh:result [ … maxCount, customer:9999 … ] ;
  sh:result [ … class, customer:9999 … ] ] .
[ a sh:ValidationReport ;
  sh:conforms true ] .

false comes with one sh:result per problem; true comes alone. Nodes that pass, like Jolynn and Carolyn, never appear in either. There is no setting that makes it true: you fix the data and run VALIDATE again.

Optional stretch (read-only): try the virtual graph, with a lie detector

Curious whether direct virtual-graph validation works on our server? VALIDATE only reads, so this is safe. The trick is a must-fail check: a check you know for certain should fail. If it passes anyway, the checking itself is broken, and you can't trust its "all clear". Ours is a shape that must fail: aurora_c360_safe has no :ssn (Chapter 8), so "every customer has an ssn" must fail. If it reports conforms true, validation isn't really seeing the virtual data, so ignore any "all clear."

#pragma simple.target on
PREFIX :   <tag:stardog:api:ecomm:>
PREFIX sh: <http://www.w3.org/ns/shacl#>
VALIDATE GRAPH <virtual://aurora_c360_safe>
USING SHAPES {
  :MustFailShape a sh:NodeShape ;
    sh:targetClass :Customer ;
    sh:property [ sh:path :ssn ; sh:minCount 1 ] .
}
LIMIT 5

Expected if it works: conforms false with 5 results (capped by LIMIT 5; the true number is 500). Aurora may take about 20 seconds to wake. If that works, swap the must-fail shape for the two sh:property blocks of :CustomerShape and rerun. My read-only SQL check found all 500 real customers have exactly one well-formed email and an existing address row, so a working validator should report conforms true. If Studio rejects the syntax or the must-fail shape passes, note it: that's useful information, not a failure on your part.

Check yourself

What does SHACL stand for, and why does it exist when we already have OWL?

Shapes Constraint Language, a W3C Recommendation from 2017. OWL and RDFS are open-world and infer rather than reject: a missing value is "unknown," and a range axiom adds a type instead of flagging an error. SHACL adds closed-world checks (required, single-valued, datatype, pattern, class) that produce a report of what's wrong.

Translate email varchar NOT NULL plus "one value per row" into SHACL.

sh:property [ sh:path :email ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ] inside a node shape that targets :Customer.

Our bad customer had two emails, one malformed. Why did deleting one triple clear two violations?

The extra value (maxCount) and the malformed value (pattern) were the same triple, :email "not-an-email".

Does a SHACL shape in Stardog stop a bad row from arriving through a virtual graph?

No. Writes go to Aurora and Redshift directly, never through Stardog, and guard mode (icv.enabled) only applies to transactions on data stored in Stardog. For virtual graphs, SHACL is a report you run.

Is validating a virtual graph directly supported? Cite your source.

Not in the main docs (Data Quality Constraints doesn't mention it). Stardog staff on the community forum describe stardog icv report --named-graph virtual://… and #pragma simple.target on + VALIDATE GRAPH <virtual://…>, but one user reported false conforms true. Possible, not officially documented; test it with a must-fail check.

Why did the exercise drop the shapes graph, not just the data graph?

shacl.shape.graphs defaults to all local graphs, so leftover shapes would join future VALIDATE ALL runs.

Chapter recap