cls,
- int index,
- Annotation[] annotations
- ) throws ParameterNotFoundException {
+ private static MaxMindDbParameter getParameterAnnotation(Annotation[] annotations) {
for (var annotation : annotations) {
if (!annotation.annotationType().equals(MaxMindDbParameter.class)) {
continue;
}
- var paramAnnotation = (MaxMindDbParameter) annotation;
- return paramAnnotation.name();
+ return (MaxMindDbParameter) annotation;
+ }
+ return null;
+ }
+
+ private static ParameterInjection getParameterInjection(Annotation[] annotations) {
+ ParameterInjection injection = ParameterInjection.NONE;
+ for (var annotation : annotations) {
+ var type = annotation.annotationType();
+ if (type.equals(MaxMindDbIpAddress.class)) {
+ if (injection != ParameterInjection.NONE) {
+ throw new DeserializationException(
+ "Constructor parameters may have at most one lookup context annotation.");
+ }
+ injection = ParameterInjection.IP_ADDRESS;
+ } else if (type.equals(MaxMindDbNetwork.class)) {
+ if (injection != ParameterInjection.NONE) {
+ throw new DeserializationException(
+ "Constructor parameters may have at most one lookup context annotation.");
+ }
+ injection = ParameterInjection.NETWORK;
+ }
+ }
+ return injection;
+ }
+
+ private static void validateInjectionTarget(
+ Class> cls,
+ int parameterIndex,
+ Class> parameterType,
+ ParameterInjection injection
+ ) {
+ if (injection == ParameterInjection.IP_ADDRESS) {
+ if (!InetAddress.class.isAssignableFrom(parameterType)
+ && !String.class.equals(parameterType)) {
+ throw new DeserializationException(
+ "Parameter index " + parameterIndex + " on class " + cls.getName()
+ + " annotated with @MaxMindDbIpAddress must be of type "
+ + "java.net.InetAddress or java.lang.String.");
+ }
+ } else if (injection == ParameterInjection.NETWORK) {
+ if (!Network.class.isAssignableFrom(parameterType)
+ && !String.class.equals(parameterType)) {
+ throw new DeserializationException(
+ "Parameter index " + parameterIndex + " on class " + cls.getName()
+ + " annotated with @MaxMindDbNetwork must be of type "
+ + "com.maxmind.db.Network or java.lang.String.");
+ }
+ }
+ }
+
+ /**
+ * Converts a decoded value to the target type using a creator method if available.
+ * If no creator method is found, returns the original value.
+ */
+ private Object convertValue(Object value, Class> targetType) {
+ if (value == null || targetType == null
+ || targetType == Object.class
+ || targetType.isInstance(value)) {
+ return value;
+ }
+
+ CachedCreator creator = getCachedCreator(targetType);
+ if (creator == null) {
+ return value;
+ }
+
+ if (!creator.parameterType().isInstance(value)) {
+ return value;
+ }
+
+ try {
+ return creator.method().invoke(null, value);
+ } catch (IllegalAccessException | InvocationTargetException e) {
+ throw new DeserializationException(
+ "Error invoking creator method " + creator.method().getName()
+ + " on class " + targetType.getName(), e);
+ }
+ }
+
+ private CachedCreator getCachedCreator(Class> cls) {
+ CachedCreator cached = this.creators.get(cls);
+ if (cached != null) {
+ return cached;
+ }
+
+ CachedCreator creator = findCreatorMethod(cls);
+ if (creator != null) {
+ this.creators.putIfAbsent(cls, creator);
+ }
+ return creator;
+ }
+
+ private static CachedCreator findCreatorMethod(Class> cls) {
+ Method[] methods = cls.getDeclaredMethods();
+ for (Method method : methods) {
+ if (!method.isAnnotationPresent(MaxMindDbCreator.class)) {
+ continue;
+ }
+ if (!Modifier.isStatic(method.getModifiers())) {
+ throw new DeserializationException(
+ "Creator method " + method.getName() + " on class " + cls.getName()
+ + " must be static.");
+ }
+ if (method.getParameterCount() != 1) {
+ throw new DeserializationException(
+ "Creator method " + method.getName() + " on class " + cls.getName()
+ + " must have exactly one parameter.");
+ }
+ if (!cls.isAssignableFrom(method.getReturnType())) {
+ throw new DeserializationException(
+ "Creator method " + method.getName() + " on class " + cls.getName()
+ + " must return " + cls.getName() + " or a subtype.");
+ }
+ return new CachedCreator(method, method.getParameterTypes()[0]);
+ }
+ return null;
+ }
+
+ private static Object parseDefault(String value, Class> target) {
+ try {
+ if (target.equals(Boolean.TYPE) || target.equals(Boolean.class)) {
+ return value.isEmpty() ? false : Boolean.parseBoolean(value);
+ }
+ if (target.equals(Byte.TYPE) || target.equals(Byte.class)) {
+ var v = value.isEmpty() ? 0 : Integer.parseInt(value);
+ if (v < Byte.MIN_VALUE || v > Byte.MAX_VALUE) {
+ throw new DeserializationException(
+ "Default value out of range for byte");
+ }
+ return (byte) v;
+ }
+ if (target.equals(Short.TYPE) || target.equals(Short.class)) {
+ var v = value.isEmpty() ? 0 : Integer.parseInt(value);
+ if (v < Short.MIN_VALUE || v > Short.MAX_VALUE) {
+ throw new DeserializationException(
+ "Default value out of range for short");
+ }
+ return (short) v;
+ }
+ if (target.equals(Integer.TYPE) || target.equals(Integer.class)) {
+ return value.isEmpty() ? 0 : Integer.parseInt(value);
+ }
+ if (target.equals(Long.TYPE) || target.equals(Long.class)) {
+ return value.isEmpty() ? 0L : Long.parseLong(value);
+ }
+ if (target.equals(Float.TYPE) || target.equals(Float.class)) {
+ return value.isEmpty() ? 0.0f : Float.parseFloat(value);
+ }
+ if (target.equals(Double.TYPE) || target.equals(Double.class)) {
+ return value.isEmpty() ? 0.0d : Double.parseDouble(value);
+ }
+ if (target.equals(String.class)) {
+ return value;
+ }
+ } catch (NumberFormatException e) {
+ throw new DeserializationException(
+ "Invalid default '" + value + "' for type " + target.getSimpleName(), e);
}
- throw new ParameterNotFoundException(
- "Constructor parameter " + index + " on class " + cls.getName()
- + " is not annotated with MaxMindDbParameter.");
+ throw new DeserializationException(
+ "Defaults are only supported for primitives, boxed types, and String.");
}
private long nextValueOffset(long offset, int numberToSkip)
diff --git a/src/main/java/com/maxmind/db/MaxMindDbCreator.java b/src/main/java/com/maxmind/db/MaxMindDbCreator.java
new file mode 100644
index 00000000..bb1f3dad
--- /dev/null
+++ b/src/main/java/com/maxmind/db/MaxMindDbCreator.java
@@ -0,0 +1,43 @@
+package com.maxmind.db;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * {@code MaxMindDbCreator} is an annotation that can be used to mark a static factory
+ * method or constructor that should be used to create an instance of a class from a
+ * decoded value when decoding a MaxMind DB file.
+ *
+ * This is similar to Jackson's {@code @JsonCreator} annotation and is useful for
+ * types that need custom deserialization logic, such as enums with non-standard
+ * string representations.
+ *
+ * Example usage:
+ *
+ * public enum ConnectionType {
+ * DIALUP("Dialup"),
+ * CABLE_DSL("Cable/DSL");
+ *
+ * private final String name;
+ *
+ * ConnectionType(String name) {
+ * this.name = name;
+ * }
+ *
+ * {@literal @}MaxMindDbCreator
+ * public static ConnectionType fromString(String s) {
+ * return switch (s) {
+ * case "Dialup" -> DIALUP;
+ * case "Cable/DSL" -> CABLE_DSL;
+ * default -> null;
+ * };
+ * }
+ * }
+ *
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.METHOD, ElementType.CONSTRUCTOR})
+public @interface MaxMindDbCreator {
+}
diff --git a/src/main/java/com/maxmind/db/MaxMindDbIpAddress.java b/src/main/java/com/maxmind/db/MaxMindDbIpAddress.java
new file mode 100644
index 00000000..fc46e741
--- /dev/null
+++ b/src/main/java/com/maxmind/db/MaxMindDbIpAddress.java
@@ -0,0 +1,14 @@
+package com.maxmind.db;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a constructor parameter that should receive the IP address used
+ * for the lookup.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.PARAMETER)
+public @interface MaxMindDbIpAddress {}
diff --git a/src/main/java/com/maxmind/db/MaxMindDbNetwork.java b/src/main/java/com/maxmind/db/MaxMindDbNetwork.java
new file mode 100644
index 00000000..3ca0e997
--- /dev/null
+++ b/src/main/java/com/maxmind/db/MaxMindDbNetwork.java
@@ -0,0 +1,14 @@
+package com.maxmind.db;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a constructor parameter that should receive the network associated
+ * with the record returned by the lookup.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.PARAMETER)
+public @interface MaxMindDbNetwork {}
diff --git a/src/main/java/com/maxmind/db/MaxMindDbParameter.java b/src/main/java/com/maxmind/db/MaxMindDbParameter.java
index b04f9ba4..a2dde87a 100644
--- a/src/main/java/com/maxmind/db/MaxMindDbParameter.java
+++ b/src/main/java/com/maxmind/db/MaxMindDbParameter.java
@@ -14,4 +14,16 @@
* @return the name of the parameter in the MaxMind DB file
*/
String name();
+
+ /**
+ * Whether to use a default when the value is missing in the database.
+ */
+ boolean useDefault() default false;
+
+ /**
+ * The default value as a string. Parsed according to the Java parameter
+ * type (e.g., "0", "false"). If empty and {@code useDefault} is true,
+ * the Java type's default is used (0, false, 0.0, and "" for String).
+ */
+ String defaultValue() default "";
}
diff --git a/src/main/java/com/maxmind/db/Networks.java b/src/main/java/com/maxmind/db/Networks.java
index 00ed79cf..af61da65 100644
--- a/src/main/java/com/maxmind/db/Networks.java
+++ b/src/main/java/com/maxmind/db/Networks.java
@@ -3,9 +3,10 @@
import java.io.IOException;
import java.net.Inet4Address;
import java.net.InetAddress;
+import java.util.ArrayDeque;
import java.util.Arrays;
+import java.util.Deque;
import java.util.Iterator;
-import java.util.Stack;
/**
* Instances of this class provide an iterator over the networks in a database.
@@ -15,7 +16,7 @@
*/
public final class Networks implements Iterator> {
private final Reader reader;
- private final Stack nodes;
+ private final Deque nodes;
private NetworkNode lastNode;
private final boolean includeAliasedNetworks;
private final Buffer buffer; /* Stores the buffer for Next() calls */
@@ -39,7 +40,7 @@ public final class Networks implements Iterator> {
this.reader = reader;
this.includeAliasedNetworks = includeAliasedNetworks;
this.buffer = reader.getBufferHolder().get();
- this.nodes = new Stack<>();
+ this.nodes = new ArrayDeque<>();
this.typeParameterClass = typeParameterClass;
for (NetworkNode node : nodes) {
this.nodes.push(node);
@@ -55,9 +56,6 @@ public final class Networks implements Iterator> {
@Override
public DatabaseRecord next() {
try {
- var data = this.reader.resolveDataPointer(
- this.buffer, this.lastNode.pointer, this.typeParameterClass);
-
var ip = this.lastNode.ip;
var prefixLength = this.lastNode.prefix;
@@ -76,7 +74,16 @@ public DatabaseRecord next() {
prefixLength -= 96;
}
- return new DatabaseRecord<>(data, ipAddr, prefixLength);
+ var network = new Network(ipAddr, prefixLength);
+ var data = this.reader.resolveDataPointer(
+ this.buffer,
+ this.lastNode.pointer,
+ this.typeParameterClass,
+ ipAddr,
+ network
+ );
+
+ return new DatabaseRecord<>(data, network);
} catch (IOException e) {
throw new NetworksIterationException(e);
}
diff --git a/src/main/java/com/maxmind/db/ParameterInjection.java b/src/main/java/com/maxmind/db/ParameterInjection.java
new file mode 100644
index 00000000..b729e4b3
--- /dev/null
+++ b/src/main/java/com/maxmind/db/ParameterInjection.java
@@ -0,0 +1,7 @@
+package com.maxmind.db;
+
+enum ParameterInjection {
+ NONE,
+ IP_ADDRESS,
+ NETWORK
+}
diff --git a/src/main/java/com/maxmind/db/Reader.java b/src/main/java/com/maxmind/db/Reader.java
index 3af489d3..f19872cc 100644
--- a/src/main/java/com/maxmind/db/Reader.java
+++ b/src/main/java/com/maxmind/db/Reader.java
@@ -28,6 +28,7 @@ public final class Reader implements Closeable {
private final AtomicReference bufferHolderReference;
private final NodeCache cache;
private final ConcurrentHashMap, CachedConstructor>> constructors;
+ private final ConcurrentHashMap, CachedCreator> creators;
/**
* The file mode to use when opening a MaxMind DB.
@@ -166,6 +167,7 @@ private Reader(BufferHolder bufferHolder, String name, NodeCache cache) throws I
this.ipV4Start = this.findIpV4StartNode(buffer);
this.constructors = new ConcurrentHashMap<>();
+ this.creators = new ConcurrentHashMap<>();
}
/**
@@ -203,22 +205,29 @@ public DatabaseRecord getRecord(InetAddress ipAddress, Class cls)
var traverseResult = traverseTree(rawAddress, rawAddress.length * 8);
long record = traverseResult[0];
- int pl = (int) traverseResult[1];
+ int prefixLength = (int) traverseResult[1];
long nodeCount = this.metadata.nodeCount();
var buffer = this.getBufferHolder().get();
+ var network = new Network(ipAddress, prefixLength);
T dataRecord = null;
if (record > nodeCount) {
// record is a data pointer
try {
- dataRecord = this.resolveDataPointer(buffer, record, cls);
+ dataRecord = this.resolveDataPointer(
+ buffer,
+ record,
+ cls,
+ ipAddress,
+ network
+ );
} catch (DeserializationException exception) {
throw new DeserializationException(
"Error getting record for IP " + ipAddress + " - " + exception.getMessage(),
exception);
}
}
- return new DatabaseRecord<>(dataRecord, ipAddress, pl);
+ return new DatabaseRecord<>(dataRecord, network);
}
/**
@@ -416,7 +425,9 @@ long readNode(Buffer buffer, long nodeNumber, int index)
T resolveDataPointer(
Buffer buffer,
long pointer,
- Class cls
+ Class cls,
+ InetAddress lookupIp,
+ Network network
) throws IOException {
long resolved = (pointer - this.metadata.nodeCount())
+ this.searchTreeSize;
@@ -433,7 +444,10 @@ T resolveDataPointer(
this.cache,
buffer,
this.searchTreeSize + DATA_SECTION_SEPARATOR_SIZE,
- this.constructors
+ this.constructors,
+ this.creators,
+ lookupIp,
+ network
);
return decoder.decode(resolved, cls);
}
diff --git a/src/test/java/com/maxmind/db/DecoderTest.java b/src/test/java/com/maxmind/db/DecoderTest.java
index 07d00d01..c68b1131 100644
--- a/src/test/java/com/maxmind/db/DecoderTest.java
+++ b/src/test/java/com/maxmind/db/DecoderTest.java
@@ -475,4 +475,105 @@ private static void testTypeDecoding(Type type, Map tests)
}
}
+ @Test
+ public void testUint64Coercion() throws IOException {
+ // Test data: small UINT64 values that fit in smaller types
+ var testData = largeUint(64);
+
+ var cache = new CHMCache();
+
+ // Test UINT64(0) → Byte
+ var zeroBytes = testData.get(BigInteger.ZERO);
+ var buffer = SingleBuffer.wrap(zeroBytes);
+ var decoder = new TestDecoder(cache, buffer, 0);
+ assertEquals((byte) 0, decoder.decode(0, Byte.class), "UINT64(0) should coerce to byte");
+
+ // Test UINT64(500) → Long
+ buffer = SingleBuffer.wrap(testData.get(BigInteger.valueOf(500)));
+ decoder = new TestDecoder(cache, buffer, 0);
+ assertEquals(500L, decoder.decode(0, Long.class), "UINT64(500) should coerce to long");
+
+ // Test UINT64(500) → Integer
+ buffer = SingleBuffer.wrap(testData.get(BigInteger.valueOf(500)));
+ decoder = new TestDecoder(cache, buffer, 0);
+ assertEquals(500, decoder.decode(0, Integer.class), "UINT64(500) should coerce to int");
+
+ // Test UINT64(500) → Short
+ buffer = SingleBuffer.wrap(testData.get(BigInteger.valueOf(500)));
+ decoder = new TestDecoder(cache, buffer, 0);
+ assertEquals((short) 500, decoder.decode(0, Short.class), "UINT64(500) should coerce to short");
+
+ // Test UINT64(500) → Byte (should fail - out of range)
+ buffer = SingleBuffer.wrap(testData.get(BigInteger.valueOf(500)));
+ decoder = new TestDecoder(cache, buffer, 0);
+ var finalDecoder1 = decoder;
+ var ex1 = assertThrows(DeserializationException.class,
+ () -> finalDecoder1.decode(0, Byte.class),
+ "UINT64(500) should not fit in byte");
+ assertThat(ex1.getMessage(), containsString("out of range for byte"));
+
+ // Test UINT64(2^64-1) → Long (should fail - too large)
+ var maxUint64 = BigInteger.valueOf(2).pow(64).subtract(BigInteger.ONE);
+ buffer = SingleBuffer.wrap(testData.get(maxUint64));
+ decoder = new TestDecoder(cache, buffer, 0);
+ var finalDecoder2 = decoder;
+ var ex2 = assertThrows(DeserializationException.class,
+ () -> finalDecoder2.decode(0, Long.class),
+ "UINT64(2^64-1) should not fit in long");
+ assertThat(ex2.getMessage(), containsString("out of range for long"));
+
+ // Test UINT64(2^64-1) → BigInteger (should work)
+ buffer = SingleBuffer.wrap(testData.get(maxUint64));
+ decoder = new TestDecoder(cache, buffer, 0);
+ assertEquals(maxUint64, decoder.decode(0, BigInteger.class),
+ "UINT64(2^64-1) should decode to BigInteger");
+
+ // Test UINT64(10872) → Float
+ buffer = SingleBuffer.wrap(testData.get(BigInteger.valueOf(10872)));
+ decoder = new TestDecoder(cache, buffer, 0);
+ assertEquals(10872.0f, decoder.decode(0, Float.class), 0.001f,
+ "UINT64(10872) should coerce to float");
+
+ // Test UINT64(10872) → Double
+ buffer = SingleBuffer.wrap(testData.get(BigInteger.valueOf(10872)));
+ decoder = new TestDecoder(cache, buffer, 0);
+ assertEquals(10872.0, decoder.decode(0, Double.class), 0.001,
+ "UINT64(10872) should coerce to double");
+ }
+
+ @Test
+ public void testUint128Coercion() throws IOException {
+ // Test data: UINT128 values
+ var testData = largeUint(128);
+
+ var cache = new CHMCache();
+
+ // Test UINT128(0) → Long
+ var zeroBytes = testData.get(BigInteger.ZERO);
+ var buffer = SingleBuffer.wrap(zeroBytes);
+ var decoder = new TestDecoder(cache, buffer, 0);
+ assertEquals(0L, decoder.decode(0, Long.class), "UINT128(0) should coerce to long");
+
+ // Test UINT128(500) → Integer
+ buffer = SingleBuffer.wrap(testData.get(BigInteger.valueOf(500)));
+ decoder = new TestDecoder(cache, buffer, 0);
+ assertEquals(500, decoder.decode(0, Integer.class), "UINT128(500) should coerce to int");
+
+ // Test UINT128(2^128-1) → Long (should fail - way too large)
+ var maxUint128 = BigInteger.valueOf(2).pow(128).subtract(BigInteger.ONE);
+ buffer = SingleBuffer.wrap(testData.get(maxUint128));
+ decoder = new TestDecoder(cache, buffer, 0);
+ var finalDecoder = decoder;
+ var ex = assertThrows(DeserializationException.class,
+ () -> finalDecoder.decode(0, Long.class),
+ "UINT128(2^128-1) should not fit in long");
+ assertThat(ex.getMessage(), containsString("out of range for long"));
+
+ // Test UINT128(2^128-1) → BigInteger (should work)
+ buffer = SingleBuffer.wrap(testData.get(maxUint128));
+ decoder = new TestDecoder(cache, buffer, 0);
+ assertEquals(maxUint128, decoder.decode(0, BigInteger.class),
+ "UINT128(2^128-1) should decode to BigInteger");
+ }
+
}
diff --git a/src/test/java/com/maxmind/db/ReaderTest.java b/src/test/java/com/maxmind/db/ReaderTest.java
index 5c8fcb90..20479831 100644
--- a/src/test/java/com/maxmind/db/ReaderTest.java
+++ b/src/test/java/com/maxmind/db/ReaderTest.java
@@ -27,7 +27,6 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Vector;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.IntStream;
@@ -506,6 +505,9 @@ public void testDecodingTypesFile(int chunkSize) throws IOException {
this.testDecodingTypesIntoModelObject(this.testReader, true);
this.testDecodingTypesIntoModelObjectBoxed(this.testReader, true);
this.testDecodingTypesIntoModelWithList(this.testReader);
+ this.testRecordImplicitConstructor(this.testReader);
+ this.testSingleConstructorWithoutAnnotation(this.testReader);
+ this.testPojoImplicitParameters(this.testReader);
}
@ParameterizedTest
@@ -516,6 +518,125 @@ public void testDecodingTypesStream(int chunkSize) throws IOException {
this.testDecodingTypesIntoModelObject(this.testReader, true);
this.testDecodingTypesIntoModelObjectBoxed(this.testReader, true);
this.testDecodingTypesIntoModelWithList(this.testReader);
+ this.testRecordImplicitConstructor(this.testReader);
+ this.testSingleConstructorWithoutAnnotation(this.testReader);
+ this.testPojoImplicitParameters(this.testReader);
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testContextAnnotations(int chunkSize) throws IOException {
+ try (var reader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize)) {
+ var firstIp = InetAddress.getByName("1.1.1.1");
+ var secondIp = InetAddress.getByName("1.1.1.3");
+
+ var expectedNetwork = reader.getRecord(firstIp, Map.class).network().toString();
+
+ var first = reader.get(firstIp, ContextModel.class);
+ var second = reader.get(secondIp, ContextModel.class);
+
+ assertEquals(firstIp, first.lookupIp);
+ assertEquals(firstIp.getHostAddress(), first.lookupIpString);
+ assertEquals(expectedNetwork, first.lookupNetwork.toString());
+ assertEquals(expectedNetwork, first.lookupNetworkString);
+ assertEquals(firstIp, first.lookupNetwork.ipAddress());
+ assertEquals(100, first.uint16Field);
+
+ assertEquals(secondIp, second.lookupIp);
+ assertEquals(secondIp.getHostAddress(), second.lookupIpString);
+ assertEquals(expectedNetwork, second.lookupNetwork.toString());
+ assertEquals(expectedNetwork, second.lookupNetworkString);
+ assertEquals(secondIp, second.lookupNetwork.ipAddress());
+ assertEquals(100, second.uint16Field);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testNestedContextAnnotations(int chunkSize) throws IOException {
+ try (var reader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize)) {
+ var firstIp = InetAddress.getByName("1.1.1.1");
+ var secondIp = InetAddress.getByName("1.1.1.3");
+ var expectedNetwork = reader.getRecord(firstIp, Map.class).network().toString();
+
+ var first = reader.get(firstIp, WrapperContextOnlyModel.class);
+ var second = reader.get(secondIp, WrapperContextOnlyModel.class);
+
+ assertNotNull(first.context);
+ assertEquals(firstIp, first.context.lookupIp);
+ assertEquals(expectedNetwork, first.context.lookupNetwork.toString());
+
+ assertNotNull(second.context);
+ assertEquals(secondIp, second.context.lookupIp);
+ assertEquals(expectedNetwork, second.context.lookupNetwork.toString());
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testNestedContextAnnotationsWithCache(int chunkSize) throws IOException {
+ var cache = new CHMCache();
+ try (var reader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), cache, chunkSize)) {
+ var firstIp = InetAddress.getByName("1.1.1.1");
+ var secondIp = InetAddress.getByName("1.1.1.3");
+ var expectedNetwork = reader.getRecord(firstIp, Map.class).network().toString();
+
+ var first = reader.get(firstIp, WrapperContextOnlyModel.class);
+ var second = reader.get(secondIp, WrapperContextOnlyModel.class);
+
+ assertNotNull(first.context);
+ assertEquals(firstIp, first.context.lookupIp);
+ assertEquals(expectedNetwork, first.context.lookupNetwork.toString());
+
+ assertNotNull(second.context);
+ assertEquals(secondIp, second.context.lookupIp);
+ assertEquals(expectedNetwork, second.context.lookupNetwork.toString());
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testCreatorMethod(int chunkSize) throws IOException {
+ try (var reader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize)) {
+ // Test with IP that has boolean=true
+ var ipTrue = InetAddress.getByName("1.1.1.1");
+ var resultTrue = reader.get(ipTrue, CreatorMethodModel.class);
+ assertNotNull(resultTrue);
+ assertNotNull(resultTrue.enumField);
+ assertEquals(BooleanEnum.TRUE_VALUE, resultTrue.enumField);
+
+ // Test with IP that has boolean=false
+ var ipFalse = InetAddress.getByName("::");
+ var resultFalse = reader.get(ipFalse, CreatorMethodModel.class);
+ assertNotNull(resultFalse);
+ assertNotNull(resultFalse.enumField);
+ assertEquals(BooleanEnum.FALSE_VALUE, resultFalse.enumField);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testCreatorMethodWithString(int chunkSize) throws IOException {
+ try (var reader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize)) {
+ // The database has utf8_stringX="hello" in map.mapX at this IP
+ var ip = InetAddress.getByName("1.1.1.1");
+
+ // Get the nested map containing utf8_stringX to verify the raw data
+ var record = reader.get(ip, Map.class);
+ var map = (Map, ?>) record.get("map");
+ assertNotNull(map);
+ var mapX = (Map, ?>) map.get("mapX");
+ assertNotNull(mapX);
+ assertEquals("hello", mapX.get("utf8_stringX"));
+
+ // Now test that the creator method converts "hello" to StringEnum.HELLO
+ var result = reader.get(ip, StringEnumModel.class);
+ assertNotNull(result);
+ assertNotNull(result.map);
+ assertNotNull(result.map.mapX);
+ assertNotNull(result.map.mapX.stringEnumField);
+ assertEquals(StringEnum.HELLO, result.map.mapX.stringEnumField);
+ }
}
@ParameterizedTest
@@ -609,6 +730,29 @@ private void testDecodingTypesIntoModelObject(Reader reader, boolean booleanValu
model.uint128Field);
}
+ static class ContextModel {
+ InetAddress lookupIp;
+ String lookupIpString;
+ Network lookupNetwork;
+ String lookupNetworkString;
+ int uint16Field;
+
+ @MaxMindDbConstructor
+ public ContextModel(
+ @MaxMindDbIpAddress InetAddress lookupIp,
+ @MaxMindDbIpAddress String lookupIpString,
+ @MaxMindDbNetwork Network lookupNetwork,
+ @MaxMindDbNetwork String lookupNetworkString,
+ @MaxMindDbParameter(name = "uint16") int uint16Field
+ ) {
+ this.lookupIp = lookupIp;
+ this.lookupIpString = lookupIpString;
+ this.lookupNetwork = lookupNetwork;
+ this.lookupNetworkString = lookupNetworkString;
+ this.uint16Field = uint16Field;
+ }
+ }
+
static class TestModel {
boolean booleanField;
byte[] bytesField;
@@ -785,6 +929,123 @@ public TestModelBoxed(
}
}
+ static class ContextOnlyModel {
+ InetAddress lookupIp;
+ Network lookupNetwork;
+
+ @MaxMindDbConstructor
+ public ContextOnlyModel(
+ @MaxMindDbIpAddress InetAddress lookupIp,
+ @MaxMindDbNetwork Network lookupNetwork
+ ) {
+ this.lookupIp = lookupIp;
+ this.lookupNetwork = lookupNetwork;
+ }
+ }
+
+ static class WrapperContextOnlyModel {
+ ContextOnlyModel context;
+
+ @MaxMindDbConstructor
+ public WrapperContextOnlyModel(
+ @MaxMindDbParameter(name = "missing_context")
+ ContextOnlyModel context
+ ) {
+ this.context = context;
+ }
+ }
+
+ enum BooleanEnum {
+ TRUE_VALUE,
+ FALSE_VALUE,
+ UNKNOWN;
+
+ @MaxMindDbCreator
+ public static BooleanEnum fromBoolean(Boolean b) {
+ if (b == null) {
+ return UNKNOWN;
+ }
+ return b ? TRUE_VALUE : FALSE_VALUE;
+ }
+ }
+
+ enum StringEnum {
+ HELLO("hello"),
+ GOODBYE("goodbye"),
+ UNKNOWN("unknown");
+
+ private final String value;
+
+ StringEnum(String value) {
+ this.value = value;
+ }
+
+ @MaxMindDbCreator
+ public static StringEnum fromString(String s) {
+ if (s == null) {
+ return UNKNOWN;
+ }
+ return switch (s) {
+ case "hello" -> HELLO;
+ case "goodbye" -> GOODBYE;
+ default -> UNKNOWN;
+ };
+ }
+
+ @Override
+ public String toString() {
+ return value;
+ }
+ }
+
+ static class CreatorMethodModel {
+ BooleanEnum enumField;
+
+ @MaxMindDbConstructor
+ public CreatorMethodModel(
+ @MaxMindDbParameter(name = "boolean")
+ BooleanEnum enumField
+ ) {
+ this.enumField = enumField;
+ }
+ }
+
+ static class MapXWithEnum {
+ StringEnum stringEnumField;
+
+ @MaxMindDbConstructor
+ public MapXWithEnum(
+ @MaxMindDbParameter(name = "utf8_stringX")
+ StringEnum stringEnumField
+ ) {
+ this.stringEnumField = stringEnumField;
+ }
+ }
+
+ static class MapWithEnum {
+ MapXWithEnum mapX;
+
+ @MaxMindDbConstructor
+ public MapWithEnum(
+ @MaxMindDbParameter(name = "mapX")
+ MapXWithEnum mapX
+ ) {
+ this.mapX = mapX;
+ }
+ }
+
+ static class StringEnumModel {
+ MapWithEnum map;
+
+ @MaxMindDbConstructor
+ public StringEnumModel(
+ @MaxMindDbParameter(name = "map")
+ MapWithEnum map
+ ) {
+ this.map = map;
+ }
+ }
+
static class MapModelBoxed {
MapXModelBoxed mapXField;
@@ -831,6 +1092,65 @@ public TestModelList(
}
}
+ // Record-based decoding without annotations
+ record MapXRecord(List arrayX) {}
+ record MapRecord(MapXRecord mapX) {}
+ record TestRecordImplicit(MapRecord map) {}
+
+ private void testRecordImplicitConstructor(Reader reader) throws IOException {
+ var model = reader.get(InetAddress.getByName("::1.1.1.0"), TestRecordImplicit.class);
+ assertEquals(List.of(7L, 8L, 9L), model.map().mapX().arrayX());
+ }
+
+ // Single-constructor classes without @MaxMindDbConstructor
+ static class MapXPojo {
+ List arrayX;
+ String utf8StringX;
+
+ public MapXPojo(
+ @MaxMindDbParameter(name = "arrayX") List arrayX,
+ @MaxMindDbParameter(name = "utf8_stringX") String utf8StringX
+ ) {
+ this.arrayX = arrayX;
+ this.utf8StringX = utf8StringX;
+ }
+ }
+
+ static class MapContainerPojo {
+ MapXPojo mapX;
+
+ public MapContainerPojo(@MaxMindDbParameter(name = "mapX") MapXPojo mapX) {
+ this.mapX = mapX;
+ }
+ }
+
+ static class TopLevelPojo {
+ MapContainerPojo map;
+
+ public TopLevelPojo(@MaxMindDbParameter(name = "map") MapContainerPojo map) {
+ this.map = map;
+ }
+ }
+
+ private void testSingleConstructorWithoutAnnotation(Reader reader) throws IOException {
+ var pojo = reader.get(InetAddress.getByName("::1.1.1.0"), TopLevelPojo.class);
+ assertEquals(List.of(7L, 8L, 9L), pojo.map.mapX.arrayX);
+ }
+
+ // Unannotated parameters on non-record types using Java parameter names
+ static class TestPojoImplicit {
+ MapContainerPojo map;
+
+ public TestPojoImplicit(MapContainerPojo map) {
+ this.map = map;
+ }
+ }
+
+ private void testPojoImplicitParameters(Reader reader) throws IOException {
+ var model = reader.get(InetAddress.getByName("::1.1.1.0"), TestPojoImplicit.class);
+ assertEquals(List.of(7L, 8L, 9L), model.map.mapX.arrayX);
+ }
+
@ParameterizedTest
@MethodSource("chunkSizes")
public void testZerosFile(int chunkSize) throws IOException {
@@ -1076,17 +1396,356 @@ public void testDecodeVector(int chunkSize) throws IOException {
}
static class TestModelVector {
- Vector arrayField;
+ ArrayList arrayField;
@MaxMindDbConstructor
public TestModelVector(
@MaxMindDbParameter(name = "array")
- Vector arrayField
+ ArrayList arrayField
) {
- this.arrayField = arrayField;
+ this.arrayField = arrayField;
}
}
+ // Positive tests for primitive constructor parameters
+ static class TestModelPrimitivesBasic {
+ boolean booleanField;
+ double doubleField;
+ float floatField;
+ int int32Field;
+ long uint32Field;
+
+ @MaxMindDbConstructor
+ public TestModelPrimitivesBasic(
+ @MaxMindDbParameter(name = "boolean") boolean booleanField,
+ @MaxMindDbParameter(name = "double") double doubleField,
+ @MaxMindDbParameter(name = "float") float floatField,
+ @MaxMindDbParameter(name = "int32") int int32Field,
+ @MaxMindDbParameter(name = "uint32") long uint32Field
+ ) {
+ this.booleanField = booleanField;
+ this.doubleField = doubleField;
+ this.floatField = floatField;
+ this.int32Field = int32Field;
+ this.uint32Field = uint32Field;
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testPrimitiveConstructorParamsBasicWorks(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+
+ var model = this.testReader.get(
+ InetAddress.getByName("::1.1.1.0"),
+ TestModelPrimitivesBasic.class
+ );
+
+ assertTrue(model.booleanField);
+ assertEquals(42.123456, model.doubleField, 0.000000001);
+ assertEquals(1.1, model.floatField, 0.000001);
+ assertEquals(-268435456, model.int32Field);
+ assertEquals(268435456L, model.uint32Field);
+ }
+
+ static class TestModelShortPrimitive {
+ short uint16Field;
+
+ @MaxMindDbConstructor
+ public TestModelShortPrimitive(
+ @MaxMindDbParameter(name = "uint16") short uint16Field
+ ) {
+ this.uint16Field = uint16Field;
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testPrimitiveConstructorParamShortWorks(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+ var model = this.testReader.get(
+ InetAddress.getByName("::1.1.1.0"),
+ TestModelShortPrimitive.class
+ );
+ assertEquals((short) 100, model.uint16Field);
+ }
+
+ static class TestModelBytePrimitive {
+ byte uint16Field;
+
+ @MaxMindDbConstructor
+ public TestModelBytePrimitive(
+ @MaxMindDbParameter(name = "uint16") byte uint16Field
+ ) {
+ this.uint16Field = uint16Field;
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testPrimitiveConstructorParamByteWorks(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+ var model = this.testReader.get(
+ InetAddress.getByName("::1.1.1.0"),
+ TestModelBytePrimitive.class
+ );
+ assertEquals((byte) 100, model.uint16Field);
+ }
+
+ // Tests for behavior when a primitive constructor parameter is missing from the DB
+ static class MissingBooleanPrimitive {
+ boolean v;
+
+ @MaxMindDbConstructor
+ public MissingBooleanPrimitive(
+ @MaxMindDbParameter(name = "missing_key") boolean v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class MissingBytePrimitive {
+ byte v;
+
+ @MaxMindDbConstructor
+ public MissingBytePrimitive(
+ @MaxMindDbParameter(name = "missing_key") byte v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class MissingShortPrimitive {
+ short v;
+
+ @MaxMindDbConstructor
+ public MissingShortPrimitive(
+ @MaxMindDbParameter(name = "missing_key") short v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class MissingIntPrimitive {
+ int v;
+
+ @MaxMindDbConstructor
+ public MissingIntPrimitive(
+ @MaxMindDbParameter(name = "missing_key") int v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class MissingLongPrimitive {
+ long v;
+
+ @MaxMindDbConstructor
+ public MissingLongPrimitive(
+ @MaxMindDbParameter(name = "missing_key") long v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class MissingFloatPrimitive {
+ float v;
+
+ @MaxMindDbConstructor
+ public MissingFloatPrimitive(
+ @MaxMindDbParameter(name = "missing_key") float v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class MissingDoublePrimitive {
+ double v;
+
+ @MaxMindDbConstructor
+ public MissingDoublePrimitive(
+ @MaxMindDbParameter(name = "missing_key") double v
+ ) {
+ this.v = v;
+ }
+ }
+
+ // Positive tests: defaults via annotation when key is missing
+ static class DefaultBooleanPrimitive {
+ boolean v;
+
+ @MaxMindDbConstructor
+ public DefaultBooleanPrimitive(
+ @MaxMindDbParameter(name = "missing_key", useDefault = true, defaultValue = "true")
+ boolean v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class DefaultBytePrimitive {
+ byte v;
+
+ @MaxMindDbConstructor
+ public DefaultBytePrimitive(
+ @MaxMindDbParameter(name = "missing_key", useDefault = true, defaultValue = "7")
+ byte v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class DefaultShortPrimitive {
+ short v;
+
+ @MaxMindDbConstructor
+ public DefaultShortPrimitive(
+ @MaxMindDbParameter(name = "missing_key", useDefault = true, defaultValue = "300")
+ short v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class DefaultIntPrimitive {
+ int v;
+
+ @MaxMindDbConstructor
+ public DefaultIntPrimitive(
+ @MaxMindDbParameter(name = "missing_key", useDefault = true, defaultValue = "-5")
+ int v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class DefaultLongPrimitive {
+ long v;
+
+ @MaxMindDbConstructor
+ public DefaultLongPrimitive(
+ @MaxMindDbParameter(name = "missing_key", useDefault = true, defaultValue = "123456789")
+ long v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class DefaultFloatPrimitive {
+ float v;
+
+ @MaxMindDbConstructor
+ public DefaultFloatPrimitive(
+ @MaxMindDbParameter(name = "missing_key", useDefault = true, defaultValue = "3.14")
+ float v
+ ) {
+ this.v = v;
+ }
+ }
+
+ static class DefaultDoublePrimitive {
+ double v;
+
+ @MaxMindDbConstructor
+ public DefaultDoublePrimitive(
+ @MaxMindDbParameter(name = "missing_key", useDefault = true, defaultValue = "2.71828")
+ double v
+ ) {
+ this.v = v;
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testMissingPrimitiveDefaultsApplied(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+
+ assertTrue(this.testReader.get(
+ InetAddress.getByName("::1.1.1.0"), DefaultBooleanPrimitive.class).v);
+ assertEquals((byte) 7, this.testReader.get(
+ InetAddress.getByName("::1.1.1.0"), DefaultBytePrimitive.class).v);
+ assertEquals((short) 300, this.testReader.get(
+ InetAddress.getByName("::1.1.1.0"), DefaultShortPrimitive.class).v);
+ assertEquals(-5, this.testReader.get(
+ InetAddress.getByName("::1.1.1.0"), DefaultIntPrimitive.class).v);
+ assertEquals(123456789L, this.testReader.get(
+ InetAddress.getByName("::1.1.1.0"), DefaultLongPrimitive.class).v);
+ assertEquals(3.14f, this.testReader.get(
+ InetAddress.getByName("::1.1.1.0"), DefaultFloatPrimitive.class).v, 0.0001);
+ assertEquals(2.71828, this.testReader.get(
+ InetAddress.getByName("::1.1.1.0"), DefaultDoublePrimitive.class).v, 0.00001);
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testMissingPrimitiveBooleanFails(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+ var ex = assertThrows(DeserializationException.class,
+ () -> this.testReader.get(InetAddress.getByName("::1.1.1.0"), MissingBooleanPrimitive.class));
+ assertThat(ex.getMessage(), containsString("Error creating object"));
+ assertThat(ex.getCause().getCause().getClass(), equalTo(IllegalArgumentException.class));
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testMissingPrimitiveByteFails(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+ var ex = assertThrows(DeserializationException.class,
+ () -> this.testReader.get(InetAddress.getByName("::1.1.1.0"), MissingBytePrimitive.class));
+ assertThat(ex.getMessage(), containsString("Error creating object"));
+ assertThat(ex.getCause().getCause().getClass(), equalTo(IllegalArgumentException.class));
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testMissingPrimitiveShortFails(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+ var ex = assertThrows(DeserializationException.class,
+ () -> this.testReader.get(InetAddress.getByName("::1.1.1.0"), MissingShortPrimitive.class));
+ assertThat(ex.getMessage(), containsString("Error creating object"));
+ assertThat(ex.getCause().getCause().getClass(), equalTo(IllegalArgumentException.class));
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testMissingPrimitiveIntFails(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+ var ex = assertThrows(DeserializationException.class,
+ () -> this.testReader.get(InetAddress.getByName("::1.1.1.0"), MissingIntPrimitive.class));
+ assertThat(ex.getMessage(), containsString("Error creating object"));
+ assertThat(ex.getCause().getCause().getClass(), equalTo(IllegalArgumentException.class));
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testMissingPrimitiveLongFails(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+ var ex = assertThrows(DeserializationException.class,
+ () -> this.testReader.get(InetAddress.getByName("::1.1.1.0"), MissingLongPrimitive.class));
+ assertThat(ex.getMessage(), containsString("Error creating object"));
+ assertThat(ex.getCause().getCause().getClass(), equalTo(IllegalArgumentException.class));
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testMissingPrimitiveFloatFails(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+ var ex = assertThrows(DeserializationException.class,
+ () -> this.testReader.get(InetAddress.getByName("::1.1.1.0"), MissingFloatPrimitive.class));
+ assertThat(ex.getMessage(), containsString("Error creating object"));
+ assertThat(ex.getCause().getCause().getClass(), equalTo(IllegalArgumentException.class));
+ }
+
+ @ParameterizedTest
+ @MethodSource("chunkSizes")
+ public void testMissingPrimitiveDoubleFails(int chunkSize) throws IOException {
+ this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
+ var ex = assertThrows(DeserializationException.class,
+ () -> this.testReader.get(InetAddress.getByName("::1.1.1.0"), MissingDoublePrimitive.class));
+ assertThat(ex.getMessage(), containsString("Error creating object"));
+ assertThat(ex.getCause().getCause().getClass(), equalTo(IllegalArgumentException.class));
+ }
+
// Test that we cache differently depending on more than the offset.
@ParameterizedTest
@MethodSource("chunkSizes")