From 5f7941c3bd11145165ed511af47690d3c92f4cef Mon Sep 17 00:00:00 2001 From: Johnny Schmidt Date: Sun, 4 Aug 2024 18:03:05 -0700 Subject: [PATCH] Avro time converter respects timezone --- .../converter/util/DateTimeUtils.java | 31 ++++-- .../converter/DateTimeUtilsTest.java | 2 + .../field_conversion_failure_listener.json | 100 +++++++++++++++--- 3 files changed, 110 insertions(+), 23 deletions(-) diff --git a/converter/src/main/java/tech/allegro/schema/json2avro/converter/util/DateTimeUtils.java b/converter/src/main/java/tech/allegro/schema/json2avro/converter/util/DateTimeUtils.java index d14ff6e..9fee9fc 100644 --- a/converter/src/main/java/tech/allegro/schema/json2avro/converter/util/DateTimeUtils.java +++ b/converter/src/main/java/tech/allegro/schema/json2avro/converter/util/DateTimeUtils.java @@ -1,11 +1,6 @@ package tech.allegro.schema.json2avro.converter.util; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; +import java.time.*; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -67,14 +62,28 @@ public static Long getMicroSeconds(String jsonTime) { return Long.valueOf(jsonTime); } try { - LocalTime time = LocalTime.parse(jsonTime, TIME_FORMATTER); - nanoOfDay = time.toNanoOfDay(); - } catch (DateTimeParseException e) { + // This will only succeed if the time has a timezone. + OffsetTime time = OffsetTime.parse(jsonTime, TIME_FORMATTER); + nanoOfDay = time.toLocalTime().toNanoOfDay(); + + // Apply the offset, wrapping around midnight. + nanoOfDay -= time.getOffset().getTotalSeconds() * 1_000_000_000L; + if (nanoOfDay < 0) { + nanoOfDay += 24 * 60 * 60 * 1_000_000_000L; + } + } catch (DateTimeException e) { try { - LocalTime time = LocalTime.parse(jsonTime, DATE_TIME_FORMATTER); + // Works on any correctly formatted time without a timezone + LocalTime time = LocalTime.parse(jsonTime, TIME_FORMATTER); nanoOfDay = time.toNanoOfDay(); } catch (DateTimeParseException ex) { - // no logging since it may generate too much noise + try { + // Catchall + LocalTime time = LocalTime.parse(jsonTime, DATE_TIME_FORMATTER); + nanoOfDay = time.toNanoOfDay(); + } catch (DateTimeParseException exc) { + // no logging since it may generate too much noise + } } } return nanoOfDay == null ? null : nanoOfDay / 1000; diff --git a/converter/src/test/java/tech/allegro/schema/json2avro/converter/DateTimeUtilsTest.java b/converter/src/test/java/tech/allegro/schema/json2avro/converter/DateTimeUtilsTest.java index 180cc05..e52ce88 100644 --- a/converter/src/test/java/tech/allegro/schema/json2avro/converter/DateTimeUtilsTest.java +++ b/converter/src/test/java/tech/allegro/schema/json2avro/converter/DateTimeUtilsTest.java @@ -51,6 +51,8 @@ public void testDateTimeConversion() { assertEquals(3660000000L, getMicroSeconds("01:01")); assertEquals(44581541000L, getMicroSeconds("12:23:01.541")); assertEquals(44581541214L, getMicroSeconds("12:23:01.541214")); + assertEquals(39600000000L, getMicroSeconds("12:00:00.000000+01:00")); + assertEquals(84600000000L, getMicroSeconds("03:30:00.000000+04:00")); } @Test diff --git a/converter/src/test/resources/field_conversion_failure_listener.json b/converter/src/test/resources/field_conversion_failure_listener.json index 6648ad6..f24c162 100644 --- a/converter/src/test/resources/field_conversion_failure_listener.json +++ b/converter/src/test/resources/field_conversion_failure_listener.json @@ -59,6 +59,17 @@ "int" ], "default": null + }, + { + "name": "TIME_WITH_TIMEZONE", + "type": [ + "null", + { + "type": "long", + "logicalType": "time-micros" + } + ], + "default": null } ] } @@ -81,7 +92,8 @@ }, "data": { "name": "Bob", - "id": 1 + "id": 1, + "time_with_timezone": "04:00:00+05:30" } }, { @@ -90,7 +102,8 @@ }, "data": { "name": "Alice", - "id": 2 + "id": 2, + "time_with_timezone": "12:00:00" } } ], @@ -107,7 +120,8 @@ }, "DATA": { "NAME": "Bob", - "ID": 1 + "ID": 1, + "TIME_WITH_TIMEZONE": 81000000000 } }, { @@ -116,7 +130,8 @@ }, "DATA": { "NAME": "Alice", - "ID": 2 + "ID": 2, + "TIME_WITH_TIMEZONE": 43200000000 } } ] @@ -134,7 +149,8 @@ "o", "b" ], - "id": 1 + "id": 1, + "time_with_timezone": "04:00:00.000+05:30" } }, { @@ -143,7 +159,8 @@ }, "data": { "name": "Alice", - "id": 2 + "id": 2, + "time_with_timezone": "12:00:00.000" } } ], @@ -160,7 +177,8 @@ }, "DATA": { "NAME": null, - "ID": 1 + "ID": 1, + "TIME_WITH_TIMEZONE": 81000000000 } }, { @@ -169,7 +187,8 @@ }, "DATA": { "NAME": "Alice", - "ID": 2 + "ID": 2, + "TIME_WITH_TIMEZONE": 43200000000 } } ] @@ -189,7 +208,8 @@ }, "data": { "name": 808, - "id": 2 + "id": 2, + "time_with_timezone": "04:00:00+05:30" } }, { @@ -198,7 +218,8 @@ }, "data": { "name": "Alice", - "id": 2 + "id": 2, + "time_with_timezone": "12:00:00Z" } } ], @@ -220,7 +241,8 @@ }, "DATA": { "NAME": null, - "ID": 2 + "ID": 2, + "TIME_WITH_TIMEZONE": 81000000000 } }, { @@ -229,10 +251,64 @@ }, "DATA": { "NAME": "Alice", - "ID": 2 + "ID": 2, + "TIME_WITH_TIMEZONE": 43200000000 } } ] + }, + { + "name": "record with malformed timezone", + "records": [ + { + "meta": { + "changes": [] + }, + "data": { + "name": "Bob", + "id": 1, + "time_with_timezone": "04:00:00+05:30" + } + }, + { + "meta": { + "changes": [] + }, + "data": { + "name": "Alice", + "id": 2, + "time_with_timezone": "12:00:00-3:00" + } + } + ], + "expectedOutput": [ + { + "META": { + "CHANGES": [] + }, + "DATA": { + "NAME": "Bob", + "ID": 1, + "TIME_WITH_TIMEZONE": 81000000000 + } + }, + { + "META": { + "CHANGES": [ + { + "FIELD": "time_with_timezone", + "CHANGE": "NULLED", + "REASON": "Could not evaluate union, field TIME_WITH_TIMEZONE is expected to be one of these: NULL, LONG. If this is a complex type, check if offending field (path: DATA.TIME_WITH_TIMEZONE) adheres to schema: 12:00:00-3:00" + } + ] + }, + "DATA": { + "NAME": "Alice", + "ID": 2, + "TIME_WITH_TIMEZONE": null + } + } + ] } ] } \ No newline at end of file