diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..be503dd4 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -2418,3 +2418,9 @@ public final class org/dexpace/sdk/core/util/SdkInfo { public final fun getSdkVersion ()Ljava/lang/String; } +public final class org/dexpace/sdk/core/util/ValueEquality { + public static final field INSTANCE Lorg/dexpace/sdk/core/util/ValueEquality; + public static final fun contentEquals (Ljava/lang/Object;Ljava/lang/Object;)Z + public static final fun contentHashCode (Ljava/lang/Object;)I +} + diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/ValueEquality.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/ValueEquality.kt new file mode 100644 index 00000000..0dc6203c --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/ValueEquality.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.util + +import java.util.Arrays +import java.util.Objects + +/** + * Deep, structural value-equality helpers for value types that hold arrays. + * + * The JDK's [java.util.Objects.equals] and [java.util.Objects.hashCode] compare arrays by + * **identity**, so two structurally-equal `ByteArray` fields (or any array-typed field) are + * reported unequal. A value type generated for a DTO with array fields therefore cannot lean + * on `Objects.equals`/`Objects.hashCode` for a correct `equals`/`hashCode`. + * + * These helpers compare any value **by content**: arrays — including primitive arrays, nested + * object arrays, and arbitrarily-deep multi-dimensional arrays — are compared element-by-element, + * while non-array values fall back to ordinary [Any.equals]/[Any.hashCode]. [contentEquals] and + * [contentHashCode] are mutually consistent: whenever `contentEquals(a, b)` is `true`, + * `contentHashCode(a) == contentHashCode(b)`. + * + * Typical use from a hand-written (or later generated) value type: + * ```kotlin + * override fun equals(other: Any?): Boolean { + * if (this === other) return true + * if (other !is Foo) return false + * return ValueEquality.contentEquals(payload, other.payload) && + * ValueEquality.contentEquals(matrix, other.matrix) + * } + * + * override fun hashCode(): Int { + * var result = ValueEquality.contentHashCode(payload) + * result = 31 * result + ValueEquality.contentHashCode(matrix) + * return result + * } + * ``` + * + * Both methods are null-safe: two `null` references are equal and hash to `0`. + */ +public object ValueEquality { + /** + * Returns `true` if [a] and [b] are equal by content. + * + * - Two `null` references are equal; a `null` and a non-`null` are not. + * - If both are arrays of the same kind, they are compared element-by-element. Object + * arrays recurse, so nested and multi-dimensional arrays are compared structurally; + * primitive arrays are compared by their element values. + * - An object array and a primitive array (e.g. `Array` vs `IntArray`) are **not** + * equal even with matching values, mirroring the JVM's distinct array types. + * - Floating-point elements follow `Arrays.equals`/`Double.equals` semantics rather than + * `==`: two `NaN`s compare **equal**, while `0.0` and `-0.0` (and `0.0f`/`-0.0f`) compare + * **unequal**. This holds for both primitive (`DoubleArray`/`FloatArray`) and boxed + * (`Array`/`Array`) arrays, and [contentHashCode] hashes to match. + * - Any other value is compared with [Any.equals]. + * + * This is exactly the contract of `java.util.Objects.deepEquals`, to which it delegates. + */ + @JvmStatic + public fun contentEquals( + a: Any?, + b: Any?, + ): Boolean = Objects.deepEquals(a, b) + + /** + * Returns a content-based hash code for [value], consistent with [contentEquals]. + * + * `null` hashes to `0`. Arrays hash by their content — object arrays recurse so nested + * and multi-dimensional arrays contribute a deep hash; primitive arrays hash by their + * element values. Any other value uses its own [Any.hashCode]. + * + * [contentEquals] delegates to `java.util.Objects.deepEquals`, whereas this method mirrors + * `java.util.Arrays.deepHashCode` element-wise — the two do not share an implementation and + * are kept deliberately in lockstep, so any change to either must preserve the contract that + * content-equal values hash equal. + */ + @JvmStatic + public fun contentHashCode(value: Any?): Int = + when (value) { + null -> 0 + is Array<*> -> Arrays.deepHashCode(value) + is BooleanArray -> value.contentHashCode() + is ByteArray -> value.contentHashCode() + is CharArray -> value.contentHashCode() + is ShortArray -> value.contentHashCode() + is IntArray -> value.contentHashCode() + is LongArray -> value.contentHashCode() + is FloatArray -> value.contentHashCode() + is DoubleArray -> value.contentHashCode() + else -> value.hashCode() + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/ValueEqualityTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/ValueEqualityTest.kt new file mode 100644 index 00000000..1486879d --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/ValueEqualityTest.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ValueEqualityTest { + // ---- null handling ------------------------------------------------------------------- + + @Test + fun `two nulls are equal and hash to zero`() { + assertTrue(ValueEquality.contentEquals(null, null)) + assertEquals(0, ValueEquality.contentHashCode(null)) + } + + @Test + fun `null and non-null are not equal in either order`() { + assertFalse(ValueEquality.contentEquals(null, byteArrayOf(1))) + assertFalse(ValueEquality.contentEquals(byteArrayOf(1), null)) + } + + // ---- identity shortcut --------------------------------------------------------------- + + @Test + fun `same reference is equal`() { + val array = intArrayOf(1, 2, 3) + assertTrue(ValueEquality.contentEquals(array, array)) + } + + // ---- scalars / non-arrays ------------------------------------------------------------ + + @Test + fun `non-array values use ordinary equals and hashCode`() { + assertTrue(ValueEquality.contentEquals("abc", "abc")) + assertFalse(ValueEquality.contentEquals("abc", "xyz")) + assertEquals("abc".hashCode(), ValueEquality.contentHashCode("abc")) + + assertTrue(ValueEquality.contentEquals(42, 42)) + assertEquals(42.hashCode(), ValueEquality.contentHashCode(42)) + } + + // ---- primitive arrays ---------------------------------------------------------------- + + @Test + fun `byte arrays compare by content`() { + val a = byteArrayOf(1, 2, 3) + val b = byteArrayOf(1, 2, 3) + assertTrue(ValueEquality.contentEquals(a, b)) + assertEquals(ValueEquality.contentHashCode(a), ValueEquality.contentHashCode(b)) + + assertFalse(ValueEquality.contentEquals(a, byteArrayOf(1, 2, 4))) + assertFalse(ValueEquality.contentEquals(a, byteArrayOf(1, 2))) + } + + @Test + fun `each primitive array kind compares by content`() { + assertContentEqualPair(booleanArrayOf(true, false), booleanArrayOf(true, false)) + assertContentEqualPair(charArrayOf('a', 'b'), charArrayOf('a', 'b')) + assertContentEqualPair(shortArrayOf(1, 2), shortArrayOf(1, 2)) + assertContentEqualPair(intArrayOf(1, 2), intArrayOf(1, 2)) + assertContentEqualPair(longArrayOf(1L, 2L), longArrayOf(1L, 2L)) + assertContentEqualPair(floatArrayOf(1.5f, 2.5f), floatArrayOf(1.5f, 2.5f)) + assertContentEqualPair(doubleArrayOf(1.5, 2.5), doubleArrayOf(1.5, 2.5)) + } + + @Test + fun `differing primitive arrays are not equal`() { + assertFalse(ValueEquality.contentEquals(intArrayOf(1, 2), intArrayOf(1, 3))) + assertFalse(ValueEquality.contentEquals(longArrayOf(1L), longArrayOf(2L))) + assertFalse(ValueEquality.contentEquals(doubleArrayOf(1.0), doubleArrayOf(2.0))) + } + + // ---- object arrays ------------------------------------------------------------------- + + @Test + fun `object arrays compare by content`() { + val a = arrayOf("x", "y", "z") + val b = arrayOf("x", "y", "z") + assertTrue(ValueEquality.contentEquals(a, b)) + assertEquals(ValueEquality.contentHashCode(a), ValueEquality.contentHashCode(b)) + + assertFalse(ValueEquality.contentEquals(a, arrayOf("x", "y"))) + } + + @Test + fun `object arrays containing nulls compare by content`() { + val a = arrayOf("x", null, "z") + val b = arrayOf("x", null, "z") + assertTrue(ValueEquality.contentEquals(a, b)) + assertEquals(ValueEquality.contentHashCode(a), ValueEquality.contentHashCode(b)) + + assertFalse(ValueEquality.contentEquals(a, arrayOf("x", "y", "z"))) + } + + // ---- nested / multi-dimensional arrays ---------------------------------------------- + + @Test + fun `nested object arrays compare structurally`() { + val a = arrayOf(arrayOf("a", "b"), arrayOf("c")) + val b = arrayOf(arrayOf("a", "b"), arrayOf("c")) + assertTrue(ValueEquality.contentEquals(a, b)) + assertEquals(ValueEquality.contentHashCode(a), ValueEquality.contentHashCode(b)) + + val different = arrayOf(arrayOf("a", "b"), arrayOf("d")) + assertFalse(ValueEquality.contentEquals(a, different)) + } + + @Test + fun `nested arrays of primitive arrays compare structurally`() { + val a = arrayOf(intArrayOf(1, 2), intArrayOf(3, 4)) + val b = arrayOf(intArrayOf(1, 2), intArrayOf(3, 4)) + assertTrue(ValueEquality.contentEquals(a, b)) + assertEquals(ValueEquality.contentHashCode(a), ValueEquality.contentHashCode(b)) + + val different = arrayOf(intArrayOf(1, 2), intArrayOf(3, 5)) + assertFalse(ValueEquality.contentEquals(a, different)) + assertNotEquals(ValueEquality.contentHashCode(a), ValueEquality.contentHashCode(different)) + } + + @Test + fun `deeply nested multi-dimensional arrays compare structurally`() { + val a = arrayOf(arrayOf(arrayOf(1, 2), arrayOf(3)), arrayOf(arrayOf(4))) + val b = arrayOf(arrayOf(arrayOf(1, 2), arrayOf(3)), arrayOf(arrayOf(4))) + assertTrue(ValueEquality.contentEquals(a, b)) + assertEquals(ValueEquality.contentHashCode(a), ValueEquality.contentHashCode(b)) + } + + @Test + fun `nested object arrays holding custom value types compare by element equals`() { + val a = arrayOf(Point(1, 2), Point(3, 4)) + val b = arrayOf(Point(1, 2), Point(3, 4)) + assertTrue(ValueEquality.contentEquals(a, b)) + assertEquals(ValueEquality.contentHashCode(a), ValueEquality.contentHashCode(b)) + + assertFalse(ValueEquality.contentEquals(a, arrayOf(Point(1, 2), Point(9, 9)))) + } + + // ---- cross-kind mismatches ----------------------------------------------------------- + + @Test + fun `object array and primitive array with matching values are not equal`() { + val boxed = arrayOf(1, 2, 3) + val primitive = intArrayOf(1, 2, 3) + assertFalse(ValueEquality.contentEquals(boxed, primitive)) + assertFalse(ValueEquality.contentEquals(primitive, boxed)) + } + + @Test + fun `arrays of different primitive kinds are not equal`() { + assertFalse(ValueEquality.contentEquals(intArrayOf(1, 2), longArrayOf(1L, 2L))) + } + + @Test + fun `array and non-array are not equal`() { + assertFalse(ValueEquality.contentEquals(intArrayOf(1), "not-an-array")) + } + + // ---- floating-point special values --------------------------------------------------- + + @Test + fun `NaN elements compare equal in primitive floating-point arrays`() { + assertTrue(ValueEquality.contentEquals(doubleArrayOf(Double.NaN), doubleArrayOf(Double.NaN))) + assertTrue(ValueEquality.contentEquals(floatArrayOf(Float.NaN), floatArrayOf(Float.NaN))) + assertEquals( + ValueEquality.contentHashCode(doubleArrayOf(Double.NaN)), + ValueEquality.contentHashCode(doubleArrayOf(Double.NaN)), + ) + assertEquals( + ValueEquality.contentHashCode(floatArrayOf(Float.NaN)), + ValueEquality.contentHashCode(floatArrayOf(Float.NaN)), + ) + } + + @Test + fun `NaN elements compare equal in boxed floating-point arrays`() { + assertTrue(ValueEquality.contentEquals(arrayOf(Double.NaN), arrayOf(Double.NaN))) + assertTrue(ValueEquality.contentEquals(arrayOf(Float.NaN), arrayOf(Float.NaN))) + assertEquals( + ValueEquality.contentHashCode(arrayOf(Double.NaN)), + ValueEquality.contentHashCode(arrayOf(Double.NaN)), + ) + assertEquals( + ValueEquality.contentHashCode(arrayOf(Float.NaN)), + ValueEquality.contentHashCode(arrayOf(Float.NaN)), + ) + } + + @Test + fun `positive and negative zero are unequal in primitive floating-point arrays`() { + assertFalse(ValueEquality.contentEquals(doubleArrayOf(0.0), doubleArrayOf(-0.0))) + assertFalse(ValueEquality.contentEquals(floatArrayOf(0.0f), floatArrayOf(-0.0f))) + assertNotEquals( + ValueEquality.contentHashCode(doubleArrayOf(0.0)), + ValueEquality.contentHashCode(doubleArrayOf(-0.0)), + ) + assertNotEquals( + ValueEquality.contentHashCode(floatArrayOf(0.0f)), + ValueEquality.contentHashCode(floatArrayOf(-0.0f)), + ) + } + + @Test + fun `positive and negative zero are unequal in boxed floating-point arrays`() { + assertFalse(ValueEquality.contentEquals(arrayOf(0.0), arrayOf(-0.0))) + assertFalse(ValueEquality.contentEquals(arrayOf(0.0f), arrayOf(-0.0f))) + assertNotEquals( + ValueEquality.contentHashCode(arrayOf(0.0)), + ValueEquality.contentHashCode(arrayOf(-0.0)), + ) + assertNotEquals( + ValueEquality.contentHashCode(arrayOf(0.0f)), + ValueEquality.contentHashCode(arrayOf(-0.0f)), + ) + } + + @Test + fun `NaN survives recursion through nested arrays`() { + // arrayOf(doubleArrayOf(...)) drives the deepEquals/deepHashCode recursion seam, so this + // proves NaN/-0.0 semantics carry through the nested path, not just at the top level. + val a = arrayOf(doubleArrayOf(1.0, Double.NaN), floatArrayOf(Float.NaN)) + val b = arrayOf(doubleArrayOf(1.0, Double.NaN), floatArrayOf(Float.NaN)) + assertTrue(ValueEquality.contentEquals(a, b)) + assertEquals(ValueEquality.contentHashCode(a), ValueEquality.contentHashCode(b)) + + val different = arrayOf(doubleArrayOf(1.0, Double.NaN), floatArrayOf(2.0f)) + assertFalse(ValueEquality.contentEquals(a, different)) + } + + @Test + fun `NaN composes with ordinary elements in the same array`() { + assertTrue( + ValueEquality.contentEquals( + doubleArrayOf(1.0, Double.NaN, 2.0), + doubleArrayOf(1.0, Double.NaN, 2.0), + ), + ) + assertFalse( + ValueEquality.contentEquals( + doubleArrayOf(1.0, Double.NaN, 2.0), + doubleArrayOf(1.0, Double.NaN, 3.0), + ), + ) + } + + @Test + fun `non-canonical NaN bit patterns compare equal and hash equal`() { + // A signaling-NaN bit pattern canonicalizes under doubleToLongBits, so it must compare + // equal to the canonical Double.NaN — a guarantee a generated value type relies on. + val signaling = Double.fromBits(0x7ff0000000000001L) + assertTrue(ValueEquality.contentEquals(doubleArrayOf(signaling), doubleArrayOf(Double.NaN))) + assertEquals( + ValueEquality.contentHashCode(doubleArrayOf(signaling)), + ValueEquality.contentHashCode(doubleArrayOf(Double.NaN)), + ) + } + + // ---- equals / hashCode consistency --------------------------------------------------- + + @Test + fun `equal empty arrays of the same kind agree on hashCode`() { + assertTrue(ValueEquality.contentEquals(intArrayOf(), intArrayOf())) + assertEquals(ValueEquality.contentHashCode(intArrayOf()), ValueEquality.contentHashCode(intArrayOf())) + } + + private fun assertContentEqualPair( + a: Any, + b: Any, + ) { + assertTrue(ValueEquality.contentEquals(a, b), "expected $a and $b to be content-equal") + assertEquals( + ValueEquality.contentHashCode(a), + ValueEquality.contentHashCode(b), + "content hashes must agree for content-equal values", + ) + } + + private data class Point(val x: Int, val y: Int) +}