From 6442cb6d9c74fbb00026445d92f19124ed174d81 Mon Sep 17 00:00:00 2001 From: oliviarla Date: Fri, 20 Feb 2026 10:27:51 +0900 Subject: [PATCH] FEATURE: Add ForceSerializeForCollection to GenericJsonSerializingTranscoder --- .../GenericJsonSerializingTranscoder.java | 160 ++++++++++++-- .../GenericJsonSerializingTranscoderTest.java | 21 +- .../transcoder/CollectionTranscoderTest.java | 107 ---------- .../ForceSerializeTranscoderTest.java | 195 ++++++++++++++++++ 4 files changed, 349 insertions(+), 134 deletions(-) delete mode 100644 src/test/manual/net/spy/memcached/collection/transcoder/CollectionTranscoderTest.java create mode 100644 src/test/manual/net/spy/memcached/collection/transcoder/ForceSerializeTranscoderTest.java diff --git a/src/main/java/net/spy/memcached/transcoders/GenericJsonSerializingTranscoder.java b/src/main/java/net/spy/memcached/transcoders/GenericJsonSerializingTranscoder.java index 26a16e917..0f349edaa 100644 --- a/src/main/java/net/spy/memcached/transcoders/GenericJsonSerializingTranscoder.java +++ b/src/main/java/net/spy/memcached/transcoders/GenericJsonSerializingTranscoder.java @@ -32,25 +32,10 @@ public class GenericJsonSerializingTranscoder extends SpyObject implements Trans private final int maxSize; private final CompressionUtils cu; private final TranscoderUtils tu; + private final boolean isCollection; + private final boolean forceJsonSerializeForCollection; - /** - * Construct a new GenericJsonSerializingTranscoder - * - * @param objectMapper the object mapper to use. This transcoder enables - * polymorphic typing to preserve concrete types. - * Jackson polymorphic deserialization can be vulnerable - * for any untrusted JSON input if default typing is - * too permissive without proper validation. - * It is recommended to configure a restrictive - * {@code BasicPolymorphicTypeValidator} - * @param typeHintPropertyName the property name to use for type hints. - * If {@code null} is given, do not set DefaultTyping of ObjectMapper. - * If empty String is given, set DefaultTyping of ObjectMapper with - * default type property name ("@class"). - * Otherwise, set DefaultTyping of ObjectMapper with given String - * to write type info into JSON. - * @param max the maximum size of a serialized object - */ + @Deprecated public GenericJsonSerializingTranscoder(ObjectMapper objectMapper, String typeHintPropertyName, int max) { this(objectMapper, max); @@ -66,6 +51,7 @@ public GenericJsonSerializingTranscoder(ObjectMapper objectMapper, String typeHi } } + @Deprecated public GenericJsonSerializingTranscoder(ObjectMapper objectMapper, int max) { if (objectMapper == null) { throw new IllegalArgumentException("ObjectMapper must not be null"); @@ -74,6 +60,57 @@ public GenericJsonSerializingTranscoder(ObjectMapper objectMapper, int max) { this.maxSize = max; this.cu = new CompressionUtils(); this.tu = new TranscoderUtils(true); + this.isCollection = false; + this.forceJsonSerializeForCollection = false; + } + + /** + * Constructor with full customization. + * Use static factory methods forKV() or forCollection() for default settings, + * or Builder for custom configurations. + */ + private GenericJsonSerializingTranscoder(ObjectMapper objectMapper, int max, + boolean isCollection, + boolean forceJsonSerializeForCollection) { + if (objectMapper == null) { + throw new IllegalArgumentException("ObjectMapper must not be null"); + } + this.objectMapper = objectMapper; + this.maxSize = max; + this.cu = new CompressionUtils(); + this.tu = new TranscoderUtils(true); + this.isCollection = isCollection; + this.forceJsonSerializeForCollection = forceJsonSerializeForCollection; + } + + /** + * Factory method for general key-value usage. + * + * @param objectMapper the object mapper to use. This transcoder enables + * polymorphic typing to preserve concrete types. + * Jackson polymorphic deserialization can be vulnerable + * for any untrusted JSON input if default typing is + * too permissive without proper validation. + * It is recommended to configure a restrictive + * {@code BasicPolymorphicTypeValidator} + */ + public static Builder forKV(ObjectMapper objectMapper) { + return new Builder(objectMapper).forKV(); + } + + /** + * Factory method for collection item usage. + * + * @param objectMapper the object mapper to use. This transcoder enables + * polymorphic typing to preserve concrete types. + * Jackson polymorphic deserialization can be vulnerable + * for any untrusted JSON input if default typing is + * too permissive without proper validation. + * It is recommended to configure a restrictive + * {@code BasicPolymorphicTypeValidator} + */ + public static Builder forCollection(ObjectMapper objectMapper) { + return new Builder(objectMapper).forCollection(); } @Override @@ -81,6 +118,11 @@ public int getMaxSize() { return maxSize; } + @Override + public boolean isForceSerializeForCollection() { + return forceJsonSerializeForCollection; + } + /** * Set the compression threshold to the given number of bytes. This * transcoder will attempt to compress any data being stored that's larger @@ -110,7 +152,7 @@ public Object decode(CachedData d) { return null; // No data to decode } - if ((d.getFlags() & COMPRESSED) != 0) { + if (!isCollection && (d.getFlags() & COMPRESSED) != 0) { data = cu.decompress(data); } @@ -162,6 +204,12 @@ public CachedData encode(Object o) { byte[] b; int flags = 0; + if (isCollection && forceJsonSerializeForCollection) { + b = serialize(o); + flags |= SERIALIZED; + return new CachedData(flags, b, getMaxSize()); + } + if (o instanceof String) { b = tu.encodeString((String) o); } else if (o instanceof Long) { @@ -193,7 +241,7 @@ public CachedData encode(Object o) { flags |= SERIALIZED; } assert b != null; - if (cu.isCompressionCandidate(b)) { + if (!isCollection && cu.isCompressionCandidate(b)) { byte[] compressed = cu.compress(b); if (compressed.length < b.length) { getLogger().debug("Compressed %s from %d to %d", @@ -229,5 +277,77 @@ private byte[] serialize(Object o) { throw new IllegalArgumentException("Non-serializable object, cause=" + e.getMessage(), e); } } + + /** + * Builder for constructing GenericJsonSerializingTranscoder instances with custom settings. + */ + public static final class Builder { + private final ObjectMapper objectMapper; + private String typeHintPropertyName = ""; + private int max; + private boolean isCollection; + private boolean forceJsonSerializeForCollection; + + private Builder(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + Builder forKV() { + this.max = CachedData.MAX_SIZE; + this.isCollection = false; + this.forceJsonSerializeForCollection = false; + return this; + } + + Builder forCollection() { + this.max = SerializingTranscoder.MAX_COLLECTION_ELEMENT_SIZE; + this.isCollection = true; + this.forceJsonSerializeForCollection = false; + return this; + } + + public Builder maxSize(int max) { + this.max = max; + return this; + } + + /** + * @param typeHintPropertyName the property name to use for type hints. + * Use "@class" by default without setting this method. + * If {@code null} is given, do not set DefaultTyping + * of given ObjectMapper. + * If empty String is given, set DefaultTyping of ObjectMapper with + * default type property name ("@class"). + * Otherwise, set DefaultTyping of ObjectMapper with given String + * to write type info into JSON. + */ + public Builder typeHintPropertyName(String typeHintPropertyName) { + this.typeHintPropertyName = typeHintPropertyName; + return this; + } + + public Builder forceJsonSerializeForCollection() { + if (!isCollection) { + throw new IllegalStateException("forceJsonSerializationForCollection can only be " + + "used with collection transcoders"); + } + this.forceJsonSerializeForCollection = true; + return this; + } + + public GenericJsonSerializingTranscoder build() { + if (typeHintPropertyName != null) { + @SuppressWarnings("deprecation") + StdTypeResolverBuilder typer = new ObjectMapper.DefaultTypeResolverBuilder( + ObjectMapper.DefaultTyping.EVERYTHING, objectMapper.getPolymorphicTypeValidator()); + typer = typer.init(JsonTypeInfo.Id.CLASS, null); + typer = typer.inclusion(JsonTypeInfo.As.PROPERTY); + typer = typer.typeProperty(typeHintPropertyName); + objectMapper.setDefaultTyping(typer); + } + return new GenericJsonSerializingTranscoder(objectMapper, max, + isCollection, forceJsonSerializeForCollection); + } + } } diff --git a/src/test/java/net/spy/memcached/transcoders/GenericJsonSerializingTranscoderTest.java b/src/test/java/net/spy/memcached/transcoders/GenericJsonSerializingTranscoderTest.java index bb287f82a..521e82ea6 100644 --- a/src/test/java/net/spy/memcached/transcoders/GenericJsonSerializingTranscoderTest.java +++ b/src/test/java/net/spy/memcached/transcoders/GenericJsonSerializingTranscoderTest.java @@ -42,12 +42,13 @@ */ class GenericJsonSerializingTranscoderTest { - private final ObjectMapper mapper = new ObjectMapper(); private GenericJsonSerializingTranscoder tc; @BeforeEach void setUp() { - tc = new GenericJsonSerializingTranscoder(mapper, "", CachedData.MAX_SIZE); + tc = GenericJsonSerializingTranscoder + .forKV(new ObjectMapper()) + .build(); } @Test @@ -239,14 +240,17 @@ void testJsonObject() { @Test void testObjectMapperWithoutDefaultTyping() { - ObjectMapper objectMapper = new ObjectMapper(); - GenericJsonSerializingTranscoder tcWithoutDefaultTyping - = new GenericJsonSerializingTranscoder(objectMapper, null, CachedData.MAX_SIZE); + GenericJsonSerializingTranscoder tcWithoutDefaultTyping = + GenericJsonSerializingTranscoder + .forKV(new ObjectMapper()) + .typeHintPropertyName(null) + .build(); TestPojo pojo = new TestPojo("test", 123); CachedData cd = tcWithoutDefaultTyping.encode(pojo); assertEquals(TranscoderUtils.SERIALIZED, cd.getFlags()); - assertFalse(new String(cd.getData()).contains("@class")); + String s = new String(cd.getData()); + assertFalse(s.contains("@class")); assertThrows(ClassCastException.class, () -> { TestPojo decoded = (TestPojo) tcWithoutDefaultTyping.decode(cd); @@ -392,7 +396,10 @@ void testEnum() { @Test void testCustomTypePropertyName() { GenericJsonSerializingTranscoder custom = - new GenericJsonSerializingTranscoder(new ObjectMapper(), "type", CachedData.MAX_SIZE); + GenericJsonSerializingTranscoder + .forKV(new ObjectMapper()) + .typeHintPropertyName("type") + .build(); TestPojo p = new TestPojo("abc", 1); CachedData cd = custom.encode(p); String json = new String(cd.getData(), StandardCharsets.UTF_8); diff --git a/src/test/manual/net/spy/memcached/collection/transcoder/CollectionTranscoderTest.java b/src/test/manual/net/spy/memcached/collection/transcoder/CollectionTranscoderTest.java deleted file mode 100644 index 8e0f3cb88..000000000 --- a/src/test/manual/net/spy/memcached/collection/transcoder/CollectionTranscoderTest.java +++ /dev/null @@ -1,107 +0,0 @@ -package net.spy.memcached.collection.transcoder; - -import java.util.Map; -import java.util.concurrent.ExecutionException; - -import net.spy.memcached.collection.BaseIntegrationTest; -import net.spy.memcached.collection.CollectionAttributes; -import net.spy.memcached.transcoders.SerializingTranscoder; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class CollectionTranscoderTest extends BaseIntegrationTest { - - private static final String KEY = "CollectionTranscoderTest"; - - private final SerializingTranscoder transcoder = SerializingTranscoder.forCollection() - .forceJDKSerializationForCollection() - .build(); - - @AfterEach - @Override - protected void tearDown() throws Exception { - mc.delete(KEY); - super.tearDown(); - } - - @Test - void forceJDKSerializationReturnTrue() { - assertTrue(transcoder.isForceSerializeForCollection()); - } - - @Test - void decodeIntegerAsString() throws ExecutionException, InterruptedException { - // given - Boolean b1 = mc.asyncMopInsert(KEY, "mkey1", "value", new CollectionAttributes()).get(); - Boolean b2 = mc.asyncMopInsert(KEY, "mkey2", 35, new CollectionAttributes()).get(); - assertTrue(b1); - assertTrue(b2); - - // when - Map map1 = mc.asyncMopGet(KEY, "mkey1", false, false).get(); - Map map2 = mc.asyncMopGet(KEY, "mkey2", false, false).get(); - - // then - assertEquals("value", map1.get("mkey1")); - assertEquals("#", map2.get("mkey2")); - } - - @Test - void failToDecodeString() throws ExecutionException, InterruptedException { - // given - Boolean b2 = mc.asyncMopInsert(KEY, "mkey3", 35, new CollectionAttributes()) - .get(); - Boolean b1 = mc.asyncMopInsert(KEY, "mkey4", "value", new CollectionAttributes()) - .get(); - assertTrue(b1); - assertTrue(b2); - - // when & then - Map map1 = mc.asyncMopGet(KEY, "mkey3", false, false).get(); - assertEquals(35, map1.get("mkey3")); - assertThrows(AssertionError.class, () -> mc.asyncMopGet(KEY, "mkey4", false, false).get()); - } - - @Test - void decodeStringAndInteger() throws ExecutionException, InterruptedException { - // given - Boolean b1 = mc.asyncMopInsert(KEY, "mkey1", "value", new CollectionAttributes(), transcoder) - .get(); - Boolean b2 = mc.asyncMopInsert(KEY, "mkey2", 35, new CollectionAttributes(), transcoder) - .get(); - assertTrue(b1); - assertTrue(b2); - - // when - Map map1 = mc.asyncMopGet(KEY, "mkey1", false, false, transcoder).get(); - Map map2 = mc.asyncMopGet(KEY, "mkey2", false, false, transcoder).get(); - - // then - assertEquals("value", map1.get("mkey1")); - assertEquals(35, map2.get("mkey2")); - } - - @Test - void decodeIntegerAndString() throws ExecutionException, InterruptedException { - // given - Boolean b2 = mc.asyncMopInsert(KEY, "mkey3", 35, new CollectionAttributes(), transcoder) - .get(); - Boolean b1 = mc.asyncMopInsert(KEY, "mkey4", "value", new CollectionAttributes(), transcoder) - .get(); - assertTrue(b1); - assertTrue(b2); - - // when - Map map1 = mc.asyncMopGet(KEY, "mkey3", false, false, transcoder).get(); - Map map2 = mc.asyncMopGet(KEY, "mkey4", false, false, transcoder).get(); - - // then - assertEquals(35, map1.get("mkey3")); - assertEquals("value", map2.get("mkey4")); - } -} diff --git a/src/test/manual/net/spy/memcached/collection/transcoder/ForceSerializeTranscoderTest.java b/src/test/manual/net/spy/memcached/collection/transcoder/ForceSerializeTranscoderTest.java new file mode 100644 index 000000000..5ee776fa5 --- /dev/null +++ b/src/test/manual/net/spy/memcached/collection/transcoder/ForceSerializeTranscoderTest.java @@ -0,0 +1,195 @@ +package net.spy.memcached.collection.transcoder; + +import java.io.Serializable; +import java.util.Date; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import net.spy.memcached.collection.BaseIntegrationTest; +import net.spy.memcached.collection.CollectionAttributes; +import net.spy.memcached.transcoders.GenericJsonSerializingTranscoder; +import net.spy.memcached.transcoders.SerializingTranscoder; +import net.spy.memcached.transcoders.Transcoder; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ForceSerializeTranscoderTest extends BaseIntegrationTest { + + private static final String KEY = "ForceSerializeTranscoderTest"; + + @AfterEach + @Override + protected void tearDown() throws Exception { + mc.delete(KEY); + super.tearDown(); + } + + @Test + void decodeIntegerAsStringWithSerializingTranscoder() + throws ExecutionException, InterruptedException { + Transcoder transcoder = SerializingTranscoder.forCollection().build(); + + // given + Boolean b1 = mc.asyncMopInsert(KEY, "mkey1", "value", + new CollectionAttributes(), transcoder).get(); + Boolean b2 = mc.asyncMopInsert(KEY, "mkey2", 35, + new CollectionAttributes(), transcoder).get(); + assertTrue(b1); + assertTrue(b2); + + // when + Map map = mc + .asyncMopGet(KEY, false, false, transcoder).get(); + + // then + assertEquals("value", map.get("mkey1")); + assertEquals("#", map.get("mkey2")); + } + + @Test + void decodeMultipleTypesWithSerializingTranscoder() + throws ExecutionException, InterruptedException { + Transcoder transcoder = SerializingTranscoder.forCollection() + .forceJDKSerializationForCollection() + .build(); + TestPojo pojo = new TestPojo("name", 100); + + // given + Boolean b1 = mc.asyncMopInsert(KEY, "mkey1", 35, + new CollectionAttributes(), transcoder).get(); + Boolean b2 = mc.asyncMopInsert(KEY, "mkey2", "value", + new CollectionAttributes(), transcoder).get(); + Boolean b3 = mc.asyncMopInsert(KEY, "mkey3", pojo, + new CollectionAttributes(), transcoder).get(); + assertTrue(b1); + assertTrue(b2); + assertTrue(b3); + + // when + Map map = mc + .asyncMopGet(KEY, false, false, transcoder).get(); + + // then + assertEquals(35, map.get("mkey1")); + assertEquals("value", map.get("mkey2")); + assertEquals(pojo, map.get("mkey3")); + } + + @Test + void decodeIntegerAsStringWithGenericJsonSerializingTranscoder() + throws ExecutionException, InterruptedException { + Transcoder transcoder = GenericJsonSerializingTranscoder + .forCollection(new ObjectMapper()) + .build(); + TestPojo pojo = new TestPojo("name", 100); + Date date = new Date(); + + // given + Boolean b1 = mc.asyncMopInsert(KEY, "mkey1", "value", + new CollectionAttributes(), transcoder).get(); + Boolean b2 = mc.asyncMopInsert(KEY, "mkey2", 35, + new CollectionAttributes(), transcoder).get(); + Boolean b3 = mc.asyncMopInsert(KEY, "mkey3", pojo, + new CollectionAttributes(), transcoder).get(); + Boolean b4 = mc.asyncMopInsert(KEY, "mkey4", date, + new CollectionAttributes(), transcoder).get(); + assertTrue(b1); + assertTrue(b2); + assertTrue(b3); + assertTrue(b4); + + // when + Map map = mc + .asyncMopGet(KEY, false, false, transcoder).get(); + + // then + assertEquals("value", map.get("mkey1")); + assertEquals("#", map.get("mkey2")); + assertNotEquals(pojo, map.get("mkey3")); + assertNotEquals(date, map.get("mkey4")); + } + + @Test + void decodeMultipleTypesWithGenericJsonSerializingTranscoder() + throws ExecutionException, InterruptedException { + Transcoder transcoder = GenericJsonSerializingTranscoder + .forCollection(new ObjectMapper()) + .forceJsonSerializeForCollection() + .build(); + TestPojo pojo = new TestPojo("name", 100); + Date date = new Date(); + + // given + Boolean b1 = mc.asyncMopInsert(KEY, "mkey1", 35, + new CollectionAttributes(), transcoder).get(); + Boolean b2 = mc.asyncMopInsert(KEY, "mkey2", "value", + new CollectionAttributes(), transcoder).get(); + Boolean b3 = mc.asyncMopInsert(KEY, "mkey3", pojo, + new CollectionAttributes(), transcoder).get(); + Boolean b4 = mc.asyncMopInsert(KEY, "mkey4", date, + new CollectionAttributes(), transcoder).get(); + assertTrue(b1); + assertTrue(b2); + assertTrue(b3); + assertTrue(b4); + + // when + Map map = mc + .asyncMopGet(KEY, false, false, transcoder).get(); + + // then + assertEquals(35, map.get("mkey1")); + assertEquals("value", map.get("mkey2")); + assertEquals(pojo, map.get("mkey3")); + assertEquals(date, map.get("mkey4")); + } + + public static class TestPojo implements Serializable { + + private static final long serialVersionUID = 4892462019291743546L; + private String name; + private int value; + + public TestPojo() { + } + + public TestPojo(String name, int value) { + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + TestPojo testPojo = (TestPojo) o; + return value == testPojo.value && Objects.equals(name, testPojo.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } + } +}