Validation API Reference

Reference for the Kastor Gen validation types (module kastor-gen:runtime) and the two SHACL engine adapters (kastor-gen:validation-jena, kastor-gen:validation-rdf4j).

Validation is explicit and optional: nothing is validated unless you pass a ValidationContext.

Core types

All in package com.geoknoesis.kastor.gen.runtime.

ValidationContext

interface ValidationContext : AutoCloseable {
    fun validate(data: RdfGraph, focus: RdfTerm): ValidationResult
    fun validateAll(data: RdfGraph, focuses: Collection<RdfTerm>): Map<RdfTerm, ValidationResult>
    override fun close() {}   // default: nothing to release
}

// one validateAll call per data graph for a collection of wrappers
fun <T : RdfBacked> ValidationContext.validateAll(instances: Iterable<T>): List<Pair<T, ValidationResult>>
  • data — the graph holding the data to check.
  • focus — the focus node (an Iri or BlankNode).
  • validateAll — validates several nodes against one state of the graph and returns the result of each in the order given. The default implementation calls validate per node; the bundled adapters check the graph for changes (and, for a graph without a modification stamp, read it) once for the whole batch. Use it instead of a loop of validate calls, see Graph cache. The extension for wrappers groups the instances by the graph object of their handle and calls validateAll once per graph.
  • close() — validators may hold resources (an RDF4J repository, parsed shapes). Whoever creates a context should close it (use { }); the default implementation does nothing, so custom contexts only override it when they own resources.

Contract: the result describes the focus node only. The bundled adapters evaluate the shapes that target focus and return only the SHACL results whose sh:focusNode is focus; violations reported for other nodes in the same graph (including nested nodes reached through sh:node) are not included. Validate each node you care about separately.

ValidationResult

sealed interface ValidationResult {
    data object Ok : ValidationResult
    data class Violations(val items: List<ShaclViolation>) : ValidationResult
}

fun ValidationResult.orThrow()                                // fails only on sh:Violation
fun ValidationResult.orThrow(minimumSeverity: ShaclSeverity)  // fails on results at least this severe

Violations holds every SHACL result, whatever its severity. orThrow() throws only when at least one item has severity sh:Violation; sh:Warning and sh:Info results are advisory and ignored. To fail on them too, pass a minimum severity (Violation > Warning > Info):

result.orThrow(ShaclSeverity.Warning)   // throws on Violation or Warning results

The thrown ValidationException carries only the items at or above the threshold.

ShaclViolation

data class ShaclViolation(
    val focusNode: RdfResource,
    val shapeIri: Iri,              // nearest IRI-named shape (blank-node property shapes resolve to their parent)
    val constraintIri: Iri,         // e.g. sh:MinCountConstraintComponent
    val path: Iri? = null,          // simple predicate path, null for node-level constraints
    val actualValue: RdfTerm? = null,
    val expectedValue: RdfTerm? = null, // the constraint parameter, e.g. the sh:datatype value
    val message: String,            // sh:message of the shape if present, else the engine message
    val severity: ShaclSeverity = ShaclSeverity.Violation,
)

enum class ShaclSeverity { Violation, Warning, Info }

ValidationException

class ValidationException(
    message: String,
    val violations: List<ShaclViolation> = emptyList(),
    cause: Throwable? = null,
) : RuntimeException(message, cause)

Thrown by ValidationResult.orThrow(), RdfHandle.validateOrThrow() and materializeValidated when there are failing results (by default, results with severity sh:Violation). violations carries the structured results.

SharedValidators

object SharedValidators {
    fun <V : ValidationContext> get(type: Class<V>, create: () -> V): V
    fun close(type: Class<out ValidationContext>): Boolean
    fun closeAll()
}

Process-wide validators, one instance per validator class. Wrappers generated with validationMode = EXTERNAL name a ValidationContext class with a no-argument constructor and obtain it with SharedValidators.get(Validator::class.java) { Validator() }, so every wrapper type that names the same class uses one shared instance instead of one each. This matters for Rdf4jValidation, which keeps a copy of the last validated graph in an in-memory store.

