Skip to content

fix: SPARQL IN/NOT IN must use value equality, not term equality - #4

Open
jdsika wants to merge 1 commit into
mainfrom
fix/sparql-in-value-equality
Open

jdsika wants to merge 1 commit into
mainfrom
fix/sparql-in-value-equality

Conversation

@jdsika

@jdsika jdsika commented Oct 2, 2026 •

Copy link
Copy Markdown

Summary of changes

RelationalExpression evaluates IN / NOT IN membership with Python ==, which is
strict RDF term equality, while = and != use Identifier.eq, which is value
equality. SPARQL 1.1 §17.4.1.9 defines
IN as exactly

(lhs = expression1) || (lhs = expression2) || ...

"The test is done with "=" operator, which tests for the same value, as determined by
the operator mapping."

and §17.4.1.10 defines NOT IN as
its negation. So the two must never disagree.

They do. RDF 1.1 treats a simple literal and an xsd:string-typed literal as the
same term, and XSD type
promotion makes 1 and 1.0 equal in value space. On main today:

# data: <urn:ex:s> <urn:ex:p> "x"^^xsd:string .
FILTER(?o IN ("x"))       # false  -- should be true
FILTER(?o = "x")          # true   -- correct

This also means main gets one of the spec's own worked examples wrong:
2 IN (<http://example/iri>, "str", 2.0) is specified as true, but evaluates to
false.

Minimal reproduction:

from rdflib import Graph, Literal, URIRef, XSD

g = Graph()
g.add((URIRef("urn:ex:s"), URIRef("urn:ex:p"), Literal("x", datatype=XSD.string)))
q = 'SELECT ?s WHERE { ?s <urn:ex:p> ?o . FILTER(?o %s) }'

len(list(g.query(q % 'IN ("x")')))      # 0 -- expected 1
len(list(g.query(q % '= "x"')))         # 1
len(list(g.query(q % 'NOT IN ("x")')))  # 1 -- expected 0
len(list(g.query(q % '!= "x"')))        # 0

The fix

Use Identifier.eq, the same comparison = already uses. It falls back to __eq__ for
IRIs and blank nodes, so their behaviour is unchanged. The call is guarded by
isinstance(expr, Identifier) because expr can legitimately be a SPARQLError
instance when the left-hand expression errors (parserutils.Expr.eval returns rather
than raises), and that case keeps the previous == path.

Switching to .eq also changes the failure modes, so both are normalised exactly as the
= path three lines below already does:

  • NotImplemented — Literal.eq reports incomparable operands this way rather than
    raising. It is truthy, so without a guard IN would match anything as soon as one
    list entry errored (FILTER(?o IN (1/0)) matched every row), and evaluating it as a
    bool is a hard TypeError on Python 3.14.
  • TypeError — Literal.eq raises this for e.g. two literals of the same unknown
    datatype with different lexical forms. Left uncaught it would escape the loop, losing a
    later genuine match ("x"^^ex:custom IN ("y"^^ex:custom, "x"^^ex:custom)) and escaping
    evaluation entirely as a non-SPARQLError.

Recording both as errors rather than as "no match" is what the spec requires: "Errors in
comparisons cause the IN expression to raise an error if the RDF term being tested is
not found elsewhere in the list"
. The pre-existing error accumulator already implements
that, so 2 IN (1/0, 2) is true while 2 IN (3, 1/0) raises.

Why this matters

The failure is silent and errs in the admitting direction: FILTER(?v NOT IN (...))
passes for a value that is in the list, so a guard intended to exclude rows lets them
through. It was found via a SHACL sh:sparql constraint where literals reach the query as
xsd:string-typed (coerced by a JSON-LD @context) while the shape lists plain literals;
the constraint reported valid data as violating.

Backwards compatibility

A bug fix with no intended backwards-incompatible change. IN / NOT IN now match
exactly the values = already considered equal. Code relying on the previous
term-equality behaviour was relying on a deviation from the spec that already disagreed
with =.

W3C SPARQL 1.0 and 1.1 conformance suites: pass / skip / xfail / xpass counts are
unchanged.

Tests

Two parametrised tests. Together they cover all six normative examples from
§17.4.1.9 and §17.4.1.10 (2 IN (1,2,3), 2 IN (),
2 IN (<http://example/iri>, "str", 2.0), 2 IN (1/0, 2), 2 IN (2, 1/0),
2 IN (3, 1/0)), plus:

  • plain vs xsd:string-typed literals, both directions
  • numerics across XSD types (1 vs 1.0)
  • IRIs in the list, exercising the __eq__ fallback
  • error-then-match and match-then-error ordering
  • erroring entries with no match, which must not match
  • genuine non-matches — language tags, xsd:date vs xsd:dateTime, boolean vs numeric —
    so the fix cannot degrade into "always true"

Seven parametrisations fail on main and all pass with this change.

The helper iterates the result rather than using len(list(...)): Result.__len__ is
consulted by list() via PyObject_LengthHint, which clears a TypeError raised
during evaluation, so a crashing filter would silently look like "no rows" instead of
failing the test.

Checklist

  • Checked that there aren't other open pull requests for
    the same change.
  • Checked that all tests and type checking passes.
  • If the change has a potential impact on users of this project:
    • Added or updated tests that fail without the change.
    • Updated relevant documentation to avoid inaccuracies.
    • Considered adding additional documentation.
  • Considered granting push permissions to the PR branch,
    so maintainers can fix minor issues and keep your PR up to date.

Checks run locally: black --check ✓ · ruff check ✓ · mypy --show-error-codes ✓ ·
pytest test/test_sparql test/test_w3c_spec/test_sparql{10,11}_w3c.py test/test_w3c_spec/test_sparql_rdflib.py
→ 1341 passed, 0 failed.

`RelationalExpression` evaluated `IN` / `NOT IN` membership with Python `==`,
which is strict RDF *term* equality, while `=` and `!=` use `Identifier.eq`,
which is value equality. SPARQL 1.1 17.4.1.9 defines `IN` as exactly

    (lhs = expression1) || (lhs = expression2) || ...

("The test is done with `=` operator, which tests for the same value"), and
17.4.1.10 defines `NOT IN` as its negation, so the two must never disagree.

They did. RDF 1.1 treats a simple literal and an `xsd:string`-typed literal as
the same term, and XSD type promotion makes `1` and `1.0` equal in value space,
so these all evaluated incorrectly:

    "x"^^xsd:string IN ("x")                  -> false (should be true)
    1 IN (1.0)                                -> false (should be true)
    2 IN (<http://example/iri>, "str", 2.0)   -> false (spec example: true)

while the equivalent `=` comparisons were correct.

The impact is silent and easy to misdiagnose: `FILTER(?v NOT IN (...))` passes
for a value that *is* in the list, so it wrongly admits rows rather than
dropping them. It was found in a SHACL `sh:sparql` constraint where literals
reach the query as `xsd:string`-typed (coerced by a JSON-LD context) while the
shape lists plain literals, which reported valid data as violating.

Use `Identifier.eq` -- the same comparison `=` already uses. It falls back to
`__eq__` for IRIs and blank nodes, so their behaviour is unchanged.

`Literal.eq` reports incomparable operands as `NotImplemented` rather than
raising, so that is normalised to a `SPARQLError`, exactly as the `=` path
below already does, and a comparison `TypeError` is normalised the same way.
This matters three times over: `NotImplemented` is truthy, so `IN` would
otherwise match anything once a list entry errored; evaluating it as a bool is
a `TypeError` on Python 3.14; and an escaping `TypeError` would abort the scan,
losing a later genuine match. Recording both as errors is what the spec
requires -- "Errors in comparisons cause the IN expression to raise an error if
the RDF term being tested is not found elsewhere in the list" -- which the
existing error accumulator already implements.

Adds parametrised regression tests covering all six normative examples from
17.4.1.9/17.4.1.10, plus value equality across plain, `xsd:string`-typed,
numeric, language-tagged and IRI operands, error-then-match ordering, and
genuine non-matches. The tests iterate the result rather than using
`len(list(...))`, which would swallow an evaluation `TypeError` via
`__length_hint__`.

Verified against the W3C SPARQL 1.0 and 1.1 conformance suites: pass/fail
counts are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant