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: tunnel
gen-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).
Summary
_build_presence_implies_value_sparqlemits aNOT INfilter. rdflib evaluates SPARQLIN/NOT INwith 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_inis 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 ofb8a388e0cf3a9e43c2e4ce476719b34a2fe35077):Reproduction
Schema:
gen-shaclemits:Validating this valid instance reports a violation:
{ "@type": "OverheadStructure", "structure_type": "tunnel", "tunnel_phase": "middle" }Root cause, isolated from LinkML
Pure rdflib 7.6.0, no LinkML involved:
Under RDF 1.1,
"tunnel"and"tunnel"^^xsd:stringare the same term, soINought to match. rdflib'sINdoes not — but its=/!=do.Note the precondition path is unaffected: it emits
FILTER ( ?pre0 = "tunnel" ), which uses value equality and behaves correctly. Only theNOT INpostcondition path is broken. That asymmetry is why this went unnoticed.Proposed fix
Expand
NOT INinto a conjunction of!=— semantically identical per the SPARQL spec, and not subject to the rdflib discrepancy:This works for both plain-literal refs and enum-
meaningIRI 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_stringpostconditions 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).