Lifecycle:

  • get(type, create) returns the shared validator of type, creating it with create on first use.
  • close(type) closes and removes the validator of type; it returns false when there was none. The next get creates a fresh instance.
  • closeAll() closes and removes every shared validator. A failure to close one does not prevent closing the others; the first failure is rethrown with the later ones suppressed.

Call closeAll() when the application, a test or a reloadable module shuts down. Do not close a shared validator directly while wrappers may still use it; use SharedValidators.close(type) instead.

Engine adapters

Both adapters offer the same two ways of supplying shapes:

Construction Where shapes come from Notes
JenaValidation(shapes: RdfGraph) / Rdf4jValidation(shapes: RdfGraph) a separate shapes graph (recommended) shapes are copied/converted once and reused for every call
JenaValidation.fromTurtle(ttl) / Rdf4jValidation.fromTurtle(ttl) Turtle text same as above
JenaValidation() / Rdf4jValidation() shapes embedded in the data graph shapes are read from data (Jena: on every call; RDF4J: again only when the graph instance or its content changes); if the data graph declares no shapes the result is Ok
JenaValidation.trustingEmbeddedShapes() / Rdf4jValidation.trustingEmbeddedShapes() shapes embedded in a trusted data graph as the no-arg constructor, but every embedded shape runs (see below)

With the no-arg constructors, a data graph that contains no SHACL shapes always validates as Ok. Supply the shapes explicitly unless your data really carries them.

Embedded shapes are untrusted input. They come with the data, and both engines (jena-shacl and RDF4J ShaclSail) run SHACL-SPARQL without a sandbox: a SERVICE clause sends a request to any host the data names, and a query has no deadline. They also run sh:pattern on java.util.regex, which a hostile pattern can make backtrack without bound. So the no-arg validators admit only SHACL Core shapes without sh:pattern. If the embedded shapes use any other term of the SHACL namespace, validate throws EmbeddedShapesRefusedException (an IllegalArgumentException) before the engine runs. Such terms include sh:sparql, sh:select, sh:ask, sh:target, sh:validator, sh:pattern, sh:flags, sh:expression, sh:rule and terms of later SHACL versions. The terms that are admitted are the targets, paths and constraints of SHACL Core, their non-validating properties, prefix declarations and the terms of a validation report.

SHACL Core alone can still cost exponential time: the engines evaluate a shape again for every reference to it, so a chain of shapes that each reference the next twice (through sh:or, sh:node, sh:property, …) costs 2^n. So admitted shapes must also stay within structural caps, which are checked before the engine runs: at most 5,000 constraints once shared shapes are unfolded into a tree, 32 levels of nested shape references or paths, 64 operands per sh:or, sh:and or sh:xone, lists of at most 1,000 members that end in rdf:nil without a cycle, and 20,000 statements in the SHACL namespace. Beyond them validate throws EmbeddedShapesRefusedException. The cost that the data adds (a nested shape is evaluated once per value node, so d nested property shapes over nodes with k values each cost k^d) is bounded only by JenaValidation(), which charges each validation of a focus node to a deterministic budget of 500,000 steps (engine validation events, data lookups and the statements they return) and throws EmbeddedShapesRefusedException when it runs out. Rdf4jValidation() does not run embedded shapes on ShaclSail at all, whose cost from the data has no bound (4 nested property shapes over a 20,000-statement graph whose nodes each have 100 values took 51 s): it validates them on the Kastor SHACL validator, with a deadline of 5 s per focus node and its work limits, and throws EmbeddedShapesRefusedException when a validation reaches them (that probe is decided in under a second). On SHACL Core the results are ShaclSail’s, except that ShaclSail skips sh:xone and the *OrMore / zeroOrOne paths, which the Kastor validator evaluates.

