diff --git a/README.md b/README.md index 201ab5c..21f6e3b 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ yourAvroInstance.schema() | `@AvroStringable`-compatible | `string` | `int`, `long`, `float`, `double`, `string`, `fixed`, `bytes` | | Ignored when the writer type is not present in the column "other compatible writer types" | | `java.math.BigDecimal` | `bytes` | `int`, `long`, `float`, `double`, `string`, `fixed`, `bytes` | `decimal` | To use it, annotate the field with `@AvroDecimal` to give the `scale` and the `precision` | | `java.math.BigDecimal` | `string` | `int`, `long`, `float`, `double`, `fixed`, `bytes` | | To use it, annotate the field with `@AvroStringable`. `@AvroDecimal` is ignored in that case | -| `java.util.UUID` | `string` | | `uuid` | To use it, just annotate the field with `@Contextual` | +| `java.util.UUID` | `fixed` | `string` | `uuid` | To use it, just annotate the field with `@Contextual` | | `java.net.URL` | `string` | | | To use it, just annotate the field with `@Contextual` | | `java.math.BigInteger` | `string` | `int`, `long`, `float`, `double` | | To use it, just annotate the field with `@Contextual` | | `java.time.LocalDate` | `int` | `long`, `string` (ISO8601) | `date` | To use it, just annotate the field with `@Contextual` | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04e546d..de3eeb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ jvm = "21" kotlinxSerialization = "1.7.0" kotestVersion = "5.9.1" okio = "3.9.0" -apache-avro = "1.11.3" +apache-avro = "1.12.0" [libraries] apache-avro = { group = "org.apache.avro", name = "avro", version.ref = "apache-avro" } diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/internal/schema/ValueVisitor.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/internal/schema/ValueVisitor.kt index e2e138b..bee848e 100644 --- a/src/main/kotlin/com/github/avrokotlin/avro4k/internal/schema/ValueVisitor.kt +++ b/src/main/kotlin/com/github/avrokotlin/avro4k/internal/schema/ValueVisitor.kt @@ -2,6 +2,7 @@ package com.github.avrokotlin.avro4k.internal.schema import com.github.avrokotlin.avro4k.Avro import com.github.avrokotlin.avro4k.internal.SerializerLocatorMiddleware +import com.github.avrokotlin.avro4k.internal.getNonNullContextualDescriptor import com.github.avrokotlin.avro4k.internal.jsonNode import com.github.avrokotlin.avro4k.internal.nonNullSerialName import com.github.avrokotlin.avro4k.internal.nullable @@ -10,6 +11,7 @@ import com.github.avrokotlin.avro4k.serializer.stringable import kotlinx.serialization.descriptors.PolymorphicKind import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind import kotlinx.serialization.descriptors.nonNullOriginal import kotlinx.serialization.modules.SerializersModule import org.apache.avro.LogicalType @@ -83,7 +85,18 @@ internal class ValueVisitor internal constructor( } override fun visitValue(descriptor: SerialDescriptor) { - val finalDescriptor = SerializerLocatorMiddleware.apply(unwrapNullable(descriptor)) + var finalDescriptor = SerializerLocatorMiddleware.apply(unwrapNullable(descriptor)) + + if (finalDescriptor is AvroSchemaSupplier) { + setSchema(finalDescriptor.getSchema(context)) + return + } + + // AvroSerializer uses the kind CONTEXTUAL, so if the descriptor is not AvroSchemaSupplier, + // we unwrap it to then check again if it is an AvroSchemaSupplier + if (finalDescriptor.kind == SerialKind.CONTEXTUAL) { + finalDescriptor = finalDescriptor.getNonNullContextualDescriptor(serializersModule) + } if (finalDescriptor is AvroSchemaSupplier) { setSchema(finalDescriptor.getSchema(context)) diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/JavaStdLibSerializers.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/JavaStdLibSerializers.kt index 5eea0d1..1bd0f74 100644 --- a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/JavaStdLibSerializers.kt +++ b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/JavaStdLibSerializers.kt @@ -52,22 +52,73 @@ public object URLSerializer : KSerializer { /** * Serializes an [UUID] as a string logical type of `uuid`. * + * By default, generates a `fixed` schema with a size of 16 bytes. + * * Note: it does not check if the schema logical type name is `uuid` as it does not make any conversion. */ public object UUIDSerializer : AvroSerializer(UUID::class.qualifiedName!!) { + private val conversion = Conversions.UUIDConversion() + override fun getSchema(context: SchemaSupplierContext): Schema { - return Schema.create(Schema.Type.STRING).copy(logicalType = LogicalType("uuid")) + val schema = + if (context.inlinedElements.any { it.stringable != null }) { + Schema.create(Schema.Type.STRING) + } else { + Schema.createFixed("uuid", null, null, 16) + } + return schema.copy(logicalType = LogicalType("uuid")) } override fun serializeAvro( encoder: AvroEncoder, value: UUID, ) { - serializeGeneric(encoder, value) + encoder.encodeResolving({ + with(encoder) { + BadEncodedValueError( + value, + encoder.currentWriterSchema, + Schema.Type.STRING, + Schema.Type.FIXED + ) + } + }) { schema -> + when (schema.type) { + Schema.Type.STRING -> { + { encoder.encodeString(value.toString()) } + } + + Schema.Type.FIXED -> { + { encoder.encodeFixed(conversion.toFixed(value, encoder.currentWriterSchema, encoder.currentWriterSchema.logicalType)) } + } + + else -> null + } + } } override fun deserializeAvro(decoder: AvroDecoder): UUID { - return deserializeGeneric(decoder) + with(decoder) { + return decoder.decodeResolvingAny({ + UnexpectedDecodeSchemaError( + "UUID", + Schema.Type.STRING, + Schema.Type.FIXED + ) + }) { schema -> + when (schema.type) { + Schema.Type.STRING -> { + AnyValueDecoder { UUID.fromString(decoder.decodeString()) } + } + + Schema.Type.FIXED -> { + AnyValueDecoder { conversion.fromFixed(decoder.decodeFixed(), schema, schema.logicalType) } + } + + else -> null + } + } + } } override fun serializeGeneric( diff --git a/src/test/kotlin/com/github/avrokotlin/avro4k/AvroObjectContainerTest.kt b/src/test/kotlin/com/github/avrokotlin/avro4k/AvroObjectContainerTest.kt index 9e0b702..b4267b5 100644 --- a/src/test/kotlin/com/github/avrokotlin/avro4k/AvroObjectContainerTest.kt +++ b/src/test/kotlin/com/github/avrokotlin/avro4k/AvroObjectContainerTest.kt @@ -167,6 +167,6 @@ internal class AvroObjectContainerTest : StringSpec({ @JvmInline @Serializable private value class UserId( - @Contextual val value: UUID, + @Contextual @AvroStringable val value: UUID, ) } \ No newline at end of file diff --git a/src/test/kotlin/com/github/avrokotlin/avro4k/encoding/LogicalTypesEncodingTest.kt b/src/test/kotlin/com/github/avrokotlin/avro4k/encoding/LogicalTypesEncodingTest.kt index 1e60f0d..7ec721c 100644 --- a/src/test/kotlin/com/github/avrokotlin/avro4k/encoding/LogicalTypesEncodingTest.kt +++ b/src/test/kotlin/com/github/avrokotlin/avro4k/encoding/LogicalTypesEncodingTest.kt @@ -17,6 +17,7 @@ import org.apache.avro.SchemaBuilder import java.math.BigDecimal import java.math.BigInteger import java.net.URL +import java.nio.ByteBuffer import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime @@ -52,6 +53,7 @@ internal class LogicalTypesEncodingTest : StringSpec({ Instant.ofEpochSecond(1577889296), Instant.ofEpochSecond(1577889296, 424000), UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), + UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), URL("http://example.com"), BigInteger("1234567890"), LocalDateTime.ofEpochSecond(1577889296, 424000000, java.time.ZoneOffset.UTC), @@ -78,6 +80,7 @@ internal class LogicalTypesEncodingTest : StringSpec({ 1577889296000, 1577889296000424, "123e4567-e89b-12d3-a456-426614174000", + UUID.fromString("123e4567-e89b-12d3-a456-426614174000").toBytes(), "http://example.com", "1234567890", 1577889296424, @@ -104,6 +107,7 @@ internal class LogicalTypesEncodingTest : StringSpec({ null, null, null, + null, null ) ) @@ -122,6 +126,7 @@ internal class LogicalTypesEncodingTest : StringSpec({ null, null, null, + null, null ) ) @@ -135,6 +140,7 @@ internal class LogicalTypesEncodingTest : StringSpec({ Instant.ofEpochSecond(1577889296), Instant.ofEpochSecond(1577889296, 424000), UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), + UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), URL("http://example.com"), BigInteger("1234567890"), LocalDateTime.ofEpochSecond(1577889296, 424000000, java.time.ZoneOffset.UTC), @@ -161,6 +167,7 @@ internal class LogicalTypesEncodingTest : StringSpec({ 1577889296000, 1577889296000424, "123e4567-e89b-12d3-a456-426614174000", + UUID.fromString("123e4567-e89b-12d3-a456-426614174000").toBytes(), "http://example.com", "1234567890", 1577889296424, @@ -181,7 +188,8 @@ internal class LogicalTypesEncodingTest : StringSpec({ @Contextual val time: LocalTime, @Contextual val instant: Instant, @Serializable(InstantToMicroSerializer::class) val instantMicros: Instant, - @Contextual val uuid: UUID, + @Contextual @AvroStringable val uuid: UUID, + @Contextual val uuidFixed: UUID, @Contextual val url: URL, @Contextual val bigInteger: BigInteger, @Contextual val dateTime: LocalDateTime, @@ -199,7 +207,8 @@ internal class LogicalTypesEncodingTest : StringSpec({ @Contextual val timeNullable: LocalTime?, @Contextual val instantNullable: Instant?, @Serializable(InstantToMicroSerializer::class) val instantMicrosNullable: Instant?, - @Contextual val uuidNullable: UUID?, + @Contextual @AvroStringable val uuidNullable: UUID?, + @Contextual val uuidFixed: UUID?, @Contextual val urlNullable: URL?, @Contextual val bigIntegerNullable: BigInteger?, @Contextual val dateTimeNullable: LocalDateTime?, @@ -207,4 +216,10 @@ internal class LogicalTypesEncodingTest : StringSpec({ @Contextual val period: java.time.Period?, @Contextual val javaDuration: java.time.Duration?, ) -} \ No newline at end of file +} + +private fun UUID.toBytes(): ByteArray = + ByteBuffer.allocate(16).apply { + putLong(mostSignificantBits) + putLong(leastSignificantBits) + }.array() \ No newline at end of file diff --git a/src/test/kotlin/com/github/avrokotlin/avro4k/schema/UUIDSchemaTest.kt b/src/test/kotlin/com/github/avrokotlin/avro4k/schema/UUIDSchemaTest.kt index 67e8795..79576de 100644 --- a/src/test/kotlin/com/github/avrokotlin/avro4k/schema/UUIDSchemaTest.kt +++ b/src/test/kotlin/com/github/avrokotlin/avro4k/schema/UUIDSchemaTest.kt @@ -1,6 +1,7 @@ package com.github.avrokotlin.avro4k.schema import com.github.avrokotlin.avro4k.AvroAssertions +import com.github.avrokotlin.avro4k.AvroStringable import com.github.avrokotlin.avro4k.internal.nullable import io.kotest.core.spec.style.FunSpec import kotlinx.serialization.Contextual @@ -11,24 +12,22 @@ import java.util.UUID internal class UUIDSchemaTest : FunSpec({ test("support UUID logical types") { - AvroAssertions.assertThat() + AvroAssertions.assertThat() + .generatesSchema(LogicalTypes.uuid().addToSchema(Schema.createFixed("uuid", null, null, 16))) + AvroAssertions.assertThat() .generatesSchema(LogicalTypes.uuid().addToSchema(Schema.create(Schema.Type.STRING))) } test("support nullable UUID logical types") { - AvroAssertions.assertThat() + AvroAssertions.assertThat() + .generatesSchema(LogicalTypes.uuid().addToSchema(Schema.createFixed("uuid", null, null, 16)).nullable) + AvroAssertions.assertThat() .generatesSchema(LogicalTypes.uuid().addToSchema(Schema.create(Schema.Type.STRING)).nullable) } }) { @JvmInline @Serializable - private value class UUIDTest( - @Contextual val uuid: UUID, - ) - - @JvmInline - @Serializable - private value class UUIDNullableTest( - @Contextual val uuid: UUID?, + private value class StringUUIDTest( + @Contextual @AvroStringable val uuid: UUID, ) } \ No newline at end of file