Node Expressions, SHACL Functions and Custom Targets

A rule that can only state constants is a macro. A rule that can compute is an inference engine. The difference is node expressions, the value-producing sublanguage that runs underneath the whole of SHACL-AF. Once you can read them, functions and custom targets follow in an afternoon, and the three together turn a handful of rules into something you can maintain.

Node Expressions, SHACL Functions and Custom Targets

Node expressions: the little language

A node expression is anything that evaluates to a set of RDF nodes for a given focus node. The forms you will use daily are few. sh:this is the focus node itself. A constant is any literal or IRI written directly. A path expression, written as a blank node with sh:path and sh:nodes, walks a property path from a starting set, so [ sh:path ex:manufacturer ; sh:nodes sh:this ] yields the manufacturers of the focus node. A filter shape expression, with sh:filterShape and sh:nodes, keeps only the nodes that conform to a given shape. A function call invokes a SHACL function by IRI with its arguments. On top of those sit the set operations sh:union and sh:intersection, the conditional sh:if with sh:then and sh:else, and sh:exists for a boolean test.

Node expressions are also where the standard is moving: the Data Shapes Working Group has taken this sublanguage out of the Note and given it its own Recommendation-track Working Draft, SHACL 1.2 Node Expressions. They nest, which is the point. The object of a triple rule can be a path expression whose input is a filter shape expression whose input is a function call. Written as prose that stays readable: sh:object [ sh:path ex:ratedPower ; sh:nodes [ sh:filterShape ex:CertifiedShape ; sh:nodes [ sh:path ex:supersededBy ; sh:nodes sh:this ] ] ] . That is “the rated power of the certified thing that supersedes me”, expressed as data rather than as code in an application.

Node expressions also appear outside rules. In a property shape, sh:values declares that the values of a path are exactly those the expression produces, and sh:expression turns any node expression into a constraint that must evaluate to true. Both are quietly useful for the kind of derived-attribute checking that otherwise ends up in a bespoke script.

SHACL functions: naming logic you use twice

The moment the same expression appears in three shapes, it should be a function. A SPARQL-based function is declared with sh:SPARQLFunction, its arguments with sh:parameter, its result type with sh:returnType, and its body with a sh:select query that returns a single result variable. Because the declaration is RDF, the function is discoverable, documentable and versionable in the same repository as the shapes, and the same engine can call it from inside a SPARQL query as well as from a node expression.

The engineering benefit is not elegance, it is review. A named function with a stated signature and a test is something a colleague can approve. The same logic inlined into six CONSTRUCT queries is something a colleague skims and nods at.

Custom targets: reaching nodes that classes cannot

Core SHACL gives five ways to select focus nodes: by class, by explicit node, by subjects of a predicate, by objects of a predicate, and implicitly when a shape is itself a class. That covers most work and then hits a wall. What selects “every asset commissioned before 2010 that has no maintenance record”? Nothing in the core list.

SHACL-AF answers with sh:target and a SPARQL target: a sh:SPARQLTarget carrying a sh:select query that returns the variable ?this. Every binding of ?this becomes a focus node, which then feeds constraints and rules exactly as a class target would. When the same shape of selection recurs with different parameters, sh:SPARQLTargetType lets you declare it once and instantiate it with arguments, so that the target itself becomes a reusable, named thing rather than a copied query.

Choosing a targeting mechanism
Mechanism Selects Use when Cost
sh:targetClass Instances of a class, including subclasses The shape genuinely applies to a class None. Portable everywhere
sh:targetSubjectsOf / sh:targetObjectsOf Subjects or objects of one predicate The rule is about a relationship, not a type None. Portable everywhere
sh:targetNode Named individuals Fixtures, tests, one-off exceptions Hard-codes identifiers
sh:SPARQLTarget Anything a SELECT can return as ?this Selection depends on values, absence or time SHACL-AF only. Slower. Needs review
sh:SPARQLTargetType A parameterised family of the above The same selection recurs with different arguments As above, plus indirection

Worked example · the Aberdeen-2 asset register

Where we left it: the register derives as well as records: ex:P101 carries a rule-inferred ex:maintenanceDue true, and ex:P114, delivered by the contractor spreadsheet, carries a guarded model-level default rating.

Every shape and rule so far targets ex:Pump, which treats all three source systems as equally trustworthy. They are not. The maintenance system and the engineering register have identifier discipline; the contractor spreadsheet is free text with no identifiers, and its rows deserve stricter checks, not the same ones. No class expresses “came from the contractor feed”. The register’s per-source named graphs do, and a SPARQL target can reach into them:

