Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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 <noreply@anthropic.com>
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)

AltStyle によって変換されたページ (->オリジナル) /