Skip to content

Commit

Permalink
Issue 230 data class with multiple constructors can select appropriat…
Browse files Browse the repository at this point in the history
…e constructor to load (#231)

* Issue 230 data class with multiple constructors only attempts to load class from first constructor.  This fix changes DataClassDecoder to loop over all available constructors in an attempt to load class from a MapNode set of params.

* Issue 230 fix WithoutDefaultsRegistryTest auto inject env var on "PATH" when env var is "Path"
  • Loading branch information
alexis-airwallex committed Oct 30, 2021
1 parent b507e07 commit 4e9f584
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 51 deletions.
Expand Up @@ -53,65 +53,70 @@ class DataClassDecoder : NullHandlingDecoder<Any> {
return ConfigFailure.DataClassWithoutConstructor(klass).invalid()
}

val constructor = klass.constructors.first()

// we have a special case, which is a data class with a single field with the name 'value'.
// we call this a "value type" and we can instantiate a value directly into this data class
// without needing nested config, if the node is a primitive type

// try for the value type
if (constructor.parameters.size == 1 && constructor.parameters[0].name == "value" && node is PrimitiveNode) {
return context.decoder(constructor.parameters[0])
.flatMap { it.decode(node, constructor.parameters[0].type, context) }
.map { constructor.parameters[0] to it }
.mapInvalid { ConfigFailure.ValueTypeFailure(klass, constructor.parameters[0], it) }
.flatMap { construct(type, constructor, mapOf(it)) }
}

data class Arg(val parameter: KParameter,
data class Arg(val constructor: KFunction<Any>,
val parameter: KParameter,
val configName: String, // the config value name that was used
val value: Any?)

// create a map of parameter to value. in the case of defaults, we skip the parameter completely.
val args: ValidatedNel<ConfigFailure, List<Arg>> = constructor.parameters.mapNotNull { param ->

var name = "<<undefined>>"

// try each parameter mapper in turn to find the node
val n = context.paramMappers.fold<ParameterMapper, Node>(Undefined) { n, mapper ->
if (n.isDefined) n else {
name = mapper.map(param)
node.atKey(name)
}
val argsList = klass.constructors.map { constructor ->

// try for the value type
// we have a special case, which is a data class with a single field with the name 'value'.
// we call this a "value type" and we can instantiate a value directly into this data class
// without needing nested config, if the node is a primitive type
if (constructor.parameters.size == 1 && constructor.parameters[0].name == "value" && node is PrimitiveNode) {
return context.decoder(constructor.parameters[0])
.flatMap { it.decode(node, constructor.parameters[0].type, context) }
.map { constructor.parameters[0] to it }
.mapInvalid { ConfigFailure.ValueTypeFailure(klass, constructor.parameters[0], it) }
.flatMap { construct(type, constructor, mapOf(it)) }
}

when {
// if we have no value for this parameter at all, and it is optional we can skip it, and
// kotlin will use the default
param.isOptional && n is Undefined -> null
else -> context.decoder(param)
.flatMap { it.decode(n, param.type, context) }
.map { Arg(param, name, it) }
.mapInvalid { ConfigFailure.ParamFailure(param, it) }
}
}.sequence()
// create a map of parameter to value. in the case of defaults, we skip the parameter completely.
val args: ValidatedNel<ConfigFailure, List<Arg>> = constructor.parameters.mapNotNull { param ->

return when (args) {
// in invalid we wrap in an error containing each individual error
is Validated.Invalid -> ConfigFailure.DataClassFieldErrors(args.error, type, node.pos).invalid()
is Validated.Valid -> {
var name = "<<undefined>>"

// in strict mode we throw an error if not all config values were used for the class
if (node is MapNode) {
if (context.mode == DecodeMode.Strict && args.value.size != node.size) {
val unusedValues = node.map.keys.minus(args.value.map { it.configName })
return ConfigFailure.UnusedConfigValues(unusedValues.toList()).invalid()
// try each parameter mapper in turn to find the node
val n = context.paramMappers.fold<ParameterMapper, Node>(Undefined) { n, mapper ->
if (n.isDefined) n else {
name = mapper.map(param)
node.atKey(name)
}
}

construct(type, constructor, args.value.map { it.parameter to it.value }.toMap())
}
when {
// if we have no value for this parameter at all, and it is optional we can skip it, and
// kotlin will use the default
param.isOptional && n is Undefined -> null
else -> context.decoder(param)
.flatMap { it.decode(n, param.type, context) }
.map { Arg(constructor, param, name, it) }
.mapInvalid { ConfigFailure.ParamFailure(param, it) }
}
}.sequence()
args
}
val firstValidOrLastInvalidArgs = argsList.firstOrNull{ it is Validated.Valid } ?:
argsList.last { it is Validated.Invalid }
return when (firstValidOrLastInvalidArgs) {
// in invalid we wrap in an error containing each individual error
is Validated.Invalid -> ConfigFailure.DataClassFieldErrors(
firstValidOrLastInvalidArgs.error, type, node.pos).invalid()
is Validated.Valid -> {

// in strict mode we throw an error if not all config values were used for the class
if (node is MapNode) {
if (context.mode == DecodeMode.Strict && firstValidOrLastInvalidArgs.value.size != node.size) {
val unusedValues = node.map.keys.minus(firstValidOrLastInvalidArgs.value.map { it.configName })
return ConfigFailure.UnusedConfigValues(unusedValues.toList()).invalid()
}
}

return construct(type, firstValidOrLastInvalidArgs.value.first().constructor,
firstValidOrLastInvalidArgs.value.map { it.parameter to it.value }.toMap())
}
}
}

private fun <A> construct(
Expand Down
Expand Up @@ -10,7 +10,7 @@ class WithoutDefaultsRegistryTest : FunSpec() {
init {
test("default registry throws no error") {
val loader = ConfigLoader {
addMapSource(mapOf("custom_value" to "\${PATH}"))
addMapSource(mapOf("custom_value" to "\${PATH}", "PATH" to "\${PATH}"))
}
val e = loader.loadConfig<Config>()
e as Validated.Valid<Config>
Expand Down Expand Up @@ -41,14 +41,14 @@ class WithoutDefaultsRegistryTest : FunSpec() {
test("empty preprocessors registry throws error") {
val loader = ConfigLoader {
withDefaultPreprocessors(false)
addMapSource(mapOf("custom_value" to "\${PATH}"))
addMapSource(mapOf("custom_value" to "\${PATH}", "PATH" to "\${PATH}"))
}
val e = loader.loadConfig<Config>()
e as Validated.Valid<Config>
e.value.customValue shouldBe "\${PATH}"
}

}

// if your env vars is not "PATH" and is "Path" auto inject doesn't work
data class Config(val PATH: String, val customValue: String)
}
Expand Up @@ -17,6 +17,7 @@ import java.time.YearMonth
import java.time.ZoneOffset
import kotlin.reflect.full.createType

enum class FooEnum { FIRST, SECOND, THIRD }
class DataClassDecoderTest : StringSpec() {
init {
"convert basic data class" {
Expand Down Expand Up @@ -132,5 +133,73 @@ class DataClassDecoderTest : StringSpec() {
DecoderContext(defaultDecoderRegistry(), defaultParamMappers(), emptyList())
) shouldBe Foo("value", "default b", false).valid()
}

"supports single param value constructor" {
data class Foo(val value: FooEnum)

val node = StringNode("SECOND", Pos.NoPos)

DataClassDecoder().decode(
node,
Foo::class.createType(),
DecoderContext(defaultDecoderRegistry(), defaultParamMappers(), emptyList())
) shouldBe Foo(FooEnum.SECOND).valid()
}

"supports multiple constructors with single param value constructor" {
data class Foo(val a: FooEnum, val b: String?, val c: Boolean?){
constructor(value: FooEnum) : this( value, null, null)
}

val node = StringNode("THIRD", Pos.NoPos)

DataClassDecoder().decode(
node,
Foo::class.createType(),
DecoderContext(defaultDecoderRegistry(), defaultParamMappers(), emptyList())
) shouldBe Foo(FooEnum.THIRD).valid()
}

"calls multiple param constructors with type that has single param value constructor" {
data class Foo(val a: FooEnum, val b: String?, val c: Boolean?){
constructor(value: FooEnum) : this( value, null, null)
}

val node = MapNode(
mapOf(
"a" to StringNode("FIRST", Pos.NoPos),
"b" to StringNode("MultiParamCallExpected", Pos.NoPos),
"c" to BooleanNode(false, Pos.NoPos)
),
Pos.NoPos
)

DataClassDecoder().decode(
node,
Foo::class.createType(),
DecoderContext(defaultDecoderRegistry(), defaultParamMappers(), emptyList())
) shouldBe Foo(FooEnum.FIRST, "MultiParamCallExpected", false).valid()
}

"calls partial param constructors with type that has single param value constructor" {
data class Foo(val a: FooEnum, val b: String?, val c: Boolean?){
constructor(value: FooEnum) : this( value, null, null)
constructor(value: FooEnum, c: Boolean) : this( value, null, c)
}

val node = MapNode(
mapOf(
"a" to StringNode("THIRD", Pos.NoPos),
"c" to BooleanNode(true, Pos.NoPos)
),
Pos.NoPos
)

DataClassDecoder().decode(
node,
Foo::class.createType(),
DecoderContext(defaultDecoderRegistry(), defaultParamMappers(), emptyList())
) shouldBe Foo(FooEnum.THIRD, true).valid()
}
}
}

0 comments on commit 4e9f584

Please sign in to comment.