Skip to content

Commit e405687

Browse files
Avoid tracking visited values that can't lead to cycle / infinite recursion.
Fixes #1854
1 parent c9c68c3 commit e405687

File tree

4 files changed

+152
-10
lines changed

4 files changed

+152
-10
lines changed

src/main/java/org/assertj/core/api/recursive/comparison/DualValue.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ final class DualValue {
6060
public boolean equals(Object other) {
6161
if (!(other instanceof DualValue)) return false;
6262
DualValue that = (DualValue) other;
63+
// it is critical to compare by reference when tracking visited dual values.
64+
// see should_fix_1854_minimal_test for an explanation
6365
return actual == that.actual && expected == that.expected;
6466
}
6567

@@ -212,4 +214,15 @@ private static List<String> fiedlPath(List<String> parentPath, String fieldName)
212214
return fieldPath;
213215
}
214216

217+
public boolean hasPotentialCyclingValues() {
218+
return isPotentialCyclingValue(actual) && isPotentialCyclingValue(expected);
219+
}
220+
221+
private static boolean isPotentialCyclingValue(Object object) {
222+
if (object == null) return false;
223+
// java.lang are base types that can't cycle to themselves of other types
224+
// we could check more type, but that's a good start
225+
return !object.getClass().getCanonicalName().startsWith("java.lang");
226+
}
227+
215228
}

