diff --git a/avro/README.md b/avro/README.md index 6ffcb7a74..2ceb76490 100644 --- a/avro/README.md +++ b/avro/README.md @@ -111,6 +111,40 @@ byte[] avroData = mapper.writer(schema) and that's about it, for now. +## Java Time Support +Serialization and deserialization support for limited set of `java.time` classes to Avro with [logical type](http://avro.apache.org/docs/current/spec.html#Logical+Types) is provided by `AvroJavaTimeModule`. + +This module is to be used either: +- Instead of Java 8 date/time module (`com.fasterxml.jackson.datatype.jsr310.JavaTimeModule`) or +- to override Java 8 date/time module and for that, module must be registered AFTER Java 8 date/time module (last registration wins). + +```java +AvroMapper mapper = AvroMapper.builder() + .addModule(new AvroJavaTimeModule()) + .build(); +``` + +#### Note +Please note that time zone information is lost at serialization. Serialized values represent point in time, +independent of a particular time zone or calendar. Upon reading a value back time instant is reconstructed but not the original time zone. + +#### Supported java.time types: + +Supported java.time types with Avro schema. + +| Type | Avro schema +| ------------------------------ | ------------- +| `java.time.OffsetDateTime` | `{"type": "long", "logicalType": "timestamp-millis"}` +| `java.time.ZonedDateTime` | `{"type": "long", "logicalType": "timestamp-millis"}` +| `java.time.Instant` | `{"type": "long", "logicalType": "timestamp-millis"}` +| `java.time.LocalDate` | `{"type": "int", "logicalType": "date"}` +| `java.time.LocalTime` | `{"type": "int", "logicalType": "time-millis"}` +| `java.time.LocalDateTime` | `{"type": "long", "logicalType": "local-timestamp-millis"}` + +#### Precision + +Avro supports milliseconds and microseconds precision for date and time related LogicalTypes, but this module only supports millisecond precision. + ## Generating Avro Schema from POJO definition Ok but wait -- you do not have to START with an Avro Schema. This module can diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule.java new file mode 100644 index 000000000..d901852d6 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule.java @@ -0,0 +1,50 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.dataformat.avro.PackageVersion; +import com.fasterxml.jackson.dataformat.avro.jsr310.deser.AvroInstantDeserializer; +import com.fasterxml.jackson.dataformat.avro.jsr310.deser.AvroLocalDateDeserializer; +import com.fasterxml.jackson.dataformat.avro.jsr310.deser.AvroLocalDateTimeDeserializer; +import com.fasterxml.jackson.dataformat.avro.jsr310.deser.AvroLocalTimeDeserializer; +import com.fasterxml.jackson.dataformat.avro.jsr310.ser.AvroInstantSerializer; +import com.fasterxml.jackson.dataformat.avro.jsr310.ser.AvroLocalDateSerializer; +import com.fasterxml.jackson.dataformat.avro.jsr310.ser.AvroLocalDateTimeSerializer; +import com.fasterxml.jackson.dataformat.avro.jsr310.ser.AvroLocalTimeSerializer; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; + +/** + * A module that installs a collection of serializers and deserializers for java.time classes. + * + * This module is to be used either: + * - Instead of Java 8 date/time module (com.fasterxml.jackson.datatype.jsr310.JavaTimeModule) or + * - to override Java 8 date/time module and for that, module must be registered AFTER Java 8 date/time module. + */ +public class AvroJavaTimeModule extends SimpleModule { + + private static final long serialVersionUID = 1L; + + public AvroJavaTimeModule() { + super(AvroJavaTimeModule.class.getName(), PackageVersion.VERSION); + + addSerializer(Instant.class, AvroInstantSerializer.INSTANT); + addSerializer(OffsetDateTime.class, AvroInstantSerializer.OFFSET_DATE_TIME); + addSerializer(ZonedDateTime.class, AvroInstantSerializer.ZONED_DATE_TIME); + addSerializer(LocalDateTime.class, AvroLocalDateTimeSerializer.INSTANCE); + addSerializer(LocalDate.class, AvroLocalDateSerializer.INSTANCE); + addSerializer(LocalTime.class, AvroLocalTimeSerializer.INSTANCE); + + addDeserializer(Instant.class, AvroInstantDeserializer.INSTANT); + addDeserializer(OffsetDateTime.class, AvroInstantDeserializer.OFFSET_DATE_TIME); + addDeserializer(ZonedDateTime.class, AvroInstantDeserializer.ZONED_DATE_TIME); + addDeserializer(LocalDateTime.class, AvroLocalDateTimeDeserializer.INSTANCE); + addDeserializer(LocalDate.class, AvroLocalDateDeserializer.INSTANCE); + addDeserializer(LocalTime.class, AvroLocalTimeDeserializer.INSTANCE); + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroInstantDeserializer.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroInstantDeserializer.java new file mode 100644 index 000000000..9a2d6e986 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroInstantDeserializer.java @@ -0,0 +1,50 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310.deser; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.Temporal; +import java.util.function.BiFunction; + +/** + * Deserializer for variants of java.time classes (Instant, OffsetDateTime, ZonedDateTime) from an integer value. + * + * Deserialized value represents an instant on the global timeline, independent of a particular time zone or + * calendar, with a precision of one millisecond from the unix epoch, 1 January 1970 00:00:00.000 UTC. + * Time zone information is lost at serialization. Time zone data types receives time zone from deserialization context. + * + * Deserialization from string is not supported. + * + * @param The type of a instant class that can be deserialized. + */ +public class AvroInstantDeserializer extends AvroJavaTimeDeserializerBase { + + private static final long serialVersionUID = 1L; + + public static final AvroInstantDeserializer INSTANT = + new AvroInstantDeserializer<>(Instant.class, (instant, zoneID) -> instant); + + public static final AvroInstantDeserializer OFFSET_DATE_TIME = + new AvroInstantDeserializer<>(OffsetDateTime.class, OffsetDateTime::ofInstant); + + public static final AvroInstantDeserializer ZONED_DATE_TIME = + new AvroInstantDeserializer<>(ZonedDateTime.class, ZonedDateTime::ofInstant); + + protected final BiFunction fromInstant; + + protected AvroInstantDeserializer(Class supportedType, BiFunction fromInstant) { + super(supportedType); + this.fromInstant = fromInstant; + } + + @Override + protected T fromLong(long longValue, ZoneId defaultZoneId) { + /** + * Number of milliseconds, independent of a particular time zone or calendar, + * from 1 January 1970 00:00:00.000 UTC. + */ + return fromInstant.apply(Instant.ofEpochMilli(longValue), defaultZoneId); + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroJavaTimeDeserializerBase.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroJavaTimeDeserializerBase.java new file mode 100644 index 000000000..130f9a335 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroJavaTimeDeserializerBase.java @@ -0,0 +1,36 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310.deser; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer; +import com.fasterxml.jackson.databind.type.LogicalType; + +import java.io.IOException; +import java.time.ZoneId; + +import static com.fasterxml.jackson.core.JsonToken.VALUE_NUMBER_INT; + +public abstract class AvroJavaTimeDeserializerBase extends StdScalarDeserializer { + + protected AvroJavaTimeDeserializerBase(Class supportedType) { + super(supportedType); + } + + @Override + public LogicalType logicalType() { + return LogicalType.DateTime; + } + + @SuppressWarnings("unchecked") + @Override + public T deserialize(JsonParser p, DeserializationContext context) throws IOException { + if (p.getCurrentToken() == VALUE_NUMBER_INT) { + final ZoneId defaultZoneId = context.getTimeZone().toZoneId().normalized(); + return fromLong(p.getLongValue(), defaultZoneId); + } else { + return (T) context.handleUnexpectedToken(_valueClass, p); + } + } + + protected abstract T fromLong(long longValue, ZoneId defaultZoneId); +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroLocalDateDeserializer.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroLocalDateDeserializer.java new file mode 100644 index 000000000..21fd7c20c --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroLocalDateDeserializer.java @@ -0,0 +1,31 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310.deser; + +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * Deserializer for {@link LocalDate} from and integer value. + * + * Deserialized value represents number of days from the unix epoch, 1 January 1970. + * + * Deserialization from string is not supported. + */ +public class AvroLocalDateDeserializer extends AvroJavaTimeDeserializerBase { + + private static final long serialVersionUID = 1L; + + public static final AvroLocalDateDeserializer INSTANCE = new AvroLocalDateDeserializer(); + + protected AvroLocalDateDeserializer() { + super(LocalDate.class); + } + + @Override + protected LocalDate fromLong(long longValue, ZoneId defaultZoneId) { + /** + * Number of days from the unix epoch, 1 January 1970.. + */ + return LocalDate.ofEpochDay(longValue); + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroLocalDateTimeDeserializer.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroLocalDateTimeDeserializer.java new file mode 100644 index 000000000..e2318fae2 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroLocalDateTimeDeserializer.java @@ -0,0 +1,35 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310.deser; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; + +/** + * Deserializer for {@link LocalDateTime} from an integer value. + * + * Deserialized value represents timestamp in a local timezone, regardless of what specific time zone + * is considered local, with a precision of one millisecond from 1 January 1970 00:00:00.000. + * + * Deserialization from string is not supported. + */ +public class AvroLocalDateTimeDeserializer extends AvroJavaTimeDeserializerBase { + + private static final long serialVersionUID = 1L; + + public static final AvroLocalDateTimeDeserializer INSTANCE = new AvroLocalDateTimeDeserializer(); + + protected AvroLocalDateTimeDeserializer() { + super(LocalDateTime.class); + } + + @Override + protected LocalDateTime fromLong(long longValue, ZoneId defaultZoneId) { + /** + * Number of milliseconds in a local timezone, regardless of what specific time zone is considered local, + * from 1 January 1970 00:00:00.000. + */ + return LocalDateTime.ofInstant(Instant.ofEpochMilli(longValue), ZoneOffset.ofTotalSeconds(0)); + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroLocalTimeDeserializer.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroLocalTimeDeserializer.java new file mode 100644 index 000000000..1d98a3890 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/deser/AvroLocalTimeDeserializer.java @@ -0,0 +1,33 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310.deser; + +import java.time.LocalTime; +import java.time.ZoneId; + +/** + * Deserializer for {@link LocalTime} from an integer value. + * + * Deserialized value represents time of day, with no reference to a particular calendar, + * time zone or date, where the int stores the number of milliseconds after midnight, 00:00:00.000. + * + * Deserialization from string is not supported. + */ +public class AvroLocalTimeDeserializer extends AvroJavaTimeDeserializerBase { + + private static final long serialVersionUID = 1L; + + public static final AvroLocalTimeDeserializer INSTANCE = new AvroLocalTimeDeserializer(); + + protected AvroLocalTimeDeserializer() { + super(LocalTime.class); + } + + @Override + protected LocalTime fromLong(long longValue, ZoneId defaultZoneId) { + /** + * Number of milliseconds, with no reference to a particular calendar, time zone or date, after + * midnight, 00:00:00.000. + */ + return LocalTime.ofNanoOfDay(longValue * 1000_000L); + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroInstantSerializer.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroInstantSerializer.java new file mode 100644 index 000000000..a816a37cd --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroInstantSerializer.java @@ -0,0 +1,76 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310.ser; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor; +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer; + +import java.io.IOException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.temporal.Temporal; +import java.util.function.Function; + +/** + * Serializer for variants of java.time classes (Instant, OffsetDateTime, ZonedDateTime) into long value. + * + * Serialized value represents an instant on the global timeline, independent of a particular time zone or + * calendar, with a precision of one millisecond from the unix epoch, 1 January 1970 00:00:00.000 UTC. + * Please note that time zone information gets lost in this process. Upon reading a value back, we can only + * reconstruct the instant, but not the original representation. + * + * Note: In combination with {@link com.fasterxml.jackson.dataformat.avro.schema.DateTimeVisitor} it aims to produce + * Avro schema with type long and logicalType timestamp-millis: + * { + * "type" : "long", + * "logicalType" : "timestamp-millis" + * } + * + * {@link AvroInstantSerializer} does not support serialization to string. + * + * @param The type of a instant class that can be serialized. + */ +public class AvroInstantSerializer extends StdScalarSerializer { + + private static final long serialVersionUID = 1L; + + public static final AvroInstantSerializer INSTANT = + new AvroInstantSerializer<>(Instant.class, Function.identity()); + + public static final AvroInstantSerializer OFFSET_DATE_TIME = + new AvroInstantSerializer<>(OffsetDateTime.class, OffsetDateTime::toInstant); + + public static final AvroInstantSerializer ZONED_DATE_TIME = + new AvroInstantSerializer<>(ZonedDateTime.class, ZonedDateTime::toInstant); + + private final Function getInstant; + + protected AvroInstantSerializer(Class t, Function getInstant) { + super(t); + this.getInstant = getInstant; + } + + @Override + public void serialize(T value, JsonGenerator gen, SerializerProvider provider) throws IOException { + /** + * Number of milliseconds, independent of a particular time zone or calendar, + * from 1 January 1970 00:00:00.000 UTC. + */ + final Instant instant = getInstant.apply(value); + gen.writeNumber(instant.toEpochMilli()); + } + + @Override + public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) throws JsonMappingException { + JsonIntegerFormatVisitor v2 = visitor.expectIntegerFormat(typeHint); + if (v2 != null) { + v2.numberType(JsonParser.NumberType.LONG); + } + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroLocalDateSerializer.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroLocalDateSerializer.java new file mode 100644 index 000000000..89d932344 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroLocalDateSerializer.java @@ -0,0 +1,56 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310.ser; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor; +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer; + +import java.io.IOException; +import java.time.LocalDate; + +/** + * Serializer for {@link LocalDate} into int value. + * + * Serialized value represents number of days from the unix epoch, 1 January 1970 with no reference + * to a particular time zone or time of day. + * + * Note: In combination with {@link com.fasterxml.jackson.dataformat.avro.schema.DateTimeVisitor} it aims to produce + * Avro schema with type int and logicalType date: + * { + * "type" : "int", + * "logicalType" : "date" + * } + * + * Serialization to string is not supported. + */ +public class AvroLocalDateSerializer extends StdScalarSerializer { + + private static final long serialVersionUID = 1L; + + public static final AvroLocalDateSerializer INSTANCE = new AvroLocalDateSerializer(); + + protected AvroLocalDateSerializer() { + super(LocalDate.class); + } + + @Override + public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider provider) throws IOException { + /** + * Number of days from the unix epoch, 1 January 1970. + */ + gen.writeNumber(value.toEpochDay()); + } + + @Override + public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) throws JsonMappingException { + JsonIntegerFormatVisitor v2 = visitor.expectIntegerFormat(typeHint); + if (v2 != null) { + v2.numberType(JsonParser.NumberType.INT); + } + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroLocalDateTimeSerializer.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroLocalDateTimeSerializer.java new file mode 100644 index 000000000..0c58c2d0f --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroLocalDateTimeSerializer.java @@ -0,0 +1,60 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310.ser; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor; +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +/** + * Serializer for {@link LocalDateTime} into long value + * + * Serialized value represents timestamp in a local timezone, regardless of what specific time zone + * is considered local, with a precision of one millisecond from 1 January 1970 00:00:00.000. + * + * Note: In combination with {@link com.fasterxml.jackson.dataformat.avro.schema.DateTimeVisitor} it aims to produce + * Avro schema with type long and logicalType local-timestamp-millis: + * { + * "type" : "long", + * "logicalType" : "local-timestamp-millis" + * } + * + * Serialization to string is not supported. + */ +public class AvroLocalDateTimeSerializer extends StdScalarSerializer { + + private static final long serialVersionUID = 1L; + + public static final AvroLocalDateTimeSerializer INSTANCE = new AvroLocalDateTimeSerializer(); + + protected AvroLocalDateTimeSerializer() { + super(LocalDateTime.class); + } + + @Override + public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider provider) throws IOException { + /** + * Number of milliseconds in a local timezone, regardless of what specific time zone is considered local, + * from 1 January 1970 00:00:00.000. + */ + final Instant instant = value.toInstant(ZoneOffset.ofTotalSeconds(0)); + gen.writeNumber(instant.toEpochMilli()); + } + + @Override + public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) throws JsonMappingException { + JsonIntegerFormatVisitor v2 = visitor.expectIntegerFormat(typeHint); + if (v2 != null) { + v2.numberType(JsonParser.NumberType.LONG); + } + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroLocalTimeSerializer.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroLocalTimeSerializer.java new file mode 100644 index 000000000..5bc2485d2 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/ser/AvroLocalTimeSerializer.java @@ -0,0 +1,58 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310.ser; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor; +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer; + +import java.io.IOException; +import java.time.LocalTime; + +/** + * Serializer for {@link LocalTime} into int value. + * + * Serialized value represents time of day, with no reference to a particular calendar, + * time zone or date, where the int stores the number of milliseconds after midnight, 00:00:00.000. + * + * Note: In combination with {@link com.fasterxml.jackson.dataformat.avro.schema.DateTimeVisitor} it aims to produce + * Avro schema with type int and logicalType time-millis: + * { + * "type" : "int", + * "logicalType" : "time-millis" + * } + * + * Serialization to string is not supported. + */ +public class AvroLocalTimeSerializer extends StdScalarSerializer { + + private static final long serialVersionUID = 1L; + + public static final AvroLocalTimeSerializer INSTANCE = new AvroLocalTimeSerializer(); + + protected AvroLocalTimeSerializer() { + super(LocalTime.class); + } + + @Override + public void serialize(LocalTime value, JsonGenerator gen, SerializerProvider provider) throws IOException { + /** + * Number of milliseconds, with no reference to a particular calendar, time zone or date, after + * midnight, 00:00:00.000. + */ + long milliOfDay = value.toNanoOfDay() / 1000_000L; + gen.writeNumber(milliOfDay); + } + + @Override + public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) throws JsonMappingException { + JsonIntegerFormatVisitor v2 = visitor.expectIntegerFormat(typeHint); + if (v2 != null) { + v2.numberType(JsonParser.NumberType.INT); + } + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/DateTimeVisitor.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/DateTimeVisitor.java new file mode 100644 index 000000000..27a6beba7 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/DateTimeVisitor.java @@ -0,0 +1,83 @@ +package com.fasterxml.jackson.dataformat.avro.schema; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor; +import org.apache.avro.LogicalType; +import org.apache.avro.Schema; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; + +public class DateTimeVisitor extends JsonIntegerFormatVisitor.Base + implements SchemaBuilder { + + protected JsonParser.NumberType _type; + protected JavaType _hint; + + public DateTimeVisitor() { + } + + public DateTimeVisitor(JavaType typeHint) { + _hint = typeHint; + } + + @Override + public void numberType(JsonParser.NumberType type) { + _type = type; + } + + @Override + public Schema builtAvroSchema() { + if (_type == null) { + throw new IllegalStateException("No number type indicated"); + } + + Schema schema = AvroSchemaHelper.numericAvroSchema(_type); + if (_hint != null) { + String logicalType = logicalType(_hint); + if (logicalType != null) { + schema.addProp(LogicalType.LOGICAL_TYPE_PROP, logicalType); + } else { + schema.addProp(AvroSchemaHelper.AVRO_SCHEMA_PROP_CLASS, AvroSchemaHelper.getTypeId(_hint)); + } + } + return schema; + } + + private String logicalType(JavaType hint) { + Class clazz = hint.getRawClass(); + + if (OffsetDateTime.class.isAssignableFrom(clazz)) { + return TIMESTAMP_MILLIS; + } + if (ZonedDateTime.class.isAssignableFrom(clazz)) { + return TIMESTAMP_MILLIS; + } + if (Instant.class.isAssignableFrom(clazz)) { + return TIMESTAMP_MILLIS; + } + + if (LocalDate.class.isAssignableFrom(clazz)) { + return DATE; + } + if (LocalTime.class.isAssignableFrom(clazz)) { + return TIME_MILLIS; + } + if (LocalDateTime.class.isAssignableFrom(clazz)) { + return LOCAL_TIMESTAMP_MILLIS; + } + + return null; + } + + private static final String DATE = "date"; + private static final String TIME_MILLIS = "time-millis"; + private static final String TIMESTAMP_MILLIS = "timestamp-millis"; + private static final String LOCAL_TIMESTAMP_MILLIS = "local-timestamp-millis"; + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/VisitorFormatWrapperImpl.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/VisitorFormatWrapperImpl.java index c180d3732..be2d8c164 100644 --- a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/VisitorFormatWrapperImpl.java +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/VisitorFormatWrapperImpl.java @@ -1,21 +1,30 @@ package com.fasterxml.jackson.dataformat.avro.schema; -import org.apache.avro.Schema; - import com.fasterxml.jackson.core.JsonGenerator; - import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; -import com.fasterxml.jackson.databind.jsonFormatVisitors.*; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonAnyFormatVisitor; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonArrayFormatVisitor; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonBooleanFormatVisitor; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonMapFormatVisitor; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonNullFormatVisitor; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonNumberFormatVisitor; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonObjectFormatVisitor; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonStringFormatVisitor; import com.fasterxml.jackson.dataformat.avro.AvroSchema; +import org.apache.avro.Schema; + +import java.time.temporal.Temporal; public class VisitorFormatWrapperImpl implements JsonFormatVisitorWrapper { protected SerializerProvider _provider; - + protected final DefinedSchemas _schemas; /** @@ -28,7 +37,7 @@ public class VisitorFormatWrapperImpl * Schema for simple types that do not need a visitor. */ protected Schema _valueSchema; - + /* /********************************************************************** /* Construction @@ -39,7 +48,7 @@ public VisitorFormatWrapperImpl(DefinedSchemas schemas, SerializerProvider p) { _schemas = schemas; _provider = p; } - + @Override public SerializerProvider getProvider() { return _provider; @@ -67,7 +76,7 @@ public Schema getAvroSchema() { } return _builder.builtAvroSchema(); } - + /* /********************************************************************** /* Callbacks @@ -97,7 +106,7 @@ public JsonMapFormatVisitor expectMapFormat(JavaType mapType) { _builder = v; return v; } - + @Override public JsonArrayFormatVisitor expectArrayFormat(final JavaType convertedType) { // 22-Mar-2016, tatu: Actually we can detect byte[] quite easily here can't we? @@ -148,6 +157,13 @@ public JsonIntegerFormatVisitor expectIntegerFormat(JavaType type) { _valueSchema = s; return null; } + + if (_isDateTimeType(type)) { + DateTimeVisitor v = new DateTimeVisitor(type); + _builder = v; + return v; + } + IntegerVisitor v = new IntegerVisitor(type); _builder = v; return v; @@ -183,7 +199,15 @@ public JsonAnyFormatVisitor expectAnyFormat(JavaType convertedType) throws JsonM protected T _throwUnsupported() { return _throwUnsupported("Format variation not supported"); } + protected T _throwUnsupported(String msg) { throw new UnsupportedOperationException(msg); } + + private boolean _isDateTimeType(JavaType type) { + if (Temporal.class.isAssignableFrom(type.getRawClass())) { + return true; + } + return false; + } } diff --git a/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule_schemaCreationTest.java b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule_schemaCreationTest.java new file mode 100644 index 000000000..b5edb2ef5 --- /dev/null +++ b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule_schemaCreationTest.java @@ -0,0 +1,75 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.dataformat.avro.AvroMapper; +import com.fasterxml.jackson.dataformat.avro.schema.AvroSchemaGenerator; +import org.apache.avro.LogicalType; +import org.apache.avro.Schema; +import org.apache.avro.specific.SpecificData; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(Parameterized.class) +public class AvroJavaTimeModule_schemaCreationTest { + + @Parameter(0) + public Class testClass; + + @Parameter(1) + public Schema.Type expectedType; + + @Parameter(2) + public String expectedLogicalType; + + @Parameters(name = "With {0}") + public static Collection testData() { + return Arrays.asList(new Object[][]{ + // Java type | expected Avro type | expected logicalType + {Instant.class, Schema.Type.LONG, "timestamp-millis"}, + {OffsetDateTime.class, Schema.Type.LONG, "timestamp-millis"}, + {ZonedDateTime.class, Schema.Type.LONG, "timestamp-millis"}, + {LocalDateTime.class, Schema.Type.LONG, "local-timestamp-millis"}, + {LocalDate.class, Schema.Type.INT, "date"}, + {LocalTime.class, Schema.Type.INT, "time-millis"}, + }); + } + + @Test + public void testSchemaCreation() throws JsonMappingException { + // GIVEN + AvroMapper mapper = AvroMapper.builder() + .addModule(new AvroJavaTimeModule()) + .build(); + AvroSchemaGenerator gen = new AvroSchemaGenerator(); + + // WHEN + mapper.acceptJsonFormatVisitor(testClass, gen); + Schema actualSchema = gen.getGeneratedSchema().getAvroSchema(); + + System.out.println(testClass.getName() + " schema:\n" + actualSchema.toString(true)); + + // THEN + assertThat(actualSchema.getType()).isEqualTo(expectedType); + assertThat(actualSchema.getProp(LogicalType.LOGICAL_TYPE_PROP)).isEqualTo(expectedLogicalType); + /** + * Having logicalType and java-class is not valid according to + * {@link LogicalType#validate(Schema)} + */ + assertThat(actualSchema.getProp(SpecificData.CLASS_PROP)).isNull(); + } + +} diff --git a/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule_serialization_and_deserializationTest.java b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule_serialization_and_deserializationTest.java new file mode 100644 index 000000000..1e67bcdc2 --- /dev/null +++ b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule_serialization_and_deserializationTest.java @@ -0,0 +1,147 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310; + +import com.fasterxml.jackson.dataformat.avro.AvroMapper; +import com.fasterxml.jackson.dataformat.avro.AvroSchema; +import org.junit.Test; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AvroJavaTimeModule_serialization_and_deserializationTest { + + static final String SCHEMA_LONG_AND_TIMESTAMP_MILLIS = "{" + + " \"type\": \"long\"," + + " \"logicalType\": \"timestamp-millis\"" + + "}"; + + static final String SCHEMA_LONG_AND_LOCAL_TIMESTAMP_MILLIS = "{" + + " \"type\": \"long\"," + + " \"logicalType\": \"local-timestamp-millis\"" + + "}"; + + static final String SCHEMA_INT_AND_TIME_MILLIS = "{" + + " \"type\": \"int\"," + + " \"logicalType\": \"time-millis\"" + + "}"; + + static final String SCHEMA_INT_AND_DATE = "{" + + " \"type\": \"int\"," + + " \"logicalType\": \"date\"" + + "}"; + + private static AvroMapper newAvroMapper() { + return AvroMapper.builder() + .addModule(new AvroJavaTimeModule()) + .build(); + } + + @Test + public void testWithInstant_millis() throws IOException { + // GIVEN + AvroMapper mapper = newAvroMapper(); + AvroSchema schema = mapper.schemaFrom(SCHEMA_LONG_AND_TIMESTAMP_MILLIS); + + Instant serializedInstant = Instant.ofEpochSecond(930303030, 333_222_111); + Instant expectedInstant = Instant.ofEpochSecond(930303030, 333_000_000); + + // WHEN + byte[] serialized = mapper.writer(schema).writeValueAsBytes(serializedInstant); + Instant deserInstant = mapper.readerFor(Instant.class).with(schema).readValue(serialized); + + // THEN + assertThat(deserInstant).isEqualTo(expectedInstant); + } + + @Test + public void testWithOffsetDateTime_millis() throws IOException { + // GIVEN + AvroMapper mapper = newAvroMapper(); + AvroSchema schema = mapper.schemaFrom(SCHEMA_LONG_AND_TIMESTAMP_MILLIS); + + OffsetDateTime serializedOffsetDateTime = OffsetDateTime.of(2021, 6, 6, 12, 00, 30, 333_222_111, ZoneOffset.ofHours(2)); + OffsetDateTime expectedOffsetDateTime = OffsetDateTime.of(2021, 6, 6, 12, 00, 30, 333_000_000, ZoneOffset.ofHours(2)); + + // WHEN + byte[] serialized = mapper.writer(schema).writeValueAsBytes(serializedOffsetDateTime); + OffsetDateTime deserOffsetDateTime = mapper.readerFor(OffsetDateTime.class).with(schema).readValue(serialized); + + // THEN + assertThat(deserOffsetDateTime.toInstant()).isEqualTo(expectedOffsetDateTime.toInstant()); + } + + @Test + public void testWithZonedDateTime_millis() throws IOException { + // GIVEN + AvroMapper mapper = newAvroMapper(); + AvroSchema schema = mapper.schemaFrom(SCHEMA_LONG_AND_TIMESTAMP_MILLIS); + + ZonedDateTime serializedZonedDateTime = ZonedDateTime.of(2021, 6, 6, 12, 00, 30, 333_222_111, ZoneOffset.ofHours(2)); + ZonedDateTime expectedZonedDateTime = ZonedDateTime.of(2021, 6, 6, 12, 00, 30, 333_000_000, ZoneOffset.ofHours(2)); + + // WHEN + byte[] serialized = mapper.writer(schema).writeValueAsBytes(serializedZonedDateTime); + ZonedDateTime deserZonedDateTime = mapper.readerFor(ZonedDateTime.class).with(schema).readValue(serialized); + + // THEN + assertThat(deserZonedDateTime.toInstant()).isEqualTo(expectedZonedDateTime.toInstant()); + } + + @Test + public void testWithLocalDateTime_millis() throws IOException { + // GIVEN + AvroMapper mapper = newAvroMapper(); + AvroSchema schema = mapper.schemaFrom(SCHEMA_LONG_AND_LOCAL_TIMESTAMP_MILLIS); + + LocalDateTime serializedLocalDateTime = LocalDateTime.of(2021, 6, 6, 12, 0, 30, 333_222_111); + LocalDateTime expectedLocalDateTime = LocalDateTime.of(2021, 6, 6, 12, 0, 30, 333_000_000); + + // WHEN + byte[] serialized = mapper.writer(schema).writeValueAsBytes(serializedLocalDateTime); + LocalDateTime deserLocalDateTime = mapper.readerFor(LocalDateTime.class).with(schema).readValue(serialized); + + // THEN + assertThat(deserLocalDateTime).isEqualTo(expectedLocalDateTime); + } + + @Test + public void testWithLocalDate() throws IOException { + // GIVEN + AvroMapper mapper = newAvroMapper(); + AvroSchema schema = mapper.schemaFrom(SCHEMA_INT_AND_DATE); + + LocalDate expectedLocalDate = LocalDate.of(2021, 6, 7); + + // WHEN + byte[] serialized = mapper.writer(schema).writeValueAsBytes(expectedLocalDate); + LocalDate deserLocalDate = mapper.readerFor(LocalDate.class).with(schema).readValue(serialized); + + // THEN + assertThat(deserLocalDate).isEqualTo(expectedLocalDate); + } + + @Test + public void testWithLocalTime_millis() throws IOException { + // GIVEN + AvroMapper mapper = newAvroMapper(); + AvroSchema schema = mapper.schemaFrom(SCHEMA_INT_AND_TIME_MILLIS); + + LocalTime serializedLocalTime = LocalTime.of(23, 6, 6, 333_222_111); + LocalTime expectedLocalTime = LocalTime.of(23, 6, 6, 333_000_000); + + // WHEN + byte[] serialized = mapper.writer(schema).writeValueAsBytes(serializedLocalTime); + LocalTime deserLocalTime = mapper.readerFor(LocalTime.class).with(schema).readValue(serialized); + + // THEN + assertThat(deserLocalTime).isEqualTo(expectedLocalTime); + } + +}