From 526001844b381169ad31beb66fa6f39db07db5bb Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 13 Oct 2025 20:15:52 -0700 Subject: [PATCH 01/14] Support fallback constructor/parameter selection - Prefer @MaxMindDbConstructor; fallback to record canonical or single public constructor - Infer parameter names: annotation > record component > Java parameter when `-parameters` is set; throw ParameterNotFoundException if names unavailable - Enable -parameters in maven-compiler-plugin --- CHANGELOG.md | 8 +++ README.md | 17 +++++ pom.xml | 1 + src/main/java/com/maxmind/db/Decoder.java | 67 ++++++++++++++++---- src/test/java/com/maxmind/db/ReaderTest.java | 65 +++++++++++++++++++ 5 files changed, 144 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c3df84..bbc17d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ CHANGELOG accessor methods (e.g., `binaryFormatMajorVersion()`, `databaseType()`, etc.). * `Network.getNetworkAddress()` and `Network.getPrefixLength()` have been replaced with record accessor methods `networkAddress()` and `prefixLength()`. +* Deserialization improvements: + * If no constructor is annotated with `@MaxMindDbConstructor`, records now + use their canonical constructor automatically. For non‑record classes with + a single public constructor, that constructor is used by default. + * `@MaxMindDbParameter` annotations are now optional when parameter names + match field names in the database: for records, component names are used; + for classes, Java parameter names are used (when compiled with + `-parameters`). Annotations still take precedence when present. 3.2.0 (2025-05-28) ------------------ diff --git a/README.md b/README.md index 37f471c8..d7803f87 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,23 @@ public class Lookup { } ``` +### Constructor and parameter selection + +- Preferred: annotate a constructor with `@MaxMindDbConstructor` and its + parameters with `@MaxMindDbParameter(name = "...")`. +- Records: if no constructor is annotated, the canonical record constructor is + used automatically. Record component names are used as field names. +- Classes with a single public constructor: if no constructor is annotated, + that constructor is used automatically. +- Unannotated parameters: when a parameter is not annotated, the reader falls + back to the parameter name. For records, this is the component name; for + classes, this is the Java parameter name. To use Java parameter names at + runtime, compile your model classes with the `-parameters` flag (Maven: + `maven-compiler-plugin` with `true`). + If Java parameter names are unavailable (no `-parameters`) and there is no + `@MaxMindDbParameter` annotation, the reader throws a + `ParameterNotFoundException` with guidance. + You can also use the reader object to iterate over the database. The `reader.networks()` and `reader.networksWithin()` methods can be used for this purpose. diff --git a/pom.xml b/pom.xml index ced36ef1..f13b9b25 100644 --- a/pom.xml +++ b/pom.xml @@ -150,6 +150,7 @@ 17 17 17 + true diff --git a/src/main/java/com/maxmind/db/Decoder.java b/src/main/java/com/maxmind/db/Decoder.java index ee6b9930..65864cab 100644 --- a/src/main/java/com/maxmind/db/Decoder.java +++ b/src/main/java/com/maxmind/db/Decoder.java @@ -388,8 +388,25 @@ private Object decodeMapIntoObject(int size, Class cls) parameterIndexes = new HashMap<>(); var annotations = constructor.getParameterAnnotations(); for (int i = 0; i < constructor.getParameterCount(); i++) { - var parameterName = getParameterName(cls, i, annotations[i]); - parameterIndexes.put(parameterName, i); + var name = getParameterName(annotations[i]); + if (name == null) { + // Fallbacks: record component name, then Java parameter name + // (requires -parameters) + if (cls.isRecord()) { + name = cls.getRecordComponents()[i].getName(); + } else { + var param = constructor.getParameters()[i]; + if (param.isNamePresent()) { + name = param.getName(); + } else { + throw new ParameterNotFoundException( + "Parameter name for index " + i + " on class " + cls.getName() + + " is not available. Annotate with @MaxMindDbParameter " + + "or compile with -parameters."); + } + } + } + parameterIndexes.put(name, i); } this.constructors.put( @@ -457,24 +474,48 @@ private CachedConstructor getCachedConstructor(Class cls) { private static Constructor findConstructor(Class cls) throws ConstructorNotFoundException { var constructors = cls.getConstructors(); + // Prefer explicitly annotated constructor for (var constructor : constructors) { if (constructor.getAnnotation(MaxMindDbConstructor.class) == null) { continue; } @SuppressWarnings("unchecked") - Constructor constructor2 = (Constructor) constructor; - return constructor2; + var selected = (Constructor) constructor; + return selected; } - throw new ConstructorNotFoundException("No constructor on class " + cls.getName() - + " with the MaxMindDbConstructor annotation was found."); + // Fallback for records: use canonical constructor + if (cls.isRecord()) { + try { + var components = cls.getRecordComponents(); + var types = new Class[components.length]; + for (int i = 0; i < components.length; i++) { + types[i] = components[i].getType(); + } + var c = cls.getDeclaredConstructor(types); + @SuppressWarnings("unchecked") + var selected = (Constructor) c; + return selected; + } catch (NoSuchMethodException e) { + // ignore and continue to next fallback + } + } + + // Fallback for single-constructor classes + if (constructors.length == 1) { + var only = constructors[0]; + @SuppressWarnings("unchecked") + var selected = (Constructor) only; + return selected; + } + + throw new ConstructorNotFoundException( + "No usable constructor on class " + cls.getName() + + ". Annotate a constructor with MaxMindDbConstructor, " + + "provide a record canonical constructor, or a single public constructor."); } - private static String getParameterName( - Class cls, - int index, - Annotation[] annotations - ) throws ParameterNotFoundException { + private static String getParameterName(Annotation[] annotations) { for (var annotation : annotations) { if (!annotation.annotationType().equals(MaxMindDbParameter.class)) { continue; @@ -482,9 +523,7 @@ private static String getParameterName( var paramAnnotation = (MaxMindDbParameter) annotation; return paramAnnotation.name(); } - throw new ParameterNotFoundException( - "Constructor parameter " + index + " on class " + cls.getName() - + " is not annotated with MaxMindDbParameter."); + return null; } private long nextValueOffset(long offset, int numberToSkip) diff --git a/src/test/java/com/maxmind/db/ReaderTest.java b/src/test/java/com/maxmind/db/ReaderTest.java index 5c8fcb90..eecacb35 100644 --- a/src/test/java/com/maxmind/db/ReaderTest.java +++ b/src/test/java/com/maxmind/db/ReaderTest.java @@ -506,6 +506,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 +519,9 @@ 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 @@ -831,6 +837,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 { From 4278ea7dff6022c6c2ade335722f482c2312ac16 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Tue, 14 Oct 2025 08:47:42 -0700 Subject: [PATCH 02/14] Decode int primitive constructor params Add coercion for INT32/UINT16/UINT32 to target primitives with range checks. --- src/main/java/com/maxmind/db/Decoder.java | 74 ++++++++++++++++- src/test/java/com/maxmind/db/ReaderTest.java | 87 +++++++++++++++++++- 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/maxmind/db/Decoder.java b/src/main/java/com/maxmind/db/Decoder.java index 65864cab..c3f24320 100644 --- a/src/main/java/com/maxmind/db/Decoder.java +++ b/src/main/java/com/maxmind/db/Decoder.java @@ -169,11 +169,11 @@ private Object decodeByType( case BYTES: return this.getByteArray(size); case UINT16: - return this.decodeUint16(size); + return coerceFromInt(this.decodeUint16(size), cls); case UINT32: - return this.decodeUint32(size); + return coerceFromLong(this.decodeUint32(size), cls); case INT32: - return this.decodeInt32(size); + return coerceFromInt(this.decodeInt32(size), cls); case UINT64: case UINT128: return this.decodeBigInteger(size); @@ -183,6 +183,74 @@ private Object decodeByType( } } + private static Object coerceFromInt(int value, Class target) { + if (target.equals(Object.class) + || target.equals(Integer.TYPE) + || target.equals(Integer.class)) { + return value; + } + if (target.equals(Long.TYPE) || target.equals(Long.class)) { + return (long) value; + } + if (target.equals(Short.TYPE) || target.equals(Short.class)) { + if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) { + throw new DeserializationException("Value " + value + " out of range for short"); + } + return (short) value; + } + if (target.equals(Byte.TYPE) || target.equals(Byte.class)) { + if (value < Byte.MIN_VALUE || value > Byte.MAX_VALUE) { + throw new DeserializationException("Value " + value + " out of range for byte"); + } + return (byte) value; + } + if (target.equals(Double.TYPE) || target.equals(Double.class)) { + return (double) value; + } + if (target.equals(Float.TYPE) || target.equals(Float.class)) { + return (float) value; + } + if (target.equals(BigInteger.class)) { + return BigInteger.valueOf(value); + } + // Fallback: return as Integer; caller may attempt to cast/assign + return value; + } + + private static Object coerceFromLong(long value, Class target) { + if (target.equals(Object.class) || target.equals(Long.TYPE) || target.equals(Long.class)) { + return value; + } + if (target.equals(Integer.TYPE) || target.equals(Integer.class)) { + if (value < 0 || value > Integer.MAX_VALUE) { + throw new DeserializationException("Value " + value + " out of range for int"); + } + return (int) value; + } + if (target.equals(Short.TYPE) || target.equals(Short.class)) { + if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) { + throw new DeserializationException("Value " + value + " out of range for short"); + } + return (short) value; + } + if (target.equals(Byte.TYPE) || target.equals(Byte.class)) { + if (value < Byte.MIN_VALUE || value > Byte.MAX_VALUE) { + throw new DeserializationException("Value " + value + " out of range for byte"); + } + return (byte) value; + } + if (target.equals(Double.TYPE) || target.equals(Double.class)) { + return (double) value; + } + if (target.equals(Float.TYPE) || target.equals(Float.class)) { + return (float) value; + } + if (target.equals(BigInteger.class)) { + return BigInteger.valueOf(value); + } + return value; + } + private String decodeString(long size) throws CharacterCodingException { var oldLimit = buffer.limit(); buffer.limit(buffer.position() + size); diff --git a/src/test/java/com/maxmind/db/ReaderTest.java b/src/test/java/com/maxmind/db/ReaderTest.java index eecacb35..7be16f7e 100644 --- a/src/test/java/com/maxmind/db/ReaderTest.java +++ b/src/test/java/com/maxmind/db/ReaderTest.java @@ -1148,10 +1148,95 @@ public TestModelVector( @MaxMindDbParameter(name = "array") Vector 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); + } + // Test that we cache differently depending on more than the offset. @ParameterizedTest @MethodSource("chunkSizes") From dcb961cd83edd9809ca4f0ff8d0b86fdd7c1e9c0 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Tue, 14 Oct 2025 10:18:18 -0700 Subject: [PATCH 03/14] Defaults for missing constructor params via annotation We need both useDefault and defaultValue as otherwise there is no way to distinguish between the default value being the empty string and no default being set. If defaultValue is not set but useDefault is, we will use the Java default value, e.g., 0 or the empty string. --- README.md | 23 ++ .../com/maxmind/db/CachedConstructor.java | 6 +- src/main/java/com/maxmind/db/Decoder.java | 69 ++++- .../com/maxmind/db/MaxMindDbParameter.java | 12 + src/test/java/com/maxmind/db/ReaderTest.java | 254 ++++++++++++++++++ 5 files changed, 356 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d7803f87..8d319ca5 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,29 @@ public class Lookup { `@MaxMindDbParameter` annotation, the reader throws a `ParameterNotFoundException` with guidance. +Defaults for missing values + +- Provide a default with + `@MaxMindDbParameter(name = "...", useDefault = true, defaultValue = "...")`. +- Supports primitives, boxed types, and `String`. If `defaultValue` is empty + and `useDefault` is true, Java defaults are used (0, false, 0.0, empty + string). +- Example: + + ```java + @MaxMindDbConstructor + Example( + @MaxMindDbParameter(name = "count", useDefault = true, defaultValue = "0") + int count, + @MaxMindDbParameter( + name = "enabled", + useDefault = true, + defaultValue = "true" + ) + boolean enabled + ) { } + ``` + You can also use the reader object to iterate over the database. The `reader.networks()` and `reader.networksWithin()` methods can be used for this purpose. diff --git a/src/main/java/com/maxmind/db/CachedConstructor.java b/src/main/java/com/maxmind/db/CachedConstructor.java index c1c5f456..ba8c9baa 100644 --- a/src/main/java/com/maxmind/db/CachedConstructor.java +++ b/src/main/java/com/maxmind/db/CachedConstructor.java @@ -7,6 +7,6 @@ record CachedConstructor( Constructor constructor, Class[] parameterTypes, java.lang.reflect.Type[] parameterGenericTypes, - Map parameterIndexes -) { -} + Map parameterIndexes, + Object[] parameterDefaults +) {} diff --git a/src/main/java/com/maxmind/db/Decoder.java b/src/main/java/com/maxmind/db/Decoder.java index c3f24320..82f3650b 100644 --- a/src/main/java/com/maxmind/db/Decoder.java +++ b/src/main/java/com/maxmind/db/Decoder.java @@ -446,6 +446,7 @@ private Object decodeMapIntoObject(int size, Class cls) Class[] parameterTypes; java.lang.reflect.Type[] parameterGenericTypes; Map parameterIndexes; + Object[] parameterDefaults; if (cachedConstructor == null) { constructor = findConstructor(cls); @@ -454,9 +455,11 @@ private Object decodeMapIntoObject(int size, Class cls) parameterGenericTypes = constructor.getGenericParameterTypes(); parameterIndexes = new HashMap<>(); + parameterDefaults = new Object[constructor.getParameterCount()]; var annotations = constructor.getParameterAnnotations(); for (int i = 0; i < constructor.getParameterCount(); i++) { - var name = getParameterName(annotations[i]); + var ann = getParameterAnnotation(annotations[i]); + var name = ann != null ? ann.name() : null; if (name == null) { // Fallbacks: record component name, then Java parameter name // (requires -parameters) @@ -474,6 +477,10 @@ private Object decodeMapIntoObject(int size, Class cls) } } } + // Prepare parsed defaults once and cache them + if (ann != null && ann.useDefault()) { + parameterDefaults[i] = parseDefault(ann.defaultValue(), parameterTypes[i]); + } parameterIndexes.put(name, i); } @@ -483,7 +490,8 @@ private Object decodeMapIntoObject(int size, Class cls) constructor, parameterTypes, parameterGenericTypes, - parameterIndexes + parameterIndexes, + parameterDefaults ) ); } else { @@ -491,6 +499,7 @@ private Object decodeMapIntoObject(int size, Class cls) parameterTypes = cachedConstructor.parameterTypes(); parameterGenericTypes = cachedConstructor.parameterGenericTypes(); parameterIndexes = cachedConstructor.parameterIndexes(); + parameterDefaults = cachedConstructor.parameterDefaults(); } var parameters = new Object[parameterTypes.length]; @@ -510,6 +519,13 @@ private Object decodeMapIntoObject(int size, Class cls) ).value(); } + // Apply cached defaults for missing parameters, if any + for (int i = 0; i < parameters.length; i++) { + if (parameters[i] == null && parameterDefaults[i] != null) { + parameters[i] = parameterDefaults[i]; + } + } + try { return constructor.newInstance(parameters); } catch (InstantiationException @@ -583,17 +599,60 @@ private static Constructor findConstructor(Class cls) + "provide a record canonical constructor, or a single public constructor."); } - private static String getParameterName(Annotation[] annotations) { + 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 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 DeserializationException( + "Defaults are only supported for primitives, boxed types, and String."); + } + private long nextValueOffset(long offset, int numberToSkip) throws InvalidDatabaseException { if (numberToSkip == 0) { 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/test/java/com/maxmind/db/ReaderTest.java b/src/test/java/com/maxmind/db/ReaderTest.java index 7be16f7e..d1b387b0 100644 --- a/src/test/java/com/maxmind/db/ReaderTest.java +++ b/src/test/java/com/maxmind/db/ReaderTest.java @@ -1237,6 +1237,260 @@ public void testPrimitiveConstructorParamByteWorks(int chunkSize) throws IOExcep 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") From 88c3ecd9478ef17f9785e69f5b4fa36634fb6d3a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Tue, 14 Oct 2025 15:40:54 -0700 Subject: [PATCH 04/14] Add lookup context injection annotations --- CHANGELOG.md | 3 + README.md | 10 + .../com/maxmind/db/CachedConstructor.java | 4 +- src/main/java/com/maxmind/db/Decoder.java | 293 ++++++++++++++---- .../com/maxmind/db/MaxMindDbIpAddress.java | 14 + .../java/com/maxmind/db/MaxMindDbNetwork.java | 14 + src/main/java/com/maxmind/db/Networks.java | 14 +- .../com/maxmind/db/ParameterInjection.java | 7 + src/main/java/com/maxmind/db/Reader.java | 21 +- src/test/java/com/maxmind/db/ReaderTest.java | 51 +++ 10 files changed, 359 insertions(+), 72 deletions(-) create mode 100644 src/main/java/com/maxmind/db/MaxMindDbIpAddress.java create mode 100644 src/main/java/com/maxmind/db/MaxMindDbNetwork.java create mode 100644 src/main/java/com/maxmind/db/ParameterInjection.java diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc17d7f..108de3b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ CHANGELOG match field names in the database: for records, component names are used; for classes, Java parameter names are used (when compiled with `-parameters`). Annotations still take precedence when present. + * Added `@MaxMindDbIpAddress` and `@MaxMindDbNetwork` annotations to inject + the lookup IP address and resulting network into constructors. Annotation + metadata is cached per type to avoid repeated reflection overhead. 3.2.0 (2025-05-28) ------------------ diff --git a/README.md b/README.md index 8d319ca5..9a4a84b7 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,16 @@ Defaults for missing values ) { } ``` +Lookup context injection + +- Use `@MaxMindDbIpAddress` to inject the IP address being decoded. + Supported parameter types are `InetAddress` and `String`. +- Use `@MaxMindDbNetwork` to inject the network of the resulting record. + Supported parameter types are `Network` and `String`. +- Context annotations cannot be combined with `@MaxMindDbParameter` on the same + constructor argument. Values are populated for every lookup without being + cached between different IPs. + You can also use the reader object to iterate over the database. The `reader.networks()` and `reader.networksWithin()` methods can be used for this purpose. diff --git a/src/main/java/com/maxmind/db/CachedConstructor.java b/src/main/java/com/maxmind/db/CachedConstructor.java index ba8c9baa..cd27db76 100644 --- a/src/main/java/com/maxmind/db/CachedConstructor.java +++ b/src/main/java/com/maxmind/db/CachedConstructor.java @@ -8,5 +8,7 @@ record CachedConstructor( Class[] parameterTypes, java.lang.reflect.Type[] parameterGenericTypes, Map parameterIndexes, - Object[] parameterDefaults + Object[] parameterDefaults, + ParameterInjection[] parameterInjections, + boolean requiresLookupContext ) {} diff --git a/src/main/java/com/maxmind/db/Decoder.java b/src/main/java/com/maxmind/db/Decoder.java index 82f3650b..852abaac 100644 --- a/src/main/java/com/maxmind/db/Decoder.java +++ b/src/main/java/com/maxmind/db/Decoder.java @@ -6,6 +6,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.ParameterizedType; import java.math.BigInteger; +import java.net.InetAddress; import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; import java.nio.charset.CharsetDecoder; @@ -37,12 +38,17 @@ class Decoder { private final ConcurrentHashMap, CachedConstructor> constructors; + private final InetAddress lookupIp; + private final Network lookupNetwork; + Decoder(NodeCache cache, Buffer buffer, long pointerBase) { this( cache, buffer, pointerBase, - new ConcurrentHashMap<>() + new ConcurrentHashMap<>(), + null, + null ); } @@ -51,11 +57,31 @@ class Decoder { Buffer buffer, long pointerBase, ConcurrentHashMap, CachedConstructor> constructors + ) { + this( + cache, + buffer, + pointerBase, + constructors, + null, + null + ); + } + + Decoder( + NodeCache cache, + Buffer buffer, + long pointerBase, + ConcurrentHashMap, CachedConstructor> constructors, + InetAddress lookupIp, + Network lookupNetwork ) { this.cache = cache; this.pointerBase = pointerBase; this.buffer = buffer; this.constructors = constructors; + this.lookupIp = lookupIp; + this.lookupNetwork = lookupNetwork; } private final NodeCache.Loader cacheLoader = this::decode; @@ -134,10 +160,42 @@ DecodedValue decodePointer(long pointer, Class cls, java.lang.reflect.Type ge var position = buffer.position(); var key = new CacheKey<>(pointer, cls, genericType); - var o = cache.get(key, cacheLoader); + DecodedValue value; + if (requiresLookupContext(cls)) { + value = this.decode(key); + } else { + value = cache.get(key, cacheLoader); + } buffer.position(position); - return o; + return value; + } + + private boolean requiresLookupContext(Class cls) { + if (cls == null + || cls.equals(Object.class) + || Map.class.isAssignableFrom(cls) + || List.class.isAssignableFrom(cls) + || isSimpleType(cls)) { + return false; + } + + var cached = getCachedConstructor(cls); + if (cached == null) { + cached = loadConstructorMetadata(cls); + } + return cached.requiresLookupContext(); + } + + private static boolean isSimpleType(Class cls) { + if (cls.isPrimitive() || cls.isArray()) { + return true; + } + return cls.equals(String.class) + || Number.class.isAssignableFrom(cls) + || cls.equals(Boolean.class) + || cls.equals(Character.class) + || cls.equals(BigInteger.class); } private Object decodeByType( @@ -439,69 +497,89 @@ private Map decodeMapIntoMap( return map; } - private Object decodeMapIntoObject(int size, Class cls) - throws IOException { - var cachedConstructor = getCachedConstructor(cls); - Constructor constructor; - Class[] parameterTypes; - java.lang.reflect.Type[] parameterGenericTypes; - Map parameterIndexes; - Object[] parameterDefaults; - if (cachedConstructor == null) { - constructor = findConstructor(cls); - - parameterTypes = constructor.getParameterTypes(); - - parameterGenericTypes = constructor.getGenericParameterTypes(); - - parameterIndexes = new HashMap<>(); - parameterDefaults = new Object[constructor.getParameterCount()]; - var annotations = constructor.getParameterAnnotations(); - for (int i = 0; i < constructor.getParameterCount(); i++) { - var ann = getParameterAnnotation(annotations[i]); - var name = ann != null ? ann.name() : null; - if (name == null) { - // Fallbacks: record component name, then Java parameter name - // (requires -parameters) - if (cls.isRecord()) { - name = cls.getRecordComponents()[i].getName(); + private CachedConstructor loadConstructorMetadata(Class cls) { + var cached = getCachedConstructor(cls); + if (cached != null) { + return cached; + } + + var constructor = findConstructor(cls); + + var parameterTypes = constructor.getParameterTypes(); + var parameterGenericTypes = constructor.getGenericParameterTypes(); + var parameterIndexes = new HashMap(); + var parameterDefaults = new Object[constructor.getParameterCount()]; + var parameterInjections = new ParameterInjection[constructor.getParameterCount()]; + boolean requiresContext = false; + + var annotations = constructor.getParameterAnnotations(); + for (int i = 0; i < constructor.getParameterCount(); i++) { + var injection = getParameterInjection(annotations[i]); + parameterInjections[i] = injection; + + var parameterAnnotation = getParameterAnnotation(annotations[i]); + + if (injection != ParameterInjection.NONE) { + requiresContext = true; + if (parameterAnnotation != null) { + throw new DeserializationException( + "Parameter index " + i + " on class " + cls.getName() + + " cannot have both @MaxMindDbParameter and a lookup context " + + "annotation."); + } + validateInjectionTarget(cls, i, parameterTypes[i], injection); + continue; + } + + if (parameterAnnotation != null && parameterAnnotation.useDefault()) { + parameterDefaults[i] = + parseDefault(parameterAnnotation.defaultValue(), parameterTypes[i]); + } + + String name = parameterAnnotation != null ? parameterAnnotation.name() : null; + if (name == null) { + if (cls.isRecord()) { + name = cls.getRecordComponents()[i].getName(); + } else { + var param = constructor.getParameters()[i]; + if (param.isNamePresent()) { + name = param.getName(); } else { - var param = constructor.getParameters()[i]; - if (param.isNamePresent()) { - name = param.getName(); - } else { - throw new ParameterNotFoundException( - "Parameter name for index " + i + " on class " + cls.getName() - + " is not available. Annotate with @MaxMindDbParameter " - + "or compile with -parameters."); - } + throw new ParameterNotFoundException( + "Parameter name for index " + i + " on class " + cls.getName() + + " is not available. Annotate with @MaxMindDbParameter " + + "or compile with -parameters."); } } - // Prepare parsed defaults once and cache them - if (ann != null && ann.useDefault()) { - parameterDefaults[i] = parseDefault(ann.defaultValue(), parameterTypes[i]); - } - parameterIndexes.put(name, i); - } - - this.constructors.put( - cls, - new CachedConstructor<>( - constructor, - parameterTypes, - parameterGenericTypes, - parameterIndexes, - parameterDefaults - ) - ); - } else { - constructor = cachedConstructor.constructor(); - parameterTypes = cachedConstructor.parameterTypes(); - parameterGenericTypes = cachedConstructor.parameterGenericTypes(); - parameterIndexes = cachedConstructor.parameterIndexes(); - parameterDefaults = cachedConstructor.parameterDefaults(); + } + parameterIndexes.put(name, i); } + var cachedConstructor = new CachedConstructor<>( + constructor, + parameterTypes, + parameterGenericTypes, + parameterIndexes, + parameterDefaults, + parameterInjections, + requiresContext + ); + @SuppressWarnings("unchecked") + var existing = (CachedConstructor) this.constructors.putIfAbsent(cls, cachedConstructor); + return existing != null ? existing : cachedConstructor; + } + + private Object decodeMapIntoObject(int size, Class cls) + throws IOException { + var cachedConstructor = loadConstructorMetadata(cls); + + var constructor = cachedConstructor.constructor(); + var parameterTypes = cachedConstructor.parameterTypes(); + var parameterGenericTypes = cachedConstructor.parameterGenericTypes(); + var parameterIndexes = cachedConstructor.parameterIndexes(); + var parameterDefaults = cachedConstructor.parameterDefaults(); + var parameterInjections = cachedConstructor.parameterInjections(); + var parameters = new Object[parameterTypes.length]; for (int i = 0; i < size; i++) { var key = (String) this.decode(String.class, null).value(); @@ -519,8 +597,11 @@ private Object decodeMapIntoObject(int size, Class cls) ).value(); } - // Apply cached defaults for missing parameters, if any for (int i = 0; i < parameters.length; i++) { + if (parameterInjections[i] != ParameterInjection.NONE) { + parameters[i] = injectParameter(parameterInjections[i], parameterTypes[i]); + continue; + } if (parameters[i] == null && parameterDefaults[i] != null) { parameters[i] = parameterDefaults[i]; } @@ -548,6 +629,48 @@ private Object decodeMapIntoObject(int size, Class cls) } } + private Object injectParameter(ParameterInjection injection, Class parameterType) { + return switch (injection) { + case IP_ADDRESS -> getLookupIpValue(parameterType); + case NETWORK -> getLookupNetworkValue(parameterType); + case NONE -> null; + }; + } + + private Object getLookupIpValue(Class parameterType) { + if (this.lookupIp == null) { + throw new DeserializationException( + "Cannot inject lookup IP address because no lookup context is available."); + } + if (String.class.equals(parameterType)) { + return this.lookupIp.getHostAddress(); + } + if (InetAddress.class.isAssignableFrom(parameterType)) { + return this.lookupIp; + } + throw new DeserializationException( + "Unsupported parameter type " + parameterType.getName() + + " for @MaxMindDbIpAddress; expected java.net.InetAddress or " + + "java.lang.String."); + } + + private Object getLookupNetworkValue(Class parameterType) { + if (this.lookupNetwork == null) { + throw new DeserializationException( + "Cannot inject lookup network because no lookup context is available."); + } + if (String.class.equals(parameterType)) { + return this.lookupNetwork.toString(); + } + if (Network.class.isAssignableFrom(parameterType)) { + return this.lookupNetwork; + } + throw new DeserializationException( + "Unsupported parameter type " + parameterType.getName() + + " for @MaxMindDbNetwork; expected com.maxmind.db.Network or " + + "java.lang.String."); + } + private CachedConstructor getCachedConstructor(Class cls) { // This cast is safe because we only put CachedConstructor for Class as the key @SuppressWarnings("unchecked") @@ -609,6 +732,52 @@ private static MaxMindDbParameter getParameterAnnotation(Annotation[] 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."); + } + } + } + private static Object parseDefault(String value, Class target) { try { if (target.equals(Boolean.TYPE) || target.equals(Boolean.class)) { 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/Networks.java b/src/main/java/com/maxmind/db/Networks.java index 00ed79cf..d7941d19 100644 --- a/src/main/java/com/maxmind/db/Networks.java +++ b/src/main/java/com/maxmind/db/Networks.java @@ -55,9 +55,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 +73,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..ff5fc2cf 100644 --- a/src/main/java/com/maxmind/db/Reader.java +++ b/src/main/java/com/maxmind/db/Reader.java @@ -203,22 +203,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 +423,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 +442,9 @@ T resolveDataPointer( this.cache, buffer, this.searchTreeSize + DATA_SECTION_SEPARATOR_SIZE, - this.constructors + this.constructors, + lookupIp, + network ); return decoder.decode(resolved, cls); } diff --git a/src/test/java/com/maxmind/db/ReaderTest.java b/src/test/java/com/maxmind/db/ReaderTest.java index d1b387b0..a4018c7d 100644 --- a/src/test/java/com/maxmind/db/ReaderTest.java +++ b/src/test/java/com/maxmind/db/ReaderTest.java @@ -524,6 +524,34 @@ public void testDecodingTypesStream(int chunkSize) throws IOException { 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 testDecodingTypesPointerDecoderFile(int chunkSize) throws IOException { @@ -615,6 +643,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; From 90d18b43ae8594bc83607aa6263dc7de9e54c898 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Tue, 14 Oct 2025 15:45:09 -0700 Subject: [PATCH 05/14] Drop DatabaseRecord InetAddress constructor --- CHANGELOG.md | 2 ++ src/main/java/com/maxmind/db/DatabaseRecord.java | 15 +-------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 108de3b6..c1e24aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ CHANGELOG accessor methods (e.g., `binaryFormatMajorVersion()`, `databaseType()`, etc.). * `Network.getNetworkAddress()` and `Network.getPrefixLength()` have been replaced with record accessor methods `networkAddress()` and `prefixLength()`. + * Removed the legacy `DatabaseRecord(T, InetAddress, int)` constructor; pass a + `Network` when constructing records manually. * Deserialization improvements: * If no constructor is annotated with `@MaxMindDbConstructor`, records now use their canonical constructor automatically. For non‑record classes with diff --git a/src/main/java/com/maxmind/db/DatabaseRecord.java b/src/main/java/com/maxmind/db/DatabaseRecord.java index 9ff1c7ac..e141da36 100644 --- a/src/main/java/com/maxmind/db/DatabaseRecord.java +++ b/src/main/java/com/maxmind/db/DatabaseRecord.java @@ -1,7 +1,5 @@ package com.maxmind.db; -import java.net.InetAddress; - /** * DatabaseRecord represents the data and metadata associated with a database * lookup. @@ -14,15 +12,4 @@ * the largest network where all of the IPs in the network have the same * data. */ -public record DatabaseRecord(T data, Network network) { - /** - * Create a new record. - * - * @param data the data for the record in the database. - * @param ipAddress the IP address used in the lookup. - * @param prefixLength the network prefix length associated with the record in the database. - */ - public DatabaseRecord(T data, InetAddress ipAddress, int prefixLength) { - this(data, new Network(ipAddress, prefixLength)); - } -} +public record DatabaseRecord(T data, Network network) {} From d746dad3c08b9b7b9d39f6c1454879b412f2eca4 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 15 Oct 2025 08:04:59 -0700 Subject: [PATCH 06/14] Handle nested context-only constructors --- src/main/java/com/maxmind/db/Decoder.java | 94 +++++++++++++++++++- src/test/java/com/maxmind/db/ReaderTest.java | 69 ++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/maxmind/db/Decoder.java b/src/main/java/com/maxmind/db/Decoder.java index 852abaac..7b0d88dc 100644 --- a/src/main/java/com/maxmind/db/Decoder.java +++ b/src/main/java/com/maxmind/db/Decoder.java @@ -555,6 +555,20 @@ private CachedConstructor loadConstructorMetadata(Class cls) { parameterIndexes.put(name, i); } + // Check for transitive context requirements: if any non-injection parameter type + // itself requires context (e.g., nested objects with @MaxMindDbIpAddress annotations), + // then this parent class also requires context to avoid incorrect caching. + if (!requiresContext) { + for (int i = 0; i < parameterTypes.length; i++) { + if (parameterInjections[i] == ParameterInjection.NONE) { + if (shouldInstantiateFromContext(parameterTypes[i])) { + requiresContext = true; + break; + } + } + } + } + var cachedConstructor = new CachedConstructor<>( constructor, parameterTypes, @@ -602,8 +616,15 @@ private Object decodeMapIntoObject(int size, Class cls) parameters[i] = injectParameter(parameterInjections[i], parameterTypes[i]); continue; } - if (parameters[i] == null && parameterDefaults[i] != null) { + if (parameters[i] != null) { + continue; + } + if (parameterDefaults[i] != null) { parameters[i] = parameterDefaults[i]; + continue; + } + if (shouldInstantiateFromContext(parameterTypes[i])) { + parameters[i] = instantiateWithLookupContext(parameterTypes[i]); } } @@ -629,6 +650,77 @@ private Object decodeMapIntoObject(int size, Class cls) } } + private boolean shouldInstantiateFromContext(Class parameterType) { + if (parameterType == null + || parameterType.isPrimitive() + || isSimpleType(parameterType) + || Map.class.isAssignableFrom(parameterType) + || List.class.isAssignableFrom(parameterType)) { + return false; + } + return requiresLookupContext(parameterType); + } + + private Object instantiateWithLookupContext(Class parameterType) { + var metadata = loadConstructorMetadata(parameterType); + if (metadata == null || !metadata.requiresLookupContext()) { + return null; + } + + var ctor = metadata.constructor(); + var types = metadata.parameterTypes(); + var defaults = metadata.parameterDefaults(); + var injections = metadata.parameterInjections(); + var args = new Object[types.length]; + + for (int i = 0; i < args.length; i++) { + if (injections[i] != ParameterInjection.NONE) { + args[i] = injectParameter(injections[i], types[i]); + } else if (defaults[i] != null) { + args[i] = defaults[i]; + } else if (types[i].isPrimitive()) { + args[i] = primitiveDefault(types[i]); + } else { + args[i] = null; + } + } + + try { + return ctor.newInstance(args); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new DeserializationException( + "Error creating object of type: " + parameterType.getName(), e); + } + } + + private static Object primitiveDefault(Class type) { + if (type.equals(Boolean.TYPE)) { + return false; + } + if (type.equals(Byte.TYPE)) { + return (byte) 0; + } + if (type.equals(Short.TYPE)) { + return (short) 0; + } + if (type.equals(Integer.TYPE)) { + return 0; + } + if (type.equals(Long.TYPE)) { + return 0L; + } + if (type.equals(Float.TYPE)) { + return 0.0f; + } + if (type.equals(Double.TYPE)) { + return 0.0d; + } + if (type.equals(Character.TYPE)) { + return '\0'; + } + return null; + } + private Object injectParameter(ParameterInjection injection, Class parameterType) { return switch (injection) { case IP_ADDRESS -> getLookupIpValue(parameterType); diff --git a/src/test/java/com/maxmind/db/ReaderTest.java b/src/test/java/com/maxmind/db/ReaderTest.java index a4018c7d..363fc2d2 100644 --- a/src/test/java/com/maxmind/db/ReaderTest.java +++ b/src/test/java/com/maxmind/db/ReaderTest.java @@ -552,6 +552,49 @@ public void testContextAnnotations(int chunkSize) throws IOException { } } + @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 testDecodingTypesPointerDecoderFile(int chunkSize) throws IOException { @@ -842,6 +885,32 @@ 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; + } + } + static class MapModelBoxed { MapXModelBoxed mapXField; From 8ffb95ab56065cc2735d2ec57c257041c52b4a8e Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 23 Oct 2025 09:33:12 -0700 Subject: [PATCH 07/14] Add @MaxMindDbCreator annotation for custom deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 32 +++++ .../java/com/maxmind/db/CachedCreator.java | 16 +++ src/main/java/com/maxmind/db/Decoder.java | 83 ++++++++++- .../java/com/maxmind/db/MaxMindDbCreator.java | 43 ++++++ src/main/java/com/maxmind/db/Reader.java | 3 + src/test/java/com/maxmind/db/ReaderTest.java | 136 ++++++++++++++++++ 6 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/maxmind/db/CachedCreator.java create mode 100644 src/main/java/com/maxmind/db/MaxMindDbCreator.java diff --git a/README.md b/README.md index 9a4a84b7..6367d115 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,38 @@ Lookup context injection constructor argument. Values are populated for every lookup without being cached between different IPs. +Custom deserialization + +- Use `@MaxMindDbCreator` to mark a static factory method or constructor that + should be used for custom deserialization of a type from a MaxMind DB file. +- This annotation is similar to Jackson's `@JsonCreator` and is useful for + types that need custom deserialization logic, such as enums with non-standard + string representations or types that require special initialization. +- The annotation can be applied to both constructors and static factory methods. +- Example with an enum: + + ```java + public enum ConnectionType { + DIALUP("Dialup"), + CABLE_DSL("Cable/DSL"); + + private final String name; + + ConnectionType(String name) { + this.name = name; + } + + @MaxMindDbCreator + public static ConnectionType fromString(String s) { + return switch (s) { + case "Dialup" -> DIALUP; + case "Cable/DSL" -> CABLE_DSL; + default -> null; + }; + } + } + ``` + You can also use the reader object to iterate over the database. The `reader.networks()` and `reader.networksWithin()` methods can be used for this purpose. diff --git a/src/main/java/com/maxmind/db/CachedCreator.java b/src/main/java/com/maxmind/db/CachedCreator.java new file mode 100644 index 00000000..0dcb105c --- /dev/null +++ b/src/main/java/com/maxmind/db/CachedCreator.java @@ -0,0 +1,16 @@ +package com.maxmind.db; + +import java.lang.reflect.Method; + +/** + * Cached creator method information for efficient deserialization. + * A creator method is a static factory method annotated with {@link MaxMindDbCreator} + * that converts a decoded value to the target type. + * + * @param method the static factory method annotated with {@link MaxMindDbCreator} + * @param parameterType the parameter type accepted by the creator method + */ +record CachedCreator( + Method method, + Class parameterType +) {} diff --git a/src/main/java/com/maxmind/db/Decoder.java b/src/main/java/com/maxmind/db/Decoder.java index 7b0d88dc..24719afa 100644 --- a/src/main/java/com/maxmind/db/Decoder.java +++ b/src/main/java/com/maxmind/db/Decoder.java @@ -4,6 +4,8 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; import java.math.BigInteger; import java.net.InetAddress; @@ -38,6 +40,8 @@ class Decoder { private final ConcurrentHashMap, CachedConstructor> constructors; + private final ConcurrentHashMap, CachedCreator> creators; + private final InetAddress lookupIp; private final Network lookupNetwork; @@ -47,6 +51,7 @@ class Decoder { buffer, pointerBase, new ConcurrentHashMap<>(), + new ConcurrentHashMap<>(), null, null ); @@ -63,6 +68,7 @@ class Decoder { buffer, pointerBase, constructors, + new ConcurrentHashMap<>(), null, null ); @@ -73,6 +79,7 @@ class Decoder { Buffer buffer, long pointerBase, ConcurrentHashMap, CachedConstructor> constructors, + ConcurrentHashMap, CachedCreator> creators, InetAddress lookupIp, Network lookupNetwork ) { @@ -80,6 +87,7 @@ class Decoder { this.pointerBase = pointerBase; this.buffer = buffer; this.constructors = constructors; + this.creators = creators; this.lookupIp = lookupIp; this.lookupNetwork = lookupNetwork; } @@ -217,9 +225,11 @@ private Object decodeByType( } return this.decodeArray(size, cls, elementClass); case BOOLEAN: - return Decoder.decodeBoolean(size); + Boolean bool = Decoder.decodeBoolean(size); + return convertValue(bool, cls); case UTF8_STRING: - return this.decodeString(size); + String str = this.decodeString(size); + return convertValue(str, cls); case DOUBLE: return this.decodeDouble(size); case FLOAT: @@ -653,6 +663,7 @@ private Object decodeMapIntoObject(int size, Class cls) private boolean shouldInstantiateFromContext(Class parameterType) { if (parameterType == null || parameterType.isPrimitive() + || parameterType.isEnum() || isSimpleType(parameterType) || Map.class.isAssignableFrom(parameterType) || List.class.isAssignableFrom(parameterType)) { @@ -870,6 +881,74 @@ private static void validateInjectionTarget( } } + /** + * 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)) { 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/Reader.java b/src/main/java/com/maxmind/db/Reader.java index ff5fc2cf..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<>(); } /** @@ -443,6 +445,7 @@ T resolveDataPointer( buffer, this.searchTreeSize + DATA_SECTION_SEPARATOR_SIZE, this.constructors, + this.creators, lookupIp, network ); diff --git a/src/test/java/com/maxmind/db/ReaderTest.java b/src/test/java/com/maxmind/db/ReaderTest.java index 363fc2d2..57d8298c 100644 --- a/src/test/java/com/maxmind/db/ReaderTest.java +++ b/src/test/java/com/maxmind/db/ReaderTest.java @@ -595,6 +595,51 @@ public void testNestedContextAnnotationsWithCache(int chunkSize) throws IOExcept } } + @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 @MethodSource("chunkSizes") public void testDecodingTypesPointerDecoderFile(int chunkSize) throws IOException { @@ -911,6 +956,97 @@ public WrapperContextOnlyModel( } } + 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; From d6c7e611464e9ac7c5b0802103d8a48657f6d671 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 23 Oct 2025 11:52:46 -0700 Subject: [PATCH 08/14] Publish snapshot release --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f13b9b25..bbf2a753 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 com.maxmind.db maxmind-db - 3.2.0 + 4.0.0-SNAPSHOT jar MaxMind DB Reader Reader for MaxMind DB From 8944a97092acfc42b87f601052105fd5e663425a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 23 Oct 2025 11:59:32 -0700 Subject: [PATCH 09/14] Remove outdated distributionManagement section --- pom.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pom.xml b/pom.xml index bbf2a753..a4ef117c 100644 --- a/pom.xml +++ b/pom.xml @@ -219,10 +219,4 @@ - - - sonatype-nexus-staging - https://oss.sonatype.org/service/local/staging/deploy/maven2/ - - From b1349a5084fb1346ffd6421a9584187f77bdb2da Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 23 Oct 2025 14:43:18 -0700 Subject: [PATCH 10/14] Fix OOM error when reading from InputStream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation attempted to allocate a temporary I/O buffer equal to chunkSize (~2GB), causing OutOfMemoryError even with increased heap settings. This was a bug introduced when BufferHolder was refactored to use DEFAULT_CHUNK_SIZE for the I/O buffer. Changes: - Use separate IO_BUFFER_SIZE constant (16KB) for reading from streams - Use ByteArrayOutputStream to accumulate data into chunkSize-sized chunks - Guarantee all non-final chunks are exactly chunkSize bytes - Support databases >2GB by creating multiple chunks - Use chunks.size() to determine SingleBuffer vs MultiBuffer - Consistent with pre-MultiBuffer approach but with >2GB support This fixes macOS CI failures and supports databases of any size with minimal memory overhead. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/com/maxmind/db/BufferHolder.java | 59 +++++++++++++------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/maxmind/db/BufferHolder.java b/src/main/java/com/maxmind/db/BufferHolder.java index cd60a915..834a2b87 100644 --- a/src/main/java/com/maxmind/db/BufferHolder.java +++ b/src/main/java/com/maxmind/db/BufferHolder.java @@ -1,6 +1,7 @@ package com.maxmind.db; import com.maxmind.db.Reader.FileMode; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -13,6 +14,10 @@ final class BufferHolder { // DO NOT PASS OUTSIDE THIS CLASS. Doing so will remove thread safety. private final Buffer buffer; + // Reasonable I/O buffer size for reading from InputStream. + // This is separate from chunk size which determines MultiBuffer chunk allocation. + private static final int IO_BUFFER_SIZE = 16 * 1024; // 16KB + BufferHolder(File database, FileMode mode) throws IOException { this(database, mode, MultiBuffer.DEFAULT_CHUNK_SIZE); } @@ -78,29 +83,49 @@ final class BufferHolder { if (null == stream) { throw new NullPointerException("Unable to use a NULL InputStream"); } - var chunks = new ArrayList(); - var total = 0L; - var tmp = new byte[chunkSize]; + + // Read data from the stream in chunks to support databases >2GB. + // Invariant: All chunks except the last are exactly chunkSize bytes. + var chunks = new ArrayList(); + var currentChunkStream = new ByteArrayOutputStream(); + var tmp = new byte[IO_BUFFER_SIZE]; int read; while (-1 != (read = stream.read(tmp))) { - var chunk = ByteBuffer.allocate(read); - chunk.put(tmp, 0, read); - chunk.flip(); - chunks.add(chunk); - total += read; - } + var offset = 0; + while (offset < read) { + var spaceInCurrentChunk = chunkSize - currentChunkStream.size(); + var toWrite = Math.min(spaceInCurrentChunk, read - offset); - if (total <= chunkSize) { - var data = new byte[(int) total]; - var pos = 0; - for (var chunk : chunks) { - System.arraycopy(chunk.array(), 0, data, pos, chunk.capacity()); - pos += chunk.capacity(); + currentChunkStream.write(tmp, offset, toWrite); + offset += toWrite; + + // When chunk is exactly full, save it and start a new one. + // This guarantees all non-final chunks are exactly chunkSize. + if (currentChunkStream.size() == chunkSize) { + chunks.add(currentChunkStream.toByteArray()); + currentChunkStream = new ByteArrayOutputStream(); + } } - this.buffer = SingleBuffer.wrap(data); + } + + // Handle last partial chunk (could be empty if total is multiple of chunkSize) + if (currentChunkStream.size() > 0) { + chunks.add(currentChunkStream.toByteArray()); + } + + if (chunks.size() == 1) { + // For databases that fit in a single chunk, use SingleBuffer + this.buffer = SingleBuffer.wrap(chunks.get(0)); } else { - this.buffer = new MultiBuffer(chunks.toArray(new ByteBuffer[0]), chunkSize); + // For large databases, wrap chunks in ByteBuffers and use MultiBuffer + // Guaranteed: chunks[0..n-2] all have length == chunkSize + // chunks[n-1] may have length < chunkSize + var buffers = new ByteBuffer[chunks.size()]; + for (var i = 0; i < chunks.size(); i++) { + buffers[i] = ByteBuffer.wrap(chunks.get(i)); + } + this.buffer = new MultiBuffer(buffers, chunkSize); } } From 5f3ac3b9662572244fff84abce70a616c5a421cd Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 24 Oct 2025 07:17:49 -0700 Subject: [PATCH 11/14] Replace legacy collections with modern alternatives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Stack with ArrayDeque in Networks.java for better performance by removing unnecessary synchronization overhead. Replace Vector with ArrayList in test code for consistency with modern Java practices. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/main/java/com/maxmind/db/Networks.java | 7 ++++--- src/test/java/com/maxmind/db/ReaderTest.java | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/maxmind/db/Networks.java b/src/main/java/com/maxmind/db/Networks.java index d7941d19..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); diff --git a/src/test/java/com/maxmind/db/ReaderTest.java b/src/test/java/com/maxmind/db/ReaderTest.java index 57d8298c..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; @@ -1397,12 +1396,12 @@ 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; } From d881a49ebb60084b73339de4200d91cba6620d21 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 24 Oct 2025 15:18:38 -0700 Subject: [PATCH 12/14] Use Integer.MIN_VALUE in coerceFromLong range check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the Integer range check consistent with Short and Byte conversions by using Integer.MIN_VALUE instead of checking for < 0. This ensures all narrowing conversions follow the same pattern and correctly handle the full range of Integer values. Addresses PR feedback in #309. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/main/java/com/maxmind/db/Decoder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/maxmind/db/Decoder.java b/src/main/java/com/maxmind/db/Decoder.java index 24719afa..cbcacef7 100644 --- a/src/main/java/com/maxmind/db/Decoder.java +++ b/src/main/java/com/maxmind/db/Decoder.java @@ -290,7 +290,7 @@ private static Object coerceFromLong(long value, Class target) { return value; } if (target.equals(Integer.TYPE) || target.equals(Integer.class)) { - if (value < 0 || value > Integer.MAX_VALUE) { + if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { throw new DeserializationException("Value " + value + " out of range for int"); } return (int) value; From c20e67801099567c24f772f8b0bb7fce5a2b457d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 24 Oct 2025 15:43:34 -0700 Subject: [PATCH 13/14] Add type coercion support for UINT64/UINT128 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement coerceFromBigInteger() method to enable type coercion for UINT64 and UINT128 values, making them consistent with UINT16, UINT32, and INT32 types. This allows UINT64/UINT128 values to be decoded into smaller types (Long, Integer, Short, Byte) when they fit within the target type's range. Add comprehensive tests covering successful coercions and proper range checking for out-of-bounds values. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/main/java/com/maxmind/db/Decoder.java | 43 +++++++- src/test/java/com/maxmind/db/DecoderTest.java | 101 ++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/maxmind/db/Decoder.java b/src/main/java/com/maxmind/db/Decoder.java index cbcacef7..d2d43827 100644 --- a/src/main/java/com/maxmind/db/Decoder.java +++ b/src/main/java/com/maxmind/db/Decoder.java @@ -244,7 +244,7 @@ private Object decodeByType( return coerceFromInt(this.decodeInt32(size), cls); case UINT64: case UINT128: - return this.decodeBigInteger(size); + return coerceFromBigInteger(this.decodeBigInteger(size), cls); default: throw new InvalidDatabaseException( "Unknown or unexpected type: " + type.name()); @@ -319,6 +319,47 @@ private static Object coerceFromLong(long value, Class target) { return value; } + private static Object coerceFromBigInteger(BigInteger value, Class target) { + if (target.equals(Object.class) || target.equals(BigInteger.class)) { + return value; + } + if (target.equals(Long.TYPE) || target.equals(Long.class)) { + if (value.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0 + || value.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) { + throw new DeserializationException("Value " + value + " out of range for long"); + } + return value.longValue(); + } + if (target.equals(Integer.TYPE) || target.equals(Integer.class)) { + if (value.compareTo(BigInteger.valueOf(Integer.MIN_VALUE)) < 0 + || value.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) { + throw new DeserializationException("Value " + value + " out of range for int"); + } + return value.intValue(); + } + if (target.equals(Short.TYPE) || target.equals(Short.class)) { + if (value.compareTo(BigInteger.valueOf(Short.MIN_VALUE)) < 0 + || value.compareTo(BigInteger.valueOf(Short.MAX_VALUE)) > 0) { + throw new DeserializationException("Value " + value + " out of range for short"); + } + return value.shortValue(); + } + if (target.equals(Byte.TYPE) || target.equals(Byte.class)) { + if (value.compareTo(BigInteger.valueOf(Byte.MIN_VALUE)) < 0 + || value.compareTo(BigInteger.valueOf(Byte.MAX_VALUE)) > 0) { + throw new DeserializationException("Value " + value + " out of range for byte"); + } + return value.byteValue(); + } + if (target.equals(Double.TYPE) || target.equals(Double.class)) { + return value.doubleValue(); + } + if (target.equals(Float.TYPE) || target.equals(Float.class)) { + return value.floatValue(); + } + return value; + } + private String decodeString(long size) throws CharacterCodingException { var oldLimit = buffer.limit(); buffer.limit(buffer.position() + size); 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"); + } + } From 1d284783ca92dc36216d75a04843406f44c90c08 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 24 Oct 2025 15:47:50 -0700 Subject: [PATCH 14/14] Optimize UINT64/UINT128 decoding to avoid BigInteger allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For UINT64/UINT128 values less than 8 bytes (which fit in long's positive range), decode directly to long instead of BigInteger when the target is a typed field. This avoids unnecessary BigInteger object allocation for common cases like UINT64 values under 2^56. Maintains backward compatibility by preserving BigInteger return type for Object.class targets (e.g., Map decoding). Performance impact: - UINT64(500) into Long field: decodes as long (no BigInteger allocation) - UINT64(500) into Object/Map: still returns BigInteger (backward compatible) - UINT64 >= 8 bytes: always uses BigInteger (correctness) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/main/java/com/maxmind/db/Decoder.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/maxmind/db/Decoder.java b/src/main/java/com/maxmind/db/Decoder.java index d2d43827..608fac57 100644 --- a/src/main/java/com/maxmind/db/Decoder.java +++ b/src/main/java/com/maxmind/db/Decoder.java @@ -244,6 +244,13 @@ private Object decodeByType( return coerceFromInt(this.decodeInt32(size), cls); case UINT64: case UINT128: + // Optimization: for typed fields, avoid BigInteger allocation when + // value fits in long. Keep Object.class behavior unchanged for + // backward compatibility. + if (size < 8 && !cls.equals(Object.class)) { + return coerceFromLong(this.decodeLong(size), cls); + } + // Size >= 8 bytes or Object.class target: use BigInteger return coerceFromBigInteger(this.decodeBigInteger(size), cls); default: throw new InvalidDatabaseException(