Skip to content

Commit 403117d

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 403117d

File tree

5 files changed

+283
-4
lines changed

5 files changed

+283
-4
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: 85 additions & 4 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,13 +225,17 @@ 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:
224-
return this.decodeDouble(size);
234+
Double d = this.decodeDouble(size);
235+
return convertValue(d, cls);
225236
case FLOAT:
226-
return this.decodeFloat(size);
237+
Float f = this.decodeFloat(size);
238+
return convertValue(f, cls);
227239
case BYTES:
228240
return this.getByteArray(size);
229241
case UINT16:
@@ -639,6 +651,7 @@ private <T> Object decodeMapIntoObject(int size, Class<T> cls)
639651
private boolean shouldInstantiateFromContext(Class<?> parameterType) {
640652
if (parameterType == null
641653
|| parameterType.isPrimitive()
654+
|| parameterType.isEnum()
642655
|| isSimpleType(parameterType)
643656
|| Map.class.isAssignableFrom(parameterType)
644657
|| List.class.isAssignableFrom(parameterType)) {
@@ -856,6 +869,74 @@ private static void validateInjectionTarget(
856869
}
857870
}
858871

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