The term allow-list, the structural caps and the budgets apply only to the no-arg validators (untrusted embedded shapes). trustingEmbeddedShapes() and shapes passed to the constructor or to fromTurtle run on jena-shacl or ShaclSail without them.

Only for a data graph from a trusted source, use trustingEmbeddedShapes(), which runs every embedded shape with no sandbox, no cap and no deadline. Shapes passed to the constructor or to fromTurtle are your own and are trusted. Never pass shapes from an untrusted source there. These two modes have no deadline and no budget at all, also when only the data is untrusted: neither engine has a cancellation API, and a deadline on another thread could only abandon a validation, not stop it. To validate against untrusted shapes, use the Kastor SHACL validator (rdf-shacl-validation): it runs SHACL-SPARQL in a sandbox with a deadline and patterns on a linear-time engine.

A generated EXTERNAL validator (SharedValidators, the class’s no-arg constructor) follows the same rule. To trust the data, name a class of your own whose no-arg constructor delegates, for example class TrustedValidation : ValidationContext by Rdf4jValidation.trustingEmbeddedShapes().

Engine failures (malformed shapes, a literal passed as focus, …) are thrown, never reported as Ok or as a violation.

JenaValidation

Package com.geoknoesis.kastor.gen.validation.jena, module kastor-gen:validation-jena (depends on kastor-gen:runtime, rdf:jena and org.apache.jena:jena-shacl).

class JenaValidation : ValidationContext {
    constructor()                      // shapes read from the data graph
    constructor(shapes: RdfGraph)      // separate shapes graph
    constructor(shapes: RdfGraph?, maxCachedGraphs: Int)
    constructor(shapes: RdfGraph? = null, maxCachedGraphs: Int = /* configured */, assumeImmutable: Boolean)
    val maxCachedGraphs: Int
    val assumeImmutable: Boolean
    companion object {
        const val DEFAULT_MAX_CACHED_GRAPHS = 16
        const val MAX_CACHED_GRAPHS_PROPERTY = "kastor.validation.jena.maxCachedGraphs"
        fun fromTurtle(shapesTurtle: String): JenaValidation
    }
    override fun close()               // drops the cached converted graphs
}
  • Backed by Jena’s ShaclValidator.
  • A standalone Jena graph (JenaBridge.fromJenaModel(...), a graph parsed by the Jena provider) is validated in place: nothing is copied or cached.
  • Every other graph, including the graphs of a JenaRepository, is copied into a Jena graph (a repository’s store is only readable inside a transaction, so the engine needs a detached copy), and the copy - together with the shapes parsed from it, for embedded shapes - is cached with the rules described under Graph cache: it is copied (and its shapes parsed) again only when the graph is new or its content changed.

Rdf4jValidation

Package com.geoknoesis.kastor.gen.validation.rdf4j, module kastor-gen:validation-rdf4j (depends on kastor-gen:runtime, rdf:rdf4j and org.eclipse.rdf4j:rdf4j-shacl).

