Skip to content

Commit ed6699c

Browse files
committed
Automatically handle deserializing to temporal types
1 parent 3352b5e commit ed6699c

File tree

4 files changed

+178
-5
lines changed

4 files changed

+178
-5
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ CHANGELOG
3030
match field names in the database: for records, component names are used;
3131
for classes, Java parameter names are used (when compiled with
3232
`-parameters`). Annotations still take precedence when present.
33+
* Automatic java.time coercion: numeric epoch timestamps (seconds or
34+
milliseconds, auto‑detected) and ISO‑8601 strings are now deserialized into
35+
`Instant`, `LocalDate`, `LocalDateTime`, `OffsetDateTime`, and `ZonedDateTime`
36+
when requested by the target type. Epoch values are interpreted in UTC.
3337

3438
3.2.0 (2025-05-28)
3539
------------------

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<modelVersion>4.0.0</modelVersion>
44
<groupId>com.maxmind.db</groupId>
55
<artifactId>maxmind-db</artifactId>
6-
<version>3.2.0</version>
6+
<version>4.0.0</version>
77
<packaging>jar</packaging>
88
<name>MaxMind DB Reader</name>
99
<description>Reader for MaxMind DB</description>

src/main/java/com/maxmind/db/Decoder.java

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
import java.nio.charset.Charset;
1111
import java.nio.charset.CharsetDecoder;
1212
import java.nio.charset.StandardCharsets;
13+
import java.time.Instant;
14+
import java.time.LocalDate;
15+
import java.time.LocalDateTime;
16+
import java.time.OffsetDateTime;
17+
import java.time.ZoneId;
18+
import java.time.ZoneOffset;
19+
import java.time.ZonedDateTime;
1320
import java.util.ArrayList;
1421
import java.util.HashMap;
1522
import java.util.List;
@@ -161,11 +168,17 @@ private <T> Object decodeByType(
161168
case BOOLEAN:
162169
return Decoder.decodeBoolean(size);
163170
case UTF8_STRING:
164-
return this.decodeString(size);
171+
var s = this.decodeString(size);
172+
var temporalFromString = coerceTemporalFromString(s, cls);
173+
return temporalFromString != null ? temporalFromString : s;
165174
case DOUBLE:
166-
return this.decodeDouble(size);
175+
var d = this.decodeDouble(size);
176+
var temporalFromDouble = coerceTemporalFromDouble(d, cls);
177+
return temporalFromDouble != null ? temporalFromDouble : d;
167178
case FLOAT:
168-
return this.decodeFloat(size);
179+
var f = this.decodeFloat(size);
180+
var temporalFromFloat = coerceTemporalFromDouble(f, cls);
181+
return temporalFromFloat != null ? temporalFromFloat : f;
169182
case BYTES:
170183
return this.getByteArray(size);
171184
case UINT16:
@@ -176,14 +189,20 @@ private <T> Object decodeByType(
176189
return coerceFromInt(this.decodeInt32(size), cls);
177190
case UINT64:
178191
case UINT128:
179-
return this.decodeBigInteger(size);
192+
var bi = this.decodeBigInteger(size);
193+
var temporalFromBigInt = coerceTemporalFromBigInteger(bi, cls);
194+
return temporalFromBigInt != null ? temporalFromBigInt : bi;
180195
default:
181196
throw new InvalidDatabaseException(
182197
"Unknown or unexpected type: " + type.name());
183198
}
184199
}
185200

186201
private static Object coerceFromInt(int value, Class<?> target) {
202+
var temporal = coerceTemporalFromLong((long) value, target);
203+
if (temporal != null) {
204+
return temporal;
205+
}
187206
if (target.equals(Object.class)
188207
|| target.equals(Integer.TYPE)
189208
|| target.equals(Integer.class)) {
@@ -218,6 +237,10 @@ private static Object coerceFromInt(int value, Class<?> target) {
218237
}
219238

220239
private static Object coerceFromLong(long value, Class<?> target) {
240+
var temporal = coerceTemporalFromLong(value, target);
241+
if (temporal != null) {
242+
return temporal;
243+
}
221244
if (target.equals(Object.class) || target.equals(Long.TYPE) || target.equals(Long.class)) {
222245
return value;
223246
}
@@ -251,6 +274,108 @@ private static Object coerceFromLong(long value, Class<?> target) {
251274
return value;
252275
}
253276

277+
private static boolean isTemporalTarget(Class<?> target) {
278+
return target.equals(Instant.class)
279+
|| target.equals(LocalDate.class)
280+
|| target.equals(LocalDateTime.class)
281+
|| target.equals(OffsetDateTime.class)
282+
|| target.equals(ZonedDateTime.class);
283+
}
284+
285+
private static Object coerceTemporalFromLong(long value, Class<?> target) {
286+
if (!isTemporalTarget(target)) {
287+
return null;
288+
}
289+
// Heuristic: >= 10^12 -> milliseconds, else seconds
290+
boolean millis = Math.abs(value) >= 1_000_000_000_000L;
291+
Instant instant = millis ? Instant.ofEpochMilli(value) : Instant.ofEpochSecond(value);
292+
return temporalFromInstant(instant, target);
293+
}
294+
295+
private static Object coerceTemporalFromBigInteger(BigInteger value, Class<?> target) {
296+
if (!isTemporalTarget(target)) {
297+
return null;
298+
}
299+
var abs = value.abs();
300+
boolean millis = abs.compareTo(BigInteger.valueOf(1_000_000_000_000L)) >= 0;
301+
Instant instant = millis
302+
? Instant.ofEpochMilli(value.longValue())
303+
: Instant.ofEpochSecond(value.longValue());
304+
return temporalFromInstant(instant, target);
305+
}
306+
307+
private static Object coerceTemporalFromDouble(double value, Class<?> target) {
308+
if (!isTemporalTarget(target)) {
309+
return null;
310+
}
311+
long seconds = (long) Math.floor(value);
312+
long nanos = Math.round((value - seconds) * 1_000_000_000.0);
313+
Instant instant = Instant.ofEpochSecond(seconds, nanos);
314+
return temporalFromInstant(instant, target);
315+
}
316+
317+
private static Object coerceTemporalFromString(String s, Class<?> target) {
318+
if (!isTemporalTarget(target)) {
319+
return null;
320+
}
321+
try {
322+
// Try exact parser for target first, with fallbacks to Instant/Offset/Zoned
323+
if (target.equals(Instant.class)) {
324+
try {
325+
return Instant.parse(s);
326+
} catch (Exception e) {
327+
return OffsetDateTime.parse(s).toInstant();
328+
}
329+
}
330+
if (target.equals(LocalDate.class)) {
331+
try {
332+
return LocalDate.parse(s);
333+
} catch (Exception e) {
334+
return OffsetDateTime.parse(s).toLocalDate();
335+
}
336+
}
337+
if (target.equals(LocalDateTime.class)) {
338+
try {
339+
return LocalDateTime.parse(s);
340+
} catch (Exception e1) {
341+
try {
342+
return LocalDateTime.ofInstant(Instant.parse(s), ZoneOffset.UTC);
343+
} catch (Exception e2) {
344+
return OffsetDateTime.parse(s).toLocalDateTime();
345+
}
346+
}
347+
}
348+
if (target.equals(OffsetDateTime.class)) {
349+
return OffsetDateTime.parse(s);
350+
}
351+
if (target.equals(ZonedDateTime.class)) {
352+
return ZonedDateTime.parse(s);
353+
}
354+
} catch (Exception ignore) {
355+
return null;
356+
}
357+
return null;
358+
}
359+
360+
private static Object temporalFromInstant(Instant instant, Class<?> target) {
361+
if (target.equals(Instant.class)) {
362+
return instant;
363+
}
364+
if (target.equals(LocalDate.class)) {
365+
return LocalDateTime.ofInstant(instant, ZoneOffset.UTC).toLocalDate();
366+
}
367+
if (target.equals(LocalDateTime.class)) {
368+
return LocalDateTime.ofInstant(instant, ZoneOffset.UTC);
369+
}
370+
if (target.equals(OffsetDateTime.class)) {
371+
return OffsetDateTime.ofInstant(instant, ZoneOffset.UTC);
372+
}
373+
if (target.equals(ZonedDateTime.class)) {
374+
return ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"));
375+
}
376+
return null;
377+
}
378+
254379
private String decodeString(long size) throws CharacterCodingException {
255380
var oldLimit = buffer.limit();
256381
buffer.limit(buffer.position() + size);

src/test/java/com/maxmind/db/ReaderTest.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.time.Instant;
2323
import java.time.LocalDate;
2424
import java.time.ZoneOffset;
25+
import java.time.LocalDateTime;
2526
import java.util.ArrayList;
2627
import java.util.Arrays;
2728
import java.util.HashMap;
@@ -534,6 +535,49 @@ public void testDecodingTypesPointerDecoderFile(int chunkSize) throws IOExceptio
534535
this.testDecodingTypesIntoModelWithList(this.testReader);
535536
}
536537

538+
static class TemporalFromUint16Model {
539+
Instant when;
540+
541+
@MaxMindDbConstructor
542+
public TemporalFromUint16Model(
543+
@MaxMindDbParameter(name = "uint16") Instant when
544+
) {
545+
this.when = when;
546+
}
547+
}
548+
549+
static class TemporalFromUint32LdtModel {
550+
LocalDateTime when;
551+
552+
@MaxMindDbConstructor
553+
public TemporalFromUint32LdtModel(
554+
@MaxMindDbParameter(name = "uint32") LocalDateTime when
555+
) {
556+
this.when = when;
557+
}
558+
}
559+
560+
@ParameterizedTest
561+
@MethodSource("chunkSizes")
562+
public void testTemporalCoercionFromNumeric(int chunkSize) throws IOException {
563+
this.testReader = new Reader(getFile("MaxMind-DB-test-decoder.mmdb"), chunkSize);
564+
565+
var modelInstant = this.testReader.get(
566+
InetAddress.getByName("::1.1.1.0"),
567+
TemporalFromUint16Model.class
568+
);
569+
assertEquals(Instant.ofEpochSecond(100), modelInstant.when);
570+
571+
var modelLdt = this.testReader.get(
572+
InetAddress.getByName("::1.1.1.0"),
573+
TemporalFromUint32LdtModel.class
574+
);
575+
assertEquals(
576+
LocalDateTime.ofInstant(Instant.ofEpochSecond(268435456L), ZoneOffset.UTC),
577+
modelLdt.when
578+
);
579+
}
580+
537581
private void testDecodingTypes(Reader reader, boolean booleanValue) throws IOException {
538582
var record = reader.get(InetAddress.getByName("::1.1.1.0"), Map.class);
539583

0 commit comments

Comments
 (0)