diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpaceDirectory.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpaceDirectory.java index a92b366b4c..9e4279d284 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpaceDirectory.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpaceDirectory.java @@ -710,7 +710,8 @@ public Object getValue() { return value; } - protected static boolean areEqual(Object o1, Object o2) { + @SuppressWarnings("PMD.CompareObjectsWithEquals") // we use ref + protected static boolean areEqual(@Nullable Object o1, @Nullable Object o2) { if (o1 == null) { return o2 == null; } else { @@ -719,6 +720,12 @@ protected static boolean areEqual(Object o1, Object o2) { } } + // Handle ANY_VALUE specially - typeOf does not support ANY_VALUE + boolean isAnyValue = (o1 == ANY_VALUE || o2 == ANY_VALUE); + if (isAnyValue) { + return Objects.equals(o1, o2); + } + KeyType o1Type = KeyType.typeOf(o1); if (o1Type != KeyType.typeOf(o2)) { return false; @@ -740,6 +747,31 @@ protected static boolean areEqual(Object o1, Object o2) { } } + protected static int valueHashCode(@Nullable Object value) { + if (value == null) { + return 0; + } + + // Handle ANY_VALUE specially + if (value == ANY_VALUE) { + return System.identityHashCode(value); + } + + switch (KeyType.typeOf(value)) { + case BYTES: + return Arrays.hashCode((byte[]) value); + case LONG: + case STRING: + case FLOAT: + case DOUBLE: + case BOOLEAN: + case UUID: + return Objects.hashCode(value); + default: + throw new RecordCoreException("Unexpected key type " + KeyType.typeOf(value)); + } + } + /** * Returns the path that leads up to this directory (including this directory), and returns it as a string * that looks something like a filesystem path. @@ -898,6 +930,27 @@ public static KeyType typeOf(@Nullable Object value) { } } + /** + * Compares two KeySpaceDirectory objects for equality based on their intrinsic properties, + * ignoring their position in the directory hierarchy (parent, subdirectories) and wrapper functions. + * + * @param other the other KeySpaceDirectory to compare with + * @return true if the directories have the same name, keyType, and value; false otherwise + */ + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public boolean equalsIgnoringHierarchy(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + KeySpaceDirectory that = (KeySpaceDirectory) other; + return Objects.equals(name, that.name) && + Objects.equals(keyType, that.keyType) && + areEqual(value, that.value); + } + private static class AnyValue { private AnyValue() { } diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpacePathImpl.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpacePathImpl.java index 7102da8936..4d45311c77 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpacePathImpl.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpacePathImpl.java @@ -276,18 +276,14 @@ public boolean equals(Object obj) { } KeySpacePath that = (KeySpacePath) obj; - // Check that the KeySpaceDirectories of the two paths are "equal enough". - // Even this is probably overkill since the isCompatible check in KeySpaceDirectory - // will keep us from doing anything too bad. We could move this check into KeySpaceDirectory - // but comparing two directories by value would necessitate traversing the entire directory - // tree, so instead we will use a narrower definition of equality here. - boolean directoriesEqual = Objects.equals(this.getDirectory().getKeyType(), that.getDirectory().getKeyType()) && - Objects.equals(this.getDirectory().getName(), that.getDirectory().getName()) && - Objects.equals(this.getDirectory().getValue(), that.getDirectory().getValue()); + // We don't care whether the two objects exist in the same hierarchy, we validate the relevant bits by comparing + // parents. + boolean directoriesEqual = this.getDirectory().equalsIgnoringHierarchy(that.getDirectory()); + // the values might be byte[] return directoriesEqual && - Objects.equals(this.getValue(), that.getValue()) && - Objects.equals(this.getParent(), that.getParent()); + KeySpaceDirectory.areEqual(this.getValue(), that.getValue()) && + Objects.equals(this.getParent(), that.getParent()); } @Override @@ -295,8 +291,7 @@ public int hashCode() { return Objects.hash( getDirectory().getKeyType(), getDirectory().getName(), - getDirectory().getValue(), - getValue(), + KeySpaceDirectory.valueHashCode(getValue()), parent); } diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/PathValue.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/PathValue.java index 5de09bd396..2582654572 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/PathValue.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/PathValue.java @@ -24,13 +24,14 @@ import javax.annotation.Nullable; import java.util.Arrays; +import java.util.Objects; /** * A class to represent the value stored at a particular element of a {@link KeySpacePath}. The resolvedValue * is the object that will appear in the {@link com.apple.foundationdb.tuple.Tuple} when * {@link KeySpacePath#toTuple(com.apple.foundationdb.record.provider.foundationdb.FDBRecordContext)} is invoked. * The metadata is left null by {@link KeySpaceDirectory} but other implementations may make use of - * it (e.g. {@link DirectoryLayerDirectory}. + * it (e.g. {@link DirectoryLayerDirectory}). */ @API(API.Status.UNSTABLE) public class PathValue { @@ -69,4 +70,22 @@ public Object getResolvedValue() { public byte[] getMetadata() { return metadata == null ? null : Arrays.copyOf(metadata, metadata.length); } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof PathValue)) { + return false; + } + PathValue that = (PathValue) other; + return KeySpaceDirectory.areEqual(this.resolvedValue, that.resolvedValue) && + Arrays.equals(this.metadata, that.metadata); + } + + @Override + public int hashCode() { + return Objects.hash(KeySpaceDirectory.valueHashCode(resolvedValue), Arrays.hashCode(metadata)); + } } diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/ResolvedKeySpacePath.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/ResolvedKeySpacePath.java index f07629954e..0afd60db10 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/ResolvedKeySpacePath.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/ResolvedKeySpacePath.java @@ -229,13 +229,15 @@ public boolean equals(Object other) { } ResolvedKeySpacePath otherPath = (ResolvedKeySpacePath) other; - return this.inner.equals(otherPath.inner) - && Objects.equals(this.getResolvedValue(), otherPath.getResolvedValue()); + return Objects.equals(this.getResolvedPathValue(), otherPath.getResolvedPathValue()) && + Objects.equals(this.getParent(), otherPath.getParent()) && + this.inner.equals(otherPath.inner) && + Objects.equals(this.remainder, otherPath.remainder); } @Override public int hashCode() { - return Objects.hash(inner, getResolvedPathValue()); + return Objects.hash(inner, getResolvedPathValue(), remainder, getParent()); } @Override diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpaceDirectoryTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpaceDirectoryTest.java index 0263ca9bf4..552001778a 100644 --- a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpaceDirectoryTest.java +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/KeySpaceDirectoryTest.java @@ -48,6 +48,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.FieldSource; +import org.junit.jupiter.params.provider.MethodSource; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -68,6 +71,7 @@ import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.IntStream; +import java.util.stream.Stream; import static com.apple.foundationdb.record.TestHelpers.assertThrows; import static com.apple.foundationdb.record.TestHelpers.eventually; @@ -110,11 +114,16 @@ public KeyTypeValue(KeyType keyType, @Nullable Object value, @Nullable Object va assertTrue(keyType.isMatch(value)); assertTrue(keyType.isMatch(generator.get())); } + + @Override + public String toString() { + return "KeyTypeValue{" + keyType + '}'; + } } - private final Random random = new Random(); + private static final Random random = new Random(); - private final List valueOfEveryType = new ImmutableList.Builder() + private static final List valueOfEveryType = new ImmutableList.Builder() .add(new KeyTypeValue(KeyType.NULL, null, null, () -> null)) .add(new KeyTypeValue(KeyType.BYTES, new byte[] { 0x01, 0x02 }, new byte[] { 0x03, 0x04 }, () -> { int size = random.nextInt(10) + 1; @@ -1218,6 +1227,123 @@ public void testListAcrossTransactions() { assertEquals(directoryEntries.size(), idx); } + static Stream testEqualsIgnoringHierarchyWithVariousTypes() { + // Basic type equality tests + KeySpaceDirectory stringDir1 = new KeySpaceDirectory("dir", KeyType.STRING, "hello"); + KeySpaceDirectory stringDir2 = new KeySpaceDirectory("dir", KeyType.STRING, "hello"); + KeySpaceDirectory stringDir3 = new KeySpaceDirectory("dir", KeyType.STRING, "goodbye"); + KeySpaceDirectory stringDir4 = new KeySpaceDirectory("different_name", KeyType.STRING, "hello"); + + KeySpaceDirectory longDir1 = new KeySpaceDirectory("dir", KeyType.LONG, 42L); + KeySpaceDirectory longDir2 = new KeySpaceDirectory("dir", KeyType.LONG, 42L); + KeySpaceDirectory longDir3 = new KeySpaceDirectory("dir", KeyType.LONG, 100L); + KeySpaceDirectory longDirAsString = new KeySpaceDirectory("dir", KeyType.STRING, "42"); + + // bytes is, important here because testEqualsIgnoringHierarchyWithAllKeyTypes could pass if calling + // .equals on the two values, but this one would not. + KeySpaceDirectory bytesDir1 = new KeySpaceDirectory("dir", KeyType.BYTES, new byte[] { 0x01, 0x02 }); + KeySpaceDirectory bytesDir2 = new KeySpaceDirectory("dir", KeyType.BYTES, new byte[] { 0x01, 0x02 }); + KeySpaceDirectory bytesDir3 = new KeySpaceDirectory("dir", KeyType.BYTES, new byte[] { 0x05, 0x06 }); + + // Wrapper tests + Function wrapper1 = TestWrapper1::new; + Function wrapper2 = TestWrapper2::new; + KeySpaceDirectory dirWithWrapper1 = new KeySpaceDirectory("test", KeyType.STRING, "value", wrapper1); + KeySpaceDirectory dirWithWrapper2 = new KeySpaceDirectory("test", KeyType.STRING, "value", wrapper2); + KeySpaceDirectory dirWithoutWrapper = new KeySpaceDirectory("test", KeyType.STRING, "value", null); + + // ANY_VALUE tests + KeySpaceDirectory anyValueDir1 = new KeySpaceDirectory("test", KeyType.STRING); + KeySpaceDirectory anyValueDir2 = new KeySpaceDirectory("test", KeyType.STRING); + KeySpaceDirectory specificValueDir = new KeySpaceDirectory("test", KeyType.STRING, "specificValue"); + + return Stream.of( + // Basic equality/inequality + Arguments.of(stringDir1, stringDir2, true), + Arguments.of(stringDir1, stringDir3, false), + Arguments.of(stringDir4, stringDir1, false), + Arguments.of(longDir1, longDir2, true), + Arguments.of(longDir1, longDir3, false), + Arguments.of(longDir1, longDirAsString, false), + Arguments.of(bytesDir1, bytesDir2, true), + Arguments.of(bytesDir1, bytesDir3, false), + Arguments.of(stringDir1, longDir1, false), + + // Wrapper tests - different wrappers should be equal + Arguments.of(dirWithWrapper1, dirWithWrapper2, true), + Arguments.of(dirWithWrapper1, dirWithoutWrapper, true), + Arguments.of(dirWithWrapper2, dirWithoutWrapper, true), + + // ANY_VALUE tests + Arguments.of(anyValueDir1, anyValueDir2, true), + Arguments.of(anyValueDir1, specificValueDir, false) + ); + } + + @ParameterizedTest + @MethodSource("testEqualsIgnoringHierarchyWithVariousTypes") + void testEqualsIgnoringHierarchyWithVariousTypes(KeySpaceDirectory dir1, KeySpaceDirectory dir2, boolean shouldBeEqual) { + if (shouldBeEqual) { + assertTrue(dir1.equalsIgnoringHierarchy(dir2), "Directories should be equal for " + dir1.getKeyType()); + assertTrue(dir2.equalsIgnoringHierarchy(dir1), "Directories should be equal for " + dir2.getKeyType() + " (reversed)"); + } else { + assertFalse(dir1.equalsIgnoringHierarchy(dir2), "Directories should not be equal"); + assertFalse(dir2.equalsIgnoringHierarchy(dir1), "Directories should not be equal (reversed)"); + } + assertTrue(dir1.equalsIgnoringHierarchy(dir1), "Directories should always equal themselves"); + assertTrue(dir2.equalsIgnoringHierarchy(dir2), "Directories should always equal themselves"); + assertFalse(dir1.equalsIgnoringHierarchy(null), "Directory should not equal null"); + assertFalse(dir1.equalsIgnoringHierarchy(dir1.value), "Directory should not equal other classes"); + } + + @Test + void testEqualsIgnoringHierarchyWithSubdirectories() { + KeySpaceDirectory parent1 = new KeySpaceDirectory("parent", KeyType.STRING, "test"); + KeySpaceDirectory parent2 = new KeySpaceDirectory("parent", KeyType.STRING, "test"); + + KeySpaceDirectory child1 = new KeySpaceDirectory("child", KeyType.LONG); + KeySpaceDirectory child2 = new KeySpaceDirectory("child", KeyType.LONG); + + parent1.addSubdirectory(child1); + parent2.addSubdirectory(child2); + + assertTrue(parent1.equalsIgnoringHierarchy(parent2), "Parents with equivalent subdirectories should be equal when ignoring hierarchy"); + assertTrue(child1.equalsIgnoringHierarchy(child2), "Children should be equal when ignoring hierarchy"); + + KeySpaceDirectory parent3 = new KeySpaceDirectory("parent", KeyType.STRING, "test"); + KeySpaceDirectory child3 = new KeySpaceDirectory("child", KeyType.STRING); + parent3.addSubdirectory(child3); + + assertTrue(parent1.equalsIgnoringHierarchy(parent3), "Parents should be equal when ignoring subdirectories"); + } + + @Test + void testEqualsIgnoringHierarchyWithParentRelationship() { + KeySpaceDirectory root1 = new KeySpaceDirectory("root", KeyType.STRING, "test"); + KeySpaceDirectory root2 = new KeySpaceDirectory("root", KeyType.STRING, "test"); + + KeySpaceDirectory child1 = new KeySpaceDirectory("child", KeyType.LONG); + KeySpaceDirectory child2 = new KeySpaceDirectory("child", KeyType.LONG); + + root1.addSubdirectory(child1); + root2.addSubdirectory(child2); + + assertTrue(child1.equalsIgnoringHierarchy(child2), "Children with different parents should be equal when ignoring hierarchy"); + } + + @ParameterizedTest + @FieldSource("valueOfEveryType") + void testEqualsIgnoringHierarchyWithAllKeyTypes(KeyTypeValue keyTypeValue) { + KeySpaceDirectory dir1 = new KeySpaceDirectory("dir", keyTypeValue.keyType, keyTypeValue.value); + KeySpaceDirectory dir2 = new KeySpaceDirectory("dir", keyTypeValue.keyType, keyTypeValue.value); + + assertTrue(dir1.equalsIgnoringHierarchy(dir2), "Directories should be equal"); + if (keyTypeValue.keyType != KeyType.NULL) { + assertFalse(dir1.equalsIgnoringHierarchy(new KeySpaceDirectory("dir", keyTypeValue.keyType, keyTypeValue.value2)), + "Directories should not be equal"); + } + } + private static class TestWrapper1 extends KeySpacePathWrapper { public TestWrapper1(KeySpacePath inner) { super(inner); diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/PathValueTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/PathValueTest.java new file mode 100644 index 0000000000..e23bd1e153 --- /dev/null +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/PathValueTest.java @@ -0,0 +1,96 @@ +/* + * PathValueTest.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2025 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.record.provider.foundationdb.keyspace; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * Tests for {@link PathValue}. + */ +class PathValueTest { + + /** + * Test data for PathValue equality tests. + */ + static Stream equalPathValuePairs() { + return Stream.of( + Arguments.of("null values", null, null, null, null), + Arguments.of("same string values", "test", null, "test", null), + Arguments.of("same long values", 42L, null, 42L, null), + Arguments.of("same boolean values", true, null, true, null), + Arguments.of("same byte[] values", new byte[] {1, 2, 3}, null, new byte[] {1, 2, 3}, null), + Arguments.of("same metadata", "test", new byte[]{1, 2, 3}, "test", new byte[]{1, 2, 3}) + ); + } + + /** + * Test data for PathValue inequality tests. + */ + static Stream unequalPathValuePairs() { + return Stream.of( + Arguments.of("different string values", "test1", null, "test2", null), + Arguments.of("different long values", 42L, null, 100L, null), + Arguments.of("different boolean values", true, null, false, null), + Arguments.of("different types", "string", null, 42L, null), + Arguments.of("different metadata", "test", new byte[]{1, 2, 3}, "test", new byte[]{4, 5, 6}), + Arguments.of("one null metadata", "test", new byte[]{1, 2, 3}, "test", null), + Arguments.of("one null value", null, null, "test", null), + Arguments.of("different value with same metadata", "test1", new byte[]{1, 2, 3}, "test2", new byte[]{1, 2, 3}) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("equalPathValuePairs") + void testEqualsAndHashCodeForEqualValues(String description, Object resolvedValue1, byte[] metadata1, + Object resolvedValue2, byte[] metadata2) { + PathValue value1 = new PathValue(resolvedValue1, metadata1); + PathValue value2 = new PathValue(resolvedValue2, metadata2); + + assertEquals(value1, value2, "PathValues should be equal: " + description); + assertEquals(value1.hashCode(), value2.hashCode(), "Equal PathValues should have equal hash codes: " + description); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("unequalPathValuePairs") + void testNotEqualsForUnequalValues(String description, Object resolvedValue1, byte[] metadata1, + Object resolvedValue2, byte[] metadata2) { + PathValue value1 = new PathValue(resolvedValue1, metadata1); + PathValue value2 = new PathValue(resolvedValue2, metadata2); + + assertNotEquals(value1, value2, "PathValues should not be equal: " + description); + } + + @Test + void testTrivialEquality() { + PathValue value1 = new PathValue("Foo", null); + + assertEquals(value1, value1, "Cover reference equality shortcut"); + assertNotEquals("Foo", value1, "Check it doesn't fail with non-PathValue"); + } +} diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/ResolvedKeySpacePathTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/ResolvedKeySpacePathTest.java new file mode 100644 index 0000000000..e3dde94acb --- /dev/null +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/keyspace/ResolvedKeySpacePathTest.java @@ -0,0 +1,235 @@ +/* + * ResolvedKeySpacePathTest.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2025 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.apple.foundationdb.record.provider.foundationdb.keyspace; + +import com.apple.foundationdb.record.provider.foundationdb.keyspace.KeySpaceDirectory.KeyType; +import com.apple.foundationdb.record.test.FDBDatabaseExtension; +import com.apple.foundationdb.tuple.Tuple; +import com.apple.test.BooleanSource; +import com.apple.test.ParameterizedTestUtils; +import com.apple.test.Tags; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Arrays; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests for {@link ResolvedKeySpacePath}. + */ +@Tag(Tags.RequiresFDB) +class ResolvedKeySpacePathTest { + @RegisterExtension + final FDBDatabaseExtension dbExtension = new FDBDatabaseExtension(); + + /** + * Test value pairs for each KeyType. + */ + private static final Map TYPE_TEST_VALUES = Map.of( + KeyType.STRING, new TestValuePair(() -> "value1", () -> "value2"), + KeyType.LONG, new TestValuePair(() -> 100L, () -> 200L), + KeyType.BYTES, new TestValuePair(() -> new byte[]{1, 2, 3}, () -> new byte[]{4, 5, 6}), + KeyType.UUID, new TestValuePair(() -> new UUID(1, 1), () -> new UUID(2, 2)), + KeyType.BOOLEAN, new TestValuePair(() -> true, () -> false), + KeyType.NULL, new TestValuePair(() -> null, () -> null), + KeyType.FLOAT, new TestValuePair(() -> 1.5f, () -> 2.5f), + KeyType.DOUBLE, new TestValuePair(() -> 1.5d, () -> 2.5d) + ); + + @Nonnull + static Stream testEqualsHashCode() { + return ParameterizedTestUtils.cartesianProduct( + Arrays.stream(KeyType.values()), + ParameterizedTestUtils.booleans("constantDirectory"), + ParameterizedTestUtils.booleans("differenceInParent") + ); + } + + /** + * Test equals and hashCode contracts for depth 1 directories. + */ + @ParameterizedTest + @MethodSource("testEqualsHashCode") + void testEqualsHashCode(@Nonnull KeyType keyType, boolean constantDirectory, boolean differenceInParent) { + @Nonnull TestValuePair values = TYPE_TEST_VALUES.get(keyType); + // Test case 1: Same logical and resolved values (existing test) + ResolvedKeySpacePath path1 = createResolvedPath(keyType, values.getValue1(), values.getValue1(), + createRootParent(), constantDirectory, differenceInParent); + ResolvedKeySpacePath path2 = createResolvedPath(keyType, values.getValue1(), values.getValue1(), + createRootParent(), constantDirectory, differenceInParent); + + // Test equality contracts + assertEquals(path1, path2, "Identical paths should be equal"); + assertEquals(path2, path1, "Symmetry: path2.equals(path1)"); + assertEquals(path1.hashCode(), path2.hashCode(), "Equal objects must have equal hash codes"); + + // Test inequality when values differ (except NULL type which only has null values) + if (keyType != KeyType.NULL) { + ResolvedKeySpacePath path3 = createResolvedPath(keyType, values.getValue2(), createRootParent(), constantDirectory); + assertNotEquals(path1, path3, "Paths with different values should not be equal"); + + assertNotEquals(createResolvedPath(keyType, values.getValue1(), values.getValue2(), createRootParent(), constantDirectory, differenceInParent), + path1, "Paths with different resolved values should not be equal"); + assertNotEquals(createResolvedPath(keyType, values.getValue2(), values.getValue1(), createRootParent(), constantDirectory, differenceInParent), + path1, "Paths with different logical values should not be equal"); + } else { + assertNull(values.getValue2()); + } + + // Test basic contracts + assertEquals(path1, path1, "Reflexivity"); + assertNotEquals(path1, null, "Null comparison"); + assertNotEquals(path1, "not a path", "Type safety"); + } + + /** + * Test that demonstrates the actual equals/hashCode behavior with different PathValue metadata. + */ + @ParameterizedTest + @BooleanSource("constantDirectory") + void testEqualsHashCodeWithDifferentMetadata(boolean constantDirectory) { + // Create two paths with same inner path and resolved value but different metadata + KeySpacePath innerPath = createKeySpacePath(createRootParent(), KeyType.STRING, "resolved", constantDirectory); + PathValue value1 = new PathValue("resolved", new byte[]{1, 2, 3}); + PathValue value2 = new PathValue("resolved", new byte[]{4, 5, 6}); + + ResolvedKeySpacePath path1 = new ResolvedKeySpacePath(null, innerPath, value1, null); + ResolvedKeySpacePath path2 = new ResolvedKeySpacePath(null, innerPath, value2, null); + + assertNotEquals(path1, path2, "Objects should be equal (same inner path and resolved value, metadata ignored)"); + assertNotEquals(path1.hashCode(), path2.hashCode(), + "Hash codes differ due to different PathValue metadata"); + } + + /** + * Test remainder field behavior in equals. + */ + @ParameterizedTest + @BooleanSource("constantDirectory") + void testRemainderComparedInEquals(boolean constantDirectory) { + KeySpacePath innerPath = createKeySpacePath(createRootParent(), KeyType.STRING, "resolved", constantDirectory); + PathValue value = new PathValue("resolved", null); + + ResolvedKeySpacePath path1 = new ResolvedKeySpacePath(null, innerPath, value, Tuple.from("remainder1")); + ResolvedKeySpacePath path2 = new ResolvedKeySpacePath(null, innerPath, value, Tuple.from("remainder2")); + ResolvedKeySpacePath path3 = new ResolvedKeySpacePath(null, innerPath, value, Tuple.from("remainder1")); + + assertNotEquals(path1, path2, "Paths with different remainders should not be equal"); + assertEquals(path1, path3, "Paths with the same remainder should be equal"); + assertEquals(path1.hashCode(), path3.hashCode(), "Paths with the same remainder should have same hashCode"); + ResolvedKeySpacePath nullRemainder = new ResolvedKeySpacePath(null, innerPath, value, null); + assertNotEquals(path1, nullRemainder, "Path without a remainder should not be the equal to one with a remainder"); + assertNotEquals(nullRemainder, null, "Make sure null is properly handled in equals"); + IntStream.range(0, 10_000).mapToObj(i -> new ResolvedKeySpacePath(null, innerPath, value, Tuple.from(i))) + .filter(path -> path.hashCode() != path1.hashCode()) + .findAny() + .orElseThrow(() -> new AssertionError("Paths with different remainders should sometimes have different hash codes")); + } + + @Nonnull + private ResolvedKeySpacePath createResolvedPath(@Nonnull KeyType keyType, @Nullable Object value, + @Nonnull ResolvedKeySpacePath parent, boolean constantDirectory) { + return createResolvedPath(keyType, value, value, parent, constantDirectory, false); + } + + @Nonnull + private ResolvedKeySpacePath createResolvedPath(@Nonnull KeyType keyType, + @Nullable Object logicalValue, @Nullable Object resolvedValue, + @Nonnull ResolvedKeySpacePath parent, + boolean constantDirectory, final boolean addConstantChild) { + KeySpacePath innerPath = createKeySpacePath(parent, keyType, logicalValue, constantDirectory); + PathValue pathValue = new PathValue(resolvedValue, null); + final ResolvedKeySpacePath resolvedKeySpacePath = new ResolvedKeySpacePath(parent, innerPath, pathValue, null); + if (addConstantChild) { + final ResolvedKeySpacePath resolvedPath = createResolvedPath(KeyType.STRING, + "Constant", "Constant", resolvedKeySpacePath, true, false); + return resolvedPath; + } else { + return resolvedKeySpacePath; + } + } + + @Nonnull + private KeySpacePath createKeySpacePath(@Nonnull ResolvedKeySpacePath parent, @Nonnull KeyType keyType, @Nullable Object value, + boolean constantDirectory) { + // Create child directory based on constantDirectory parameter + KeySpaceDirectory childDir; + if (constantDirectory) { + childDir = new KeySpaceDirectory("test", keyType, value); + } else { + childDir = new KeySpaceDirectory("test", keyType); + } + parent.getDirectory().addSubdirectory(childDir); + + if (constantDirectory) { + return parent.toPath().add("test"); + } else { + return parent.toPath().add("test", value); + } + } + + @Nonnull + private static ResolvedKeySpacePath createRootParent() { + final KeySpaceDirectory parentDir = new KeySpaceDirectory("root", KeyType.STRING, "root"); + KeySpacePath parent = new KeySpace(parentDir).path("root"); + return new ResolvedKeySpacePath(null, parent, new PathValue("root", null), null); + } + + /** + * Test value pair for each KeyType. + * We use {@link Supplier} here to make sure that if it is falling back to reference equality (e.g. byte[]), + * we want to catch if it doesn't consider those equal. + */ + private static class TestValuePair { + @Nonnull + private final Supplier value1Supplier; + @Nonnull + private final Supplier value2Supplier; + + TestValuePair(@Nonnull Supplier value1Supplier, @Nonnull Supplier value2Supplier) { + this.value1Supplier = value1Supplier; + this.value2Supplier = value2Supplier; + } + + @Nullable + Object getValue1() { + return value1Supplier.get(); + } + + @Nullable + Object getValue2() { + return value2Supplier.get(); + } + } +}