diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c3df84..c1e24aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ 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 + 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. + * 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 37f471c8..6367d115 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,88 @@ 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. + +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 + ) { } + ``` + +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. + +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/pom.xml b/pom.xml index ced36ef1..a4ef117c 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 @@ -150,6 +150,7 @@ 17 17 17 + true @@ -218,10 +219,4 @@ - - - sonatype-nexus-staging - https://oss.sonatype.org/service/local/staging/deploy/maven2/ - - 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); } } diff --git a/src/main/java/com/maxmind/db/CachedConstructor.java b/src/main/java/com/maxmind/db/CachedConstructor.java index c1c5f456..cd27db76 100644 --- a/src/main/java/com/maxmind/db/CachedConstructor.java +++ b/src/main/java/com/maxmind/db/CachedConstructor.java @@ -7,6 +7,8 @@ record CachedConstructor( Constructor constructor, Class[] parameterTypes, java.lang.reflect.Type[] parameterGenericTypes, - Map parameterIndexes -) { -} + Map parameterIndexes, + Object[] parameterDefaults, + ParameterInjection[] parameterInjections, + boolean requiresLookupContext +) {} 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/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) {} diff --git a/src/main/java/com/maxmind/db/Decoder.java b/src/main/java/com/maxmind/db/Decoder.java index ee6b9930..608fac57 100644 --- a/src/main/java/com/maxmind/db/Decoder.java +++ b/src/main/java/com/maxmind/db/Decoder.java @@ -4,8 +4,11 @@ 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; import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; import java.nio.charset.CharsetDecoder; @@ -37,12 +40,20 @@ class Decoder { private final ConcurrentHashMap, CachedConstructor> constructors; + private final ConcurrentHashMap, CachedCreator> creators; + + private final InetAddress lookupIp; + private final Network lookupNetwork; + Decoder(NodeCache cache, Buffer buffer, long pointerBase) { this( cache, buffer, pointerBase, - new ConcurrentHashMap<>() + new ConcurrentHashMap<>(), + new ConcurrentHashMap<>(), + null, + null ); } @@ -51,11 +62,34 @@ class Decoder { Buffer buffer, long pointerBase, ConcurrentHashMap, CachedConstructor> constructors + ) { + this( + cache, + buffer, + pointerBase, + constructors, + new ConcurrentHashMap<>(), + null, + null + ); + } + + Decoder( + NodeCache cache, + Buffer buffer, + long pointerBase, + ConcurrentHashMap, CachedConstructor> constructors, + ConcurrentHashMap, CachedCreator> creators, + InetAddress lookupIp, + Network lookupNetwork ) { this.cache = cache; this.pointerBase = pointerBase; this.buffer = buffer; this.constructors = constructors; + this.creators = creators; + this.lookupIp = lookupIp; + this.lookupNetwork = lookupNetwork; } private final NodeCache.Loader cacheLoader = this::decode; @@ -134,10 +168,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( @@ -159,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: @@ -169,20 +237,136 @@ 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); + // 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( "Unknown or unexpected type: " + type.name()); } } + 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 < Integer.MIN_VALUE || 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 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); @@ -371,42 +555,102 @@ private Map decodeMapIntoMap( return map; } + 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 { + 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); + } + + // 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, + 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 = getCachedConstructor(cls); - Constructor constructor; - Class[] parameterTypes; - java.lang.reflect.Type[] parameterGenericTypes; - Map parameterIndexes; - if (cachedConstructor == null) { - constructor = findConstructor(cls); - - parameterTypes = constructor.getParameterTypes(); - - parameterGenericTypes = constructor.getGenericParameterTypes(); - - 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); - } - - this.constructors.put( - cls, - new CachedConstructor<>( - constructor, - parameterTypes, - parameterGenericTypes, - parameterIndexes - ) - ); - } else { - constructor = cachedConstructor.constructor(); - parameterTypes = cachedConstructor.parameterTypes(); - parameterGenericTypes = cachedConstructor.parameterGenericTypes(); - parameterIndexes = cachedConstructor.parameterIndexes(); - } + 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++) { @@ -425,6 +669,23 @@ private Object decodeMapIntoObject(int size, Class cls) ).value(); } + 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) { + continue; + } + if (parameterDefaults[i] != null) { + parameters[i] = parameterDefaults[i]; + continue; + } + if (shouldInstantiateFromContext(parameterTypes[i])) { + parameters[i] = instantiateWithLookupContext(parameterTypes[i]); + } + } + try { return constructor.newInstance(parameters); } catch (InstantiationException @@ -447,6 +708,120 @@ 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)) { + 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); + 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") @@ -457,34 +832,213 @@ 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 MaxMindDbParameter getParameterAnnotation(Annotation[] annotations) { for (var annotation : annotations) { if (!annotation.annotationType().equals(MaxMindDbParameter.class)) { continue; } - var paramAnnotation = (MaxMindDbParameter) annotation; - return paramAnnotation.name(); + return (MaxMindDbParameter) annotation; + } + return null; + } + + private static ParameterInjection getParameterInjection(Annotation[] annotations) { + ParameterInjection injection = ParameterInjection.NONE; + for (var annotation : annotations) { + var type = annotation.annotationType(); + if (type.equals(MaxMindDbIpAddress.class)) { + if (injection != ParameterInjection.NONE) { + throw new DeserializationException( + "Constructor parameters may have at most one lookup context annotation."); + } + injection = ParameterInjection.IP_ADDRESS; + } else if (type.equals(MaxMindDbNetwork.class)) { + if (injection != ParameterInjection.NONE) { + throw new DeserializationException( + "Constructor parameters may have at most one lookup context annotation."); + } + injection = ParameterInjection.NETWORK; + } + } + return injection; + } + + private static void validateInjectionTarget( + Class cls, + int parameterIndex, + Class parameterType, + ParameterInjection injection + ) { + if (injection == ParameterInjection.IP_ADDRESS) { + if (!InetAddress.class.isAssignableFrom(parameterType) + && !String.class.equals(parameterType)) { + throw new DeserializationException( + "Parameter index " + parameterIndex + " on class " + cls.getName() + + " annotated with @MaxMindDbIpAddress must be of type " + + "java.net.InetAddress or java.lang.String."); + } + } else if (injection == ParameterInjection.NETWORK) { + if (!Network.class.isAssignableFrom(parameterType) + && !String.class.equals(parameterType)) { + throw new DeserializationException( + "Parameter index " + parameterIndex + " on class " + cls.getName() + + " annotated with @MaxMindDbNetwork must be of type " + + "com.maxmind.db.Network or java.lang.String."); + } + } + } + + /** + * Converts a decoded value to the target type using a creator method if available. + * If no creator method is found, returns the original value. + */ + private Object convertValue(Object value, Class targetType) { + if (value == null || targetType == null + || targetType == Object.class + || targetType.isInstance(value)) { + return value; + } + + CachedCreator creator = getCachedCreator(targetType); + if (creator == null) { + return value; + } + + if (!creator.parameterType().isInstance(value)) { + return value; + } + + try { + return creator.method().invoke(null, value); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new DeserializationException( + "Error invoking creator method " + creator.method().getName() + + " on class " + targetType.getName(), e); + } + } + + private CachedCreator getCachedCreator(Class cls) { + CachedCreator cached = this.creators.get(cls); + if (cached != null) { + return cached; + } + + CachedCreator creator = findCreatorMethod(cls); + if (creator != null) { + this.creators.putIfAbsent(cls, creator); + } + return creator; + } + + private static CachedCreator findCreatorMethod(Class cls) { + Method[] methods = cls.getDeclaredMethods(); + for (Method method : methods) { + if (!method.isAnnotationPresent(MaxMindDbCreator.class)) { + continue; + } + if (!Modifier.isStatic(method.getModifiers())) { + throw new DeserializationException( + "Creator method " + method.getName() + " on class " + cls.getName() + + " must be static."); + } + if (method.getParameterCount() != 1) { + throw new DeserializationException( + "Creator method " + method.getName() + " on class " + cls.getName() + + " must have exactly one parameter."); + } + if (!cls.isAssignableFrom(method.getReturnType())) { + throw new DeserializationException( + "Creator method " + method.getName() + " on class " + cls.getName() + + " must return " + cls.getName() + " or a subtype."); + } + return new CachedCreator(method, method.getParameterTypes()[0]); + } + return null; + } + + private static Object parseDefault(String value, Class target) { + try { + if (target.equals(Boolean.TYPE) || target.equals(Boolean.class)) { + return value.isEmpty() ? false : Boolean.parseBoolean(value); + } + if (target.equals(Byte.TYPE) || target.equals(Byte.class)) { + var v = value.isEmpty() ? 0 : Integer.parseInt(value); + if (v < Byte.MIN_VALUE || v > Byte.MAX_VALUE) { + throw new DeserializationException( + "Default value out of range for byte"); + } + return (byte) v; + } + if (target.equals(Short.TYPE) || target.equals(Short.class)) { + var v = value.isEmpty() ? 0 : Integer.parseInt(value); + if (v < Short.MIN_VALUE || v > Short.MAX_VALUE) { + throw new DeserializationException( + "Default value out of range for short"); + } + return (short) v; + } + if (target.equals(Integer.TYPE) || target.equals(Integer.class)) { + return value.isEmpty() ? 0 : Integer.parseInt(value); + } + if (target.equals(Long.TYPE) || target.equals(Long.class)) { + return value.isEmpty() ? 0L : Long.parseLong(value); + } + if (target.equals(Float.TYPE) || target.equals(Float.class)) { + return value.isEmpty() ? 0.0f : Float.parseFloat(value); + } + if (target.equals(Double.TYPE) || target.equals(Double.class)) { + return value.isEmpty() ? 0.0d : Double.parseDouble(value); + } + if (target.equals(String.class)) { + return value; + } + } catch (NumberFormatException e) { + throw new DeserializationException( + "Invalid default '" + value + "' for type " + target.getSimpleName(), e); } - throw new ParameterNotFoundException( - "Constructor parameter " + index + " on class " + cls.getName() - + " is not annotated with MaxMindDbParameter."); + throw new DeserializationException( + "Defaults are only supported for primitives, boxed types, and String."); } private long nextValueOffset(long offset, int numberToSkip) diff --git a/src/main/java/com/maxmind/db/MaxMindDbCreator.java b/src/main/java/com/maxmind/db/MaxMindDbCreator.java new file mode 100644 index 00000000..bb1f3dad --- /dev/null +++ b/src/main/java/com/maxmind/db/MaxMindDbCreator.java @@ -0,0 +1,43 @@ +package com.maxmind.db; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * {@code MaxMindDbCreator} is an annotation that can be used to mark a static factory + * method or constructor that should be used to create an instance of a class from a + * decoded value when decoding a MaxMind DB file. + * + *

This is similar to Jackson's {@code @JsonCreator} annotation and is useful for + * types that need custom deserialization logic, such as enums with non-standard + * string representations.

+ * + *

Example usage:

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