ex:ContractorEquipmentShape a sh:NodeShape ;
  sh:target [ a sh:SPARQLTarget ;
    sh:prefixes ex:shapes ;
    sh:select """
      SELECT ?this WHERE {
        GRAPH ex:g-contractor { ?this a ex:Equipment }
      } """ ] ;
  sh:property [ sh:path ex:serialNumber ; sh:minCount 1 ] ;
  sh:property [ sh:path ex:pumpModel ; sh:minCount 1 ] .

The selection criterion is provenance, not type: whatever the contractor graph asserts to be equipment becomes a focus node, and only those nodes face the stricter constraints. Serial numbers are mandatory here precisely because contractor tags cannot be trusted as identifiers. Run it and the target selects twelve contractor rows; ex:P114 conforms on ex:pumpModel, since its HX-300 assignment is what fed last lesson’s default, and fails on ex:serialNumber, which is a genuine finding: a pump on the register with no traceable identity. One note of engineering honesty: reaching into named graphs from a target assumes the validator sees the register as a dataset rather than a single merged graph, so confirm how your engine presents the data graph before you rely on this pattern.

State of the register: validation now discriminates by source, with contractor-fed equipment held to stricter shapes; what nothing yet records is where the rule-derived triples themselves came from, which is the next lesson’s problem.

What breaks · the target that selects nothing

A SPARQL target is a query, and a query that matches nothing is not an error. During a tidy-up, the prefix declaration the target depends on is edited, and the namespace picks up a one-character typo:

ex:shapes a owl:Ontology ;
  sh:declare [ sh:prefix "ex" ;
    # was: https://data.example.com/id/
    sh:namespace "https://data.example.org/id/"^^xsd:anyURI ] .

The target query still parses, because the prefix is declared. It now expands ex:g-contractor and ex:Equipment into IRIs that exist nowhere in the register, so the SELECT binds no ?this, the shape has zero focus nodes, and the validation report is the most dangerous artefact in this course:

[ a sh:ValidationReport ; sh:conforms true ]

No violations, because nothing was validated. The contractor feed, the register’s worst source, now flows in unchecked while the dashboard shows green, and ex:P114’s missing serial number quietly stops being a finding. Class targets fail differently: a misspelt class in sh:targetClass is at least visible in the shape file next to the ontology it contradicts, whereas a SPARQL target buries the reference inside a string the RDF tooling cannot check. The defence is a canary: assert, in CI, that every custom target selects at least the number of nodes you know it must, so that an empty selection fails the build instead of passing the audit.

A working habit

Write the target first, and check what it selects before you attach a single constraint or rule to it. A rule that fires on the wrong focus nodes is not a broken rule, it is a broken target, and the symptom appears three steps downstream in triples nobody expected. The next lesson turns that habit into a process: provenance for what the rules produced, termination for the rules themselves, and an honest comparison with the other two engines you could have used instead.

Check yourself

The strict contractor shape flagged three rows in its first week. After a refactor a month ago it has reported zero violations, yet a manual audit finds ex:P114 still has no serial number. The refactored target reads: SELECT ?this WHERE { GRAPH ex:g-contactor { ?this a ex:Equipment } }. Why does every validation run pass?

  1. The engine does not implement sh:SPARQLTarget, so the shape was skipped entirely.
  2. The GRAPH clause names ex:g-contactor, a graph that does not exist, so the SELECT binds no ?this and a shape with zero focus nodes conforms vacuously.
  3. sh:minCount 1 can only report on values that are present, so a completely missing ex:serialNumber is invisible to it.
  4. The violations are produced but carry the default severity sh:Warning, which CI is configured to ignore.
Show the answer, and why each wrong option is wrong

B is correct. The graph name has lost an r: ex:g-contactor instead of ex:g-contractor. The query is syntactically valid, matches nothing, and returns zero bindings. Zero focus nodes means zero constraint evaluations, and a report with no results says sh:conforms true. Validation did not pass; it did not happen.

A is the reach-for-the-engine reflex. It cannot explain the history: the same engine produced three genuine violations in week one, so sh:SPARQLTarget support is proven. Blaming the platform before reading the query is how this class of bug survives a month. C inverts what cardinality constraints do. sh:minCount 1 exists precisely to report absence; it fires when the path has no values at all. The misconception, that validators can only inspect data that is there, is a leftover database instinct, and it would make minimum cardinality unimplementable. D misremembers the default: an unstated sh:severity is sh:Violation, not sh:Warning. Believing warnings are the default trains people to accept quiet reports, which is exactly the failure mode B describes.

Node Expressions, SHACL Functions and Custom Targets in practice

Apply it to your work