Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import io.ebean.core.type.ScalarType;
import io.ebean.text.TextException;
import io.ebeaninternal.server.deploy.meta.DeployBeanProperty;
import io.ebeaninternal.server.util.Checksum;
import io.ebeaninternal.server.util.JsonContentHash;

import jakarta.persistence.PersistenceException;
import java.sql.SQLException;
Expand Down Expand Up @@ -141,7 +141,10 @@ public MutableValueInfo info() {
}

/**
* Hold checksum of json source content to use for dirty detection.
* Hold canonical hash of json content to use for dirty detection.
* <p>
* Uses an order-independent hash so that databases which reorder JSON object
* keys (e.g. PostgreSQL JSONB) do not cause false dirty detection.
* <p>
* Does not support rebuilding 'oldValue' as no original json content.
*/
Expand All @@ -152,7 +155,7 @@ private static final class ChecksumMutableValue implements MutableValueInfo {

ChecksumMutableValue(ScalarType<?> parent, String json) {
this.parent = parent;
this.checksum = Checksum.checksum(json);
this.checksum = JsonContentHash.hash(json);
}

/**
Expand All @@ -165,13 +168,13 @@ private static final class ChecksumMutableValue implements MutableValueInfo {

@Override
public MutableValueNext nextDirty(String json) {
final long nextChecksum = Checksum.checksum(json);
final long nextChecksum = JsonContentHash.hash(json);
return nextChecksum == checksum ? null : new NextPair(json, new ChecksumMutableValue(parent, nextChecksum));
}

@Override
public boolean isEqualToObject(Object obj) {
return Checksum.checksum(parent.format(obj)) == checksum;
return JsonContentHash.hash(parent.format(obj)) == checksum;
}

@Override
Expand All @@ -182,6 +185,10 @@ public Object get() {

/**
* Hold json source content. This supports rebuilding the 'oldValue'.
* <p>
* Uses fast string equality as primary check, with an order-independent
* canonical hash as fallback to handle databases that reorder JSON object
* keys (e.g. PostgreSQL JSONB).
*/
private static final class SourceMutableValue implements MutableValueInfo, MutableValueNext {

Expand All @@ -195,12 +202,15 @@ private static final class SourceMutableValue implements MutableValueInfo, Mutab

@Override
public MutableValueNext nextDirty(String json) {
return Objects.equals(originalJson, json) ? null : new SourceMutableValue(parent, json);
if (jsonContentEqual(originalJson, json)) {
return null;
}
return new SourceMutableValue(parent, json);
}

@Override
public boolean isEqualToObject(Object obj) {
return Objects.equals(originalJson, parent.format(obj));
return jsonContentEqual(originalJson, parent.format(obj));
}

@Override
Expand All @@ -219,4 +229,13 @@ public MutableValueInfo info() {
return this;
}
}

/**
* Compare two JSON strings for content equality, ignoring key ordering.
* Uses fast string equality first, falls back to order-independent hash comparison.
*/
private static boolean jsonContentEqual(String json1, String json2) {
return Objects.equals(json1, json2)
|| JsonContentHash.hash(json1) == JsonContentHash.hash(json2);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package io.ebeaninternal.server.util;

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;

import java.io.IOException;

/**
* Compute an order-independent structural hash of JSON content using Jackson's streaming parser.
* <p>
* Object key ordering does NOT affect the hash value (handles PostgreSQL JSONB key reordering),
* while array element ordering DOES affect it (array position is semantically significant).
* <p>
* This is significantly faster than a full parse/format roundtrip because it performs
* zero object allocation beyond the parser itself — no tree building, no reflection,
* no type conversion. Single-pass O(n) time with O(depth) stack space.
*/
public final class JsonContentHash {

private static final JsonFactory FACTORY = new JsonFactory();

/**
* Compute an order-independent hash of JSON content.
* Two JSON strings with identical content but different key ordering
* will produce the same hash value.
*/
public static long hash(String json) {
if (json == null || json.isEmpty()) {
return 0L;
}
try (JsonParser parser = FACTORY.createParser(json)) {
parser.nextToken();
return computeHash(parser);
} catch (IOException e) {
// Fallback to regular string hash if JSON is malformed.
// This is safe: two identical malformed strings produce the same hash,
// and a malformed string won't falsely match a valid one.
return stringHash(json);
}
}

private static long computeHash(JsonParser parser) throws IOException {
JsonToken token = parser.currentToken();
if (token == null) {
return 0L;
}
switch (token) {
case START_OBJECT:
return hashObject(parser);
case START_ARRAY:
return hashArray(parser);
case VALUE_STRING:
return mix(stringHash(parser.getText()));
case VALUE_NUMBER_INT:
case VALUE_NUMBER_FLOAT:
// Use text representation for numeric consistency across int/long/double
return mix(stringHash(parser.getText()));
case VALUE_TRUE:
return 0x9E3779B97F4A7C15L;
case VALUE_FALSE:
return 0x517CC1B727220A95L;
case VALUE_NULL:
return 0x6C62272E07BB0142L;
default:
return 0L;
}
}

// Type markers to distinguish empty object {}, empty array [], and null
private static final long OBJECT_SEED = 0x7A5662B4E8B10FA3L;
private static final long ARRAY_SEED = 0x3C6EF372FE94F82BL;

/**
* Hash an object using commutative addition of entry hashes.
* Addition is commutative (a + b == b + a), so the result is
* independent of the order in which keys appear in the JSON.
*/
private static long hashObject(JsonParser parser) throws IOException {
long hash = OBJECT_SEED;
while (parser.nextToken() != JsonToken.END_OBJECT) {
long keyHash = stringHash(parser.currentName());
parser.nextToken();
long valueHash = computeHash(parser);
// Mix key+value into a single entry hash, then add (commutative)
hash += mix(keyHash * 0x9E3779B97F4A7C15L + valueHash);
}
return hash;
}

/**
* Hash an array using position-dependent combination.
* Array element order IS semantically significant in JSON.
*/
private static long hashArray(JsonParser parser) throws IOException {
long hash = ARRAY_SEED;
while (parser.nextToken() != JsonToken.END_ARRAY) {
hash = hash * 31 + computeHash(parser);
}
return mix(hash);
}

/**
* 64-bit FNV-1a inspired string hash for better distribution than String.hashCode().
*/
private static long stringHash(String s) {
long h = 0xcbf29ce484222325L;
for (int i = 0; i < s.length(); i++) {
h ^= s.charAt(i);
h *= 0x100000001b3L;
}
return h;
}

/**
* Mixing/finalizer function to improve hash distribution and break
* additive symmetry (prevents collisions when values are swapped between keys).
* <p>
* This is fmix64 from MurmurHash3 by Austin Appleby (public domain).
* See: https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp
*/
private static long mix(long h) {
h ^= (h >>> 33);
h *= 0xff51afd7ed558ccdL;
h ^= (h >>> 33);
h *= 0xc4ceb9fe1a85ec53L;
h ^= (h >>> 33);
return h;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package io.ebeaninternal.server.util;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class JsonContentHashTest {

@Test
void sameContent_sameHash() {
String json = "{\"name\":\"Alice\",\"age\":30}";
assertThat(JsonContentHash.hash(json)).isEqualTo(JsonContentHash.hash(json));
}

@Test
void reorderedKeys_sameHash() {
// The core scenario: PostgreSQL JSONB reorders keys
String jackson = "{\"status\":\"ACTIVE\",\"type\":\"ADMIN\"}";
String postgres = "{\"type\":\"ADMIN\",\"status\":\"ACTIVE\"}";
assertThat(JsonContentHash.hash(jackson)).isEqualTo(JsonContentHash.hash(postgres));
}

@Test
void reorderedKeys_multipleFields() {
String a = "{\"zebra\":1,\"apple\":2,\"mango\":3}";
String b = "{\"apple\":2,\"mango\":3,\"zebra\":1}";
String c = "{\"mango\":3,\"zebra\":1,\"apple\":2}";
long hashA = JsonContentHash.hash(a);
long hashB = JsonContentHash.hash(b);
long hashC = JsonContentHash.hash(c);
assertThat(hashA).isEqualTo(hashB);
assertThat(hashA).isEqualTo(hashC);
}

@Test
void differentValues_differentHash() {
String a = "{\"status\":\"ACTIVE\",\"type\":\"ADMIN\"}";
String b = "{\"status\":\"INACTIVE\",\"type\":\"ADMIN\"}";
assertThat(JsonContentHash.hash(a)).isNotEqualTo(JsonContentHash.hash(b));
}

@Test
void differentKeys_differentHash() {
String a = "{\"name\":\"Alice\"}";
String b = "{\"nome\":\"Alice\"}";
assertThat(JsonContentHash.hash(a)).isNotEqualTo(JsonContentHash.hash(b));
}

@Test
void nestedObjects_reorderedKeys() {
String a = "{\"user\":{\"first\":\"Alice\",\"last\":\"Smith\"},\"active\":true}";
String b = "{\"active\":true,\"user\":{\"last\":\"Smith\",\"first\":\"Alice\"}}";
assertThat(JsonContentHash.hash(a)).isEqualTo(JsonContentHash.hash(b));
}

@Test
void nestedObjects_differentValues() {
String a = "{\"user\":{\"first\":\"Alice\",\"last\":\"Smith\"}}";
String b = "{\"user\":{\"first\":\"Bob\",\"last\":\"Smith\"}}";
assertThat(JsonContentHash.hash(a)).isNotEqualTo(JsonContentHash.hash(b));
}

@Test
void arrayOrder_matters() {
// Array element order IS semantically significant
String a = "[1,2,3]";
String b = "[3,2,1]";
assertThat(JsonContentHash.hash(a)).isNotEqualTo(JsonContentHash.hash(b));
}

@Test
void arrayOrder_sameOrder_sameHash() {
String a = "[1,2,3]";
String b = "[1,2,3]";
assertThat(JsonContentHash.hash(a)).isEqualTo(JsonContentHash.hash(b));
}

@Test
void enumValues_reorderedKeys() {
// The exact scenario from issue #3129: POJO with multiple enum fields
String jackson = "{\"status\":\"ACTIVE\",\"role\":\"ADMIN\",\"priority\":\"HIGH\"}";
String postgres = "{\"role\":\"ADMIN\",\"priority\":\"HIGH\",\"status\":\"ACTIVE\"}";
assertThat(JsonContentHash.hash(jackson)).isEqualTo(JsonContentHash.hash(postgres));
}

@Test
void swappedValues_differentHash() {
// Swapping values between keys must produce different hashes
String a = "{\"a\":1,\"b\":2}";
String b = "{\"a\":2,\"b\":1}";
assertThat(JsonContentHash.hash(a)).isNotEqualTo(JsonContentHash.hash(b));
}

@Test
void emptyObject() {
assertThat(JsonContentHash.hash("{}")).isNotEqualTo(0L);
}

@Test
void emptyArray() {
assertThat(JsonContentHash.hash("[]")).isNotEqualTo(0L);
}

@Test
void emptyObject_vs_emptyArray() {
assertThat(JsonContentHash.hash("{}")).isNotEqualTo(JsonContentHash.hash("[]"));
}

@Test
void nullInput() {
assertThat(JsonContentHash.hash(null)).isEqualTo(0L);
}

@Test
void emptyString() {
assertThat(JsonContentHash.hash("")).isEqualTo(0L);
}

@Test
void booleanValues() {
String a = "{\"flag\":true}";
String b = "{\"flag\":false}";
assertThat(JsonContentHash.hash(a)).isNotEqualTo(JsonContentHash.hash(b));
}

@Test
void nullValues() {
String a = "{\"value\":null}";
String b = "{\"value\":\"text\"}";
assertThat(JsonContentHash.hash(a)).isNotEqualTo(JsonContentHash.hash(b));
}

@Test
void numericTypes() {
String a = "{\"count\":42}";
String b = "{\"count\":43}";
assertThat(JsonContentHash.hash(a)).isNotEqualTo(JsonContentHash.hash(b));
}

@Test
void whitespaceVariations() {
// Whitespace in JSON structure (not in values) should not matter
String compact = "{\"a\":1,\"b\":2}";
String spaced = "{ \"a\" : 1 , \"b\" : 2 }";
assertThat(JsonContentHash.hash(compact)).isEqualTo(JsonContentHash.hash(spaced));
}

@Test
void complexNestedStructure() {
String a = "{\"users\":[{\"name\":\"Alice\"},{\"name\":\"Bob\"}],\"count\":2,\"active\":true}";
String b = "{\"active\":true,\"count\":2,\"users\":[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]}";
assertThat(JsonContentHash.hash(a)).isEqualTo(JsonContentHash.hash(b));
}

@Test
void postgresJsonbKeyReordering_realistic() {
// Simulates PostgreSQL JSONB storage which reorders by key length, then alphabetically
String javaOrder = "{\"status\":\"ACTIVE\",\"type\":\"STANDARD\",\"createdAt\":\"2024-01-01\",\"id\":123}";
String pgOrder = "{\"id\":123,\"type\":\"STANDARD\",\"status\":\"ACTIVE\",\"createdAt\":\"2024-01-01\"}";
assertThat(JsonContentHash.hash(javaOrder)).isEqualTo(JsonContentHash.hash(pgOrder));
}
}
Loading