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