diff --git a/README.md b/README.md index 1eeffab..d28d2c5 100644 --- a/README.md +++ b/README.md @@ -327,34 +327,35 @@ yourAvroInstance.schema() ## Types matrix -| Kotlin type | Avro reader type | Compatible avro writer type | Avro logical type | Note / Serializer class | -|---------------------------|------------------|--------------------------------------------------------------|--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Boolean` | `boolean` | `string` | | | -| `Byte`, `Short`, `Int` | `int` | `long`, `float`, `double`, `string` | | | -| `Long` | `long` | `int`, `float`, `double`, `string` | | | -| `Float` | `float` | `double`, `string` | | | -| `Double` | `double` | `float`, `string` | | | -| `Char` | `int` | `string` (1 char) | `char` | The value serialized is the char code. When reading from a `string`, requires exactly 1 char | -| `String` | `string` | `bytes` (UTF8), `fixed` (UTF8) | | | -| `ByteArray` | `bytes` | `string` (UTF8), `fixed` (UTF8) | | | -| `Map<*, *>` | `map` | | | The map key must be string-able. Mainly everything is string-able except null and composite types (collection, data classes) | -| `Collection<*>` | `array` | | | | -| `data class` | `record` | | | | -| `enum class` | `enum` | `string` | | | -| `@AvroFixed`-compatible | `fixed` | `bytes`, `string` | | You can only annotated fields that are compatible with `bytes`or `string`, otherwise it throws an error at runtime | -| `java.math.BigDecimal` | `bytes` | `int`, `long`, `float`, `double`, `string`, `fixed`, `bytes` | `decimal` | By default, the scale is `2` and the precision `8`. To change it, annotate the field with `@AvroDecimal` | -| `java.math.BigDecimal` | `string` | `int`, `long`, `float`, `double`, `fixed`, `bytes` | | To use it, [register the serializer](#support-additional-non-serializable-types) `com.github.avrokotlin.avro4k.serializer.BigDecimalAsStringSerializer`. `@AvroDecimal` is ignored in that case | -| `java.util.UUID` | `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` | -| `java.time.Instant` | `long` | `string` (ISO8601) | `timestamp-millis` | To use it, just annotate the field with `@Contextual` | -| `java.time.Instant` | `long` | `string` (ISO8601) | `timestamp-micros` | To use it, [register the serializer](#support-additional-non-serializable-types) `com.github.avrokotlin.avro4k.serializer.InstantToMicroSerializer` | -| `java.time.LocalDateTime` | `long` | `string` (ISO8601) | `timestamp-millis` | To use it, just annotate the field with `@Contextual` | -| `java.time.LocalTime` | `int` | `long`, `string` (ISO8601) | `time-millis` | To use it, just annotate the field with `@Contextual` | -| `java.time.Duration` | `fixed` of 12 | `string` (ISO8601) | `duration` | To use it, just annotate the field with `@Contextual` | -| `java.time.Period` | `fixed` of 12 | `string` (ISO8601) | `duration` | To use it, just annotate the field with `@Contextual` | -| `kotlin.time.Duration` | `fixed` of 12 | `string` (ISO8601) | `duration` | | +| Kotlin type | Generated schema type | Other compatible writer types | Compatible logical type | Note / Serializer class | +|------------------------------|-----------------------|--------------------------------------------------------------|-------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Boolean` | `boolean` | `string` | | | +| `Byte`, `Short`, `Int` | `int` | `long`, `float`, `double`, `string` | | | +| `Long` | `long` | `int`, `float`, `double`, `string` | | | +| `Float` | `float` | `double`, `string` | | | +| `Double` | `double` | `float`, `string` | | | +| `Char` | `int` | `string` (exactly 1 char required) | `char` | The value serialized is the char code. When reading from a `string`, requires exactly 1 char | +| `String` | `string` | `bytes` (UTF8), `fixed` (UTF8) | | | +| `ByteArray` | `bytes` | `string` (UTF8), `fixed` (UTF8) | | | +| `Map<*, *>` | `map` | | | The map key must be string-able. Mainly everything is string-able except null and composite types (collection, data classes) | +| `Collection<*>` | `array` | | | | +| `data class` | `record` | | | | +| `enum class` | `enum` | `string` | | | +| `@AvroFixed`-compatible | `fixed` | `bytes`, `string` | | Throws an error at runtime if the writer type is not present in the column "other compatible writer types" | +| `@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` | By default, the scale is `2` and the precision `8`. To change it, annotate the field with `@AvroDecimal` | +| `java.math.BigDecimal` | `string` | `int`, `long`, `float`, `double`, `fixed`, `bytes` | | To use it, [register the serializer](#support-additional-non-serializable-types) `com.github.avrokotlin.avro4k.serializer.BigDecimalAsStringSerializer`. `@AvroDecimal` is ignored in that case | +| `java.util.UUID` | `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` | +| `java.time.Instant` | `long` | `string` (ISO8601) | `timestamp-millis` | To use it, just annotate the field with `@Contextual` | +| `java.time.Instant` | `long` | `string` (ISO8601) | `timestamp-micros` | To use it, [register the serializer](#support-additional-non-serializable-types) `com.github.avrokotlin.avro4k.serializer.InstantToMicroSerializer` | +| `java.time.LocalDateTime` | `long` | `string` (ISO8601) | `timestamp-millis` | To use it, just annotate the field with `@Contextual` | +| `java.time.LocalTime` | `int` | `long`, `string` (ISO8601) | `time-millis` | To use it, just annotate the field with `@Contextual` | +| `java.time.Duration` | `fixed` of 12 | `string` (ISO8601) | `duration` | To use it, just annotate the field with `@Contextual` | +| `java.time.Period` | `fixed` of 12 | `string` (ISO8601) | `duration` | To use it, just annotate the field with `@Contextual` | +| `kotlin.time.Duration` | `fixed` of 12 | `string` (ISO8601) | `duration` | | > [!NOTE] > For more details, check the [built-in classes in kotlinx-serialization](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/builtin-classes.md) @@ -788,6 +789,34 @@ Avro { > [!NOTE] > This impacts the schema generation, the serialization and the deserialization. +## Force a field to be a `string` type + +You can force a field (or the value class' property) to have its inferred schema as a `string` type by annotating it with `@AvroString`. + +Compatible types visible in the [types matrix](#types-matrix), indicated by the "Other compatible writer types" column. The **writer schema compatibility is still respected**, so if the field has been written as an int, a stringified int will be deserialized as an int without the need of parsing it. It is the same for the rerverse: If an int has been written as a string, it will be deserialized as an int by parsing the string content. + +> [!INFO] +> Note that the type must be compatible with the `string` type, otherwise it will be ignored. +> Your custom serializer generated schema must handle this annotation, or it will be ignored. + +**Examples:** +```kotlin +@Serializable +data class MyData( + @AvroString val anInt: Int, + @AvroString val rawString: ByteArray, + @AvroString @Contextual val bigDecimal: BigDecimal, +) +@JvmInline +@Serializable +value class StringifiedPrice( + @AvroString val amount: Double, +) +``` + +> [!NOTE] +> This impacts the schema generation, the serialization and the deserialization. + # Nullable fields, optional fields and compatibility With avro, you can have nullable fields and optional fields, that are taken into account for compatibility checking when using the schema registry. diff --git a/api/avro4k-core.api b/api/avro4k-core.api index be30726..b65aa06 100644 --- a/api/avro4k-core.api +++ b/api/avro4k-core.api @@ -228,6 +228,13 @@ public final class com/github/avrokotlin/avro4k/AvroSingleObjectKt { public static final fun encodeToByteArray (Lcom/github/avrokotlin/avro4k/AvroSingleObject;Lorg/apache/avro/Schema;Lkotlinx/serialization/SerializationStrategy;Ljava/lang/Object;)[B } +public abstract interface annotation class com/github/avrokotlin/avro4k/AvroStringable : java/lang/annotation/Annotation { +} + +public synthetic class com/github/avrokotlin/avro4k/AvroStringable$Impl : com/github/avrokotlin/avro4k/AvroStringable { + public fun ()V +} + public abstract interface class com/github/avrokotlin/avro4k/BooleanValueDecoder { public abstract fun decodeBoolean (Lcom/github/avrokotlin/avro4k/AvroDecoder;)Z } @@ -361,9 +368,11 @@ public abstract class com/github/avrokotlin/avro4k/serializer/AvroSerializer : c } public final class com/github/avrokotlin/avro4k/serializer/AvroSerializerKt { - public static final fun createSchema (Lcom/github/avrokotlin/avro4k/serializer/FoundElementAnnotation;)Lorg/apache/avro/Schema; - public static final fun getDecimal (Lcom/github/avrokotlin/avro4k/serializer/SchemaSupplierContext;)Lcom/github/avrokotlin/avro4k/serializer/FoundElementAnnotation; - public static final fun getFixed (Lcom/github/avrokotlin/avro4k/serializer/SchemaSupplierContext;)Lcom/github/avrokotlin/avro4k/serializer/FoundElementAnnotation; + public static final fun createSchema (Lcom/github/avrokotlin/avro4k/AvroFixed;Lcom/github/avrokotlin/avro4k/serializer/ElementLocation;)Lorg/apache/avro/Schema; + public static final fun createSchema (Lcom/github/avrokotlin/avro4k/AvroStringable;)Lorg/apache/avro/Schema; + public static final fun getDecimal (Lcom/github/avrokotlin/avro4k/serializer/ElementLocation;)Lcom/github/avrokotlin/avro4k/AvroDecimal; + public static final fun getFixed (Lcom/github/avrokotlin/avro4k/serializer/ElementLocation;)Lcom/github/avrokotlin/avro4k/AvroFixed; + public static final fun getStringable (Lcom/github/avrokotlin/avro4k/serializer/ElementLocation;)Lcom/github/avrokotlin/avro4k/AvroStringable; } public final class com/github/avrokotlin/avro4k/serializer/BigDecimalAsStringSerializer : com/github/avrokotlin/avro4k/serializer/AvroSerializer { @@ -418,21 +427,6 @@ public final class com/github/avrokotlin/avro4k/serializer/ElementLocation { public fun toString ()Ljava/lang/String; } -public final class com/github/avrokotlin/avro4k/serializer/FoundElementAnnotation { - public fun (Lkotlinx/serialization/descriptors/SerialDescriptor;ILjava/lang/annotation/Annotation;)V - public final fun component1 ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun component2 ()I - public final fun component3 ()Ljava/lang/annotation/Annotation; - public final fun copy (Lkotlinx/serialization/descriptors/SerialDescriptor;ILjava/lang/annotation/Annotation;)Lcom/github/avrokotlin/avro4k/serializer/FoundElementAnnotation; - public static synthetic fun copy$default (Lcom/github/avrokotlin/avro4k/serializer/FoundElementAnnotation;Lkotlinx/serialization/descriptors/SerialDescriptor;ILjava/lang/annotation/Annotation;ILjava/lang/Object;)Lcom/github/avrokotlin/avro4k/serializer/FoundElementAnnotation; - public fun equals (Ljava/lang/Object;)Z - public final fun getAnnotation ()Ljava/lang/annotation/Annotation; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun getElementIndex ()I - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public final class com/github/avrokotlin/avro4k/serializer/InstantSerializer : com/github/avrokotlin/avro4k/serializer/AvroSerializer { public static final field INSTANCE Lcom/github/avrokotlin/avro4k/serializer/InstantSerializer; public synthetic fun deserializeAvro (Lcom/github/avrokotlin/avro4k/AvroDecoder;)Ljava/lang/Object; diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/Annotations.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/Annotations.kt index 8f8701a..a147470 100644 --- a/src/main/kotlin/com/github/avrokotlin/avro4k/Annotations.kt +++ b/src/main/kotlin/com/github/avrokotlin/avro4k/Annotations.kt @@ -1,7 +1,6 @@ -@file:OptIn(ExperimentalSerializationApi::class) - package com.github.avrokotlin.avro4k +import com.github.avrokotlin.avro4k.serializer.AvroSerializer import com.github.avrokotlin.avro4k.serializer.BigDecimalSerializer import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialInfo @@ -46,6 +45,29 @@ public annotation class AvroDoc(val value: String) @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS) public annotation class AvroAlias(vararg val value: String) +/** + * Sets the annotated property as a `string` type when inferring the class' schema. Takes precedence over any other schema modifier built-in annotation, except + * the fields referring to a custom serializer implementing [AvroSerializer] where you'll need to handle the annotation. + * + * If the given property is not string-able, the schema will be generated as a string, but it will fail at runtime during serialization or deserialization. + * You may need to provide a custom serializer for the given property to handle the string specifications. + * + * Only works with class properties for the following inferred schemas: + * - `string` + * - `boolean` + * - `int` + * - `long` + * - `float` + * - `double` + * - `bytes` & `fixed` (will take the fixed bytes as UTF-8 string, or custom toString/parse for logical types) + * - works also for all the nullable types of the above + * The rest will fail, except if your custom serializers handle the string type. + */ +@SerialInfo +@ExperimentalSerializationApi +@Target(AnnotationTarget.PROPERTY) +public annotation class AvroStringable + /** * To be used with [BigDecimalSerializer] to specify the scale, precision, type and rounding mode of the decimal value. * diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/internal/SerializerLocatorMiddleware.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/internal/SerializerLocatorMiddleware.kt index 283b4e3..36486ee 100644 --- a/src/main/kotlin/com/github/avrokotlin/avro4k/internal/SerializerLocatorMiddleware.kt +++ b/src/main/kotlin/com/github/avrokotlin/avro4k/internal/SerializerLocatorMiddleware.kt @@ -10,6 +10,7 @@ import com.github.avrokotlin.avro4k.serializer.SchemaSupplierContext import com.github.avrokotlin.avro4k.serializer.SerialDescriptorWithAvroSchemaDelegate import com.github.avrokotlin.avro4k.serializer.createSchema import com.github.avrokotlin.avro4k.serializer.fixed +import com.github.avrokotlin.avro4k.serializer.stringable import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer @@ -64,14 +65,17 @@ internal object SerializerLocatorMiddleware { private val AvroStringSerialDescriptor: SerialDescriptor = SerialDescriptorWithAvroSchemaDelegate(String.serializer().descriptor) { context -> - context.fixed?.createSchema() ?: Schema.create(Schema.Type.STRING) + context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() ?: it.fixed?.createSchema(it) + } ?: Schema.create(Schema.Type.STRING) } private object KotlinDurationSerializer : AvroSerializer(Duration::class.qualifiedName!!) { private const val MILLIS_PER_DAY = 1000 * 60 * 60 * 24 override fun getSchema(context: SchemaSupplierContext): Schema { - return AvroDurationSerializer.DURATION_SCHEMA + return context.inlinedElements.firstNotNullOfOrNull { it.stringable?.createSchema() } + ?: AvroDurationSerializer.DURATION_SCHEMA } override fun serializeAvro( @@ -128,7 +132,9 @@ private object KotlinDurationSerializer : AvroSerializer(Duration::cla private object AvroByteArraySerializer : AvroSerializer(ByteArray::class.qualifiedName!!) { override fun getSchema(context: SchemaSupplierContext): Schema { - return context.fixed?.createSchema() ?: Schema.create(Schema.Type.BYTES) + return context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() ?: it.fixed?.createSchema(it) + } ?: Schema.create(Schema.Type.BYTES) } override fun serializeAvro( diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/internal/encoder/direct/AbstractAvroDirectEncoder.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/internal/encoder/direct/AbstractAvroDirectEncoder.kt index 999711c..87d36be 100644 --- a/src/main/kotlin/com/github/avrokotlin/avro4k/internal/encoder/direct/AbstractAvroDirectEncoder.kt +++ b/src/main/kotlin/com/github/avrokotlin/avro4k/internal/encoder/direct/AbstractAvroDirectEncoder.kt @@ -17,6 +17,7 @@ import kotlinx.serialization.encoding.CompositeEncoder import kotlinx.serialization.modules.SerializersModule import org.apache.avro.Schema import org.apache.avro.generic.GenericFixed +import org.apache.avro.util.Utf8 import java.nio.ByteBuffer internal class AvroValueDirectEncoder( @@ -138,12 +139,14 @@ internal sealed class AbstractAvroDirectEncoder( { BadEncodedValueError(value, currentWriterSchema, Schema.Type.BYTES, Schema.Type.STRING, Schema.Type.FIXED) } ) { when (it.type) { - Schema.Type.STRING, - Schema.Type.BYTES, - -> { + Schema.Type.BYTES -> { { binaryEncoder.writeBytes(value) } } + Schema.Type.STRING -> { + { binaryEncoder.writeString(Utf8(value.array())) } + } + Schema.Type.FIXED -> { if (value.remaining() == it.fixedSize) { { binaryEncoder.writeFixed(value.array()) } @@ -162,12 +165,14 @@ internal sealed class AbstractAvroDirectEncoder( { BadEncodedValueError(value, currentWriterSchema, Schema.Type.BYTES, Schema.Type.STRING, Schema.Type.FIXED) } ) { when (it.type) { - Schema.Type.STRING, - Schema.Type.BYTES, - -> { + Schema.Type.BYTES -> { { binaryEncoder.writeBytes(value) } } + Schema.Type.STRING -> { + { binaryEncoder.writeString(Utf8(value)) } + } + Schema.Type.FIXED -> { if (value.size == it.fixedSize) { { binaryEncoder.writeFixed(value) } @@ -194,12 +199,14 @@ internal sealed class AbstractAvroDirectEncoder( } } - Schema.Type.STRING, - Schema.Type.BYTES, - -> { + Schema.Type.BYTES -> { { binaryEncoder.writeBytes(value.bytes()) } } + Schema.Type.STRING -> { + { binaryEncoder.writeString(Utf8(value.bytes())) } + } + else -> null } } @@ -217,12 +224,14 @@ internal sealed class AbstractAvroDirectEncoder( null } - Schema.Type.STRING, - Schema.Type.BYTES, - -> { + Schema.Type.BYTES -> { { binaryEncoder.writeBytes(value) } } + Schema.Type.STRING -> { + { binaryEncoder.writeString(Utf8(value)) } + } + else -> null } } 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 1c7cc7f..e2e138b 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 @@ -6,6 +6,7 @@ import com.github.avrokotlin.avro4k.internal.jsonNode import com.github.avrokotlin.avro4k.internal.nonNullSerialName import com.github.avrokotlin.avro4k.internal.nullable import com.github.avrokotlin.avro4k.serializer.AvroSchemaSupplier +import com.github.avrokotlin.avro4k.serializer.stringable import kotlinx.serialization.descriptors.PolymorphicKind import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.SerialDescriptor @@ -84,9 +85,17 @@ internal class ValueVisitor internal constructor( override fun visitValue(descriptor: SerialDescriptor) { val finalDescriptor = SerializerLocatorMiddleware.apply(unwrapNullable(descriptor)) - (finalDescriptor.nonNullOriginal as? AvroSchemaSupplier) - ?.getSchema(context)?.let { setSchema(it) } - ?: super.visitValue(finalDescriptor) + if (finalDescriptor is AvroSchemaSupplier) { + setSchema(finalDescriptor.getSchema(context)) + return + } + + if (context.inlinedElements.any { it.stringable != null }) { + setSchema(Schema.create(Schema.Type.STRING)) + return + } + + super.visitValue(finalDescriptor) } private fun unwrapNullable(descriptor: SerialDescriptor): SerialDescriptor { diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroDuration.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroDuration.kt index 15a9508..21c8e43 100644 --- a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroDuration.kt +++ b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroDuration.kt @@ -44,8 +44,8 @@ public data class AvroDuration( if (days != 0u) { append("${days}D") } - append("T") if (millis != 0u) { + append("T") append(millis / 1000u) val millisPart = millis % 1000u if (millisPart != 0u) { @@ -89,12 +89,20 @@ public data class AvroDuration( val match = PATTERN.matchEntire(value) ?: return null val (years, months, weeks, days, hours, minutes, seconds, millis) = match.destructured return AvroDuration( - months = years.toUInt() * 12u + months.toUInt(), - days = weeks.toUInt() * 7u + days.toUInt(), - millis = hours.toUInt() * 60u * 60u * 1000u + minutes.toUInt() * 60u * 1000u + seconds.toUInt() * 1000u + millis.toUInt() + months = years * 12u + months.toUIntOrZero(), + days = weeks * 7u + days.toUIntOrZero(), + millis = hours * 60u * 60u * 1000u + minutes * 60u * 1000u + seconds * 1000u + millis.toUIntOrZero() ) } + private operator fun String.times(other: UInt): UInt { + return toUIntOrNull()?.times(other) ?: 0u + } + + private fun String.toUIntOrZero(): UInt { + return toUIntOrNull() ?: 0u + } + @JvmStatic public fun parse(value: String): AvroDuration { return tryParse(value) ?: throw AvroDurationParseException(value) @@ -114,7 +122,9 @@ internal object AvroDurationSerializer : AvroSerializer(AvroDurati } override fun getSchema(context: SchemaSupplierContext): Schema { - return DURATION_SCHEMA + return context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() + } ?: DURATION_SCHEMA } override fun serializeAvro( diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroSerializer.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroSerializer.kt index f9b072e..ad60eb5 100644 --- a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroSerializer.kt +++ b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroSerializer.kt @@ -5,6 +5,7 @@ import com.github.avrokotlin.avro4k.AvroDecimal import com.github.avrokotlin.avro4k.AvroDecoder import com.github.avrokotlin.avro4k.AvroEncoder import com.github.avrokotlin.avro4k.AvroFixed +import com.github.avrokotlin.avro4k.AvroStringable import com.github.avrokotlin.avro4k.internal.findElementAnnotation import com.github.avrokotlin.avro4k.internal.namespace import kotlinx.serialization.ExperimentalSerializationApi @@ -100,57 +101,53 @@ public interface SchemaSupplierContext { } /** - * Search for the first annotation of type [T] in the [SchemaSupplierContext.inlinedElements]. - * - * The top-est matching annotation is returned, that means the one that is closest to the class element. + * Search for the first annotation of type [T] in the given [ElementLocation]. */ @ExperimentalSerializationApi -public inline fun SchemaSupplierContext.findAnnotation(): FoundElementAnnotation? { - return inlinedElements.firstNotNullOfOrNull { elementLocation -> - elementLocation.descriptor.findElementAnnotation(elementLocation.elementIndex)?.let { - FoundElementAnnotation(elementLocation.descriptor, elementLocation.elementIndex, it) - } - } +public inline fun ElementLocation.findAnnotation(): T? { + return descriptor.findElementAnnotation(elementIndex) } /** * Shorthand for [findAnnotation] with [AvroDecimal] as it is a built-in annotation. */ @ExperimentalSerializationApi -public val SchemaSupplierContext.decimal: FoundElementAnnotation? +public val ElementLocation.decimal: AvroDecimal? get() = findAnnotation() /** - * Shorthand for [findAnnotation] with [AvroFixed] as it is a built-in annotation. + * Shorthand for [findAnnotation] with [AvroStringable] as it is a built-in annotation. */ @ExperimentalSerializationApi -public val SchemaSupplierContext.fixed: FoundElementAnnotation? +public val ElementLocation.stringable: AvroStringable? get() = findAnnotation() /** - * Creates a fixed schema from the [AvroFixed] annotation. + * Creates a string schema from the [AvroStringable] annotation. */ @ExperimentalSerializationApi -public fun FoundElementAnnotation.createSchema(): Schema = Schema.createFixed(descriptor.getElementName(elementIndex), null, descriptor.namespace, annotation.size) +public fun AvroStringable.createSchema(): Schema = Schema.create(Schema.Type.STRING) +/** + * Shorthand for [findAnnotation] with [AvroFixed] as it is a built-in annotation. + */ @ExperimentalSerializationApi -public data class ElementLocation - @PublishedApi - internal constructor( - val descriptor: SerialDescriptor, - val elementIndex: Int, - ) +public val ElementLocation.fixed: AvroFixed? + get() = findAnnotation() /** - * Represents a found annotation on an element, provided by [findAnnotation]. + * Creates a fixed schema from the [AvroFixed] annotation. */ @ExperimentalSerializationApi -public data class FoundElementAnnotation +public fun AvroFixed.createSchema(elementLocation: ElementLocation): Schema = + Schema.createFixed(elementLocation.descriptor.getElementName(elementLocation.elementIndex), null, elementLocation.descriptor.namespace, size) + +@ExperimentalSerializationApi +public data class ElementLocation @PublishedApi internal constructor( val descriptor: SerialDescriptor, val elementIndex: Int, - val annotation: T, ) internal fun interface AvroSchemaSupplier { 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 2814b95..2a39a95 100644 --- a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/JavaStdLibSerializers.kt +++ b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/JavaStdLibSerializers.kt @@ -185,10 +185,10 @@ private val defaultAnnotation = AvroDecimal() public object BigDecimalSerializer : AvroSerializer(BigDecimal::class.qualifiedName!!) { override fun getSchema(context: SchemaSupplierContext): Schema { - val decimalAnnotation = context.decimal?.annotation ?: defaultAnnotation - val schema = context.fixed?.createSchema() ?: Schema.create(Schema.Type.BYTES) - decimalAnnotation.logicalType.addToSchema(schema) - return schema + val logicalType = (context.inlinedElements.firstNotNullOfOrNull { it.decimal } ?: defaultAnnotation).logicalType + return context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() ?: it.fixed?.createSchema(it)?.copy(logicalType = logicalType) + } ?: Schema.create(Schema.Type.BYTES).copy(logicalType = logicalType) } override fun serializeAvro( diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/JavaTimeSerializers.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/JavaTimeSerializers.kt index 0ff28f2..9d7d711 100644 --- a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/JavaTimeSerializers.kt +++ b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/JavaTimeSerializers.kt @@ -42,7 +42,9 @@ private const val LOGICAL_TYPE_NAME_TIMESTAMP_MICROS = "timestamp-micros" public object LocalDateSerializer : AvroSerializer(LocalDate::class.qualifiedName!!) { override fun getSchema(context: SchemaSupplierContext): Schema { - return Schema.create(Schema.Type.INT).copy(logicalType = LogicalType(LOGICAL_TYPE_NAME_DATE)) + return context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() + } ?: Schema.create(Schema.Type.INT).copy(logicalType = LogicalType(LOGICAL_TYPE_NAME_DATE)) } override fun serializeAvro( @@ -74,6 +76,10 @@ public object LocalDateSerializer : AvroSerializer(LocalDate::class.q else -> null } + Schema.Type.STRING -> { + { encoder.encodeString(value.toString()) } + } + else -> null } } @@ -83,7 +89,7 @@ public object LocalDateSerializer : AvroSerializer(LocalDate::class.q encoder: Encoder, value: LocalDate, ) { - encoder.encodeInt(value.toEpochDay().toInt()) + encoder.encodeString(value.toString()) } override fun deserializeAvro(decoder: AvroDecoder): LocalDate { @@ -112,6 +118,10 @@ public object LocalDateSerializer : AvroSerializer(LocalDate::class.q } } + Schema.Type.STRING -> { + AnyValueDecoder { LocalDate.parse(decoder.decodeString()) } + } + else -> null } } @@ -119,7 +129,7 @@ public object LocalDateSerializer : AvroSerializer(LocalDate::class.q } override fun deserializeGeneric(decoder: Decoder): LocalDate { - return LocalDate.ofEpochDay(decoder.decodeInt().toLong()) + return LocalDate.parse(decoder.decodeString()) } } @@ -128,7 +138,9 @@ private const val NANOS_PER_MICROSECOND = 1_000L public object LocalTimeSerializer : AvroSerializer(LocalTime::class.qualifiedName!!) { override fun getSchema(context: SchemaSupplierContext): Schema { - return Schema.create(Schema.Type.INT).copy(logicalType = LogicalType(LOGICAL_TYPE_NAME_TIME_MILLIS)) + return context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() + } ?: Schema.create(Schema.Type.INT).copy(logicalType = LogicalType(LOGICAL_TYPE_NAME_TIME_MILLIS)) } override fun serializeAvro( @@ -137,7 +149,7 @@ public object LocalTimeSerializer : AvroSerializer(LocalTime::class.q ) { with(encoder) { encodeResolving({ - BadEncodedValueError(value, encoder.currentWriterSchema, Schema.Type.INT, Schema.Type.LONG) + BadEncodedValueError(value, encoder.currentWriterSchema, Schema.Type.INT, Schema.Type.LONG, Schema.Type.STRING) }) { schema -> when (schema.type) { Schema.Type.INT -> @@ -163,6 +175,10 @@ public object LocalTimeSerializer : AvroSerializer(LocalTime::class.q else -> null } + Schema.Type.STRING -> { + { encoder.encodeString(value.toString()) } + } + else -> null } } @@ -173,7 +189,7 @@ public object LocalTimeSerializer : AvroSerializer(LocalTime::class.q encoder: Encoder, value: LocalTime, ) { - encoder.encodeInt(value.toMillisOfDay()) + encoder.encodeString(value.toString()) } private fun LocalTime.toMillisOfDay() = (toNanoOfDay() / NANOS_PER_MILLISECOND).toInt() @@ -186,7 +202,8 @@ public object LocalTimeSerializer : AvroSerializer(LocalTime::class.q UnexpectedDecodeSchemaError( "LocalTime", Schema.Type.INT, - Schema.Type.LONG + Schema.Type.LONG, + Schema.Type.STRING ) }) { when (it.type) { @@ -214,6 +231,10 @@ public object LocalTimeSerializer : AvroSerializer(LocalTime::class.q } } + Schema.Type.STRING -> { + AnyValueDecoder { LocalTime.parse(decoder.decodeString()) } + } + else -> null } } @@ -221,41 +242,93 @@ public object LocalTimeSerializer : AvroSerializer(LocalTime::class.q } override fun deserializeGeneric(decoder: Decoder): LocalTime { - return LocalTime.ofNanoOfDay(decoder.decodeInt() * NANOS_PER_MILLISECOND) + return LocalTime.parse(decoder.decodeString()) } } public object LocalDateTimeSerializer : AvroSerializer(LocalDateTime::class.qualifiedName!!) { override fun getSchema(context: SchemaSupplierContext): Schema { - return Schema.create(Schema.Type.LONG).copy(logicalType = LogicalType(LOGICAL_TYPE_NAME_TIMESTAMP_MILLIS)) + return context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() + } ?: Schema.create(Schema.Type.LONG).copy(logicalType = LogicalType(LOGICAL_TYPE_NAME_TIMESTAMP_MILLIS)) } override fun serializeAvro( encoder: AvroEncoder, value: LocalDateTime, ) { - InstantSerializer.serializeAvro(encoder, value.toInstant(ZoneOffset.UTC)) + encoder.encodeResolving({ + with(encoder) { + BadEncodedValueError(value, encoder.currentWriterSchema, Schema.Type.LONG, Schema.Type.STRING) + } + }) { + when (it.type) { + Schema.Type.LONG -> + when (it.logicalType?.name) { + LOGICAL_TYPE_NAME_TIMESTAMP_MILLIS, null -> { + { encoder.encodeLong(value.toInstant(ZoneOffset.UTC).toEpochMilli()) } + } + + LOGICAL_TYPE_NAME_TIMESTAMP_MICROS -> { + { encoder.encodeLong(value.toInstant(ZoneOffset.UTC).toEpochMicros()) } + } + + else -> null + } + + Schema.Type.STRING -> { + { encoder.encodeString(value.toString()) } + } + + else -> null + } + } } override fun serializeGeneric( encoder: Encoder, value: LocalDateTime, ) { - InstantSerializer.serializeGeneric(encoder, value.toInstant(ZoneOffset.UTC)) + encoder.encodeString(value.toString()) } override fun deserializeAvro(decoder: AvroDecoder): LocalDateTime { - return LocalDateTime.ofInstant(InstantSerializer.deserializeAvro(decoder), ZoneOffset.UTC) + return with(decoder) { + decodeResolvingAny({ UnexpectedDecodeSchemaError("Instant", Schema.Type.LONG) }) { + when (it.type) { + Schema.Type.LONG -> + when (it.logicalType?.name) { + LOGICAL_TYPE_NAME_TIMESTAMP_MILLIS, null -> { + AnyValueDecoder { LocalDateTime.ofInstant(Instant.ofEpochMilli(decoder.decodeLong()), ZoneOffset.UTC) } + } + + LOGICAL_TYPE_NAME_TIMESTAMP_MICROS -> { + AnyValueDecoder { LocalDateTime.ofInstant(Instant.EPOCH.plus(decoder.decodeLong(), ChronoUnit.MICROS), ZoneOffset.UTC) } + } + + else -> null + } + + Schema.Type.STRING -> { + AnyValueDecoder { LocalDateTime.parse(decoder.decodeString()) } + } + + else -> null + } + } + } } override fun deserializeGeneric(decoder: Decoder): LocalDateTime { - return LocalDateTime.ofInstant(InstantSerializer.deserializeGeneric(decoder), ZoneOffset.UTC) + return LocalDateTime.parse(decoder.decodeString()) } } public object InstantSerializer : AvroSerializer(Instant::class.qualifiedName!!) { override fun getSchema(context: SchemaSupplierContext): Schema { - return Schema.create(Schema.Type.LONG).copy(logicalType = LogicalType(LOGICAL_TYPE_NAME_TIMESTAMP_MILLIS)) + return context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() + } ?: Schema.create(Schema.Type.LONG).copy(logicalType = LogicalType(LOGICAL_TYPE_NAME_TIMESTAMP_MILLIS)) } override fun serializeAvro( @@ -264,7 +337,7 @@ public object InstantSerializer : AvroSerializer(Instant::class.qualifi ) { encoder.encodeResolving({ with(encoder) { - BadEncodedValueError(value, encoder.currentWriterSchema, Schema.Type.LONG) + BadEncodedValueError(value, encoder.currentWriterSchema, Schema.Type.LONG, Schema.Type.STRING) } }) { when (it.type) { @@ -281,6 +354,10 @@ public object InstantSerializer : AvroSerializer(Instant::class.qualifi else -> null } + Schema.Type.STRING -> { + { encoder.encodeString(value.toString()) } + } + else -> null } } @@ -290,7 +367,7 @@ public object InstantSerializer : AvroSerializer(Instant::class.qualifi encoder: Encoder, value: Instant, ) { - encoder.encodeLong(value.toEpochMilli()) + encoder.encodeString(value.toString()) } override fun deserializeAvro(decoder: AvroDecoder): Instant = @@ -310,19 +387,25 @@ public object InstantSerializer : AvroSerializer(Instant::class.qualifi else -> null } + Schema.Type.STRING -> { + AnyValueDecoder { Instant.parse(decoder.decodeString()) } + } + else -> null } } } override fun deserializeGeneric(decoder: Decoder): Instant { - return Instant.ofEpochMilli(decoder.decodeLong()) + return Instant.parse(decoder.decodeString()) } } public object InstantToMicroSerializer : AvroSerializer(Instant::class.qualifiedName!!) { override fun getSchema(context: SchemaSupplierContext): Schema { - return Schema.create(Schema.Type.LONG).copy(logicalType = LogicalType(LOGICAL_TYPE_NAME_TIMESTAMP_MICROS)) + return context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() + } ?: Schema.create(Schema.Type.LONG).copy(logicalType = LogicalType(LOGICAL_TYPE_NAME_TIMESTAMP_MICROS)) } override fun serializeAvro( @@ -331,7 +414,7 @@ public object InstantToMicroSerializer : AvroSerializer(Instant::class. ) { encoder.encodeResolving({ with(encoder) { - BadEncodedValueError(value, encoder.currentWriterSchema, Schema.Type.LONG) + BadEncodedValueError(value, encoder.currentWriterSchema, Schema.Type.LONG, Schema.Type.STRING) } }) { when (it.type) { @@ -348,6 +431,10 @@ public object InstantToMicroSerializer : AvroSerializer(Instant::class. else -> null } + Schema.Type.STRING -> { + { encoder.encodeString(value.toString()) } + } + else -> null } } @@ -357,12 +444,12 @@ public object InstantToMicroSerializer : AvroSerializer(Instant::class. encoder: Encoder, value: Instant, ) { - encoder.encodeLong(value.toEpochMicros()) + encoder.encodeString(value.toString()) } override fun deserializeAvro(decoder: AvroDecoder): Instant { with(decoder) { - return decodeResolvingAny({ UnexpectedDecodeSchemaError("Instant", Schema.Type.LONG) }) { + return decodeResolvingAny({ UnexpectedDecodeSchemaError("Instant", Schema.Type.LONG, Schema.Type.STRING) }) { when (it.type) { Schema.Type.LONG -> when (it.logicalType?.name) { @@ -377,6 +464,10 @@ public object InstantToMicroSerializer : AvroSerializer(Instant::class. else -> null } + Schema.Type.STRING -> { + AnyValueDecoder { Instant.parse(decoder.decodeString()) } + } + else -> null } } @@ -384,7 +475,7 @@ public object InstantToMicroSerializer : AvroSerializer(Instant::class. } override fun deserializeGeneric(decoder: Decoder): Instant { - return Instant.EPOCH.plus(decoder.decodeLong(), ChronoUnit.MICROS) + return Instant.parse(decoder.decodeString()) } } @@ -397,7 +488,9 @@ private fun Instant.toEpochMicros() = ChronoUnit.MICROS.between(Instant.EPOCH, t */ public object JavaDurationSerializer : AvroSerializer(Duration::class.qualifiedName!!) { override fun getSchema(context: SchemaSupplierContext): Schema { - return AvroDurationSerializer.DURATION_SCHEMA + return context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() + } ?: AvroDurationSerializer.DURATION_SCHEMA } override fun serializeAvro( @@ -449,7 +542,9 @@ public object JavaDurationSerializer : AvroSerializer(Duration::class. */ public object JavaPeriodSerializer : AvroSerializer(Period::class.qualifiedName!!) { override fun getSchema(context: SchemaSupplierContext): Schema { - return AvroDurationSerializer.DURATION_SCHEMA + return context.inlinedElements.firstNotNullOfOrNull { + it.stringable?.createSchema() + } ?: AvroDurationSerializer.DURATION_SCHEMA } override fun serializeAvro( diff --git a/src/test/kotlin/com/github/avrokotlin/avro4k/AvroAssertions.kt b/src/test/kotlin/com/github/avrokotlin/avro4k/AvroAssertions.kt index 10afe80..15f47e7 100644 --- a/src/test/kotlin/com/github/avrokotlin/avro4k/AvroAssertions.kt +++ b/src/test/kotlin/com/github/avrokotlin/avro4k/AvroAssertions.kt @@ -50,6 +50,7 @@ internal class AvroEncodingAssertions( expectedEncodedGenericValue: Any?, expectedDecodedValue: T = valueToEncode, writerSchema: Schema = avro.schema(serializer), + decodedComparator: (actual: T, expected: T) -> Unit = { a, b -> a shouldBe b }, ): AvroEncodingAssertions { val actualEncodedBytes = avro4kEncode(valueToEncode, writerSchema) val apacheEncodedBytes = avroApacheEncode(expectedEncodedGenericValue, writerSchema) @@ -69,7 +70,7 @@ internal class AvroEncodingAssertions( val decodedValue = avro4kDecode(apacheEncodedBytes, writerSchema, serializer) withClue("Decoded value is not the same as the expected one.") { - decodedValue shouldBe expectedDecodedValue + decodedComparator(decodedValue, expectedDecodedValue) } return this } diff --git a/src/test/kotlin/com/github/avrokotlin/avro4k/encoding/AvroStringableEncodingTest.kt b/src/test/kotlin/com/github/avrokotlin/avro4k/encoding/AvroStringableEncodingTest.kt new file mode 100644 index 0000000..9b965bd --- /dev/null +++ b/src/test/kotlin/com/github/avrokotlin/avro4k/encoding/AvroStringableEncodingTest.kt @@ -0,0 +1,180 @@ +package com.github.avrokotlin.avro4k.encoding + +import com.github.avrokotlin.avro4k.Avro +import com.github.avrokotlin.avro4k.AvroAssertions +import com.github.avrokotlin.avro4k.AvroFixed +import com.github.avrokotlin.avro4k.AvroStringable +import com.github.avrokotlin.avro4k.internal.nullable +import com.github.avrokotlin.avro4k.record +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.serializer +import org.apache.avro.Schema +import org.apache.avro.SchemaBuilder +import java.math.BigDecimal +import kotlin.time.Duration.Companion.days + +internal class AvroStringableEncodingTest : StringSpec({ + listOf( + true to "true", + 1.toByte() to "1", + 2.toShort() to "2", + 3 to "3", + 4L to "4", + 5.0f to "5.0", + 6.0 to "6.0", + '7' to "7", + "1234567" to "1234567", + 1.5.days to "P1DT43200S", + BigDecimal("1234567890.1234567890") to "1234567890.1234567890", + java.time.Duration.parse("PT36H") to "P1DT43200S", + java.time.Period.parse("P3Y4D") to "P36M4D", + java.time.LocalTime.parse("12:34:56") to "12:34:56", + java.time.LocalDate.parse("2021-01-01") to "2021-01-01", + java.time.LocalDateTime.parse("2021-01-01T12:34:56") to "2021-01-01T12:34:56", + java.time.Instant.parse("2020-01-01T12:34:56Z") to "2020-01-01T12:34:56Z" + ).forEach { (value, stringifiedValue) -> + "${value::class.qualifiedName}: data class property" { + AvroAssertions.assertThat(StringifiedDataClass(value), StringifiedDataClass.serializer(Avro.serializersModule.serializer(value::class, emptyList(), false))) + .generatesSchema( + SchemaBuilder.record(StringifiedDataClass::class.qualifiedName).fields() + .name("value").type(Schema.create(Schema.Type.STRING)).noDefault() + .endRecord() + ) + .isEncodedAs(record(stringifiedValue)) + } + "${value::class.qualifiedName}: nullable data class property" { + AvroAssertions.assertThat(StringifiedDataClass(value), StringifiedDataClass.serializer(Avro.serializersModule.serializer(value::class, emptyList(), true))) + .generatesSchema( + SchemaBuilder.record(StringifiedDataClass::class.qualifiedName).fields() + .name("value").type(Schema.create(Schema.Type.STRING).nullable).withDefault(null) + .endRecord() + ) + .isEncodedAs(record(stringifiedValue)) + AvroAssertions.assertThat(StringifiedDataClass(null), StringifiedDataClass.serializer(Avro.serializersModule.serializer(value::class, emptyList(), true))) + .generatesSchema( + SchemaBuilder.record(StringifiedDataClass::class.qualifiedName).fields() + .name("value").type(Schema.create(Schema.Type.STRING).nullable).withDefault(null) + .endRecord() + ) + .isEncodedAs(record(null)) + } + "${value::class.qualifiedName}: value class" { + AvroAssertions.assertThat(StringifiedValueClass(value), StringifiedValueClass.serializer(Avro.serializersModule.serializer(value::class, emptyList(), false))) + .generatesSchema(Schema.create(Schema.Type.STRING)) + .isEncodedAs(stringifiedValue, decodedComparator = { a, b -> a.value shouldBe b.value }) + } + "${value::class.qualifiedName}: nullable value class" { + AvroAssertions.assertThat(StringifiedValueClass(value), StringifiedValueClass.serializer(Avro.serializersModule.serializer(value::class, emptyList(), true))) + .generatesSchema(Schema.create(Schema.Type.STRING).nullable) + .isEncodedAs(stringifiedValue, decodedComparator = { a, b -> a.value shouldBe b.value }) + AvroAssertions.assertThat(StringifiedValueClass(null), StringifiedValueClass.serializer(Avro.serializersModule.serializer(value::class, emptyList(), true))) + .generatesSchema(Schema.create(Schema.Type.STRING).nullable) + .isEncodedAs(null) + } + } + + "ByteArray: data class property" { + AvroAssertions.assertThat(StringifiedByteArrayDataClass("hello".toByteArray())) + .generatesSchema( + SchemaBuilder.record(StringifiedByteArrayDataClass::class.qualifiedName).fields() + .name("value").type(Schema.create(Schema.Type.STRING)).noDefault() + .endRecord() + ) + .isEncodedAs(record("hello")) + } + "ByteArray: nullable data class property" { + AvroAssertions.assertThat(StringifiedNullableByteArrayDataClass("hello".toByteArray())) + .generatesSchema( + SchemaBuilder.record(StringifiedNullableByteArrayDataClass::class.qualifiedName).fields() + .name("value").type(Schema.create(Schema.Type.STRING).nullable).withDefault(null) + .endRecord() + ) + .isEncodedAs(record("hello")) + AvroAssertions.assertThat(StringifiedNullableByteArrayDataClass(null)) + .generatesSchema( + SchemaBuilder.record(StringifiedNullableByteArrayDataClass::class.qualifiedName).fields() + .name("value").type(Schema.create(Schema.Type.STRING).nullable).withDefault(null) + .endRecord() + ) + .isEncodedAs(record(null)) + } + "ByteArray: value class" { + AvroAssertions.assertThat(StringifiedValueClass("hello".toByteArray()), StringifiedValueClass.serializer(ByteArraySerializer())) + .generatesSchema(Schema.create(Schema.Type.STRING)) + .isEncodedAs("hello", decodedComparator = { a, b -> a.value shouldBe b.value }) + } + "ByteArray: nullable value class" { + AvroAssertions.assertThat(StringifiedValueClass("hello".toByteArray()), StringifiedValueClass.serializer(ByteArraySerializer().nullable)) + .generatesSchema(Schema.create(Schema.Type.STRING).nullable) + .isEncodedAs("hello", decodedComparator = { a, b -> a.value shouldBe b.value }) + AvroAssertions.assertThat(StringifiedValueClass(null), StringifiedValueClass.serializer(ByteArraySerializer().nullable)) + .generatesSchema(Schema.create(Schema.Type.STRING).nullable) + .isEncodedAs(null) + } +}) { + @Serializable + private data class StringifiedByteArrayDataClass( + @AvroFixed(10) // ignored as @AvroStringable takes precedence + @AvroStringable + val value: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StringifiedByteArrayDataClass + + return value.contentEquals(other.value) + } + + override fun hashCode(): Int { + return value.contentHashCode() + } + } + + @Serializable + private data class StringifiedNullableByteArrayDataClass( + @AvroFixed(10) // ignored as @AvroStringable takes precedence + @AvroStringable + val value: ByteArray?, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StringifiedNullableByteArrayDataClass + + if (value != null) { + if (other.value == null) return false + if (!value.contentEquals(other.value)) return false + } else if (other.value != null) { + return false + } + + return true + } + + override fun hashCode(): Int { + return value?.contentHashCode() ?: 0 + } + } + + @Serializable + private data class StringifiedDataClass( + @AvroFixed(10) // ignored as @AvroStringable takes precedence + @AvroStringable + val value: T, + ) + + @JvmInline + @Serializable + private value class StringifiedValueClass( + @AvroFixed(10) // ignored as @AvroStringable takes precedence + @AvroStringable + val value: T, + ) +} \ No newline at end of file