From cb8fa398166011c943b8610dc32d2b4ad5e7c521 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 05:35:05 +0300 Subject: [PATCH 1/2] feat: add deep-array value-equality helpers in sdk-core Add ValueEquality.contentEquals / contentHashCode to org.dexpace.sdk.core.util for value types that hold array-typed fields. java.util.Objects.equals compares arrays by identity, so structurally-equal ByteArray (or any array) fields are wrongly reported unequal; these helpers compare by content instead. Both helpers handle primitive arrays, object arrays, nulls, and arbitrarily-deep nested / multi-dimensional arrays (via Arrays.deepEquals / deepHashCode), while falling back to ordinary equals/hashCode for non-array values. The two methods are mutually consistent: content-equal values always share a content hash. --- sdk-core/api/sdk-core.api | 6 + .../dexpace/sdk/core/util/ValueEquality.kt | 99 +++++++++ .../sdk/core/util/ValueEqualityTest.kt | 188 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/ValueEquality.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/ValueEqualityTest.kt 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..dd301d64 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/ValueEquality.kt @@ -0,0 +1,99 @@ +/* + * 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 + +/** + * 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. + * - Any other value is compared with [Any.equals]. + */ + @JvmStatic + public fun contentEquals( + a: Any?, + b: Any?, + ): Boolean { + if (a === b) return true + if (a == null || b == null) return false + return when { + a is Array<*> && b is Array<*> -> Arrays.deepEquals(a, b) + a is BooleanArray && b is BooleanArray -> a.contentEquals(b) + a is ByteArray && b is ByteArray -> a.contentEquals(b) + a is CharArray && b is CharArray -> a.contentEquals(b) + a is ShortArray && b is ShortArray -> a.contentEquals(b) + a is IntArray && b is IntArray -> a.contentEquals(b) + a is LongArray && b is LongArray -> a.contentEquals(b) + a is FloatArray && b is FloatArray -> a.contentEquals(b) + a is DoubleArray && b is DoubleArray -> a.contentEquals(b) + else -> 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]. + */ + @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..5cfaaab8 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/util/ValueEqualityTest.kt @@ -0,0 +1,188 @@ +/* + * 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")) + } + + // ---- 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) +} From 41636147f52b1b8e7d8b33e7b9c8fd864ed6ead0 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 21 Jun 2026 00:00:47 +0300 Subject: [PATCH 2/2] refactor: delegate contentEquals to Objects.deepEquals and pin float semantics contentEquals reimplemented java.util.Objects.deepEquals branch for branch, so delegate to it directly instead. Document that array comparison follows Arrays.equals/Double.equals semantics rather than ==: NaN compares equal and 0.0 != -0.0, for both primitive and boxed arrays. Note that contentHashCode mirrors Arrays.deepHashCode by hand and is kept in lockstep with contentEquals. Add tests for NaN and signed zero across primitive and boxed float/double arrays, plus nested arrays, mixed elements, and non-canonical NaN bit patterns. --- .../dexpace/sdk/core/util/ValueEquality.kt | 29 +++--- .../sdk/core/util/ValueEqualityTest.kt | 99 +++++++++++++++++++ 2 files changed, 112 insertions(+), 16 deletions(-) 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 index dd301d64..0dc6203c 100644 --- 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 @@ -8,6 +8,7 @@ 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. @@ -51,28 +52,19 @@ public object ValueEquality { * 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 { - if (a === b) return true - if (a == null || b == null) return false - return when { - a is Array<*> && b is Array<*> -> Arrays.deepEquals(a, b) - a is BooleanArray && b is BooleanArray -> a.contentEquals(b) - a is ByteArray && b is ByteArray -> a.contentEquals(b) - a is CharArray && b is CharArray -> a.contentEquals(b) - a is ShortArray && b is ShortArray -> a.contentEquals(b) - a is IntArray && b is IntArray -> a.contentEquals(b) - a is LongArray && b is LongArray -> a.contentEquals(b) - a is FloatArray && b is FloatArray -> a.contentEquals(b) - a is DoubleArray && b is DoubleArray -> a.contentEquals(b) - else -> a == b - } - } + ): Boolean = Objects.deepEquals(a, b) /** * Returns a content-based hash code for [value], consistent with [contentEquals]. @@ -80,6 +72,11 @@ public object ValueEquality { * `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 = 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 index 5cfaaab8..1486879d 100644 --- 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 @@ -164,6 +164,105 @@ class ValueEqualityTest { 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