This page is fully self-contained: no external network request is possible (see the Content-Security-Policy above). A live mode twin of this same post can load map tiles and remote SPARQL endpoints.

The previous post used SHACL as one tool among several to check SKOS's own integrity conditions. This post looks at SHACL on its own terms: a shape is a reusable contract — "a foaf:Person must have at least one name" — checked against any graph, independent of whatever vocabulary that graph happens to use. Factoidal's SHACL Core validator scores 120 pass, 0 fail (of 120: 98 core constraint-component tests plus 22 sh:sparql-based tests, including custom constraint components) against the W3C test suite — see the test-results dashboard for the current run.

A shape for foaf:Person#

Back to post 01's Alice and Bob. A shape requiring every foaf:Person to have a name, and every foaf:knows edge to point at another foaf:Person:

@prefix sh:   <http://www.w3.org/ns/shacl#> .
@prefix foaf: <http://xmlns.com/foaf/0.1/> .
@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .
@prefix ex:   <http://example.org/shapes/> .

ex:PersonShape
    a sh:NodeShape ;
    sh:targetClass foaf:Person ;
    sh:property [
        sh:path foaf:name ;
        sh:minCount 1 ;
        sh:message "foaf:Person needs at least one foaf:name" ;
    ] ;
    sh:property [
        sh:path foaf:name ;
        sh:datatype xsd:string ;
        sh:message "foaf:name must be a plain xsd:string" ;
    ] ;
    sh:property [
        sh:path foaf:knows ;
        sh:class foaf:Person ;
        sh:message "foaf:knows must point to another foaf:Person" ;
    ] .

