Skip to content

Commit ffc9a7b

Browse files
oschwaldclaude
andcommitted
Add @MaxMindDbCreator annotation for custom deserialization
This adds support for marking static factory methods with @MaxMindDbCreator to enable custom deserialization logic, similar to Jackson's @JsonCreator. The decoder now automatically invokes creator methods when decoding values to target types, allowing for custom type conversions such as string-to-enum mappings with non-standard representations. This eliminates the need for redundant constructors that only perform type conversions, as the decoder can now apply conversions automatically via annotated static factory methods. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent d746dad commit ffc9a7b

File tree

5 files changed

+279
-2
lines changed

5 files changed

+279
-2
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.maxmind.db;
2+
3+
import java.lang.reflect.Method;
4+
5+
/**
6+
* Cached creator method information for efficient deserialization.
7+
* A creator method is a static factory method annotated with {@link MaxMindDbCreator}
8+
* that converts a decoded value to the target type.
9+
*
10+
* @param method the static factory method annotated with {@link MaxMindDbCreator}
11+
* @param parameterType the parameter type accepted by the creator method
12+
*/
13+
record CachedCreator(
14+
Method method,
15+
Class<?> parameterType
16+
) {}

src/main/java/com/maxmind/db/Decoder.java

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import java.lang.annotation.Annotation;
55
import java.lang.reflect.Constructor;
66
import java.lang.reflect.InvocationTargetException;
7+
import java.lang.reflect.Method;
8+
import java.lang.reflect.Modifier;
79
import java.lang.reflect.ParameterizedType;
810
import java.math.BigInteger;
911
import java.net.InetAddress;
@@ -38,6 +40,8 @@ class Decoder {
3840

3941
private final ConcurrentHashMap<Class<?>, CachedConstructor<?>> constructors;
4042

43+
private final ConcurrentHashMap<Class<?>, CachedCreator> creators;
44+
4145
private final InetAddress lookupIp;
4246
private final Network lookupNetwork;
4347

@@ -47,6 +51,7 @@ class Decoder {
4751
buffer,
4852
pointerBase,
4953
new ConcurrentHashMap<>(),
54+
new ConcurrentHashMap<>(),
5055
null,
5156
null
5257
);
@@ -63,6 +68,7 @@ class Decoder {
6368
buffer,
6469
pointerBase,
6570
constructors,
71+
new ConcurrentHashMap<>(),
6672
null,
6773
null
6874
);
@@ -73,13 +79,15 @@ class Decoder {
7379
Buffer buffer,
7480
long pointerBase,
7581
ConcurrentHashMap<Class<?>, CachedConstructor<?>> constructors,
82+
ConcurrentHashMap<Class<?>, CachedCreator> creators,
7683
InetAddress lookupIp,
7784
Network lookupNetwork
7885
) {
7986
this.cache = cache;
8087
this.pointerBase = pointerBase;
8188
this.buffer = buffer;
8289
this.constructors = constructors;
90+
this.creators = creators;
8391
this.lookupIp = lookupIp;
8492
this.lookupNetwork = lookupNetwork;
8593
}
@@ -217,9 +225,11 @@ private <T> Object decodeByType(
217225
}
218226
return this.decodeArray(size, cls, elementClass);
219227
case BOOLEAN:
220-
return Decoder.decodeBoolean(size);
228+
Boolean bool = Decoder.decodeBoolean(size);
229+
return convertValue(bool, cls);
221230
case UTF8_STRING:
222-
return this.decodeString(size);
231+
String str = this.decodeString(size);
232+
return convertValue(str, cls);
223233
case DOUBLE:
224234
return this.decodeDouble(size);
225235
case FLOAT:
@@ -653,6 +663,7 @@ private <T> Object decodeMapIntoObject(int size, Class<T> cls)
653663
private boolean shouldInstantiateFromContext(Class<?> parameterType) {
654664
if (parameterType == null
655665
|| parameterType.isPrimitive()
666+
|| parameterType.isEnum()
656667
|| isSimpleType(parameterType)
657668
|| Map.class.isAssignableFrom(parameterType)
658669
|| List.class.isAssignableFrom(parameterType)) {
@@ -870,6 +881,74 @@ private static void validateInjectionTarget(
870881
}
871882
}
872883

884+
/**
885+
* Converts a decoded value to the target type using a creator method if available.
886+
* If no creator method is found, returns the original value.
887+
*/
888+
private Object convertValue(Object value, Class<?> targetType) {
889+
if (value == null || targetType == null
890+
|| targetType == Object.class
891+
|| targetType.isInstance(value)) {
892+
return value;
893+
}
894+
895+
CachedCreator creator = getCachedCreator(targetType);
896+
if (creator == null) {
897+
return value;
898+
}
899+
900+
if (!creator.parameterType().isInstance(value)) {
901+
return value;
902+
}
903+
904+
try {
905+
return creator.method().invoke(null, value);
906+
} catch (IllegalAccessException | InvocationTargetException e) {
907+
throw new DeserializationException(
908+
"Error invoking creator method " + creator.method().getName()
909+
+ " on class " + targetType.getName(), e);
910+
}
911+
}
912+
913+
private CachedCreator getCachedCreator(Class<?> cls) {
914+
CachedCreator cached = this.creators.get(cls);
915+
if (cached != null) {
916+
return cached;
917+
}
918+
919+
CachedCreator creator = findCreatorMethod(cls);
920+
if (creator != null) {
921+
this.creators.putIfAbsent(cls, creator);
922+
}
923+
return creator;
924+
}
925+
926+
private static CachedCreator findCreatorMethod(Class<?> cls) {
927+
Method[] methods = cls.getDeclaredMethods();
928+
for (Method method : methods) {
929+
if (!method.isAnnotationPresent(MaxMindDbCreator.class)) {
930+
continue;
931+
}
932+
if (!Modifier.isStatic(method.getModifiers())) {
933+
throw new DeserializationException(
934+
"Creator method " + method.getName() + " on class " + cls.getName()
935+
+ " must be static.");
936+
}
937+
if (method.getParameterCount() != 1) {
938+
throw new DeserializationException(
939+
"Creator method " + method.getName() + " on class " + cls.getName()
940+
+ " must have exactly one parameter.");
941+
}
942+
if (!cls.isAssignableFrom(method.getReturnType())) {
943+
throw new DeserializationException(
944+
"Creator method " + method.getName() + " on class " + cls.getName()
945+
+ " must return " + cls.getName() + " or a subtype.");
946+
}
947+
return new CachedCreator(method, method.getParameterTypes()[0]);
948+
}
949+
return null;
950+
}
951+
873952
private static Object parseDefault(String value, Class<?> target) {
874953
try {
875954
if (target.equals(Boolean.TYPE) || target.equals(Boolean.class)) {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.maxmind.db;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
/**
9+
* {@code MaxMindDbCreator} is an annotation that can be used to mark a static factory
10+
* method or constructor that should be used to create an instance of a class from a
11+
* decoded value when decoding a MaxMind DB file.
12+
*
13+
* <p>This is similar to Jackson's {@code @JsonCreator} annotation and is useful for
14+
* types that need custom deserialization logic, such as enums with non-standard
15+
* string representations.</p>
16+
*
17+
* <p>Example usage:</p>
18+
* <pre>
19+
* public enum ConnectionType {
20+
* DIALUP("Dialup"),
21+
* CABLE_DSL("Cable/DSL");
22+
*
23+
* private final String name;
24+
*
25+
* ConnectionType(String name) {
26+
* this.name = name;
27+
* }
28+
*
29+
* {@literal @}MaxMindDbCreator
30+
* public static ConnectionType fromString(String s) {
31+
* return switch (s) {
32+
* case "Dialup" -&gt; DIALUP;
33+
* case "Cable/DSL" -&gt; CABLE_DSL;
34+
* default -&gt; null;
35+
* };
36+
* }
37+
* }
38+
* </pre>
39+
*/
40+
@Retention(RetentionPolicy.RUNTIME)
41+
@Target({ElementType.METHOD, ElementType.CONSTRUCTOR})
42+
public @interface MaxMindDbCreator {
43+
}

src/main/java/com/maxmind/db/Reader.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public final class Reader implements Closeable {
2828
private final AtomicReference<BufferHolder> bufferHolderReference;
2929
private final NodeCache cache;
3030
private final ConcurrentHashMap<Class<?>, CachedConstructor<?>> constructors;
31+
private final ConcurrentHashMap<Class<?>, CachedCreator> creators;
3132

3233
/**
3334
* The file mode to use when opening a MaxMind DB.
@@ -166,6 +167,7 @@ private Reader(BufferHolder bufferHolder, String name, NodeCache cache) throws I
166167
this.ipV4Start = this.findIpV4StartNode(buffer);
167168

168169
this.constructors = new ConcurrentHashMap<>();
170+
this.creators = new ConcurrentHashMap<>();
169171
}
170172

171173
/**
@@ -443,6 +445,7 @@ <T> T resolveDataPointer(
443445
buffer,
444446
this.searchTreeSize + DATA_SECTION_SEPARATOR_SIZE,
445447
this.constructors,
448+
this.creators,
446449
lookupIp,
447450
network
448451
);

src/test/java/com/maxmind/db/ReaderTest.java

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,51 @@ public void testNestedContextAnnotationsWithCache(int chunkSize) throws IOExcept
595595
}
596596
}
597597

598+
@ParameterizedTest
599+
@MethodSource("chunkSizes")
600+
public void testCreatorMethod(int chunkSize) throws IOException {
601+
try (var reader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize)) {
602+
// Test with IP that has boolean=true
603+
var ipTrue = InetAddress.getByName("1.1.1.1");
604+
var resultTrue = reader.get(ipTrue, CreatorMethodModel.class);
605+
assertNotNull(resultTrue);
606+
assertNotNull(resultTrue.enumField);
607+
assertEquals(BooleanEnum.TRUE_VALUE, resultTrue.enumField);
608+
609+
// Test with IP that has boolean=false
610+
var ipFalse = InetAddress.getByName("::");
611+
var resultFalse = reader.get(ipFalse, CreatorMethodModel.class);
612+
assertNotNull(resultFalse);
613+
assertNotNull(resultFalse.enumField);
614+
assertEquals(BooleanEnum.FALSE_VALUE, resultFalse.enumField);
615+
}
616+
}
617+
618+
@ParameterizedTest
619+
@MethodSource("chunkSizes")
620+
public void testCreatorMethodWithString(int chunkSize) throws IOException {
621+
try (var reader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize)) {
622+
// The database has utf8_stringX="hello" in map.mapX at this IP
623+
var ip = InetAddress.getByName("1.1.1.1");
624+
625+
// Get the nested map containing utf8_stringX to verify the raw data
626+
var record = reader.get(ip, Map.class);
627+
var map = (Map<?, ?>) record.get("map");
628+
assertNotNull(map);
629+
var mapX = (Map<?, ?>) map.get("mapX");
630+
assertNotNull(mapX);
631+
assertEquals("hello", mapX.get("utf8_stringX"));
632+
633+
// Now test that the creator method converts "hello" to StringEnum.HELLO
634+
var result = reader.get(ip, StringEnumModel.class);
635+
assertNotNull(result);
636+
assertNotNull(result.map);
637+
assertNotNull(result.map.mapX);
638+
assertNotNull(result.map.mapX.stringEnumField);
639+
assertEquals(StringEnum.HELLO, result.map.mapX.stringEnumField);
640+
}
641+
}
642+
598643
@ParameterizedTest
599644
@MethodSource("chunkSizes")
600645
public void testDecodingTypesPointerDecoderFile(int chunkSize) throws IOException {
@@ -911,6 +956,97 @@ public WrapperContextOnlyModel(
911956
}
912957
}
913958

959+
enum BooleanEnum {
960+
TRUE_VALUE,
961+
FALSE_VALUE,
962+
UNKNOWN;
963+
964+
@MaxMindDbCreator
965+
public static BooleanEnum fromBoolean(Boolean b) {
966+
if (b == null) {
967+
return UNKNOWN;
968+
}
969+
return b ? TRUE_VALUE : FALSE_VALUE;
970+
}
971+
}
972+
973+
enum StringEnum {
974+
HELLO("hello"),
975+
GOODBYE("goodbye"),
976+
UNKNOWN("unknown");
977+
978+
private final String value;
979+
980+
StringEnum(String value) {
981+
this.value = value;
982+
}
983+
984+
@MaxMindDbCreator
985+
public static StringEnum fromString(String s) {
986+
if (s == null) {
987+
return UNKNOWN;
988+
}
989+
return switch (s) {
990+
case "hello" -> HELLO;
991+
case "goodbye" -> GOODBYE;
992+
default -> UNKNOWN;
993+
};
994+
}
995+
996+
@Override
997+
public String toString() {
998+
return value;
999+
}
1000+
}
1001+
1002+
static class CreatorMethodModel {
1003+
BooleanEnum enumField;
1004+
1005+
@MaxMindDbConstructor
1006+
public CreatorMethodModel(
1007+
@MaxMindDbParameter(name = "boolean")
1008+
BooleanEnum enumField
1009+
) {
1010+
this.enumField = enumField;
1011+
}
1012+
}
1013+
1014+
static class MapXWithEnum {
1015+
StringEnum stringEnumField;
1016+
1017+
@MaxMindDbConstructor
1018+
public MapXWithEnum(
1019+
@MaxMindDbParameter(name = "utf8_stringX")
1020+
StringEnum stringEnumField
1021+
) {
1022+
this.stringEnumField = stringEnumField;
1023+
}
1024+
}
1025+
1026+
static class MapWithEnum {
1027+
MapXWithEnum mapX;
1028+
1029+
@MaxMindDbConstructor
1030+
public MapWithEnum(
1031+
@MaxMindDbParameter(name = "mapX")
1032+
MapXWithEnum mapX
1033+
) {
1034+
this.mapX = mapX;
1035+
}
1036+
}
1037+
1038+
static class StringEnumModel {
1039+
MapWithEnum map;
1040+
1041+
@MaxMindDbConstructor
1042+
public StringEnumModel(
1043+
@MaxMindDbParameter(name = "map")
1044+
MapWithEnum map
1045+
) {
1046+
this.map = map;
1047+
}
1048+
}
1049+
9141050
static class MapModelBoxed {
9151051
MapXModelBoxed mapXField;
9161052

0 commit comments

Comments
 (0)