Skip to content

IS NULL constraint incorrectly matches rows with absent association when preceded by a DisjunctionConstraint on the same path #111

@javier-godoy

Description

@javier-godoy

Summary

Adding a DisjunctionConstraint that navigates an association before an IS NULL constraint on the same association causes the IS NULL to match rows where the association is absent (null foreign key). The IS NULL constraint should only match rows where the association exists but the attribute value is null.

Steps to reproduce

Given a Person entity with a nullable city association, and a Person instance (persistedPerson) with no city assigned:

// Baseline: correctly returns 0
// persistedPerson has no city, so city.population IS NULL does not match them
PersonFilter baseline = new PersonFilter();
baseline.addConstraint(ConstraintBuilder.of("id").equal(persistedPerson.getId()));
baseline.addConstraint(ConstraintBuilder.of("city", "population").isNull());
assertEquals(0, dao.count(baseline));  // passes

// Adding an OR that includes persistedPerson should not change the result
PersonFilter withOr = new PersonFilter();
withOr.addConstraint(
    ConstraintBuilder.of("city", "id").equal(cities.get(0).getId())
        .or(ConstraintBuilder.of("id").equal(persistedPerson.getId())));
withOr.addConstraint(ConstraintBuilder.of("city", "population").isNull());
assertEquals(0, dao.count(withOr));  // fails: actual is 1

Expected behavior

Both queries return 0. The presence of the DisjunctionConstraint is irrelevant to whether city.population IS NULL matches persistedPerson — since persistedPerson has no city, the IS NULL constraint cannot be satisfied by them regardless.

Actual behavior

The second query returns 1. persistedPerson is incorrectly matched.

Root cause

ConstraintTransformerJpaImpl.join() reuses an existing join by attribute name only, without checking join type. transformDisjunctionConstraint sets currentJoinType = LEFT before processing its branches, causing a LEFT JOIN on city to be created. After the disjunction completes, currentJoinType is restored to INNER, but the LEFT JOIN remains in the join collection. When the subsequent IS NULL constraint navigates city, join() finds the LEFT JOIN and reuses it. persistedPerson then has a null-padded city.population, making NULL IS NULL evaluate to TRUE.

Prior analysis

The fix for this issue was proposed by CodeRabbit in PR #108 but subsequently rejected; see this comment. The conclusion was that the join-type mismatch has no observable effect on query results. The analysis correctly covers cases where the non-disjunction constraint uses an equality or comparison predicate — for those, SQL three-valued logic causes NULL = value to evaluate to UNKNOWN, which is treated as false in WHERE, producing the same result as an INNER JOIN would.

However, this reasoning does not hold for IS NULL predicates. Unlike comparison operators, IS NULL returns TRUE (not UNKNOWN) when the operand is null. A null-padded column from a LEFT JOIN satisfies IS NULL, so the join-type leak has a directly observable effect in this case.

Proposed fix

Add a join-type check to join() so that it only reuses a join whose type matches currentJoinType:

@SuppressWarnings("rawtypes")
private From<?,?> join(From<?,?> source, String attributeName) {
    Optional<Join> existingJoin = source.getJoins().stream()
            .map(join -> (Join) join)
            .filter(join -> join.getAttribute().getName().equals(attributeName))
            .filter(join -> join.getJoinType() == currentJoinType)
            .findFirst();
    return existingJoin.orElseGet(() -> source.join(attributeName, currentJoinType));
}

The IS NULL constraint then creates its own INNER JOIN, which correctly excludes rows with no city before the predicate is evaluated.

Metadata

Metadata

Assignees

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