Skip to content

gen-shacl: presence-implies-value rules reject valid data (NOT IN is term-based in rdflib) #28

Description

@jdsika

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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions