Repository navigation
gen-shacl: presence-implies-value rules reject valid data (NOT IN is term-based in rdflib) #28
Description
Summary
_build_presence_implies_value_sparql emits a NOT IN filter. rdflib evaluates SPARQL IN/NOT IN with term equality but =/!= with value equality. Because rdflib's JSON-LD parser materialises plain JSON strings as "x"^^xsd:string, the generated constraint fires on valid data — it reports a violation whenever the target slot holds exactly the value the rule requires.
Any rule whose postcondition asserts equals_string / equals_string_in is affected. The generated shape does not merely miss violations — it actively rejects conforming data, so this is a correctness (false-positive) bug, not a gap.
Affected code
packages/linkml/src/linkml/generators/shaclgen.py, line 1058 (as of b8a388e0cf3a9e43c2e4ce476719b34a2fe35077):
return (
f"SELECT $this WHERE {{\n"
f" $this <{value_uri}> ?value .\n"
f" OPTIONAL {{ $this <{target_uri}> ?target . }}\n"
f" FILTER ( !BOUND(?target) || ?target NOT IN ({refs}) )\n"
f"}}"
)Reproduction
Schema:
classes:
OverheadStructure:
attributes:
structure_type:
range: OverheadStructureType # permissible values include: tunnel, bridge
tunnel_phase:
range: TunnelPhase
rules:
- title: phase_requires_tunnel
preconditions:
slot_conditions:
tunnel_phase:
value_presence: PRESENT
postconditions:
slot_conditions:
structure_type:
equals_string: tunnelgen-shacl emits:
SELECT $this WHERE {
$this <.../tunnel_phase> ?value .
OPTIONAL { $this <.../structure_type> ?target . }
FILTER ( !BOUND(?target) || ?target NOT IN ("tunnel") )
}Validating this valid instance reports a violation:
{
"@type": "OverheadStructure",
"structure_type": "tunnel",
"tunnel_phase": "middle"
}Constraint Violation in SPARQLConstraintComponent:
Source Shape: scenario:OverheadStructure
Focus Node: [ scenario:structure_type Literal("tunnel", datatype=xsd:string) ;
scenario:tunnel_phase Literal("middle", datatype=xsd:string) ]
Root cause, isolated from LinkML
Pure rdflib 7.6.0, no LinkML involved:
from rdflib import Graph
g = Graph()
g.parse(data='''
@prefix ex: <https://example.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:typed ex:p "tunnel"^^xsd:string .
ex:plain ex:p "tunnel" .
ex:other ex:p "bridge"^^xsd:string .
''', format="turtle")
for filt in ('?t IN ("tunnel")', '?t = "tunnel"',
'?t NOT IN ("tunnel")', '?t != "tunnel"'):
q = f'SELECT ?s WHERE {{ ?s <https://example.org/p> ?t . FILTER ( {filt} ) }}'
print(f'{filt:24} -> {sorted(str(r[0]).split("/")[-1] for r in g.query(q))}')?t IN ("tunnel") -> ['plain'] <-- misses the xsd:string-typed term
?t = "tunnel" -> ['plain', 'typed']
?t NOT IN ("tunnel") -> ['other', 'typed'] <-- 'typed' is a FALSE POSITIVE
?t != "tunnel" -> ['other']
Under RDF 1.1, "tunnel" and "tunnel"^^xsd:string are the same term, so IN ought to match. rdflib's IN does not — but its =/!= do.
Note the precondition path is unaffected: it emits FILTER ( ?pre0 = "tunnel" ), which uses value equality and behaves correctly. Only the NOT IN postcondition path is broken. That asymmetry is why this went unnoticed.
Proposed fix
Expand NOT IN into a conjunction of != — semantically identical per the SPARQL spec, and not subject to the rdflib discrepancy:
refs = [self._resolve_enum_value_ref(sv, target_slot_name, v, cls) for v in allowed_values]
# Deliberately NOT `?target NOT IN (...)`: rdflib compares IN/NOT IN by term,
# so a "x"^^xsd:string value (what its JSON-LD parser produces) fails to match
# the plain literal "x" and the constraint fires on valid data. `!=` compares
# by value and is equivalent per the SPARQL spec.
not_any = " && ".join(f"?target != {ref}" for ref in refs)
return (
f"SELECT $this WHERE {{\n"
f" $this <{value_uri}> ?value .\n"
f" OPTIONAL {{ $this <{target_uri}> ?target . }}\n"
f" FILTER ( !BOUND(?target) || ( {not_any} ) )\n"
f"}}"
)This works for both plain-literal refs and enum-meaning IRI refs.
A regression test would ideally assert on behaviour (validate a conforming instance whose literal is xsd:string-typed) rather than on the generated query string, since a string-level assertion would not have caught this.
Impact
Found while wiring SHACL validation into CI for an ontology that consumes this fork: a rule of exactly this shape rejected every conforming instance. Since equals_string postconditions are the idiomatic way to express "slot A present ⇒ slot B has value V", this likely affects any downstream schema using conditional value rules, and it only becomes visible once generated shapes are actually run against JSON-LD data.
Environment: rdflib 7.6.0, fork rev b8a388e0cf3a9e43c2e4ce476719b34a2fe35077 (feat/envited-x-pipeline, #14).
Activity
jdsika commentedon Oct 6, 2026
Edited: updated for the current head of linkml#3989, which replaces the commit this comment first cited.
Fixed in linkml#3989 (head
1e506c2). I'm leaving this open until that PR is merged upstream.NOT INor triple-pattern constants. They spell out?value = v1 || ?value = v2 || …, which is exactly how SPARQL 1.1 §17.4.1.9 definesIN, wrapped inCOALESCE(…, false).=compares values, so"tunnel"^^xsd:stringmatches"tunnel"; RDF 1.1 Concepts §3.3 treats them as the same term.INfix (fix: SPARQL IN/NOT IN must use value equality, not term equality RDFLib/rdflib#3564, fork PR fix: SPARQL IN/NOT IN must use value equality, not term equality rdflib#4).xsd:stringform of the value forstring,curie, string-derived types, an enum without meanings and the literal value of a mixed enum. Enum values with a meaning render as IRIs and have no such form."@type": "xsd:string"coercion that the test adds to the context, and checks the SHACL verdict against JSON Schema.sh:in(enum and slot-levelequals_stringshapes) still compares by term identity in pyshacl. The root cause is rdflib's literal identity (Literal equality fails for datatypesNoneandXSD.stringRDFLib/rdflib#2123), tracked in RDF 1.1 literal identity: simple literal and "x"^^xsd:string must be the same term (track RDFLib#2123, RDFLib#3564) rdflib#5.