src/main/java/org/assertj/core/api/recursive/comparison/RecursiveComparisonDifferenceCalculator.java

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ public boolean hasDualValuesToCompare() {
9797

9898
public DualValue pickDualValueToCompare() {
9999
final DualValue dualValue = dualValuesToCompare.removeFirst();
100-
visitedDualValues.add(dualValue);
100+
if (dualValue.hasPotentialCyclingValues()) {
101+
// visited dual values are here to avoid cycle, java types don't have cycle, there is no need to track them.
102+
// moreover this would make should_fix_1854_minimal_test to fail (see the test for a detailed explanation)
103+
visitedDualValues.add(dualValue);
104+
}
101105
return dualValue;
102106
}
103107

@@ -136,15 +140,13 @@ private void initDualValuesToCompare(Object actual, Object expected, List<String
136140
// parent -> set{child} with child having a reference back to parent
137141
// it occurs to unordered collection where we compare all possible combination of the collection elements recursively
138142
// --
139-
// Don't use removeAll which uses equals to compare values, comparison must be done by reference otherwise we could remove
140-
// values too agressively, that occurs when we add a duplicate of a value already visited.
141-
// Example:
142-
// - a and a are duplicates but are not the same object, i.e. a equals a' but a' != a
143-
// - visitedDualValues = {a , b , c}
144-
// - dualValuesToCompare = {a'}
145-
// dualValuesToCompare.removeAll(visitedDualValues) would remove it which is incorrect
146-
// If we compare references then a' won't be removed from dualValuesToCompare
147-
visitedDualValues.forEach(visited -> dualValuesToCompare.removeIf(toCompare -> toCompare == visited));
143+
// remove visited values one by one, DualValue.equals correctly compare respective actual and expected fields by reference
144+
visitedDualValues.forEach(visitedDualValue -> {
145+
dualValuesToCompare.stream()
146+
.filter(dualValueToCompare -> dualValueToCompare.equals(visitedDualValue))
147+
.findFirst()
148+
.ifPresent(dualValuesToCompare::remove);
149+
});
148150
}
149151

150152
private boolean mustCompareFieldsRecursively(boolean isRootObject, DualValue dualValue) {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
3+
* the License. You may obtain a copy of the License at
4+
*
5+
* http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
8+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
9+
* specific language governing permissions and limitations under the License.
10+
*
11+
* Copyright 2012-2020 the original author or authors.
12+
*/
13+
package org.assertj.core.api.recursive.comparison;
14+
15+
import static org.assertj.core.api.BDDAssertions.then;
16+
import static org.assertj.core.util.Lists.list;
17+
18+
import java.util.List;
19+
import java.util.stream.Stream;
20+
21+
import org.assertj.core.internal.objects.data.FriendlyPerson;
22+
import org.junit.jupiter.api.DisplayName;
23+
import org.junit.jupiter.params.ParameterizedTest;
24+
import org.junit.jupiter.params.provider.Arguments;
25+
import org.junit.jupiter.params.provider.MethodSource;
26+
27+
@DisplayName("DualValue hasPotentialCyclingValues")
28+
public class DualValue_hasPotentialCyclingValues_Test {
29+
30+
private static final List<String> PATH = list("foo", "bar");
31+
32+
@ParameterizedTest(name = "actual {0} / expected {1}")
33+
@MethodSource("values")
34+
void should_return_false_when_actual_or_expected_is_a_container_type_and_true_otherwise(Object actual, Object expected,
35+
boolean expectedResult) {
36+
// GIVEN
37+
DualValue dualValue = new DualValue(PATH, actual, expected);
38+
// WHEN
39+
boolean hasPotentialCyclingValuess = dualValue.hasPotentialCyclingValues();
40+
// THEN
41+
then(hasPotentialCyclingValuess).isEqualTo(expectedResult);
42+
}
43+
44+
static Stream<Arguments> values() {
45+
FriendlyPerson person1 = new FriendlyPerson();
46+
FriendlyPerson person2 = new FriendlyPerson();
47+
person1.otherFriends.add(person1);
48+
person1.otherFriends.add(person2);
49+
person2.otherFriends.add(person2);
50+
person2.otherFriends.add(person1);
51+
52+
return Stream.of(Arguments.of(null, person2, false),
53+
Arguments.of(person1, null, false),
54+
Arguments.of(person1, "abc", false),
55+
Arguments.of("abc", person2, false),
56+
Arguments.of("abc", 2, false),
57+
Arguments.of((byte) 1, (byte) 2, false),
58+
Arguments.of((short) 1, (short) 2, false),
59+
Arguments.of(1, 2, false),
60+
Arguments.of(1.0, 2.0, false),
61+
Arguments.of(1.0f, 2.0f, false),
62+
Arguments.of('a', 'b', false),
63+
Arguments.of(person1, person1, true),
64+
Arguments.of(person1, person2, true),
65+
Arguments.of(list(person1), list(person1), true),
66+
Arguments.of(list(person1), list(person2), true),
67+
Arguments.of(list(person1, person2), list(person2, person1), true));
68+
}
69+
}

src/test/java/org/assertj/core/api/recursive/comparison/RecursiveComparisonAssert_isEqualTo_ignoringCollectionOrder_Test.java

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,4 +295,62 @@ public void should_fix_1854() {
295295
.isEqualTo(list(listCReverse, listAReverse));
296296
}
297297

298+
/**
299+
* This test shows that we can't track all visited values, only the one with potential cycles.
300+
* <p>
301+
* Let's run it step by step with tracking all visited values:<br>
302+
* list(listA, listB) vs list(listAReverse, listBReverse) means trying to find<br>
303+
* - listA in list(listAReverse, listBReverse) and then listB
304+
* <p>
305+
* After comparing possible pairs (listA element, listAReverse element) we conclude that listA matches listAReverse<br>
306+
* - here are the pairs (1, 2), (1, 1), (2, 2), we add them to the visited ones<br>
307+
* <p>
308+
* We now try to find listB in list(listBReverse) - listAReverse must not be taken into account as it had already been matched<br>
309+
* - we would like to try (1, 2), (1, 1), (2, 2) but they have already been visited so we skip them<br>
310+
* at this point, we know listB won't be found because (1, 1), (2, 2) won't be considered.
311+
* <p>
312+
* Comparing dualValues actual and expected with == does not solve the issue because Java does not always create different objects
313+
* for primitive wrapping the same basic value, i.e. {@code new Integer(1) == new Integer(1)}.
314+
* <p>
315+
* The solution is to avoid adding all pairs to visited values. <br>
316+
* Visited values are here to track cycles, a pair of wrapped primitive types can't cycle back to itself, we thus can and must ignore them.
317+
* <p>
318+
* For good measure we don't track pair that include any java.lang values.
319+
* <p>
320+
* If listA and listB contained non wrapped basic types then == is enough to differentiate them.
321+
*/
322+
@Test
323+
public void should_fix_1854_minimal_test() {
324+
// GIVEN
325+
List<Integer> listA = list(1, 2);
326+
List<Integer> listB = list(1, 2);
327+
// Reversed lists
328+
List<Integer> listAReverse = list(2, 1);
329+
List<Integer> listBReverse = list(2, 1);
330+
// WHEN - THEN
331+
assertThat(list(listA, listB)).usingRecursiveComparison()
332+
.ignoringCollectionOrder()
333+
.isEqualTo(list(listAReverse, listBReverse));
334+
335+
}
336+
337+
@Test
338+
public void should_fix_1854_with_non_wrapped_basic_types() {
339+
// GIVEN
340+
FriendlyPerson p1 = friend("Sherlock Holmes");
341+
FriendlyPerson p2 = friend("Watson");
342+
FriendlyPerson p3 = friend("Sherlock Holmes");
343+
FriendlyPerson p4 = friend("Watson");
344+
List<FriendlyPerson> listA = list(p1, p2);
345+
List<FriendlyPerson> listB = list(p1, p2);
346+
// Reversed lists
347+
List<FriendlyPerson> listAReverse = list(p4, p3);
348+
List<FriendlyPerson> listBReverse = list(p4, p3);
349+
// WHEN - THEN
350+
assertThat(list(listA, listB)).usingRecursiveComparison()
351+
.ignoringCollectionOrder()
352+
.isEqualTo(list(listAReverse, listBReverse));
353+
354+
}
355+
298356
}

0 commit comments

Comments
 (0)