Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
CHANGELOG
=========

4.0.2
------------------

* Fixed a bug where enums with `@MaxMindDbCreator` would throw
`ConstructorNotFoundException` when the data was stored via a pointer
in the database. This commonly occurred with deduplicated data in larger
databases. Reported by Fabrice Bacchella. GitHub #644 in GeoIP2-java.
* Improved error messages when constructor invocation fails. The error now
correctly identifies null values being passed to primitive parameters
instead of reporting misleading boxed/primitive type mismatches.

4.0.1 (2025-12-02)
------------------

Expand Down
79 changes: 70 additions & 9 deletions src/main/java/com/maxmind/db/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class Decoder {

private static final int[] POINTER_VALUE_OFFSETS = {0, 0, 1 << 11, (1 << 19) + (1 << 11), 0};

// Sentinel to cache "no creator method exists" to avoid repeated method scanning
private static final CachedCreator NO_CREATOR = new CachedCreator(null, null);

private final NodeCache cache;

private final long pointerBase;
Expand Down Expand Up @@ -184,10 +187,17 @@ private boolean requiresLookupContext(Class<?> cls) {
|| cls.equals(Object.class)
|| Map.class.isAssignableFrom(cls)
|| List.class.isAssignableFrom(cls)
|| cls.isEnum()
|| isSimpleType(cls)) {
return false;
}

// Non-enum classes with @MaxMindDbCreator don't require lookup context
// since they just convert simple values (strings, booleans, etc.)
if (getCachedCreator(cls) != null) {
return false;
}

var cached = getCachedConstructor(cls);
if (cached == null) {
cached = loadConstructorMetadata(cls);
Expand Down Expand Up @@ -696,16 +706,66 @@ private <T> Object decodeMapIntoObject(int size, Class<T> cls)
var sbErrors = new StringBuilder();
for (var key : parameterIndexes.keySet()) {
var index = parameterIndexes.get(key);
if (parameters[index] != null
&& !parameters[index].getClass().isAssignableFrom(parameterTypes[index])) {
sbErrors.append(" argument type mismatch in " + key + " MMDB Type: "
+ parameters[index].getClass().getCanonicalName()
+ " Java Type: " + parameterTypes[index].getCanonicalName());
if (parameters[index] == null && parameterTypes[index].isPrimitive()) {
sbErrors.append(" null value for primitive ")
.append(parameterTypes[index].getName())
.append(" parameter '").append(key).append("'.");
} else if (parameters[index] != null
&& !isAssignableType(parameters[index].getClass(), parameterTypes[index])) {
sbErrors.append(" argument type mismatch in ").append(key)
.append(" MMDB Type: ")
.append(parameters[index].getClass().getCanonicalName())
.append(" Java Type: ")
.append(parameterTypes[index].getCanonicalName())
.append(".");
}
}
throw new DeserializationException(
"Error creating object of type: " + cls.getSimpleName() + " - " + sbErrors, e);
"Error creating object of type: " + cls.getSimpleName() + " -" + sbErrors, e);
}
}

/**
* Checks if an actual type can be assigned to an expected type,
* accounting for primitive/boxed equivalents (e.g., Boolean to boolean).
*/
private static boolean isAssignableType(Class<?> actual, Class<?> expected) {
if (expected.isAssignableFrom(actual)) {
return true;
}
// Check primitive/boxed equivalents for auto-unboxing
if (expected.isPrimitive()) {
return actual.equals(boxedType(expected));
}
return false;
}

private static Class<?> boxedType(Class<?> primitive) {
if (primitive == boolean.class) {
return Boolean.class;
}
if (primitive == byte.class) {
return Byte.class;
}
if (primitive == short.class) {
return Short.class;
}
if (primitive == int.class) {
return Integer.class;
}
if (primitive == long.class) {
return Long.class;
}
if (primitive == float.class) {
return Float.class;
}
if (primitive == double.class) {
return Double.class;
}
if (primitive == char.class) {
return Character.class;
}
return primitive;
Comment on lines +744 to +768
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The boxedType method uses a chain of if statements to map primitive types to their boxed equivalents. Consider using a switch expression for better readability and maintainability, similar to the pattern used in ConnectionTypeEnum.fromString().

Suggested change
if (primitive == boolean.class) {
return Boolean.class;
}
if (primitive == byte.class) {
return Byte.class;
}
if (primitive == short.class) {
return Short.class;
}
if (primitive == int.class) {
return Integer.class;
}
if (primitive == long.class) {
return Long.class;
}
if (primitive == float.class) {
return Float.class;
}
if (primitive == double.class) {
return Double.class;
}
if (primitive == char.class) {
return Character.class;
}
return primitive;
return switch (primitive.getName()) {
case "boolean" -> Boolean.class;
case "byte" -> Byte.class;
case "short" -> Short.class;
case "int" -> Integer.class;
case "long" -> Long.class;
case "float" -> Float.class;
case "double" -> Double.class;
case "char" -> Character.class;
default -> primitive;
};

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love hardcoding the string values like this. I am going to reject this approach. Another option would be a static map, but the original seems fine, if a bit verbose.

}

private boolean shouldInstantiateFromContext(Class<?> parameterType) {
Expand Down Expand Up @@ -960,14 +1020,15 @@ private Object convertValue(Object value, Class<?> targetType) {

private CachedCreator getCachedCreator(Class<?> cls) {
CachedCreator cached = this.creators.get(cls);
if (cached == NO_CREATOR) {
return null; // Known to have no creator
}
if (cached != null) {
return cached;
}

CachedCreator creator = findCreatorMethod(cls);
if (creator != null) {
this.creators.putIfAbsent(cls, creator);
}
this.creators.putIfAbsent(cls, creator != null ? creator : NO_CREATOR);
return creator;
}

Expand Down
146 changes: 144 additions & 2 deletions src/test/java/com/maxmind/db/ReaderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -493,8 +493,8 @@ public void testNoIpV4SearchTreeStream(int chunkSizes) throws IOException {

private void testNoIpV4SearchTree(Reader reader) throws IOException {

assertEquals("::0/64", reader.get(InetAddress.getByName("1.1.1.1"), String.class));
assertEquals("::0/64", reader.get(InetAddress.getByName("192.1.1.1"), String.class));
assertEquals("::/64", reader.get(InetAddress.getByName("1.1.1.1"), String.class));
assertEquals("::/64", reader.get(InetAddress.getByName("192.1.1.1"), String.class));
}

@ParameterizedTest
Expand Down Expand Up @@ -2042,6 +2042,148 @@ private void testIpV6(Reader reader, File file) throws IOException {
}
}

// =========================================================================
// Tests for enum with @MaxMindDbCreator when data is stored via pointer
// See: https://github.com/maxmind/GeoIP2-java/issues/644
// =========================================================================

/**
* Enum with @MaxMindDbCreator for converting string values.
* This simulates how ConnectionType enum works in geoip2.
*/
enum ConnectionTypeEnum {
DIALUP("Dialup"),
CABLE_DSL("Cable/DSL"),
CORPORATE("Corporate"),
CELLULAR("Cellular"),
SATELLITE("Satellite"),
UNKNOWN("Unknown");

private final String name;

ConnectionTypeEnum(String name) {
this.name = name;
}

@MaxMindDbCreator
public static ConnectionTypeEnum fromString(String s) {
if (s == null) {
return UNKNOWN;
}
return switch (s) {
case "Dialup" -> DIALUP;
case "Cable/DSL" -> CABLE_DSL;
case "Corporate" -> CORPORATE;
case "Cellular" -> CELLULAR;
case "Satellite" -> SATELLITE;
default -> UNKNOWN;
};
}
}

/**
* Model class that uses the ConnectionTypeEnum for the connection_type field.
*/
static class TraitsModel {
ConnectionTypeEnum connectionType;

@MaxMindDbConstructor
public TraitsModel(
@MaxMindDbParameter(name = "connection_type")
ConnectionTypeEnum connectionType
) {
this.connectionType = connectionType;
}
}

/**
* Top-level model for Enterprise database records.
*/
static class EnterpriseModel {
TraitsModel traits;

@MaxMindDbConstructor
public EnterpriseModel(
@MaxMindDbParameter(name = "traits")
TraitsModel traits
) {
this.traits = traits;
}
}

/**
* This test passes because IP 74.209.24.0 has connection_type stored inline.
*/
@ParameterizedTest
@MethodSource("chunkSizes")
public void testEnumCreatorWithInlineData(int chunkSize) throws IOException {
try (var reader = new Reader(getFile("GeoIP2-Enterprise-Test.mmdb"), chunkSize)) {
var ip = InetAddress.getByName("74.209.24.0");
var result = reader.get(ip, EnterpriseModel.class);
assertNotNull(result);
assertNotNull(result.traits);
assertEquals(ConnectionTypeEnum.CABLE_DSL, result.traits.connectionType);
}
}

/**
* This test verifies that enums with @MaxMindDbCreator work correctly when
* the data is stored via a pointer (common for deduplication in databases).
*
* <p>Previously, this would throw ConstructorNotFoundException because
* requiresLookupContext() called loadConstructorMetadata() before checking
* for creator methods.
*/
@ParameterizedTest
@MethodSource("chunkSizes")
public void testEnumCreatorWithPointerData(int chunkSize) throws IOException {
try (var reader = new Reader(getFile("GeoIP2-Enterprise-Test.mmdb"), chunkSize)) {
var ip = InetAddress.getByName("89.160.20.112");
var result = reader.get(ip, EnterpriseModel.class);
assertNotNull(result);
assertNotNull(result.traits);
assertEquals(ConnectionTypeEnum.CORPORATE, result.traits.connectionType);
}
}

// Model class with primitive double field for testing null-to-primitive error messages
static class IpRiskModelWithPrimitive {
public final double ipRisk;

@MaxMindDbConstructor
public IpRiskModelWithPrimitive(
@MaxMindDbParameter(name = "ip_risk") double ipRisk
) {
this.ipRisk = ipRisk;
}
}

/**
* Tests that error messages correctly report null-to-primitive issues instead of
* misleading boxed/primitive type mismatch messages.
*
* <p>IP 11.1.2.3 in the IP Risk test database doesn't have an ip_risk field.
* When deserializing to a model with a primitive double, the error message should
* correctly identify "null value for primitive double" rather than reporting
* misleading Boolean/boolean mismatches.
*/
@ParameterizedTest
@MethodSource("chunkSizes")
public void testNullToPrimitiveErrorMessage(int chunkSize) throws IOException {
try (var reader = new Reader(getFile("GeoIP2-IP-Risk-Test.mmdb"), chunkSize)) {
var ip = InetAddress.getByName("11.1.2.3");

var exception = assertThrows(DeserializationException.class,
() -> reader.get(ip, IpRiskModelWithPrimitive.class));

// Error message should mention null value for primitive, not type mismatch
assertTrue(exception.getMessage().contains("null value for primitive double"),
"Error message should identify null-to-primitive issue: " + exception.getMessage());
assertTrue(exception.getMessage().contains("ip_risk"),
"Error message should name the problematic parameter: " + exception.getMessage());
}
}

static File getFile(String name) {
return new File(ReaderTest.class.getResource("/maxmind-db/test-data/" + name).getFile());
}
Expand Down
2 changes: 1 addition & 1 deletion src/test/resources/maxmind-db
Submodule maxmind-db updated 83 files
+17 −0 .github/dependabot.yml
+53 −0 .github/workflows/codeql-analysis.yml
+42 −0 .github/workflows/go.yml
+22 −0 .github/workflows/golangci-lint.yml
+23 −0 .github/workflows/zizmor.yml
+1 −0 .gitignore
+750 −0 .golangci.yml
+0 −11 .perltidyallrc
+0 −5 .tidyallrc
+0 −4 LICENSE
+202 −0 LICENSE-APACHE
+17 −0 LICENSE-MIT
+1 −2 MaxMind-DB-spec.md
+8 −1 README.md
+68 −0 cmd/write-test-data/main.go
+13 −0 go.mod
+16 −0 go.sum
+0 −12 perltidyrc
+178 −0 pkg/writer/decoder.go
+188 −0 pkg/writer/geoip2.go
+39 −0 pkg/writer/ip.go
+246 −0 pkg/writer/maxmind.go
+73 −0 pkg/writer/nestedstructures.go
+61 −0 pkg/writer/writer.go
+175 −0 source-data/GeoIP-Anonymous-Plus-Test.json
+6 −0 source-data/GeoIP2-Anonymous-IP-Test.json
+13,482 −12,831 source-data/GeoIP2-City-Test.json
+35 −10 source-data/GeoIP2-Connection-Type-Test.json
+145 −25 source-data/GeoIP2-Country-Test.json
+5 −0 source-data/GeoIP2-Domain-Test.json
+432 −6 source-data/GeoIP2-Enterprise-Test.json
+91 −0 source-data/GeoIP2-IP-Risk-Test.json
+10 −0 source-data/GeoIP2-ISP-Test.json
+296 −0 source-data/GeoIP2-Precision-Enterprise-Sandbox-Test.json
+1,348 −143 source-data/GeoIP2-Precision-Enterprise-Test.json
+15 −0 source-data/GeoIP2-Static-IP-Score-Test.json
+18 −0 source-data/GeoIP2-User-Count-Test.json
+20 −36 source-data/GeoLite2-ASN-Test.json
+168 −3 source-data/GeoLite2-City-Test.json
+92 −3 source-data/GeoLite2-Country-Test.json
+0 −15 source-data/README
+ test-data/GeoIP-Anonymous-Plus-Test.mmdb
+ test-data/GeoIP-Anonymous-Plus.mmdb
+ test-data/GeoIP2-Anonymous-IP-Test.mmdb
+ test-data/GeoIP2-City-Shield-Test.mmdb
+ test-data/GeoIP2-City-Test-Broken-Double-Format.mmdb
+ test-data/GeoIP2-City-Test-Invalid-Node-Count.mmdb
+ test-data/GeoIP2-City-Test.mmdb
+ test-data/GeoIP2-Connection-Type-Test.mmdb
+ test-data/GeoIP2-Country-Shield-Test.mmdb
+ test-data/GeoIP2-Country-Test.mmdb
+ test-data/GeoIP2-DensityIncome-Test.mmdb
+ test-data/GeoIP2-Domain-Test.mmdb
+ test-data/GeoIP2-Enterprise-Shield-Test.mmdb
+ test-data/GeoIP2-Enterprise-Test.mmdb
+ test-data/GeoIP2-IP-Risk-Test.mmdb
+ test-data/GeoIP2-ISP-Test.mmdb
+ test-data/GeoIP2-Precision-Enterprise-Shield-Test.mmdb
+ test-data/GeoIP2-Precision-Enterprise-Test.mmdb
+ test-data/GeoIP2-Static-IP-Score-Test.mmdb
+ test-data/GeoIP2-User-Count-Test.mmdb
+ test-data/GeoLite2-ASN-Test.mmdb
+ test-data/GeoLite2-City-Test.mmdb
+ test-data/GeoLite2-Country-Test.mmdb
+ test-data/MaxMind-DB-no-ipv4-search-tree.mmdb
+ test-data/MaxMind-DB-string-value-entries.mmdb
+ test-data/MaxMind-DB-test-broken-pointers-24.mmdb
+ test-data/MaxMind-DB-test-broken-search-tree-24.mmdb
+ test-data/MaxMind-DB-test-decoder.mmdb
+ test-data/MaxMind-DB-test-ipv4-24.mmdb
+ test-data/MaxMind-DB-test-ipv4-28.mmdb
+ test-data/MaxMind-DB-test-ipv4-32.mmdb
+ test-data/MaxMind-DB-test-ipv6-24.mmdb
+ test-data/MaxMind-DB-test-ipv6-28.mmdb
+ test-data/MaxMind-DB-test-ipv6-32.mmdb
+ test-data/MaxMind-DB-test-metadata-pointers.mmdb
+ test-data/MaxMind-DB-test-mixed-24.mmdb
+ test-data/MaxMind-DB-test-mixed-28.mmdb
+ test-data/MaxMind-DB-test-mixed-32.mmdb
+ test-data/MaxMind-DB-test-nested.mmdb
+ test-data/MaxMind-DB-test-pointer-decoder.mmdb
+28 −12 test-data/README.md
+0 −693 test-data/write-test-data.pl
Loading