Skip to content

Commit

Permalink
feat: Add @AvroStringable
Browse files Browse the repository at this point in the history
  • Loading branch information
Chuckame committed Jul 11, 2024
1 parent 143d6a9 commit 837ea2f
Show file tree
Hide file tree
Showing 12 changed files with 475 additions and 123 deletions.
85 changes: 57 additions & 28 deletions README.md

Large diffs are not rendered by default.

30 changes: 12 additions & 18 deletions api/avro4k-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()V
}

public abstract interface class com/github/avrokotlin/avro4k/BooleanValueDecoder {
public abstract fun decodeBoolean (Lcom/github/avrokotlin/avro4k/AvroDecoder;)Z
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <init> (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;
Expand Down
26 changes: 24 additions & 2 deletions src/main/kotlin/com/github/avrokotlin/avro4k/Annotations.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>(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(
Expand Down Expand Up @@ -128,7 +132,9 @@ private object KotlinDurationSerializer : AvroSerializer<Duration>(Duration::cla

private object AvroByteArraySerializer : AvroSerializer<ByteArray>(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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()) }
Expand All @@ -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) }
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -114,7 +122,9 @@ internal object AvroDurationSerializer : AvroSerializer<AvroDuration>(AvroDurati
}

override fun getSchema(context: SchemaSupplierContext): Schema {
return DURATION_SCHEMA
return context.inlinedElements.firstNotNullOfOrNull {
it.stringable?.createSchema()
} ?: DURATION_SCHEMA
}

override fun serializeAvro(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <reified T : Annotation> SchemaSupplierContext.findAnnotation(): FoundElementAnnotation<T>? {
return inlinedElements.firstNotNullOfOrNull { elementLocation ->
elementLocation.descriptor.findElementAnnotation<T>(elementLocation.elementIndex)?.let {
FoundElementAnnotation(elementLocation.descriptor, elementLocation.elementIndex, it)
}
}
public inline fun <reified T : Annotation> 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<AvroDecimal>?
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<AvroFixed>?
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<AvroFixed>.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<T : Annotation>
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,10 @@ private val defaultAnnotation = AvroDecimal()

public object BigDecimalSerializer : AvroSerializer<BigDecimal>(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(
Expand Down
Loading

0 comments on commit 837ea2f

Please sign in to comment.