Three property shapes, three distinct constraint kinds: sh:minCount (cardinality — at least one value), sh:datatype (the literal's datatype must match), sh:class (the value must belong to a class). All three target the same path or a sibling one; each fails independently of the others.

Validating conforming data#

Alice and Bob, unchanged from post 01, against the shape above:

PERSON_SHAPE_TTL = `
  @prefix sh:   <http://www.w3.org/ns/shacl#> .
  @prefix foaf: <http://xmlns.com/foaf/0.1/> .
  @prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .
  @prefix ex:   <http://example.org/shapes/> .

  ex:PersonShape
      a sh:NodeShape ;
      sh:targetClass foaf:Person ;
      sh:property [
          sh:path foaf:name ;
          sh:minCount 1 ;
          sh:message "foaf:Person needs at least one foaf:name" ;
      ] ;
      sh:property [
          sh:path foaf:name ;
          sh:datatype xsd:string ;
          sh:message "foaf:name must be a plain xsd:string" ;
      ] ;
      sh:property [
          sh:path foaf:knows ;
          sh:class foaf:Person ;
          sh:message "foaf:knows must point to another foaf:Person" ;
      ] .
`

The shape is named once, above, and every cell below references PERSON_SHAPE_TTL by name rather than repeating it:

const PEOPLE_TTL = `
  @prefix foaf: <http://xmlns.com/foaf/0.1/> .
  @prefix ex:   <http://example.org/> .

  ex:alice a foaf:Person ;
    foaf:name  "Alice" ;
    foaf:knows ex:bob .

  ex:bob a foaf:Person ;
    foaf:name "Bob" .
`;

const result = await fn.shaclValidate(PEOPLE_TTL, PERSON_SHAPE_TTL);
return { conforms: result.conforms, reportSize: result.report.size };

conforms: true — both Alice and Bob have a foaf:name, it's a plain string in both cases, and Bob (the only foaf:knows target here) is himself a foaf:Person. result.report is still a real Dataset even when there's nothing wrong — sh:conforms true plus a sh:ValidationReport type triple, no sh:ValidationResult entries.

Breaking it#

Delete Bob's foaf:name and validate again:

const NO_NAME_TTL = `
  @prefix foaf: <http://xmlns.com/foaf/0.1/> .
  @prefix ex:   <http://example.org/> .

  ex:alice a foaf:Person ;
    foaf:name  "Alice" ;
    foaf:knows ex:bob .

  ex:bob a foaf:Person .
`;

const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const SH_VALIDATION_RESULT = "http://www.w3.org/ns/shacl#ValidationResult";

const result = await fn.shaclValidate(NO_NAME_TTL, PERSON_SHAPE_TTL);
const quads = [...result.report];

const rows = quads
  .filter((q) => q.predicate.value === RDF_TYPE && q.object.value === SH_VALIDATION_RESULT)
  .map((resultTypeQuad) => {
    const resultNode = resultTypeQuad.subject.value;
    const fieldOf = (predSuffix) => {
      const hit = quads.find(
        (q) => q.subject.value === resultNode && q.predicate.value.endsWith(predSuffix)
      );
      return hit ? hit.object.value : null;
    };
    return {
      focusNode: fieldOf("#focusNode"),
      path: fieldOf("#resultPath"),
      message: fieldOf("#resultMessage"),
    };
  });

return { conforms: result.conforms, violations: rows };

One violation row: focus node ex:bob, path foaf:name, message "foaf:Person needs at least one foaf:name" — the report is itself an RDF graph (sh:ValidationReport/sh:ValidationResult, SHACL §3), read here the same way every other post's live cells read a Dataset: iterate the quads, filter by predicate.

Three constraints, three distinct violations#

sh:minCount, sh:datatype, and sh:class each catch a different kind of mistake. Break the graph three separate ways, one constraint at a time, and validate each against the same shape:

const MIN_COUNT_VIOLATION_TTL = `
  @prefix foaf: <http://xmlns.com/foaf/0.1/> .
  @prefix ex:   <http://example.org/> .
  ex:alice a foaf:Person ; foaf:name "Alice" ; foaf:knows ex:bob .
  ex:bob a foaf:Person .
`;

const DATATYPE_VIOLATION_TTL = `
  @prefix foaf: <http://xmlns.com/foaf/0.1/> .
  @prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .
  @prefix ex:   <http://example.org/> .
  ex:alice a foaf:Person ; foaf:name "42"^^xsd:integer ; foaf:knows ex:bob .
  ex:bob a foaf:Person ; foaf:name "Bob" .
`;

const CLASS_VIOLATION_TTL = `
  @prefix foaf: <http://xmlns.com/foaf/0.1/> .
  @prefix ex:   <http://example.org/> .
  ex:alice a foaf:Person ; foaf:name "Alice" ; foaf:knows ex:mallory .
  ex:mallory foaf:name "Mallory" .
`;

const SH_COMPONENT = "http://www.w3.org/ns/shacl#sourceConstraintComponent";

async function violatedComponent(dataTtl) {
  const result = await fn.shaclValidate(dataTtl, PERSON_SHAPE_TTL);
  const quads = [...result.report];
  const componentQuad = quads.find((q) => q.predicate.value === SH_COMPONENT);
  return {
    conforms: result.conforms,
    component: componentQuad ? componentQuad.object.value.split("#")[1] : null,
  };
}

return {
  minCount: await violatedComponent(MIN_COUNT_VIOLATION_TTL),
  datatype: await violatedComponent(DATATYPE_VIOLATION_TTL),
  class: await violatedComponent(CLASS_VIOLATION_TTL),
};

Each broken graph is otherwise identical to the conforming one — one triple changed, one constraint tripped: MinCountConstraintComponent when Bob's foaf:name is missing entirely, DatatypeConstraintComponent when Alice's name is an xsd:integer instead of a plain string, ClassConstraintComponent when Alice's foaf:knows points at ex:mallory, who is never typed foaf:Person. sh:sourceConstraintComponent is SHACL's own way of naming which constraint fired — the same IRI the W3C test suite's 98 core-constraint tests check against.

What's next#

Other posts in this series cover the other RDF syntaxes, SPARQL Update and the HTTP protocol, ShEx, JSON-LD, RDFC-1.0 canonicalization, and the verification story. See the series plan for the full map.

Every live cell above is pinned in tests/hub/post05_test.mjs — the exact same source, executed against the real npm/factoidal typed API instead of the in-browser fn adapter.