SHACL
purrdf-shapes (re-exported as
purrdf::shapes) is PurRDF’s native SHACL validator: the complete SHACL
Core feature set — all constraint components, full property paths,
qualified value shapes, property pairs — plus SHACL-SPARQL constraints
and targets and the SHACL-AF surface, running entirely on PurRDF’s own
interned IR and native SPARQL engine (no oxigraph, no PyO3).
It validates an RDF 1.2 data graph against a SHACL shapes graph with no
inference (parity with pySHACL inference="none"); combine with
Entailment if you want to validate a materialized closure.
What it covers
- SHACL Core — every constraint component, full property paths, qualified
value shapes, property pairs. The W3C
data-shapessuite passes clean (126/126, zero ledgered gaps at the time of writing — the live number is indocs/CONFORMANCE.md). - SHACL-SPARQL — SPARQL-based constraints and targets, custom constraint
components with pre-binding semantics, user-defined
sh:SPARQLFunctioncalls, andsh:SPARQLTargetType, evaluated on the native SPARQL engine. - SHACL-AF — node expressions (including
sh:ExpressionConstraintComponent) and SHACL Rules (sh:TripleRuleandsh:SPARQLRule, withsh:condition,sh:order,sh:deactivated): rules fire in an iterative fixpoint and the derivation is materialized as a new dataset (base ⊎ derived), leaving the input graph untouched. Some node-expression conveniences (sh:if, aggregations, ordering wrappers) are DASH/TopBraid conventions with no normative RDF definition; PurRDF documents its adopted reading and pins it with a frozen corpus — see the SHACL-AF section of docs/CONFORMANCE.md.
The SHACL 1.2 reifier-shape draft scope
The crate implements a scoped SHACL 1.2 Working Draft feature:
sh:reifierShape and sh:reificationRequired for direct IRI property paths,
so shapes can constrain the RDF 1.2 reifier metadata attached to statements
(see RDF 1.2 Features). The relevant SHACL 1.2 Core
draft is dated 2026-06-02. This is not a claim of full SHACL 1.2
conformance — it is one draft feature, explicitly scoped and tested.
Pydantic v2 projection
purrdf-shapes can transliterate a compiled SHACL-derived JSON Schema into a
deterministic, typed Pydantic v2 package entirely in memory. The public
emit_pydantic function consumes CompiledSchema; PydanticConfig requires the
caller to supply the package name and package/module prose, so the library does
not invent a vocabulary, namespace, or downstream brand.
Every $defs entry gets a stable import path, JSON property names remain exact
through Pydantic aliases, and generated classes expose the originating
definition through model_json_schema(by_alias=True). Pydantic runtime
annotations enforce the representable portion. A JSON Schema assertion with no
exact runtime annotation remains visible on that schema surface and produces a
located entry in the always-computed json-schema → pydantic-v2
LossLedger; a lossless input yields an empty ledger. The renderer itself has no
Python dependency and stays wasm-clean. A dev-only Python oracle executes the
generated code and checks the live reverse/schema surface.
LinkML 1.11 projection
The same CompiledSchema carrier can be projected to canonical LinkML 1.11
with emit_linkml. LinkmlConfig requires the caller’s schema IRI, name,
description, default prefix, and complete prefix map, so PurRDF never mints a
consumer vocabulary or identity. The returned LinkmlPackage includes the
typed document, deterministic YAML, a reversible $defs-key mapping, and a
located json-schema → linkml-1.11 loss ledger.
Classes and exact property aliases, types, enums, local references, inline
objects, requiredness, homogeneous arrays, patterns, inclusive bounds, and
LinkML boolean expressions are represented directly. Every unsupported
assertion is classified by a closed capability table; malformed inputs,
external/dynamic/dangling references, prefix mistakes, and deterministic-name
collisions fail closed. parse_linkml and write_linkml preserve all
JSON-compatible metamodel fields and provide byte-stable read/write round trips
while rejecting YAML-only tags, duplicate keys, non-string keys, and non-finite
numbers.
The Rust production path has no LinkML-toolkit dependency. CI uses the locked official LinkML 1.11.1 Python packages only as a differential oracle:
make linkml-oracle
TypeScript 7.0 projection
emit_typescript projects the same CompiledSchema into deterministic
TypeScript 7.0 declarations. The caller supplies the package name and all
package/module prose through TypeScriptConfig. The returned package contains
one index.d.ts, a reversible $defs-key to exported-type map, and a located
json-schema → typescript-7.0 loss ledger; PurRDF invents no consumer
identity or vocabulary.
The fixed declaration dialect uses strict plus
exactOptionalPropertyTypes. Type aliases preserve JSON primitives and
literals, required versus optional fields, explicit null, local recursive
references, unions, intersections, homogeneous arrays, and bounded tuples.
There are no runtime enums, mergeable interfaces, branded pseudo-validators,
or any escape hatches. Invalid keywords, open/dangling references, and name
collisions fail before bytes are emitted.
Runtime assertions outside TypeScript structural assignability are never silently erased: integer, numeric/string predicate, closure, pattern-property, dependency, conditional, negation, contains/unique, evaluation-state, and bounded-expansion gaps receive stable codes and JSON Pointer locations. CI classifies instances independently with a draft 2020-12 validator and compiles the generated declarations with the locked TypeScript 7.0.2 compiler, including fresh-literal and through-variable probes:
make typescript-oracle
The projection intentionally has no arbitrary TypeScript reader. TypeScript
declarations do not define a unique runtime JSON acceptance relation, and the
projection is many-to-one. The retained CompiledSchema plus the reversible
name map remains the authoritative reverse surface. TypeScript is only a
dev-time oracle dependency; the Rust emitter is filesystem-free and wasm-clean.
GraphQL September 2025 projection
emit_graphql projects CompiledSchema into deterministic GraphQL September
2025 SDL. GraphqlConfig has no defaults: the caller supplies the schema name,
package and module prose, and a non-built-in fallback-scalar name. The returned
GraphqlPackage contains schema.graphql, canonical name-map.json, the same
name map as typed Rust data, a located json-schema →
graphql-september-2025 loss ledger, and the production value codec.
The SDL is deliberately a type-system fragment. PurRDF emits paired output
type and input input objects, but no query, mutation, or subscription root,
resolver, pagination rule, authorization policy, federation directive, or
other application behavior. A caller composes the fragment with its own
executable schema.
The exact grammar includes GraphQL booleans, strings, numbers, the signed
32-bit Int domain, explicit nullability, finite JSON const/enum sets,
closed object fields, requiredness, homogeneous lists, direct local $defs
references and aliases, descriptions, and inline object helpers. One global
collision-checked namespace covers types, helpers, and the fallback scalar;
fields and enum symbols are checked in their GraphQL-local namespaces. The
typed/canonical name maps retain the source definition keys, property keys, and
finite JSON values.
GraphqlPackage::encode_input maps source JSON keys and finite values to input
field names and enum symbols. decode_output performs the inverse for fields
present in a GraphQL response, without inventing omitted selections. Unknown or
incompatible values fail. This package codec is the precise reverse boundary;
arbitrary GraphQL SDL has no unique JSON Schema acceptance relation and is not
accepted as an inverse format.
GraphQL variable coercion differs from JSON Schema validation at these closed boundaries:
| Boundary | Located loss families |
|---|---|
| object fields and names | additional properties, pattern properties, property names/counts |
| requiredness and recursion | nullable-presence widening, one deterministic recursive-input nullability relaxation |
| lists | singleton coercion, cardinality, contains, uniqueness, tuples, unevaluated items |
| scalar assertions | integer domain delegation, numeric predicates, string predicates |
| applicators | conditionals, dependencies, intersections, unions, oneOf, negation |
| runtime boundary | custom-scalar and unknown-keyword validation delegation |
The caller-named fallback scalar is declared but PurRDF does not invent its
parseValue, parseLiteral, or serialization semantics. Every delegated use
is therefore ledgered. Loss entries carry stable codes and source JSON Pointer
locations; an exact package has an empty ledger.
Emission fails before returning bytes for invalid caller configuration,
malformed schema keywords, $id rebasing, external/indirect/dangling $ref,
$dynamicRef/$recursiveRef, alias cycles, unsatisfiable closed required
fields, and generated-name collisions. The fixed limits are 16 MiB for the
input schema, each artifact, and one codec value; 65,536 definitions, fields
per object, or finite values; depth 128; and 255 bytes per GraphQL name.
The independent dev oracle classifies source values with boon, builds the
SDL with locked official GraphQL.js 16.14.0, and executes real variable
coercion. It verifies exact agreement, every closed loss family and location,
the name map and production codec, and deliberate corruption failures:
make graphql-oracle
GraphQL.js is dev-only. Emission and value translation remain filesystem-free, wasm-clean Rust.
From Python
from purrdf_native import shacl
report = shacl.validate(shapes_ttl="...", data_nt="...")
print(report["conforms"]) # True / False
print(report["results"]) # list of violation dicts
Each result dict keeps the stable keys focus, path, value, severity,
component, source_shape, and message.
SARIF output
Validation reports stay structured in the engine; the SARIF 2.1.0 boundary is
the separate purrdf-validate crate
(purrdf::validate), which renders a report — or parser diagnostics — as a
source-traced, byte-deterministic SARIF log for editors, CI, and
code-scanning dashboards:
use purrdf::validate::{validate_to_sarif_string, SarifOptions};
let shapes = r#"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:PersonShape a sh:NodeShape ;
sh:targetClass ex:Person ;
sh:property [ sh:path ex:age ; sh:datatype xsd:integer ] .
"#;
let data = r#"<http://example.org/alice> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Person> .
<http://example.org/alice> <http://example.org/age> "nope" .
"#;
let sarif = validate_to_sarif_string(shapes, data, &SarifOptions::default())
.expect("sarif produced");
assert!(sarif.contains("\"version\": \"2.1.0\""));
Lower-level entry points (build_report_sarif, build_diagnostics_sarif)
build a SarifLog value instead of a string, so a host can merge runs before
serializing.
Conformance
The validator is gated by the vendored W3C data-shapes suite, a vendored
DASH SHACL-AF/rules corpus, and a first-party frozen corpus of 69 cases with
byte-frozen expected reports; SHACL Rules output is compared to expected
inferred graphs by RDFC-1.0 isomorphism. See
Conformance & Testing.