diff --git a/modules/core/src/main/scala/vulcan/Codec.scala b/modules/core/src/main/scala/vulcan/Codec.scala index 006992fd..43418e03 100644 --- a/modules/core/src/main/scala/vulcan/Codec.scala +++ b/modules/core/src/main/scala/vulcan/Codec.scala @@ -14,7 +14,8 @@ import cats.implicits._ import java.io.{ByteArrayInputStream, ByteArrayOutputStream} import java.nio.ByteBuffer import java.nio.charset.StandardCharsets -import java.time.{Instant, LocalDate} +import java.time.{Instant, LocalDate, LocalTime} +import java.util.concurrent.TimeUnit import java.util.UUID import org.apache.avro.{Conversions, LogicalTypes, Schema, SchemaBuilder} import org.apache.avro.generic._ @@ -710,6 +711,48 @@ object Codec extends CodecCompanionCompat { } ) + /** + * @group JavaTime + */ + final val localTimeMillis: Codec.Aux[java.lang.Integer, LocalTime] = + Codec.instanceForTypes( + "Integer", + "LocalTime", + Right(LogicalTypes.timeMillis().addToSchema(SchemaBuilder.builder().intType())), { + localTime => + val millis = TimeUnit.NANOSECONDS.toMillis(localTime.toNanoOfDay()) + Right(java.lang.Integer.valueOf(millis.toInt)) + }, { + case (int: java.lang.Integer, schema) => + val logicalType = schema.getLogicalType() + if (logicalType == LogicalTypes.timeMillis()) { + val nanos = TimeUnit.MILLISECONDS.toNanos(int.toLong) + Right(LocalTime.ofNanoOfDay(nanos)) + } else Left(AvroError.decodeUnexpectedLogicalType(logicalType)) + } + ) + + /** + * @group JavaTime + */ + final val localTimeMicros: Codec.Aux[java.lang.Long, LocalTime] = + Codec.instanceForTypes( + "Long", + "LocalTime", + Right(LogicalTypes.timeMicros().addToSchema(SchemaBuilder.builder().longType())), { + localTime => + val micros = TimeUnit.NANOSECONDS.toMicros(localTime.toNanoOfDay()) + Right(java.lang.Long.valueOf(micros)) + }, { + case (long: java.lang.Long, schema) => + val logicalType = schema.getLogicalType() + if (logicalType == LogicalTypes.timeMicros()) { + val nanos = TimeUnit.MICROSECONDS.toNanos(long) + Right(LocalTime.ofNanoOfDay(nanos)) + } else Left(AvroError.decodeUnexpectedLogicalType(logicalType)) + } + ) + /** * @group General */ diff --git a/modules/core/src/test/scala/vulcan/CodecSpec.scala b/modules/core/src/test/scala/vulcan/CodecSpec.scala index 79f52cd6..57c7cc53 100644 --- a/modules/core/src/test/scala/vulcan/CodecSpec.scala +++ b/modules/core/src/test/scala/vulcan/CodecSpec.scala @@ -4,7 +4,9 @@ import cats.data._ import cats.implicits._ import java.nio.ByteBuffer import java.nio.charset.StandardCharsets -import java.time.{Instant, LocalDate} +import java.time.{Instant, LocalDate, LocalTime} +import java.util.concurrent.TimeUnit +import java.time.temporal.ChronoUnit import java.util.UUID import org.apache.avro.{Conversions, LogicalTypes, Schema, SchemaBuilder} @@ -1121,6 +1123,117 @@ final class CodecSpec extends BaseSpec with CodecSpecHelpers { } } + describe("localTimeMillis") { + implicit val codec: Codec[LocalTime] = Codec.localTimeMillis + describe("schema") { + it("should be encoded as int with logical type time-millis") { + assertSchemaIs[LocalTime] { + """{"type":"int","logicalType":"time-millis"}""" + } + } + } + + describe("encode") { + it("should encode as int") { + val value = LocalTime.now() + assertEncodeIs[LocalTime]( + value, + Right(java.lang.Integer.valueOf(TimeUnit.NANOSECONDS.toMillis(value.toNanoOfDay()).toInt)) + ) + } + } + + describe("decode") { + it("should error if schema is not int") { + assertDecodeError[LocalTime]( + unsafeEncode(LocalTime.now()), + unsafeSchema[Long], + "Error decoding LocalTime: Got unexpected schema type LONG, expected schema type INT" + ) + } + + it("should error if logical type is not time-millis") { + assertDecodeError[LocalTime]( + unsafeEncode(LocalTime.now()), + unsafeSchema[Int], + "Error decoding LocalTime: Got unexpected missing logical type" + ) + } + + it("should error if value is not int") { + assertDecodeError[LocalTime]( + unsafeEncode(123L), + unsafeSchema[LocalTime], + "Error decoding LocalTime: Got unexpected type java.lang.Long, expected type Integer" + ) + } + + it("should decode int as local time-millis") { + val value = LocalTime.now() + assertDecodeIs[LocalTime]( + unsafeEncode(value), + Right(value.truncatedTo(ChronoUnit.MILLIS)) + ) + } + } + } + + describe("localTimeMicros") { + implicit val codec: Codec[LocalTime] = Codec.localTimeMicros + describe("schema") { + it("should be encoded as int with logical type time-micros") { + assertSchemaIs[LocalTime] { + """{"type":"long","logicalType":"time-micros"}""" + } + } + } + + describe("encode") { + it("should encode as long") { + val value = LocalTime.now() + assertEncodeIs[LocalTime]( + value, + Right(java.lang.Long.valueOf(TimeUnit.NANOSECONDS.toMicros(value.toNanoOfDay()))) + ) + } + } + + describe("decode") { + it("should error if schema is not int") { + assertDecodeError[LocalTime]( + unsafeEncode(LocalTime.now()), + unsafeSchema[Int], + "Error decoding LocalTime: Got unexpected schema type INT, expected schema type LONG" + ) + } + + it("should error if logical type is not time-micros") { + assertDecodeError[LocalTime]( + unsafeEncode(LocalTime.now()), + unsafeSchema[Long], + "Error decoding LocalTime: Got unexpected missing logical type" + ) + } + + it("should error if value is not long") { + assertDecodeError[LocalTime]( + unsafeEncode(123), + unsafeSchema[LocalTime], + "Error decoding LocalTime: Got unexpected type java.lang.Integer, expected type Long" + ) + } + + it("should decode int as local time-micros") { + val value = LocalTime.now() + assertDecodeIs[LocalTime]( + unsafeEncode(value), + Right(value.truncatedTo(ChronoUnit.MICROS)) + ) + } + } + } + + describe("long") { describe("schema") { it("should be encoded as long") {