Skip to content

Commit 7fbdef3

Browse files
Squirysalatmaster
andauthored
Add support for kotlin data class default values in configs (#585)
Co-authored-by: A Y <38766980+salatmaster@users.noreply.github.com>
1 parent 42e7c5d commit 7fbdef3

3 files changed

Lines changed: 138 additions & 47 deletions

File tree

config/config-symbol-processor/src/main/kotlin/ru/tinkoff/kora/config/ksp/ConfigParserGenerator.kt

Lines changed: 53 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -45,24 +45,6 @@ class ConfigParserGenerator(private val resolver: Resolver) {
4545
val implClassName = ClassName(packageName, typeName, element.simpleName.asString() + "_Impl")
4646
if (defaultsType != null) {
4747
typeBuilder.addType(defaultsType)
48-
val defaultImplClassName = ClassName(packageName, typeName, element.simpleName.asString() + "_Defaults")
49-
val property = PropertySpec.builder("DEFAULTS", defaultImplClassName, KModifier.PRIVATE, KModifier.FINAL)
50-
.initializer("%T()", defaultImplClassName)
51-
typeBuilder.addProperty(property.build())
52-
if (!hasRequiredFields) {
53-
val initializer = CodeBlock.builder().add("%T(", implClassName).indent()
54-
for (i in fields.indices) {
55-
if (i > 0) {
56-
initializer.add(",")
57-
}
58-
if (fields[i].hasDefault) {
59-
initializer.add("\n DEFAULTS.%N()", fields[i].name)
60-
} else {
61-
initializer.add("\n null")
62-
}
63-
}
64-
initializer.unindent().add("\n)")
65-
}
6648
} else {
6749
if (fields.isEmpty()) {
6850
typeBuilder.addProperty(
@@ -126,19 +108,7 @@ class ConfigParserGenerator(private val resolver: Resolver) {
126108
.generated(ConfigParserGenerator::class)
127109
.addOriginatingKSFile(element)
128110
val fields = f.value
129-
val hasRequiredFields = fields.stream()
130-
.anyMatch { !it.hasDefault && !it.isNullable }
131111
val implClassName = element.toClassName()
132-
if (!hasRequiredFields) {
133-
val initializer = CodeBlock.builder().add("%T(", implClassName).indent()
134-
for (i in fields.indices) {
135-
if (i > 0) {
136-
initializer.add(",")
137-
}
138-
initializer.add("\nnull")
139-
}
140-
initializer.unindent().add("\n)")
141-
}
142112
val constructor = buildConstructor(typeBuilder, fields)
143113
typeBuilder.primaryConstructor(constructor)
144114
typeBuilder.addFunction(buildExtractMethod(element, targetType.toTypeName(), implClassName, fields))
@@ -268,7 +238,7 @@ class ConfigParserGenerator(private val resolver: Resolver) {
268238

269239
private fun buildDefaultsType(type: KSType, typeDecl: KSClassDeclaration, fields: List<ConfigField>): TypeSpec? {
270240
var hasDefaults = false
271-
val defaults = TypeSpec.classBuilder(typeDecl.simpleName.asString() + "_Defaults")
241+
val defaults = TypeSpec.objectBuilder(typeDecl.simpleName.asString() + "_Defaults")
272242
.addModifiers(KModifier.PRIVATE)
273243
.addSuperinterface(type.toTypeName())
274244
for (tp in typeDecl.typeParameters) {
@@ -332,8 +302,36 @@ class ConfigParserGenerator(private val resolver: Resolver) {
332302
rootParse.returns(typeName.copy(true))
333303
}
334304
}
305+
val isDataClassWithDefaults = typeDecl.classKind == ClassKind.CLASS
306+
&& typeDecl.modifiers.contains(Modifier.DATA)
307+
&& fields.any { it.hasDefault }
308+
val isPojo = typeDecl.classKind == ClassKind.CLASS && !typeDecl.modifiers.contains(Modifier.DATA) && !typeDecl.isRecord()
309+
310+
// Parse required fields first
335311
for (field in fields) {
336-
rootParse.addStatement("val %N = this.%N(_config)", field.name, "parse_${field.name}")
312+
if (!field.hasDefault) {
313+
rootParse.addStatement("val %N = this.%N(_config)", field.name, "parse_${field.name}")
314+
}
315+
}
316+
317+
if (isDataClassWithDefaults) {
318+
// Create local _defaults using parsed required fields (Kotlin fills in default values)
319+
val requiredNamedArgs = fields.filter { !it.hasDefault }.joinToCode(",\n") { CodeBlock.of("%N = %N", it.name, it.name) }
320+
rootParse.addStatement("val _defaults = %T(%L)", implClassName, requiredNamedArgs)
321+
}
322+
if (isPojo) {
323+
// todo generics?
324+
rootParse.addStatement("val _defaults = %T()", typeDecl.toTypeName())
325+
}
326+
val defaults = when {
327+
isDataClassWithDefaults || isPojo -> CodeBlock.of("%N", "_defaults")
328+
else -> CodeBlock.of("%T", implClassName.peerClass(typeDecl.simpleName.asString() + "_Defaults"))
329+
}
330+
331+
for (field in fields) {
332+
if (field.hasDefault) {
333+
rootParse.addStatement("val %N = this.%N(%L, _config)", field.name, "parse_${field.name}", defaults)
334+
}
337335
}
338336
if (typeDecl.classKind == ClassKind.CLASS && !typeDecl.modifiers.contains(Modifier.DATA) && !typeDecl.isRecord()) {
339337
rootParse.addStatement("val _result = %T()", implClassName)
@@ -364,34 +362,43 @@ class ConfigParserGenerator(private val resolver: Resolver) {
364362
val parse = FunSpec.builder("parse_" + field.name)
365363
.addModifiers(KModifier.PRIVATE)
366364
.returns(field.typeName)
365+
if (field.hasDefault) {
366+
parse.addParameter("defaults", typeDecl.toTypeName())
367+
}
367368
parse.addParameter("config", ConfigClassNames.objectValue)
368-
parse.addStatement("var value = config.get(%N)", "_${field.name}_path")
369-
val isSupportedType = field.mapping == null && supportedTypes.containsKey(field.typeName)
370-
parse.controlFlow("if (value is %T.NullValue)", ConfigClassNames.configValue) {
371-
if (field.isNullable && !field.hasDefault) {
372-
addStatement("return null")
373-
} else if (field.hasDefault) {
369+
parse.addStatement("val value = config.get(%N)", "_${field.name}_path")
370+
371+
val returnDefaultOrThrow = CodeBlock.builder().apply {
372+
if (field.hasDefault) {
374373
if (typeDecl.classKind == ClassKind.INTERFACE) {
375-
addStatement("return DEFAULTS.%N()", field.name)
374+
addStatement("return defaults.%N()", field.name)
376375
} else {
377-
addStatement("return DEFAULTS.%N", field.name)
376+
addStatement("return defaults.%N", field.name)
378377
}
379-
} else if (isSupportedType) {
378+
} else if (field.isNullable) {
379+
addStatement("return null")
380+
} else {
380381
addStatement("throw %T.missingValue(value)", ConfigClassNames.configValueExtractionException)
381382
}
382-
}
383+
}.build()
383384

385+
val isSupportedType = field.mapping == null && supportedTypes.containsKey(field.typeName)
384386
if (isSupportedType) {
387+
parse.controlFlow("if (value is %T.NullValue)", ConfigClassNames.configValue) {
388+
addCode(returnDefaultOrThrow)
389+
}
385390
parse.addStatement("return %L", this.parseSupportedType(field.typeName))
386391
} else if (field.isNullable) {
392+
parse.controlFlow("if (value is %T.NullValue)", ConfigClassNames.configValue) {
393+
addCode(returnDefaultOrThrow)
394+
}
387395
parse.addStatement("return %N.extract(value)", "${field.name}_parser")
388396
} else {
389-
parse.addStatement("var parsed = %N.extract(value)", "${field.name}_parser")
397+
parse.addStatement("val parsed = %N.extract(value)", "${field.name}_parser")
390398
parse.controlFlow("if (parsed == null)") {
391-
parse.addStatement("throw %T.missingValueAfterParse(value)", ConfigClassNames.configValueExtractionException)
392-
parse.nextControlFlow("else")
393-
parse.addStatement("return parsed")
399+
addCode(returnDefaultOrThrow)
394400
}
401+
parse.addStatement("return parsed")
395402
}
396403
return parse.build()
397404
}

config/config-symbol-processor/src/main/kotlin/ru/tinkoff/kora/config/ksp/ConfigUtils.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ object ConfigUtils {
101101
val fieldType = parameter.type.resolve()
102102
val isNullable = fieldType.isMarkedNullable
103103
val mapping = parameter.parseMappingData().getMapping(ConfigClassNames.configValueExtractor)
104-
fields.add(ConfigField(name, fieldType.toTypeName(), isNullable, false, false, mapping))
104+
fields.add(ConfigField(name, fieldType.toTypeName(), isNullable, parameter.hasDefault, false, mapping))
105105
}
106106
}
107107

config/config-symbol-processor/src/test/kotlin/ru/tinkoff/kora/config/symbol/processor/AnnotationConfigTest.kt

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,90 @@ class AnnotationConfigTest : AbstractConfigTest() {
322322
.isEqualTo(new("TestConfig", Duration.ofDays(3000), null))
323323
}
324324

325+
@Test
326+
fun testDataClassWithDefaultValue() {
327+
val extractor = compileConfig(
328+
listOf<Any>(), """
329+
@ConfigValueExtractor
330+
data class TestConfig(val value1: String, val value2: String = "default-value")
331+
""".trimIndent()
332+
)
333+
334+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test1", "value2" to "test2")).root()))
335+
.isEqualTo(new("TestConfig", "test1", "test2"))
336+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test1")).root()))
337+
.isEqualTo(new("TestConfig", "test1", "default-value"))
338+
}
339+
340+
@Test
341+
fun testDataClassWithAllDefaults() {
342+
val extractor = compileConfig(
343+
listOf<Any>(), """
344+
@ConfigValueExtractor
345+
data class TestConfig(val value1: String = "default1", val value2: Int = 42)
346+
""".trimIndent()
347+
)
348+
349+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test1", "value2" to 100)).root()).toString())
350+
.isEqualTo("TestConfig(value1=test1, value2=100)")
351+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test1")).root()).toString())
352+
.isEqualTo("TestConfig(value1=test1, value2=42)")
353+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value2" to 100)).root()).toString())
354+
.isEqualTo("TestConfig(value1=default1, value2=100)")
355+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf<String, Any>()).root()).toString())
356+
.isEqualTo("TestConfig(value1=default1, value2=42)")
357+
}
358+
359+
@Test
360+
fun testDataClassWithDefaultAndNullable() {
361+
val extractor = compileConfig(
362+
listOf<Any>(), """
363+
@ConfigValueExtractor
364+
data class TestConfig(val value1: String, val value2: String? = "default-value")
365+
""".trimIndent()
366+
)
367+
368+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test1", "value2" to "test2")).root()))
369+
.isEqualTo(new("TestConfig", "test1", "test2"))
370+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test1")).root()))
371+
.isEqualTo(new("TestConfig", "test1", "default-value"))
372+
}
373+
374+
@Test
375+
fun testDataClassWithMultipleDefaults() {
376+
val extractor = compileConfig(
377+
listOf<Any>(), """
378+
@ConfigValueExtractor
379+
data class TestConfig(val value1: String, val value2: String = "default2", val value3: Int = 42, val value4: Boolean = true)
380+
""".trimIndent()
381+
)
382+
383+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test1")).root()))
384+
.isEqualTo(new("TestConfig", "test1", "default2", 42, true))
385+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test1", "value2" to "v2", "value3" to 100, "value4" to false)).root()))
386+
.isEqualTo(new("TestConfig", "test1", "v2", 100, false))
387+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test1", "value3" to 100)).root()))
388+
.isEqualTo(new("TestConfig", "test1", "default2", 100, true))
389+
}
390+
391+
@Test
392+
fun testDataClassWithDefaultAndCustomType() {
393+
val mapper = Mockito.mock(ConfigValueExtractor::class.java)
394+
whenever(mapper.extract(ArgumentMatchers.isA(StringValue::class.java))).thenReturn(Duration.ofDays(3000))
395+
396+
val extractor = compileConfig(
397+
listOf(mapper), """
398+
@ConfigValueExtractor
399+
data class TestConfig(val value1: java.time.Duration, val value2: String = "default-value")
400+
""".trimIndent()
401+
)
402+
403+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test")).root()).toString())
404+
.isEqualTo("TestConfig(value1=PT72000H, value2=default-value)")
405+
assertThat(extractor.extract(MapConfigFactory.fromMap(mapOf("value1" to "test", "value2" to "custom")).root()).toString())
406+
.isEqualTo("TestConfig(value1=PT72000H, value2=custom)")
407+
}
408+
325409
@Test
326410
fun testEmptyConfig() {
327411
val extractor = compileConfig(

0 commit comments

Comments
 (0)