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 (anIriorBlankNode).validateAll— validates several nodes against one state of the graph and returns the result of each in the order given. The default implementation callsvalidateper 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 ofvalidatecalls, see Graph cache. The extension for wrappers groups the instances by the graph object of their handle and callsvalidateAllonce 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 oftype, creating it withcreateon first use.close(type)closes and removes the validator oftype; it returnsfalsewhen there was none. The nextgetcreates 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, includingrdfs:subClassOfinstances 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 throughsh:node,sh:propertyetc. are evaluated as usual. When no shape targets the focus node the result isOkwithout 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-basedsh: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 useuse { }) to release the repositories and the loaded data (a repository still used by a call is released when that call returns);validateafterclose()throwsIllegalStateException. - RDF4J 5.x SHACL has no base-direction support.
Rdf4jValidationrejects directional language strings in shapes and data, including nested triple terms, before loading them. UseJenaValidationor 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:hasValueparameters. These shapes fail admission withIllegalArgumentException, 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 requiresValidationConfig.allowTripleTermsInShapeParameters = truefor 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
validatecall: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 Nvalidatecalls over such a graph therefore reads it N times. Two ways to avoid that:validator.validateAll(graph, nodes)(orvalidator.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.
- Graphs that implement
- 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 withequalsonly when it overridesequals/hashCodeand implementsVersionedRdfGraphorHandleEqualGraph(a marker interface inkastor-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 withequals.equalsnever 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. WithassumeImmutable = truethe 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 ofhashCode()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
maxCachedGraphscopies 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 propertieskastor.validation.rdf4j.maxCachedGraphs/kastor.validation.jena.maxCachedGraphs(a positive integer; any other value, such as0orabc, is reported once with a WARN entry on theSystem.Loggercom.geoknoesis.kastor.gen.runtime.GraphStateCacheand 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 inmaxCachedGraphs(reference and digest comparisons; no graph is read and noequalsof 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
maxCachedGraphstemporary copies are made that way at a time. Beyond that (more than2 x maxCachedGraphsdistinct 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 aGraphStateCacheSaturatedExceptionafter 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(anIllegalStateException); 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
validatecan be called insiderepository.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),
validateinside 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 withmaterialize, notmaterializeValidated.Rdf4jValidation has been closed— the adapter was used afterclose().