Skip to content

Commit 8d8c498

Browse files
committed
GH-5447 improve queries using MINUS
1 parent 1e80c2f commit 8d8c498

10 files changed

Lines changed: 848 additions & 39 deletions

File tree

core/query/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,10 @@
4141
<artifactId>junit-platform-suite-engine</artifactId>
4242
<scope>test</scope>
4343
</dependency>
44+
<dependency>
45+
<groupId>org.junit.jupiter</groupId>
46+
<artifactId>junit-jupiter-params</artifactId>
47+
<scope>test</scope>
48+
</dependency>
4449
</dependencies>
4550
</project>

core/query/src/main/java/org/eclipse/rdf4j/query/AbstractBindingSet.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ public abstract class AbstractBindingSet implements BindingSet {
2626

2727
@Override
2828
public boolean equals(Object other) {
29+
if (other == null) {
30+
return false;
31+
}
2932
if (this == other) {
3033
return true;
3134
}
@@ -61,7 +64,7 @@ public boolean equals(Object other) {
6164
}
6265

6366
@Override
64-
public final int hashCode() {
67+
public int hashCode() {
6568
int hashCode = 0;
6669

6770
for (Binding binding : this) {

core/query/src/main/java/org/eclipse/rdf4j/query/BindingSet.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,19 @@ public interface BindingSet extends Iterable<Binding>, Serializable {
101101
default boolean isEmpty() {
102102
return size() == 0;
103103
}
104+
105+
/**
106+
* Check whether this BindingSet is compatible with another. Two binding sets are compatible if they have equal
107+
* values for each variable that is bound in both binding sets. A variable that is unbound in either set is
108+
* considered compatible.
109+
*
110+
* <p>
111+
* Default implementation mirrors {@link QueryResults#bindingSetsCompatible(BindingSet, BindingSet)}.
112+
*
113+
* @param other the other binding set to compare with
114+
* @return true if compatible
115+
*/
116+
default boolean isCompatible(BindingSet other) {
117+
return QueryResults.bindingSetsCompatible(this, other);
118+
}
104119
}

core/query/src/main/java/org/eclipse/rdf4j/query/QueryResults.java

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -540,20 +540,19 @@ private static boolean bindingSetsMatch(BindingSet bs1, BindingSet bs2, Map<BNod
540540
* @return true if compatible
541541
*/
542542
public static boolean bindingSetsCompatible(BindingSet bs1, BindingSet bs2) {
543-
Set<String> bs1BindingNames = bs1.getBindingNames();
544-
if (bs1BindingNames.isEmpty()) {
543+
if (bs1.isEmpty() || bs2.isEmpty()) {
545544
return true;
546545
}
547546

548-
Set<String> bs2BindingNames = bs2.getBindingNames();
549-
550547
for (Binding binding : bs1) {
551-
if (bs2BindingNames.contains(binding.getName())) {
548+
Binding other = bs2.getBinding(binding.getName());
549+
550+
if (other != null) {
552551
Value value1 = binding.getValue();
553552

554553
// if a variable is unbound in one set it is compatible
555554
if (value1 != null) {
556-
Value value2 = bs2.getValue(binding.getName());
555+
Value value2 = other.getValue();
557556

558557
// if a variable is unbound in one set it is compatible
559558
if (value2 != null && !value1.equals(value2)) {
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Eclipse RDF4J contributors.
3+
*
4+
* All rights reserved. This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Distribution License v1.0
6+
* which accompanies this distribution, and is available at
7+
* http://www.eclipse.org/org/documents/edl-v10.php.
8+
*
9+
* SPDX-License-Identifier: BSD-3-Clause
10+
******************************************************************************/
11+
12+
package org.eclipse.rdf4j.query;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
import static org.junit.jupiter.api.Assertions.fail;
16+
17+
import java.lang.reflect.Method;
18+
import java.util.Arrays;
19+
import java.util.List;
20+
import java.util.Random;
21+
import java.util.stream.Stream;
22+
23+
import org.eclipse.rdf4j.model.Value;
24+
import org.eclipse.rdf4j.model.ValueFactory;
25+
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
26+
import org.eclipse.rdf4j.query.impl.ListBindingSet;
27+
import org.junit.jupiter.api.Test;
28+
import org.junit.jupiter.params.ParameterizedTest;
29+
import org.junit.jupiter.params.provider.Arguments;
30+
import org.junit.jupiter.params.provider.MethodSource;
31+
32+
/**
33+
* Tests for BindingSet compatibility API. Verifies that BindingSet#isCompatible has identical semantics to
34+
* QueryResults#bindingSetsCompatible across a variety of scenarios.
35+
*/
36+
public class BindingSetCompatibleTest {
37+
38+
private static final ValueFactory VF = SimpleValueFactory.getInstance();
39+
40+
@Test
41+
public void isCompatible_exists_and_matches_QueryResults() throws Exception {
42+
// Prefer the current API name; fallback to legacy name if present
43+
Method m;
44+
try {
45+
m = BindingSet.class.getMethod("isCompatible", BindingSet.class);
46+
} catch (NoSuchMethodException e1) {
47+
try {
48+
m = BindingSet.class.getMethod("bindingSetCompatible", BindingSet.class);
49+
} catch (NoSuchMethodException e2) {
50+
fail("BindingSet#isCompatible(BindingSet) method is missing");
51+
return;
52+
}
53+
}
54+
55+
// Verify semantics align with QueryResults.bindingSetsCompatible on a few basic cases
56+
List<String> names = Arrays.asList("a", "b");
57+
58+
BindingSet s1 = new ListBindingSet(names, VF.createIRI("urn:x"), VF.createLiteral(1));
59+
BindingSet s2 = new ListBindingSet(names, VF.createIRI("urn:x"), VF.createLiteral(2));
60+
boolean expected = QueryResults.bindingSetsCompatible(s1, s2);
61+
boolean actual = (Boolean) m.invoke(s1, s2);
62+
assertThat(actual).isEqualTo(expected);
63+
64+
BindingSet s3 = new ListBindingSet(names, VF.createIRI("urn:x"), VF.createLiteral(1));
65+
BindingSet s4 = new ListBindingSet(names, VF.createIRI("urn:x"), VF.createLiteral(1));
66+
expected = QueryResults.bindingSetsCompatible(s3, s4);
67+
actual = (Boolean) m.invoke(s3, s4);
68+
assertThat(actual).isEqualTo(expected);
69+
70+
BindingSet s5 = new ListBindingSet(names, null, VF.createLiteral(1));
71+
BindingSet s6 = new ListBindingSet(names, null, VF.createLiteral(2));
72+
expected = QueryResults.bindingSetsCompatible(s5, s6);
73+
actual = (Boolean) m.invoke(s5, s6);
74+
assertThat(actual).isEqualTo(expected);
75+
}
76+
77+
@Test
78+
public void isCompatible_empty_sets_true() {
79+
BindingSet empty1 = new ListBindingSet(List.of());
80+
BindingSet empty2 = new ListBindingSet(List.of());
81+
assertThat(empty1.isCompatible(empty2)).isTrue();
82+
assertThat(empty2.isCompatible(empty1)).isTrue();
83+
}
84+
85+
@Test
86+
public void isCompatible_one_empty_true() {
87+
List<String> names = Arrays.asList("a", "b");
88+
BindingSet nonEmpty = new ListBindingSet(names, VF.createIRI("urn:x"), VF.createLiteral(1));
89+
BindingSet empty = new ListBindingSet(List.of());
90+
assertThat(nonEmpty.isCompatible(empty)).isTrue();
91+
assertThat(empty.isCompatible(nonEmpty)).isTrue();
92+
}
93+
94+
@Test
95+
public void isCompatible_disjoint_names_true() {
96+
BindingSet aOnly = new ListBindingSet(List.of("a"), VF.createLiteral(1));
97+
BindingSet bOnly = new ListBindingSet(List.of("b"), VF.createLiteral(2));
98+
assertThat(aOnly.isCompatible(bOnly)).isTrue();
99+
assertThat(bOnly.isCompatible(aOnly)).isTrue();
100+
}
101+
102+
@Test
103+
public void isCompatible_partial_overlap_true_when_equal() {
104+
List<String> namesAB = Arrays.asList("a", "b");
105+
BindingSet ab = new ListBindingSet(namesAB, VF.createIRI("urn:x"), VF.createLiteral(1));
106+
BindingSet b = new ListBindingSet(List.of("b"), VF.createLiteral(1));
107+
assertThat(ab.isCompatible(b)).isTrue();
108+
assertThat(b.isCompatible(ab)).isTrue();
109+
}
110+
111+
@Test
112+
public void isCompatible_partial_overlap_false_when_conflict() {
113+
List<String> namesAB = Arrays.asList("a", "b");
114+
BindingSet ab = new ListBindingSet(namesAB, VF.createIRI("urn:x"), VF.createLiteral(1));
115+
BindingSet bConflict = new ListBindingSet(List.of("b"), VF.createLiteral(2));
116+
assertThat(ab.isCompatible(bConflict)).isFalse();
117+
assertThat(bConflict.isCompatible(ab)).isFalse();
118+
}
119+
120+
@Test
121+
public void isCompatible_null_variable_ignored_when_overlap_equal() {
122+
List<String> namesAB = Arrays.asList("a", "b");
123+
BindingSet s1 = new ListBindingSet(namesAB, null, VF.createLiteral(1));
124+
BindingSet s2 = new ListBindingSet(namesAB, null, VF.createLiteral(1));
125+
assertThat(s1.isCompatible(s2)).isTrue();
126+
assertThat(s2.isCompatible(s1)).isTrue();
127+
}
128+
129+
@ParameterizedTest(name = "fuzz case {index}")
130+
@MethodSource("fuzzCases")
131+
public void isCompatible_fuzz_parity_and_symmetry(BindingSet s1, BindingSet s2) {
132+
boolean expected = QueryResults.bindingSetsCompatible(s1, s2);
133+
assertThat(s1.isCompatible(s2)).isEqualTo(expected);
134+
// symmetry
135+
boolean expectedReverse = QueryResults.bindingSetsCompatible(s2, s1);
136+
assertThat(s2.isCompatible(s1)).isEqualTo(expectedReverse);
137+
}
138+
139+
static Stream<Arguments> fuzzCases() {
140+
Random rnd = new Random(424242);
141+
List<String> universe = Arrays.asList("a", "b", "c", "d", "e", "x", "y", "z");
142+
int cases = 128; // balanced coverage and speed
143+
Stream.Builder<Arguments> b = Stream.builder();
144+
for (int i = 0; i < cases; i++) {
145+
BindingSet s1 = randomBindingSet(rnd, universe);
146+
BindingSet s2 = randomBindingSet(rnd, universe);
147+
b.add(Arguments.of(s1, s2));
148+
}
149+
return b.build();
150+
}
151+
152+
private static BindingSet randomBindingSet(Random rnd, List<String> universe) {
153+
// Randomly decide how many variables to include (possibly zero)
154+
int n = rnd.nextInt(universe.size() + 1); // 0..universe.size()
155+
// Shuffle-like selection via random threshold
156+
java.util.ArrayList<String> selected = new java.util.ArrayList<>(universe.size());
157+
for (String name : universe) {
158+
if (selected.size() >= n) {
159+
break;
160+
}
161+
// ~50% chance to include each name until we reach n
162+
if (rnd.nextBoolean()) {
163+
selected.add(name);
164+
}
165+
}
166+
// If selection under-filled, top up from remaining deterministically
167+
for (String name : universe) {
168+
if (selected.size() >= n) {
169+
break;
170+
}
171+
if (!selected.contains(name)) {
172+
selected.add(name);
173+
}
174+
}
175+
176+
Value[] values = new Value[selected.size()];
177+
for (int i = 0; i < selected.size(); i++) {
178+
values[i] = randomValueOrNull(rnd, i);
179+
}
180+
return new ListBindingSet(selected, values);
181+
}
182+
183+
private static org.eclipse.rdf4j.model.Value randomValueOrNull(Random rnd, int salt) {
184+
int pick = rnd.nextInt(6);
185+
switch (pick) {
186+
case 0:
187+
return null; // unbound
188+
case 1:
189+
return VF.createIRI("urn:res:" + rnd.nextInt(10));
190+
case 2:
191+
return VF.createLiteral(rnd.nextInt(5));
192+
case 3:
193+
return VF.createLiteral("s" + rnd.nextInt(5));
194+
case 4:
195+
return VF.createBNode("b" + rnd.nextInt(5));
196+
default:
197+
return VF.createIRI("urn:x:" + ((salt + rnd.nextInt(5)) % 7));
198+
}
199+
}
200+
201+
}

0 commit comments

Comments
 (0)