Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add @AvroStringable #236

Merged
merged 1 commit into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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