class Rdf4jValidation : ValidationContext, AutoCloseable {
    constructor()                      // shapes read from the data graph
    constructor(shapes: RdfGraph)      // separate shapes graph
    constructor(shapes: RdfGraph?, maxCachedGraphs: Int)
    constructor(shapes: RdfGraph? = null, maxCachedGraphs: Int = /* configured */, assumeImmutable: Boolean)
    val maxCachedGraphs: Int
    val assumeImmutable: Boolean
    companion object {
        const val DEFAULT_MAX_CACHED_GRAPHS = 16
        const val MAX_CACHED_GRAPHS_PROPERTY = "kastor.validation.rdf4j.maxCachedGraphs"
        fun fromTurtle(shapesTurtle: String): Rdf4jValidation
    }
    override fun close()
}
  • Backed by RDF4J’s ShaclSail. A shapes graph is converted once at construction.
  • One repository per data graph. ShaclSail validates data held in its own store, so the Kastor graph is converted to RDF4J statements and loaded into an in-memory repository, which is kept and reloaded only when the graph’s content changed (see Graph cache). Validating many nodes of one graph therefore converts and loads it once. With the no-arg constructor the embedded shapes are extracted at the same time.
  • Reloads are atomic. The statements and embedded shapes are prepared first, then the store content is replaced in one transaction (rolled back on failure). The loaded graph, its digest and its embedded shapes are updated together only after the commit, so a failed conversion or load leaves the previously loaded state intact.
  • Only shapes that target the focus node are evaluated. The target declarations (sh:targetClass, including rdfs:subClassOf instances and implicit class targets, sh:targetNode, sh:targetSubjectsOf, sh:targetObjectsOf) are resolved for the focus node, and only those shapes are validated, in a transaction that is always rolled back. Shapes reached through sh:node, sh:property etc. are evaluated as usual. When no shape targets the focus node the result is Ok without running the engine.
  • Fallbacks: for a blank-node focus (RDF4J does not accept a blank node as sh:targetNode) the selected shapes keep their own target declarations; shapes with other target kinds (e.g. SPARQL-based sh:target) are validated for all their targets. In both cases the report is filtered to the focus node.
  • Calls for one data graph are serialized on that graph’s repository; calls for different graphs run concurrently, so a validator can be shared between threads. Call close() (or use use { }) to release the repositories and the loaded data (a repository still used by a call is released when that call returns); validate after close() throws IllegalStateException.
  • RDF4J 5.x SHACL has no base-direction support. Rdf4jValidation rejects directional language strings in shapes and data, including nested triple terms, before loading them. Use JenaValidation or the native validator for these terms. A failed conversion leaves the previous cached data intact.
  • Its SHACL query compiler also cannot handle blank-node or triple-term sh:hasValue parameters. These shapes fail admission with IllegalArgumentException, including embedded shapes and failed reloads. Use Jena or native validation for those parameters; blank nodes and triple terms in data remain supported. Native validation requires ValidationConfig.allowTripleTermsInShapeParameters = true for triple-term parameters.

Graph cache

