diff --git a/hll/dynamodb-mapper/dynamodb-mapper-annotations/api/dynamodb-mapper-annotations.api b/hll/dynamodb-mapper/dynamodb-mapper-annotations/api/dynamodb-mapper-annotations.api index 8f5c7313892..7231af4ef73 100644 --- a/hll/dynamodb-mapper/dynamodb-mapper-annotations/api/dynamodb-mapper-annotations.api +++ b/hll/dynamodb-mapper/dynamodb-mapper-annotations/api/dynamodb-mapper-annotations.api @@ -2,6 +2,10 @@ public abstract interface annotation class aws/sdk/kotlin/hll/dynamodbmapper/Dyn public abstract fun name ()Ljava/lang/String; } +public abstract interface annotation class aws/sdk/kotlin/hll/dynamodbmapper/DynamoDbAttributeConverter : java/lang/annotation/Annotation { + public abstract fun converter ()Ljava/lang/Class; +} + public abstract interface annotation class aws/sdk/kotlin/hll/dynamodbmapper/DynamoDbIgnore : java/lang/annotation/Annotation { } diff --git a/hll/dynamodb-mapper/dynamodb-mapper-annotations/build.gradle.kts b/hll/dynamodb-mapper/dynamodb-mapper-annotations/build.gradle.kts index 2576670bf70..f47c55c8a02 100644 --- a/hll/dynamodb-mapper/dynamodb-mapper-annotations/build.gradle.kts +++ b/hll/dynamodb-mapper/dynamodb-mapper-annotations/build.gradle.kts @@ -6,3 +6,14 @@ description = "DynamoDbMapper annotations" extra["displayName"] = "AWS :: SDK :: Kotlin :: HLL :: DynamoDbMapper :: Annotations" extra["moduleName"] = "aws.sdk.kotlin.hll.dynamodbmapper.annotations" + +kotlin { + sourceSets { + commonMain { + dependencies { + // For ValueConverter + implementation(project(":hll:dynamodb-mapper:dynamodb-mapper")) + } + } + } +} diff --git a/hll/dynamodb-mapper/dynamodb-mapper-annotations/common/src/aws/sdk/kotlin/hll/dynamodbmapper/Annotations.kt b/hll/dynamodb-mapper/dynamodb-mapper-annotations/common/src/aws/sdk/kotlin/hll/dynamodbmapper/Annotations.kt index e102c7f49c4..eb7f84436fe 100644 --- a/hll/dynamodb-mapper/dynamodb-mapper-annotations/common/src/aws/sdk/kotlin/hll/dynamodbmapper/Annotations.kt +++ b/hll/dynamodb-mapper/dynamodb-mapper-annotations/common/src/aws/sdk/kotlin/hll/dynamodbmapper/Annotations.kt @@ -4,6 +4,9 @@ */ package aws.sdk.kotlin.hll.dynamodbmapper +import aws.sdk.kotlin.hll.dynamodbmapper.values.ValueConverter +import kotlin.reflect.KClass + /** * Specifies the attribute name for a property in a [DynamoDbItem]-annotated class/interface. If this annotation is not * included then the attribute name matches the property name. @@ -11,13 +14,18 @@ package aws.sdk.kotlin.hll.dynamodbmapper @Target(AnnotationTarget.PROPERTY) public annotation class DynamoDbAttribute(val name: String) +/** + * Specifies the type of [ValueConverter] to be used when processing this attribute. + */ +public annotation class DynamoDbAttributeConverter(val converter: KClass>) + /** * Specifies that this class/interface describes an item type in a table. All public properties of this type will be mapped to * attributes unless they are explicitly ignored. * @param converterName The fully qualified name of the item converter to be used for converting this class/interface. * If not set, one will be automatically generated. */ -// FIXME Update to take a KClass, which will require splitting codegen modules due to a circular dependency +// FIXME Update to take a KClass? @Target(AnnotationTarget.CLASS) public annotation class DynamoDbItem(val converterName: String = "") diff --git a/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/build.gradle.kts b/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/build.gradle.kts index 202f5a60899..3d9a2376f0b 100644 --- a/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/build.gradle.kts +++ b/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation(project(":hll:hll-codegen")) implementation(project(":hll:dynamodb-mapper:dynamodb-mapper-annotations")) implementation(project(":hll:dynamodb-mapper:dynamodb-mapper-codegen")) + implementation(project(":hll:dynamodb-mapper:dynamodb-mapper")) // for ValueConverter.kt testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter.params) diff --git a/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/src/main/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/codegen/annotations/rendering/HighLevelRenderer.kt b/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/src/main/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/codegen/annotations/rendering/HighLevelRenderer.kt index 51f3fd12253..e0ca6c60438 100644 --- a/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/src/main/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/codegen/annotations/rendering/HighLevelRenderer.kt +++ b/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/src/main/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/codegen/annotations/rendering/HighLevelRenderer.kt @@ -42,7 +42,7 @@ internal class HighLevelRenderer( attributes, ) - val annotation = SchemaRenderer(annotated, renderCtx) + val annotation = SchemaRenderer(logger, annotated, renderCtx) annotation.render() } } diff --git a/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/src/main/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/codegen/annotations/rendering/SchemaRenderer.kt b/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/src/main/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/codegen/annotations/rendering/SchemaRenderer.kt index fe149516dff..37861c92f48 100644 --- a/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/src/main/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/codegen/annotations/rendering/SchemaRenderer.kt +++ b/hll/dynamodb-mapper/dynamodb-mapper-schema-codegen/src/main/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/codegen/annotations/rendering/SchemaRenderer.kt @@ -4,6 +4,7 @@ */ package aws.sdk.kotlin.hll.dynamodbmapper.codegen.annotations.rendering +import aws.sdk.kotlin.hll.codegen.core.ImportDirective import aws.sdk.kotlin.hll.codegen.model.* import aws.sdk.kotlin.hll.codegen.rendering.BuilderRenderer import aws.sdk.kotlin.hll.codegen.rendering.RenderContext @@ -18,6 +19,7 @@ import com.google.devtools.ksp.KspExperimental import com.google.devtools.ksp.getAnnotationsByType import com.google.devtools.ksp.getConstructors import com.google.devtools.ksp.isAnnotationPresent +import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.symbol.* /** @@ -27,6 +29,7 @@ import com.google.devtools.ksp.symbol.* */ @OptIn(KspExperimental::class) internal class SchemaRenderer( + private val logger: KSPLogger, private val classDeclaration: KSClassDeclaration, private val ctx: RenderContext, ) : RendererBase(ctx, "${classDeclaration.qualifiedName!!.getShortName()}Schema") { @@ -148,6 +151,7 @@ internal class SchemaRenderer( } private fun renderAttributeDescriptor(prop: KSPropertyDeclaration) { + logger.info("Rendering an attribute descriptor for ${prop.simpleName.asString()}") withBlock("#T(", "),", MapperTypes.Items.AttributeDescriptor) { write("#S,", prop.ddbName) // key write("#L,", "$className::${prop.name}") // getter @@ -160,8 +164,25 @@ internal class SchemaRenderer( } // converter - renderValueConverter(prop.type.resolve()) - write(",") + // KSP requires extra work to get a class argument out of an annotation, can't just use getAnnotationsByType + // https://slack-chats.kotlinlang.org/t/8480301/hello-again-how-do-you-get-a-kclass-out-from-an-annotation-a + val attributeValueConverterFqn = prop.annotations + .singleOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == DynamoDbAttributeConverter::class.qualifiedName } + ?.arguments + ?.single() + ?.value + ?.let { it as? KSType } + ?.declaration + ?.qualifiedName + ?.asString() + + attributeValueConverterFqn?.let { + imports += ImportDirective(it) + write("$it(),") + } ?: run { + renderValueConverter(prop.type.resolve()) + write(",") + } } } @@ -229,7 +250,6 @@ internal class SchemaRenderer( Types.Kotlin.UInt -> MapperTypes.Values.Scalars.UIntValueConverter Types.Kotlin.UShort -> MapperTypes.Values.Scalars.UShortValueConverter Types.Kotlin.ULong -> MapperTypes.Values.Scalars.ULongValueConverter - else -> error("Unsupported attribute type $type") }, ) diff --git a/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/build.gradle.kts b/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/build.gradle.kts index 548f5f23b04..20153be0b26 100644 --- a/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/build.gradle.kts +++ b/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(libs.ksp.gradle.plugin) implementation(project(":hll:hll-codegen")) // for RenderOptions + implementation(project(":hll:dynamodb-mapper:dynamodb-mapper")) // for ValueConverter implementation(project(":hll:dynamodb-mapper:dynamodb-mapper-schema-codegen")) // for AnnotationsProcessorOptions implementation(libs.smithy.kotlin.runtime.core) // for AttributeKey diff --git a/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/src/test/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/plugins/SchemaGeneratorPluginTest.kt b/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/src/test/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/plugins/SchemaGeneratorPluginTest.kt index d39cf9ff92f..3299e426adf 100644 --- a/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/src/test/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/plugins/SchemaGeneratorPluginTest.kt +++ b/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/src/test/kotlin/aws/sdk/kotlin/hll/dynamodbmapper/plugins/SchemaGeneratorPluginTest.kt @@ -569,4 +569,41 @@ class SchemaGeneratorPluginTest { """.trimIndent(), ) } + + @Test + fun testDynamoDbAttributeConverter() { + createClassFile("attribute-converter/Employee") + createClassFile("attribute-converter/HealthcareConverter") + + val result = runner.build() + assertContains(setOf(TaskOutcome.SUCCESS, TaskOutcome.UP_TO_DATE), result.task(":build")?.outcome) + + val schemaFile = File(testProjectDir, "build/generated/ksp/main/kotlin/org/example/dynamodbmapper/generatedschemas/EmployeeSchema.kt") + assertTrue(schemaFile.exists()) + + val schemaContents = schemaFile.readText() + + assertContains(schemaContents, "import org.example.OccupationConverter") + assertContains( + schemaContents, + """ AttributeDescriptor( + "occupation", + Employee::occupation, + Employee::occupation::set, + org.example.OccupationConverter(), + ),""", + ) + + // Test cross-package converter + assertContains(schemaContents, "import a.different.pkg.HealthcareConverter") + assertContains( + schemaContents, + """ AttributeDescriptor( + "healthcare", + Employee::healthcare, + Employee::healthcare::set, + a.different.pkg.HealthcareConverter(), + ),""", + ) + } } diff --git a/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/src/test/resources/attribute-converter/Employee.kt b/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/src/test/resources/attribute-converter/Employee.kt new file mode 100644 index 00000000000..0017d2b8d01 --- /dev/null +++ b/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/src/test/resources/attribute-converter/Employee.kt @@ -0,0 +1,41 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.example + +import a.different.pkg.HealthcareConverter +import aws.sdk.kotlin.hll.dynamodbmapper.DynamoDbAttributeConverter +import aws.sdk.kotlin.hll.dynamodbmapper.DynamoDbItem +import aws.sdk.kotlin.hll.dynamodbmapper.DynamoDbPartitionKey +import aws.sdk.kotlin.hll.dynamodbmapper.values.ValueConverter +import aws.sdk.kotlin.hll.mapping.core.converters.MonoConverter +import aws.sdk.kotlin.services.dynamodb.model.AttributeValue + +@DynamoDbItem +data class Employee( + @DynamoDbPartitionKey + var id: Int = 1, + var givenName: String = "Johnny", + var surname: String = "Appleseed", + + @property:DynamoDbAttributeConverter(OccupationConverter::class) + var occupation: Occupation = Occupation("Student", 0), + + @property:DynamoDbAttributeConverter(HealthcareConverter::class) + var healthcare: Healthcare = Healthcare(false), +) + +data class Occupation(val title: String, val salary: Int) +data class Healthcare(val enrolled: Boolean) + +class OccupationConverter : ValueConverter { + override val right = MonoConverter { AttributeValue.S(it.title + "#" + it.salary) } + + override val left = MonoConverter { + val content = it.asS() + val (title, salary) = content.split("#") + Occupation(title, salary.toInt()) + } +} diff --git a/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/src/test/resources/attribute-converter/HealthcareConverter.kt b/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/src/test/resources/attribute-converter/HealthcareConverter.kt new file mode 100644 index 00000000000..c36a560b805 --- /dev/null +++ b/hll/dynamodb-mapper/dynamodb-mapper-schema-generator-plugin/src/test/resources/attribute-converter/HealthcareConverter.kt @@ -0,0 +1,21 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package a.different.pkg + +import aws.sdk.kotlin.hll.dynamodbmapper.values.ValueConverter +import aws.sdk.kotlin.hll.mapping.core.converters.MonoConverter +import aws.sdk.kotlin.services.dynamodb.model.AttributeValue +import org.example.Healthcare + +class HealthcareConverter : ValueConverter { + override val right = MonoConverter { AttributeValue.S(it.enrolled.toString()) } + + override val left = MonoConverter { + val content = it.asS() + val enrolled = (content == "true") + Healthcare(enrolled) + } +}