Runtime API Reference
Complete reference for Kastor Gen runtime interfaces and classes.
Core Interfaces
RdfProjection
Marker interface for immutable data-class snapshots produced by a generated factory.
interface RdfProjection
Every data class generated with generateDataClass = true implements RdfProjection. This lets code distinguish eagerly-loaded snapshots from live RdfBacked wrappers at the type level:
when (instance) {
is RdfProjection -> { /* immutable snapshot — structural equality, copy() */ }
is RdfBacked -> { /* live wrapper — lazy delegates, side-channel access */ }
}
RdfProjection instances carry no RdfHandle and have no RDF dependencies in their class body. All data was loaded eagerly at factory time.
RdfBacked
Marker interface for domain instances backed by an RDF node.
interface RdfBacked {
val rdf: RdfHandle
}
Properties:
rdf: RdfHandle- Side-channel handle for RDF access
Usage:
import com.geoknoesis.kastor.gen.runtime.*
import com.geoknoesis.kastor.rdf.RdfGraph
import com.geoknoesis.kastor.rdf.RdfTerm
val node: RdfTerm = /* subject IRI or blank node */
val graph: RdfGraph = /* graph containing that subject's triples */
val person: Person = graph.materialize(node)
val rdfHandle = person.asRdf() // Extension on RdfBacked
RdfHandle
Side-channel handle for RDF power without polluting domain API.
interface RdfHandle {
val node: RdfTerm // Iri or BlankNode
val graph: RdfGraph // Kastor Graph (Jena/RDF4J under the hood)
val extras: PropertyBag // Unmapped triples (lazy & memoized)
val isValidationConfigured: Boolean // false unless built with a ValidationContext
fun validate(): ValidationResult
fun validateOrThrow() // Validate against SHACL shapes when configured
}
Properties:
node: RdfTerm- The RDF node (IRI or blank node)graph: RdfGraph- The containing RDF graphextras: PropertyBag- Unmapped propertiesisValidationConfigured- When false,validate()/validateOrThrow()will error; usematerializeValidated(or a handle constructed with aValidationContext) to enable SHACL
Methods:
validate()- Validate and returnValidationResultvalidateOrThrow()- Validate and throw on violations
Usage:
val rdfHandle = person.asRdf()
val node = rdfHandle.node
val graph = rdfHandle.graph
val extras = rdfHandle.extras
if (rdfHandle.isValidationConfigured) {
rdfHandle.validateOrThrow()
}
PropertyBag
Strongly typed property bag for unmapped RDF properties.
interface PropertyBag {
fun predicates(): Set<Iri>
fun values(pred: Iri): List<RdfTerm>
fun literals(pred: Iri): List<Literal>
fun strings(pred: Iri): List<String>
fun iris(pred: Iri): List<Iri>
fun <T : Any> objects(pred: Iri, asType: Class<T>): List<T>
}
Methods:
predicates(): Set<Iri>- Get all unmapped predicatesvalues(pred: Iri): List<RdfTerm>- Get all values for a predicateliterals(pred: Iri): List<Literal>- Get literal values onlystrings(pred: Iri): List<String>- Get string valuesiris(pred: Iri): List<Iri>- Get IRI valuesobjects(pred: Iri, asType: Class<T>): List<T>- Materialize object values
Usage:
val extras = person.asRdf().extras
// Get all unmapped predicates
val predicates = extras.predicates()
// Get string values
val altLabels = extras.strings(SKOS.altLabel)
// Get materialized objects
val relatedPeople = extras.objects(FOAF.knows, Person::class.java)
Core Classes
OntoMapper
Central materializer populated by generated registration code.
object OntoMapper {
fun <T : Any> register(type: Class<T>, factory: (RdfHandle) -> T)
fun <T : Any> register(type: Class<T>, replace: Boolean, factory: (RdfHandle) -> T)
fun unregister(type: Class<*>): Boolean
fun isRegistered(type: Class<*>): Boolean
fun registeredTypes(): Set<Class<*>>
fun <T : Any> materialize(ref: RdfRef, type: Class<T>): T
fun <T : Any> materializeValidated(ref: RdfRef, type: Class<T>, validation: ValidationContext): T
fun initialize(vararg types: Class<*>)
}
The factory registry itself is private (a ConcurrentHashMap); use the functions below.
Registration:
register(type, factory)— registers the factory fortype. Generated wrappers call it from theircompanion objectinitblock and data-class factories from theirobjectinitblock (OntoMapper.register(Person::class.java) { handle -> PersonWrapper(handle) }). You rarely call it yourself, except to plug in a hand-written implementation. A type has one factory, keyed by the interface class:- Each class has its own registration. An interface defined by two class loaders (two plugins that each bundle the generated code) is two types with independent factories.
- Registering the same factory class again is idempotent. The same instance is a no-op; another instance of the same factory class (a capturing lambda evaluated again, e.g. in
@BeforeEach) takes its place. - A different factory class is a conflict and throws
IllegalStateException(for example when two generated modules claim the same interface), because it would silently change how the whole application materializes that type. Usereplace = trueorunregisterfirst. - Class reloading. When the registered factory is the same-named factory class defined by a different class loader (a hot-reload framework re-defined the wrapper while
typestays in a shared parent loader), the new factory replaces the stale one, and the old class loader is no longer reachable from the registry. This is indistinguishable from two live plugins bundling the same wrapper for a shared interface (the last one wins), so the replacement is logged as a warning naming both class loaders.
The registry holds registered classes weakly (the factory is stored with the class): when a module’s class loader is discarded with its interfaces and wrappers, the registry does not keep it alive and no
unregisteris needed. A factory registered for an interface of a longer-lived class loader keeps its own class loader alive until it is replaced or unregistered.register(type, replace = true, factory)— replaces an existing factory deliberately (tests, plugins). Withreplace = falseit behaves like the two-argument form.unregister(type)— removes a factory; returnstrueif one was registered.isRegistered(type)/registeredTypes()— inspect the registry (a snapshot copy).
Methods:
materialize— invokes the registered factory with a provisional handle; generated wrappers typically replacerdfsoextrasand validation behave correctly. If the class is not yet registered,OntoMapperattempts to class-load${type.name}Wrapperand then${type.name}Factorybefore erroring.materializeValidated— same, with a non-nullValidationContexton the provisional handle, then validates the focus node and throwsValidationExceptionon violationsinitialize(Class…)— optional eager class-loading of wrapper or factory types to avoid first-hit registration races
Nested materialization and cycles: each outermost materialize call opens a per-thread scope. Within
that call, a node already materialized as the same type from the same graph is reused (shared nodes are
built once). Re-entering a node that is still being built throws a MaterializationException naming the
cycle (for example PersonRecord <a> -> PersonRecord <b> -> PersonRecord <a>) instead of overflowing the
stack. This only affects eagerly-loaded snapshots generated with NestedMode.DATA_CLASS over cyclic data
such as a foaf:knows b . b foaf:knows a; live wrappers load nested objects lazily and are unaffected. Use
NestedMode.INTERFACE or NestedMode.IRI_ONLY for cyclic data.
Auto-discovery order (on first materialize call for a type not yet registered):
- Try to load
${type.name}Wrapper— registers a liveRdfBackedwrapper. - If still unregistered, try to load
${type.name}Factory— registers an eagerly-loading data-class factory. - If still unregistered, throw
IllegalStateException.
Usage (prefer extensions at call sites):
import com.geoknoesis.kastor.gen.runtime.*
import com.geoknoesis.kastor.gen.validation.jena.JenaValidation
import com.geoknoesis.kastor.rdf.*
val person: Person = graph.materialize(node)
val validation: ValidationContext = JenaValidation.fromTurtle(shapesTtl) // optional module `kastor-gen:validation-jena`
val person2: Person = graph.materializeValidated(node, validation)
val person3 = OntoMapper.materialize(ref, Person::class.java)
val person4 = OntoMapper.materializeValidated(ref, Person::class.java, validation)
| API | When to use |
|---|---|
graph.materialize<T>(node) |
Default Kotlin call site |
repo.materialize<T>(node) |
When you already hold a repository |
node.materializeIn<T>(graph) |
Reads left-to-right after building an IRI |
RdfRef.asType<T>() |
You are passing (node, graph) around |
OntoMapper.materialize |
Non-reified Class<T> or Java callers |
RdfRef
Reference to an RDF node in a specific graph.
data class RdfRef(val node: RdfTerm, val graph: RdfGraph)
Properties:
node: RdfTerm- The RDF nodegraph: RdfGraph- The containing graph
Usage:
val ref = RdfRef(iri("http://example.org/person"), graph)
val person: Person = ref.asType()
DefaultRdfHandle
Default implementation of RdfHandle.
class DefaultRdfHandle(
override val node: RdfTerm,
override val graph: RdfGraph,
private val known: Set<Iri>,
internal val validationContext: ValidationContext? = null,
) : RdfHandle
Constructor parameters:
node— subject focusgraph— backing graphknown— predicates mapped on the wrapper; excluded fromextrasvalidationContext— when non-null, powersvalidate()/validateOrThrow()on that handle
Usage:
val handle = DefaultRdfHandle(node, graph, setOf(FOAF.name, FOAF.age))
Utility Classes
KastorGraphOps
Utility object for graph operations.
object KastorGraphOps {
fun extras(graph: RdfGraph, subj: RdfTerm, exclude: Set<Iri>): PropertyBag
fun getLiteralValues(graph: RdfGraph, subj: RdfTerm, pred: Iri): List<Literal>
fun getRequiredLiteralValue(graph: RdfGraph, subj: RdfTerm, pred: Iri): Literal
fun <T: Any> getObjectValues(graph: RdfGraph, subj: RdfTerm, pred: Iri, factory: (RdfTerm) -> T): List<T>
fun getValues(graph: RdfGraph, subj: RdfTerm, pred: Iri): List<RdfTerm>
// Policy-aware readers: values of an unexpected term kind follow MaterializationPolicy.unexpectedTerm
fun getIriValues(graph: RdfGraph, subj: RdfTerm, pred: Iri, property: String): List<Iri>
fun getResourceValues(graph: RdfGraph, subj: RdfTerm, pred: Iri, property: String): List<RdfResource>
fun getLiteralValues(graph: RdfGraph, subj: RdfTerm, pred: Iri, property: String): List<Literal>
fun <T : Any> getObjectValues(graph: RdfGraph, subj: RdfTerm, pred: Iri, property: String, factory: (RdfTerm) -> T): List<T>
fun hasNodeKind(term: RdfTerm, nodeKind: Iri): Boolean
fun isInstanceOf(graph: RdfGraph, term: RdfTerm, cls: Iri): Boolean
}
Methods:
extras(graph: RdfGraph, subj: RdfTerm, exclude: Set<Iri>): PropertyBag- Create property baggetLiteralValues(graph: RdfGraph, subj: RdfTerm, pred: Iri): List<Literal>- Get literal valuesgetRequiredLiteralValue(graph: RdfGraph, subj: RdfTerm, pred: Iri): Literal- Get required literal valuegetObjectValues(graph: RdfGraph, subj: RdfTerm, pred: Iri, factory: (RdfTerm) -> T): List<T>- Materialize IRI and blank-node objects (literal objects are skipped). Failures are never silently dropped:Error,ValidationExceptionandMaterializationExceptionfromfactorypropagate unchanged; any other exception is rethrown as aMaterializationExceptionnaming the subject, predicate and objectgetValues(graph, subj, pred): List<RdfTerm>- All objects (IRIs, blank nodes, literals, triple terms)hasNodeKind(term, nodeKind): Boolean- SHACLsh:nodeKindtest (sh:IRI,sh:BlankNode,sh:Literal,sh:BlankNodeOrIRI,sh:BlankNodeOrLiteral,sh:IRIOrLiteral); any other IRI throwsIllegalArgumentExceptionisInstanceOf(graph, term, cls): Boolean- SHACLsh:classtest: anrdf:typeequal toclsor a transitiverdfs:subClassOfof it; literals are never instances
The last three are used by generated embedded validation.
The overloads taking a property label (used in error messages) never drop values silently: a value of an
unexpected term kind is passed to MaterializationPolicy.unexpectedTerm, which
throws MaterializationException by default and skips the value under IllTypedValueHandling.SKIP.
getIriValues(graph, subj, pred, property): List<Iri>- IRI values; blank nodes, literals and triple terms are unexpectedgetResourceValues(graph, subj, pred, property): List<RdfResource>- IRI and blank-node values (sh:BlankNodeOrIRI); literals and triple terms are unexpectedgetLiteralValues(graph, subj, pred, property): List<Literal>- literal values; IRIs, blank nodes and triple terms are unexpectedgetObjectValues(graph, subj, pred, property, factory): List<T>- likegetObjectValuesabove, but literals and triple terms are unexpected instead of skipped
Generated SHACL wrappers and data-class factories, and wrappers for hand-written Iri / RdfResource
members, read through these overloads.
Usage:
val literals = KastorGraphOps.getLiteralValues(graph, node, FOAF.name)
val required = KastorGraphOps.getRequiredLiteralValue(graph, node, FOAF.name)
val objects = KastorGraphOps.getObjectValues(graph, node, FOAF.knows) { term ->
materializeObject(term)
}
PropertyBagImpl
Internal implementation of PropertyBag.
internal class PropertyBagImpl(
private val graph: RdfGraph,
private val subj: RdfTerm,
private val exclude: Set<Iri>
) : PropertyBag
Constructor Parameters:
graph: RdfGraph- The RDF graphsubj: RdfTerm- The subject nodeexclude: Set<Iri>- Predicates to exclude
Extension Functions
Materialization Extensions
inline fun <reified T: Any> RdfRef.asType(): T
inline fun <reified T: Any> RdfRef.asValidatedType(validation: ValidationContext): T
inline fun <reified T : Any> RdfGraph.materialize(node: RdfTerm): T
inline fun <reified T : Any> RdfGraph.materializeValidated(node: RdfTerm, validation: ValidationContext): T
Parameters:
validation: ValidationContext- Validation context (required for the…Validated…variants)
Returns:
T- Materialized domain object
Usage:
val validation = JenaValidation.fromTurtle(shapesTtl) // shapesTtl: your SHACL shapes as Turtle text
val person: Person = ref.asValidatedType(validation)
RDF Access Extensions
inline fun <reified T: Any> T.asRdf(): RdfHandle
Returns:
RdfHandle- RDF side-channel handle
Usage:
val rdfHandle = person.asRdf()
Write-Support Extensions
Graph mutation helpers for integrating toTriples output into a live MutableRdfGraph. Declared in WriteSupport.kt in the kastor-gen:runtime module.
fun MutableRdfGraph.replaceValues(
subject: RdfResource,
predicate: Iri,
newTriples: Collection<RdfTriple>,
)
fun MutableRdfGraph.replacePredicates(
subject: RdfResource,
predicates: Collection<Iri>,
triples: Collection<RdfTriple>,
)
fun MutableRdfGraph.replaceResource(
subject: RdfResource,
triples: Collection<RdfTriple>,
)
replaceValues — targeted predicate-level update. Removes all existing triples for the (subject, predicate) pair, then adds newTriples. Use this when you have updated a single property and want to replace only those triples without touching others.
replacePredicates — scoped update. Removes the existing values of every predicate in predicates, then adds triples (which must all have subject; triples of other predicates, such as rdf:type, are added next to the existing values). Every other triple of subject is kept. To write a modified record back, use the generated <Record>Factory.replaceInGraph(graph, record, subject), which goes through mergeRecord (below).
mergeRecord — lossless record write, used by the generated replaceInGraph:
class RecordField<T : Any>(
val predicate: Iri,
val values: List<T>, // the record's values for the predicate
val singleValued: Boolean,
val decode: (RdfTerm) -> T?, // the value the record's reader makes of a term; null = not representable
val encode: (T) -> RdfTerm, // the term written for a new value
)
fun MutableRdfGraph.mergeRecord(subject: RdfResource, types: Collection<Iri>, fields: Collection<RecordField<*>>)
For each field it compares the record’s values with the decoded values of the predicate’s current terms:
- a term whose value the record still holds is kept as it is, so its language tag, datatype and lexical form
survive (
"Chat"@frread into aStringis not rewritten as"Chat","2020-01-01+05:00"^^xsd:datekeeps its timezone,"02"^^xsd:integerits spelling); - a single-valued field whose value equals the value its reader returns (the first representable term, in
findorder) is unchanged, so values beyondsh:maxCountthat its reader did not return are kept; any other value is written even when the graph already holds it beyondsh:maxCount: one term of that value is kept and the other representable terms are removed, so the next read returns it; - a term the record cannot represent (an ill-typed literal, another datatype, another term kind) is kept;
- the other terms are removed and the record’s new values are added with
encode.
rdf:type triples for types are added when missing. Every read, decode and encode happens before the first
change, so an ill-typed new value (encode throws) leaves the graph unchanged.
replaceResource — full-resource swap. Removes all triples for subject (every predicate, including ones the replacement does not model), then adds triples. It is only right when triples is the complete description of the resource; a data-class record usually is not (unmodelled predicates, complex-path triples and, in NestedMode.DATA_CLASS, object links are not in toTriples).
Usage:
// Targeted: replace only the name values
val nameTriples = listOf(RdfTriple(subject, NAME_IRI, Literal("New Name")))
graph.replaceValues(subject, NAME_IRI, nameTriples)
// Write a modified record back: edits only the values the record changed
PersonRecordFactory.replaceInGraph(graph, updatedRecord, subject)
All four are atomic: they require an AtomicMutableRdfGraph (standalone MemoryGraph, graphs of Memory, Jena and RDF4J repositories), reject other graphs and out-of-scope triples before changing anything, roll back on failure, and concurrent readers see either the old or the new values, never the gap between removal and insertion. The removal step is a no-op when there are no matching existing triples, and the add step when newTriples / triples is empty.
Serialization Extensions (live wrappers)
fun <T : RdfBacked> T.writeToGraph(
targetGraph: MutableRdfGraph,
subject: Iri? = null
)
Parameters:
targetGraph: MutableRdfGraph- The mutable graph to write triples tosubject: Iri? = null- Optional subject IRI. If not provided, usesrdf.nodeas Iri
Throws:
IllegalArgumentException- If subject is required but not available
Description:
Writes the CBD (Concise Bounded Description) closure of this RDF-backed instance to the target graph. CBD includes:
- All triples where the resource is the subject (direct properties)
- Recursively, for any blank node object, all triples where that blank node is the subject
This method extracts the complete resource description from the backing graph and writes it to the target graph, following blank nodes recursively but not following IRIs.
Usage:
import com.geoknoesis.kastor.gen.runtime.writeToGraph
import com.geoknoesis.kastor.rdf.Rdf
val person: Person = // ... your instance
val targetGraph = Rdf.graph()
// Write CBD closure (uses rdf.node as subject)
person.writeToGraph(targetGraph)
// Write to different subject
person.writeToGraph(targetGraph, subject = Iri("http://example.org/copy"))
// Serialize the CBD closure
val turtle = targetGraph.serialize(RdfFormat.TURTLE)
Note: The writeToGraph() method is available both as:
- An extension function on
RdfBackedtypes (recommended) - A generated method on wrapper classes (for direct wrapper access)
See also:
- Serializing Domain Instances - Complete guide to serialization
- CBD Closure - Understanding Concise Bounded Description
Error Handling
ValidationException
Exception thrown when SHACL validation fails.
class ValidationException(
message: String,
val violations: List<ShaclViolation> = emptyList(),
cause: Throwable? = null,
) : RuntimeException(message, cause)
Usage:
try {
rdfHandle.validateOrThrow()
} catch (e: ValidationException) {
e.violations.forEach { println("${it.path?.value}: ${it.message}") }
}
See the Validation API Reference.
MaterializationException
class MaterializationException(message: String, cause: Throwable? = null) : IllegalStateException(message, cause)
Thrown when materializing a domain object fails: a required value is missing, a literal value is ill-typed
for its member under IllTypedValueHandling.THROW (see MaterializationPolicy), a
value has an unexpected term kind (for example a literal where an IRI is expected) under the same policy, a
nested value could not be converted, a factory threw (wrapped by KastorGraphOps.getObjectValues), or an
eagerly-loaded NestedMode.DATA_CLASS snapshot graph is cyclic (the message names the cycle). Because it
extends IllegalStateException, existing catch (e: IllegalStateException) blocks still catch it. Earlier
versions silently dropped nested values that failed to materialize and ill-typed literal values; they now
surface as this exception.
MaterializationPolicy
enum class IllTypedValueHandling { THROW, SKIP }
object MaterializationPolicy {
@JvmStatic var illTypedValues: IllTypedValueHandling // default THROW; see "Scope" below
@JvmStatic fun <T> withIllTypedValues(handling: IllTypedValueHandling, block: () -> T): T
@JvmStatic fun illTyped(literal: Literal, property: String, expected: String): Nothing? // called by generated code
@JvmStatic fun unexpectedTerm(value: RdfTerm, property: String, expected: String): Nothing? // called by generated code
@JvmStatic fun missingRequired(property: String): Nothing // called by generated code
}
Rules applied by every generated reader: SHACL live wrappers, SHACL data-class factories, and wrappers
generated for hand-written @Rdf interfaces.
- Missing required values always throw a
MaterializationExceptionwith the messageRequired value missing for <member> <path>, followed byof shape <shapeIri>for SHACL-generated types. A member is required when it is a single-valued member withsh:minCount >= 1, a list member withsh:minCount >= 1that is empty, or a non-null member of a hand-written@Rdfinterface. There is no lenient option. A nullable member without a value readsnull; an optional list reads empty. - Values that fail to decode (e.g.
"abc"^^xsd:integerread as a number, or"42"^^xsd:string, whose datatype is not numeric, read as anInt; see XsdLiterals) followillTypedValues:THROW(default): reading fails with aMaterializationExceptionnaming the value, its datatype, the member and the expected type.SKIP: the value is left out of the result and a warning is logged; the remaining values are read.
For hand-written
@Rdfwrappers this covers read-only decoded lists, nullable members, non-null members and mutable getters, which used to skip such values silently. - Values of an unexpected term kind (a literal for an IRI or object member, an IRI for a literal member)
go through
unexpectedTermand follow the same handling:THROW(default) raises aMaterializationExceptionnaming the value, the member and the expected kind;SKIPleaves the value out and logs a warning. This applies to SHACL-generated wrappers and data-class factories, and toIri,RdfResourceand IRI-valuedsh:inenum members of hand-written@Rdfinterfaces. Literal members of hand-written interfaces (includingString) read literals only. - Under
SKIPthe rules combine: if every value of a required member was skipped, the member counts as missing and rule 1 throws.
MaterializationPolicy.illTypedValues = IllTypedValueHandling.SKIP // e.g. at startup, for messy data
Scope
illTypedValues is the process-wide default. withIllTypedValues(handling) { … } overrides it for the
current thread only while the block runs, then restores the previous handling; nested scopes restore
the enclosing value. Tests and request handlers can therefore use a different policy without racing other
threads.
val catalog = MaterializationPolicy.withIllTypedValues(IllTypedValueHandling.SKIP) {
graph.materialize<Catalog>(node) // the wrapper is created inside the block and keeps SKIP
}
catalog.title // read later, on any thread: still SKIP
A wrapper uses the handling in effect when it was created. Wrapper members are read on demand, so each
wrapper captures the handling at creation (OntoMapper.materialize or a factory call) and applies it to every
later read of every member: val members (lazy, read once) and the var members of @Rdf wrappers (read
from the graph on every access) alike, after the block has ended and on other threads too. The converse also
holds: a scope around a read does not change a wrapper that already exists.
val strict = graph.materialize<Catalog>(node) // created under THROW
MaterializationPolicy.withIllTypedValues(IllTypedValueHandling.SKIP) {
strict.title // still THROW: wrap materialize() instead
}
Wrappers reached through an object member are created when that member is read, under the handling of the wrapper they are read from. Data-class factories read every value eagerly, under the handling in effect during the factory call.
The override itself is thread-local: it does not propagate to other threads or coroutines, so a wrapper
created on an executor thread, or in a coroutine that resumed on another thread, captures the process-wide
default. Assigning illTypedValues changes the process-wide default; a scope running on the current thread
still wins.
The generic literal delegates in com.geoknoesis.kastor.gen.runtime.delegates apply the same policy:
rdfLiteral(predicate, decoder): the first decoded value; throwsMaterializationExceptionwhen no value (remains) to return.rdfLiteralOrNull(predicate, decoder): the first decoded value ornull.rdfLiterals(predicate, decoder): all decoded values as a list.
A value the decoder rejects (returns null for) follows illTypedValues. Decoders from XsdLiterals
fit directly, e.g. rdfLiteral(DCTERMS.title, XsdLiterals::string).
The fixed-type delegates rdfString, rdfInt, rdfDouble and rdfBoolean are deprecated: they invent
a default ("", 0, 0.0, false) when the value is missing instead of throwing. So are rdfIntOrNull,
rdfDoubleOrNull, rdfBooleanOrNull and the list variants rdfInts, rdfDoubles and rdfBooleans, which
silently drop values they cannot decode instead of following MaterializationPolicy. Each deprecation
carries a replacement:
| Deprecated | Replacement |
|---|---|
rdfString(p) |
rdfLiteral(p, XsdLiterals::string) |
rdfInt(p) / rdfIntOrNull(p) / rdfInts(p) |
rdfLiteral / rdfLiteralOrNull / rdfLiterals with XsdLiterals::int |
rdfDouble(p) / rdfDoubleOrNull(p) / rdfDoubles(p) |
rdfLiteral / rdfLiteralOrNull / rdfLiterals with XsdLiterals::double |
rdfBoolean(p) / rdfBooleanOrNull(p) / rdfBooleans(p) |
rdfLiteral / rdfLiteralOrNull / rdfLiterals with XsdLiterals::boolean |
Generated code does not use the deprecated delegates. rdfObject and rdfIri throw
MaterializationException when the required value is missing.
Type System
Supported Types
- Hand-written domain interfaces (
@Rdf(iri = …), noshacl), single, nullable orListmembers of:- literal types
String,Int,Long,Float,Double,Boolean,java.math.BigInteger,java.math.BigDecimal,java.time.LocalDateandcom.geoknoesis.kastor.rdf.LangString, read through the policy-awareXsdLiteralsdecoders (Stringmembers read literals only); - Kotlin
enum classtypes, read by constant name, and enums generated fromsh:in, read through theirfromfactory (literal codes or IRIs); - the RDF terms
IriandRdfResource(IRI or blank node); - other
@Rdfdomain interfaces.
A missing value of a non-null member throws
MaterializationException. See the annotations reference. - literal types
- SHACL-generated types: literal types follow the shape’s
sh:datatype— see the type-mapping table (xsd:integer→BigInteger,xsd:decimal→BigDecimal,xsd:date→LocalDate,rdf:langString→LangString, …).
XsdLiterals
com.geoknoesis.kastor.gen.runtime.XsdLiterals holds the lexical codecs used by generated wrappers,
data-class factories, writers and DSL builders. You can use it in hand-written code too:
object XsdLiterals {
fun string(literal: Literal): String // lexical form of any literal
fun lexical(literal: Literal, datatype: Iri): String? // lexical form of a well-formed literal of `datatype`
fun int(literal: Literal): Int?
fun long(literal: Literal): Long?
fun bigInteger(literal: Literal): BigInteger?
fun bigDecimal(literal: Literal): BigDecimal?
fun float(literal: Literal): Float? // accepts INF, -INF, NaN
fun double(literal: Literal): Double? // accepts INF, -INF, NaN
fun boolean(literal: Literal): Boolean? // "true"/"1" and "false"/"0"
fun booleanLexical(lexical: String): Boolean?
fun localDate(literal: Literal): LocalDate? // optional timezone suffix accepted and dropped
fun langString(literal: Literal): LangString?
fun encode(value: Any, datatype: Iri): Literal // throws IllegalArgumentException for an ill-typed value
fun isWellFormed(literal: Literal): Boolean // lexical form valid for the literal's own datatype
fun hasDatatype(term: RdfTerm, datatype: Iri): Boolean // SHACL sh:datatype
fun compareNumeric(term: RdfTerm, bound: String): Int? // exact comparison for sh:minInclusive & co.
}
- Decoders return
nullfor ill-typed literals: a literal that is not well formed for its own datatype ("300"^^xsd:unsignedByte,"-1"^^xsd:unsignedInt,"INF"^^xsd:decimal,"1e5"^^xsd:integer), a datatype outside the target’s XSD family, or a value outside the Kotlin type ("3000000000"^^xsd:integerasInt).int,longandbigIntegeraccept the XSD integer types (xsd:integerand its derived types such asxsd:int,xsd:long,xsd:nonNegativeInteger);bigDecimalalsoxsd:decimal;floatanddoubleevery XSD numeric type;booleanonlyxsd:boolean;localDateonlyxsd:date. So"42"^^xsd:string(or a plain"42") is ill-typed for anIntmember and followsMaterializationPolicy.illTypedValues;stringreads the lexical form of any literal (on purpose: a String member without a datatype, or withxsd:string, also reads"Chat"@fras"Chat", andreplaceInGraphkeeps that original term while the value is unchanged), andlexical(the decoder of String members that declare a datatype other thanxsd:string, such asxsd:dateTime) only that of a well-formed literal of the declared datatype. Lexical forms follow XML Schema rules rather than Kotlin’s:- integers (
int,long,bigInteger) accept[+-]?digits, so a leading+is valid, within the value range of the literal’s datatype (xsd:byte,xsd:unsignedInt,xsd:positiveInteger, …) and of the Kotlin type; bigDecimalaccepts[+-]?(digits[.digits] | .digits); exponents (1e3) are not decimal syntax and are rejected;localDateaccepts years with more than four digits (12345-06-07, without the+ISO-8601 would require) and negative years (-0044-03-15);encodewrites such dates back in the same form, so years above 9999 round-trip.
- integers (
isWellFormedchecks the XSD numeric types (including the ranges of the derived integer types),xsd:boolean,xsd:date,xsd:dateTime,xsd:dateTimeStamp,xsd:time, the Gregorian types (xsd:gYear,xsd:gYearMonth, …) and the duration types. It also checksxsd:language,xsd:hexBinary,xsd:base64Binary,xsd:normalizedString,xsd:token, the XML name and token types (xsd:NCName,xsd:NMTOKEN,xsd:QName, …) andxsd:anyURI, with the same parser as the Kastor SHACL validator. Other datatypes are accepted. Every decoder applies it, andencodethrowsIllegalArgumentExceptioninstead of producing a literal it rejects (300asxsd:unsignedByte,"yesterday"asxsd:dateTime), so generated writers and DSL setters never write an ill-typed literal.hasDatatyperequires a literal of exactly that datatype with a well-formed lexical form.compareNumericreturns the sign ofvalue - bound, ornullwhen the value is not a well-formed numeric literal or isNaN. Generated embedded validation uses these three.encodealways produces a literal with the declared datatype (literals such asLangStringare returned unchanged), so values round-trip: aStringproperty declaredxsd:dateTimeis written back as"…"^^xsd:dateTime, not as a plain string.
Regenerate after upgrading
Generated wrappers, factories and DSL builders call these runtime APIs (OntoMapper.register,
XsdLiterals, …). Code generated by earlier Kastor Gen versions (which wrote to OntoMapper.registry
directly) does not compile against this runtime: regenerate it (clean build / re-run the generation task)
when upgrading.
Custom Types
For custom types not directly supported, use the side-channel:
val customData = person.asRdf().extras.objects(CUSTOM_PREDICATE, CustomType::class.java)
Performance Considerations
Lazy Evaluation
Properties are evaluated lazily and cached:
import com.geoknoesis.kastor.gen.runtime.RdfRef
val person: Person = graph.materialize(node)
// No RDF queries yet (until property delegates run)
val name = person.name.firstOrNull() // Now RDF query is executed
val name2 = person.name.firstOrNull() // Uses cached result
Memory Usage
- Property bags are lazy and memoized
- Large graphs should be processed in batches
- Consider using pagination for large datasets
Thread Safety
OntoMapperregistration and lookup are thread-safe (the registry is a concurrent map); callOntoMapper.initialize(...)at startup to avoid first-hit class-loading races- The cycle-detection scope of
materializeis per thread - Wrapper property delegates are memoized with synchronized one-shot initialization; the underlying graph’s own thread-safety rules still apply
Best Practices
✅ Do
- Use
List<T>for all properties (single or multiple values) - Access single values with
firstOrNull() - Use side-channel for RDF-specific operations
- Handle validation errors gracefully
- Cache expensive operations when appropriate
❌ Don’t
- Include RDF types in domain interfaces
- Assume properties always have values
- Mix RDF operations with domain logic
- Ignore validation errors
- Access side-channel in tight loops
CBD Closure
What is CBD?
CBD (Concise Bounded Description) is a standard RDF pattern for extracting a complete description of a resource. It includes:
- Direct properties: All triples where the resource is the subject
- Recursive blank nodes: For any blank node object, all triples where that blank node is the subject (recursively)
Key Characteristics
- ✅ Follows blank nodes recursively: Complete anonymous resource descriptions
- ✅ Does not follow IRIs: IRI objects remain as references (not expanded)
- ✅ Prevents cycles: Uses visited set to avoid infinite recursion
- ✅ Complete descriptions: Includes all nested anonymous structures
Example
// Graph structure:
// :alice foaf:name "Alice"
// :alice foaf:knows _:b1
// _:b1 foaf:name "Bob"
// _:b1 foaf:email "bob@example.com"
// _:b1 foaf:knows _:b2
// _:b2 foaf:name "Charlie"
val person: Person = // ... alice instance
val targetGraph = Rdf.graph()
person.writeToGraph(targetGraph)
// targetGraph now contains all 6 triples:
// - :alice foaf:name "Alice" (direct property)
// - :alice foaf:knows _:b1 (direct property, blank node object)
// - _:b1 foaf:name "Bob" (blank node property, followed recursively)
// - _:b1 foaf:email "bob@example.com" (blank node property, followed recursively)
// - _:b1 foaf:knows _:b2 (blank node property, blank node object)
// - _:b2 foaf:name "Charlie" (nested blank node property, followed recursively)
When to Use CBD
Use CBD closure when:
- ✅ You need the complete resource description (including blank node properties)
- ✅ Exporting a single resource with all its nested anonymous structures
- ✅ Copying an instance to a different graph
- ✅ Implementing RDFBeans-like bidirectional conversion (domain object ↔ RDF)
Don’t use CBD closure when:
- ❌ You need only direct properties (no blank node recursion)
- ❌ You need custom filtering logic
- ❌ You need to include incoming references (triples where instance is object)
Implementation
CBD closure extraction is implemented as an extension function on RdfGraph:
fun RdfGraph.getCbdClosure(resource: RdfResource): Set<RdfTriple>
This function is used internally by writeToGraph() but can also be used directly:
import com.geoknoesis.kastor.rdf.getCbdClosure
val cbdTriples = graph.getCbdClosure(Iri("http://example.org/person"))
val cbdGraph = Rdf.graph {
cbdTriples.forEach { add(it) }
}