Both adapters keep the converted copy of recently validated data graphs (the Jena adapter for every graph except standalone Jena graphs). The cache itself (GraphStateCache in kastor-gen:runtime) is internal API, shared by the two adapters only: it is marked @KastorGenInternalApi (an opt-in at error level), is not part of the binary compatibility dumps and may change in any release. What follows describes what a validator does with it.

  • What a call costs.
    • Graphs that implement VersionedRdfGraph (MemoryGraph, the named graphs of the memory repository, the graphs of repositories whose provider stamps them) are checked in O(1) by their modification stamp; the triples are not read.
      • The stamp of a repository usually moves when any of its graphs is written. When the stamp of a graph moved, the graph is read once and its content digest is compared with the one of the copy: if this graph did not change, the copy is kept and only the new stamp is recorded - a write to one graph does not reload the copies of all the others. (The digest is computed on such a reload only, never on a hit.)
      • Reloads are single-flight: when several callers find the copy of a graph stale at the same time, the first one reads the graph and the others wait for it and use its result, instead of each running a full getTriples(). A caller that reads another version of the graph (another stamp, e.g. inside its own transaction) does not wait, and a wait is bounded (two seconds), after which the caller reads the graph itself: a caller that holds a repository lock is delayed, never deadlocked.
    • Graphs without a stamp are read in full on every validate call: getTriples() - a complete download for a SPARQL-endpoint graph - plus a digest of every triple (one SHA-256 each), because nothing cheaper proves that the content is unchanged. Only the conversion, the store writes and the shapes parse are saved when the digest matches. A loop of N validate calls over such a graph therefore reads it N times. Two ways to avoid that:
      • validator.validateAll(graph, nodes) (or validator.validateAll(wrappers)): one read for all nodes;
      • Rdf4jValidation(shapes, assumeImmutable = true) / JenaValidation(shapes, assumeImmutable = true): you guarantee that graphs without a stamp do not change while the validator lives. A graph that is passed again (the same object, or an equal handle) is then not read again. A change of such a graph is not detected - results keep describing the content first seen - so use it for immutable snapshots and batch jobs only. Graphs with a stamp are still checked.
  • Which copy belongs to a graph. First by handle: the same graph object, or an equal handle (repository.getGraph(name) returns a new object per call). Handle equality is an explicit contract: a graph is compared with equals only when it overrides equals/hashCode and implements VersionedRdfGraph or HandleEqualGraph (a marker interface in kastor-gen:runtime, internal API like the cache), or is one of Kastor’s own graph classes. Data classes, Java records and other foreign classes are never compared with equals. equals never runs under a lock of the cache. Then, for graphs without a stamp, by content: a copy built for the same digest is reused whichever handle it was built for. With assumeImmutable = true the handle of a graph found by content is remembered too, so a second graph with the same content (two empty graphs, say) is not read again on its next call either.
  • A changing graph keeps one copy when it is found by handle: the copy is replaced in place. A changing graph without a stamp that is read through fresh handles without handle equality cannot be linked to its previous copy; the obsolete copies are evicted first when the cache is full and expire after a minute without use once their handle was garbage collected.
  • The digest is the sum modulo 2^256 of SHA-256(salt || encoding of the triple) over the triples, plus the triple count: order independent, one pass, and unlike a sum of hashCode()s it does not miss changes such as a literal "Aa" becoming "BB". A plain sum of hashes can be attacked with crafted data (generalised birthday attack), so each validator uses its own random 32-byte salt that never leaves the process: the per-triple values cannot be computed by someone who only controls the data, and a collision cannot be prepared in advance. The digest is not a stable or public fingerprint.
  • Size. At most maxCachedGraphs copies are kept (default 16). Set it with the constructor argument, or process-wide - which is how to size the validators shared by generated wrappers - with the system properties kastor.validation.rdf4j.maxCachedGraphs / kastor.validation.jena.maxCachedGraphs (a positive integer; any other value, such as 0 or abc, is reported once with a WARN entry on the System.Logger com.geoknoesis.kastor.gen.runtime.GraphStateCache and the default is used). When the cache is full the least recently used copy that no call is using is released, copies whose handle was garbage collected first. Each call does bookkeeping under one cache lock that is linear in maxCachedGraphs (reference and digest comparisons; no graph is read and no equals of a graph runs under the lock, and validation itself runs outside it). That is negligible at the default and at a few hundred entries; for thousands of small graphs prefer one validator per group of graphs over one very large cache.
  • Saturation. When every copy is in use, a call validates in a private temporary copy that is released when the call returns, instead of waiting for the validation of another graph. At most maxCachedGraphs temporary copies are made that way at a time. Beyond that (more than 2 x maxCachedGraphs distinct graphs being validated at the same moment) a call waits a quarter of a second for a copy to become free and then makes a private copy all the same: under such a load a validation is slowed down, it neither stalls nor fails. (The users of the other copies may be waiting for a repository lock that the caller holds; a long wait there used to end in a GraphStateCacheSaturatedException after 10 seconds. The validators no longer throw it.) Size the cache to the number of graphs that are validated repeatedly (every miss converts and loads the whole graph).
  • Interruption. A thread that is interrupted while it waits for another caller gets a GraphStateCacheInterruptedException (an IllegalStateException); its interrupt status stays set.
  • Release. Copies that can no longer be found by their handle are released on the next call: those of stamped graphs and of handle-equal handles once the handle was garbage collected (handle-equal handles are softly referenced, i.e. dropped under memory pressure), and content-identified copies whose handle was garbage collected after a minute without use. The rest stays until it is evicted or the validator is closed.
  • Locking. The data graph is read only while the validator holds no lock, so validate can be called inside repository.transaction { } while other threads validate the same graph.
  • Transactions. The stamp and the content are read on the calling thread, and a copy is only ever used for a caller whose own stamp it was made for. With providers whose stamps identify what the calling thread reads (a thread with uncommitted writes gets stamps no other thread sees; everyone else keeps the committed stamp until the commit), validate inside a transaction validates that thread’s uncommitted content, and no other thread is ever served it; a thread inside its transaction is never served the committed content it has changed. A copy made for a transaction is not kept when the provider says the stamp is private to the transaction (TransactionScopedGraph, internal API); otherwise it takes the place of the graph’s copy and is replaced by the next caller - there is one copy per graph at all times, never one per stamp.
  • Closed repositories. When reading the stamp fails (a closed repository), the copy kept for that graph is released at once and the failure is rethrown; it does not stay until it is evicted.
  • Jena-backed graphs of a repository are copied natively by JenaValidation (Jena graph to Jena graph, in one read transaction), not through Kastor’s term model: a store that holds a statement Kastor’s strict reads reject (a language tag with a subtag longer than eight characters, legal in Turtle) is validated like any other.

