Skip to content

Commit 1a75d10

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 289be53 commit 1a75d10

File tree

5 files changed

+277
-1
lines changed

5 files changed

+277
-1
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: 79 additions & 1 deletion
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
}
@@ -219,7 +227,8 @@ private <T> Object decodeByType(
219227
case BOOLEAN:
220228
return Decoder.decodeBoolean(size);
221229
case UTF8_STRING:
222-
return this.decodeString(size);
230+
String str = this.decodeString(size);
231+
return convertValue(str, cls);
223232
case DOUBLE:
224233
return this.decodeDouble(size);
225234
case FLOAT:
@@ -639,6 +648,7 @@ private <T> Object decodeMapIntoObject(int size, Class<T> cls)
639648
private boolean shouldInstantiateFromContext(Class<?> parameterType) {
640649
if (parameterType == null
641650
|| parameterType.isPrimitive()
651+
|| parameterType.isEnum()
642652
|| isSimpleType(parameterType)
643653
|| Map.class.isAssignableFrom(parameterType)
644654
|| List.class.isAssignableFrom(parameterType)) {
@@ -856,6 +866,74 @@ private static void validateInjectionTarget(
856866
}
857867
}
858868

869+
/**
870+
* Converts a decoded value to the target type using a creator method if available.
871+
* If no creator method is found, returns the original value.
872+
*/
873+
private Object convertValue(Object value, Class<?> targetType) {
874+
if (value == null || targetType == null
875+
|| targetType == Object.class
876+
|| targetType.isInstance(value)) {
877+
return value;
878+
}
879+
880+
CachedCreator creator = getCachedCreator(targetType);
881+
if (creator == null) {
882+
return value;
883+
}
884+
885+
if (!creator.parameterType().isInstance(value)) {
886+
return value;
887+
}
888+
889+
try {
890+
return creator.method().invoke(null, value);
891+
} catch (IllegalAccessException | InvocationTargetException e) {
892+
throw new DeserializationException(
893+
"Error invoking creator method " + creator.method().getName()
894+
+ " on class " + targetType.getName(), e);
895+
}
896+
}
897+
898+
private CachedCreator getCachedCreator(Class<?> cls) {
899+
CachedCreator cached = this.creators.get(cls);
900+
if (cached != null) {
901+
return cached;
902+
}
903+
904+
CachedCreator creator = findCreatorMethod(cls);
905+
if (creator != null) {
906+
this.creators.putIfAbsent(cls, creator);
907+
}
908+
return creator;
909+
}
910+
911+
private static CachedCreator findCreatorMethod(Class<?> cls) {
912+
Method[] methods = cls.getDeclaredMethods();
913+
for (Method method : methods) {
914+
if (!method.isAnnotationPresent(MaxMindDbCreator.class)) {
915+
continue;
916+
}
917+
if (!Modifier.isStatic(method.getModifiers())) {
918+
throw new DeserializationException(
919+
"Creator method " + method.getName() + " on class " + cls.getName()
920+
+ " must be static.");
921+
}
922+
if (method.getParameterCount() != 1) {
923+
throw new DeserializationException(
924+
"Creator method " + method.getName() + " on class " + cls.getName()
925+
+ " must have exactly one parameter.");
926+
}
927+
if (!cls.isAssignableFrom(method.getReturnType())) {
928+
throw new DeserializationException(
929+
"Creator method " + method.getName() + " on class " + cls.getName()
930+
+ " must return " + cls.getName() + " or a subtype.");
931+
}
932+
return new CachedCreator(method, method.getParameterTypes()[0]);
933+
}
934+
return null;
935+
}
936+
859937
private static Object parseDefault(String value, Class<?> target) {
860938
try {
861939
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
@@ -573,6 +573,51 @@ public void testNestedContextAnnotations(int chunkSize) throws IOException {
573573
}
574574
}
575575

576+
@ParameterizedTest
577+
@MethodSource("chunkSizes")
578+
public void testCreatorMethod(int chunkSize) throws IOException {
579+
try (var reader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize)) {
580+
// Test with IP that has boolean=true
581+
var ipTrue = InetAddress.getByName("1.1.1.1");
582+
var resultTrue = reader.get(ipTrue, CreatorMethodModel.class);
583+
assertNotNull(resultTrue);
584+
assertNotNull(resultTrue.enumField);
585+
assertEquals(BooleanEnum.TRUE_VALUE, resultTrue.enumField);
586+
587+
// Test with IP that has boolean=false
588+
var ipFalse = InetAddress.getByName("::");
589+
var resultFalse = reader.get(ipFalse, CreatorMethodModel.class);
590+
assertNotNull(resultFalse);
591+
assertNotNull(resultFalse.enumField);
592+
assertEquals(BooleanEnum.FALSE_VALUE, resultFalse.enumField);
593+
}
594+
}
595+
596+
@ParameterizedTest
597+
@MethodSource("chunkSizes")
598+
public void testCreatorMethodWithString(int chunkSize) throws IOException {
599+
try (var reader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize)) {
600+
// The database has utf8_stringX="hello" in map.mapX at this IP
601+
var ip = InetAddress.getByName("1.1.1.1");
602+
603+
// Get the nested map containing utf8_stringX to verify the raw data
604+
var record = reader.get(ip, Map.class);
605+
var map = (Map<?, ?>) record.get("map");
606+
assertNotNull(map);
607+
var mapX = (Map<?, ?>) map.get("mapX");
608+
assertNotNull(mapX);
609+
assertEquals("hello", mapX.get("utf8_stringX"));
610+
611+
// Now test that the creator method converts "hello" to StringEnum.HELLO
612+
var result = reader.get(ip, StringEnumModel.class);
613+
assertNotNull(result);
614+
assertNotNull(result.map);
615+
assertNotNull(result.map.mapX);
616+
assertNotNull(result.map.mapX.stringEnumField);
617+
assertEquals(StringEnum.HELLO, result.map.mapX.stringEnumField);
618+
}
619+
}
620+
576621
@ParameterizedTest
577622
@MethodSource("chunkSizes")
578623
public void testDecodingTypesPointerDecoderFile(int chunkSize) throws IOException {
@@ -889,6 +934,97 @@ public WrapperContextOnlyModel(
889934
}
890935
}
891936

937+
enum BooleanEnum {
938+
TRUE_VALUE,
939+
FALSE_VALUE,
940+
UNKNOWN;
941+
942+
@MaxMindDbCreator
943+
public static BooleanEnum fromBoolean(Boolean b) {
944+
if (b == null) {
945+
return UNKNOWN;
946+
}
947+
return b ? TRUE_VALUE : FALSE_VALUE;
948+
}
949+
}
950+
951+
enum StringEnum {
952+
HELLO("hello"),
953+
GOODBYE("goodbye"),
954+
UNKNOWN("unknown");
955+
956+
private final String value;
957+
958+
StringEnum(String value) {
959+
this.value = value;
960+
}
961+
962+
@MaxMindDbCreator
963+
public static StringEnum fromString(String s) {
964+
if (s == null) {
965+
return UNKNOWN;
966+
}
967+
return switch (s) {
968+
case "hello" -> HELLO;
969+
case "goodbye" -> GOODBYE;
970+
default -> UNKNOWN;
971+
};
972+
}
973+
974+
@Override
975+
public String toString() {
976+
return value;
977+
}
978+
}
979+
980+
static class CreatorMethodModel {
981+
BooleanEnum enumField;
982+
983+
@MaxMindDbConstructor
984+
public CreatorMethodModel(
985+
@MaxMindDbParameter(name = "boolean")
986+
BooleanEnum enumField
987+
) {
988+
this.enumField = enumField;
989+
}
990+
}
991+
992+
static class MapXWithEnum {
993+
StringEnum stringEnumField;
994+
995+
@MaxMindDbConstructor
996+
public MapXWithEnum(
997+
@MaxMindDbParameter(name = "utf8_stringX")
998+
StringEnum stringEnumField
999+
) {
1000+
this.stringEnumField = stringEnumField;
1001+
}
1002+
}
1003+
1004+
static class MapWithEnum {
1005+
MapXWithEnum mapX;
1006+
1007+
@MaxMindDbConstructor
1008+
public MapWithEnum(
1009+
@MaxMindDbParameter(name = "mapX")
1010+
MapXWithEnum mapX
1011+
) {
1012+
this.mapX = mapX;
1013+
}
1014+
}
1015+
1016+
static class StringEnumModel {
1017+
MapWithEnum map;
1018+
1019+
@MaxMindDbConstructor
1020+
public StringEnumModel(
1021+
@MaxMindDbParameter(name = "map")
1022+
MapWithEnum map
1023+
) {
1024+
this.map = map;
1025+
}
1026+
}
1027+
8921028
static class MapModelBoxed {
8931029
MapXModelBoxed mapXField;
8941030

0 commit comments

Comments
 (0)