Usage

Materialize with validation

import com.geoknoesis.kastor.gen.runtime.*
import com.geoknoesis.kastor.gen.validation.jena.JenaValidation
import com.geoknoesis.kastor.rdf.*

val shapesTtl = javaClass.getResource("/person-shape.ttl")!!.readText()
val validation = JenaValidation.fromTurtle(shapesTtl)

// Throws ValidationException if the focus node violates its shapes.
val person: Person = graph.materializeValidated(personIri, validation)

materializeValidated (and RdfRef.asValidatedType, OntoMapper.materializeValidated) builds the object, then validates the focus node and throws on violations. The resulting handle keeps the context, so person.asRdf().validate() can be called again later (for example after mutating the graph).

Validate a node directly

when (val result = validation.validate(graph, personIri)) {
    ValidationResult.Ok -> println("valid")
    is ValidationResult.Violations -> result.items.forEach {
        println("${it.severity} ${it.path?.value}: ${it.message}")
    }
}

Separate shapes graph

val shapes: RdfGraph = Rdf.parse(shapesTtl, "TURTLE")
Rdf4jValidation(shapes).use { validation ->
    validation.validate(graph, personIri).orThrow()
}

Handle without validation

A handle created by plain materialize has no context: rdf.isValidationConfigured is false and validate() / validateOrThrow() throw IllegalStateException. Either materialize with materializeValidated or call validation.validate(graph, node) yourself.

Custom contexts

ValidationContext is a single-method interface, so business rules can be layered on top of an engine adapter:

class WithBusinessRules(private val shacl: ValidationContext) : ValidationContext {
    override fun validate(data: RdfGraph, focus: RdfTerm): ValidationResult {
        val shaclResult = shacl.validate(data, focus)
        val extra = mutableListOf<ShaclViolation>()
        // ... add ShaclViolation(focusNode = focus as RdfResource, shapeIri = ..., constraintIri = ..., message = ...)
        val all = (shaclResult as? ValidationResult.Violations)?.items.orEmpty() + extra
        return if (all.isEmpty()) ValidationResult.Ok else ValidationResult.Violations(all)
    }
}

Dependencies

Inside the Kastor build (artifacts are not yet published to a public repository; see Getting Started for publishToMavenLocal / composite builds):

dependencies {
    implementation(project(":kastor-gen:validation-jena"))   // brings rdf:jena
    // or
    implementation(project(":kastor-gen:validation-rdf4j"))  // brings rdf:rdf4j
}

Published artifact ids: com.geoknoesis.kastor:kastor-gen-validation-jena and com.geoknoesis.kastor:kastor-gen-validation-rdf4j.

Troubleshooting

  • Always Ok — you used the no-arg constructor and the data graph contains no shapes. Pass the shapes (JenaValidation(shapesGraph) / fromTurtle).
  • Violation on a nested node not reported — results are filtered to the focus node; validate the nested node as its own focus.
  • Validation context not configured for this handle — the object was created with materialize, not materializeValidated.
  • Rdf4jValidation has been closed — the adapter was used after close().