Skip to content

Commit 4e9f584

Browse files
Issue 230 data class with multiple constructors can select appropriate 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"
1 parent b507e07 commit 4e9f584

File tree

3 files changed

+125
-51
lines changed

3 files changed

+125
-51
lines changed

hoplite-core/src/main/kotlin/com/sksamuel/hoplite/decoder/DataClassDecoder.kt

Lines changed: 53 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -53,65 +53,70 @@ class DataClassDecoder : NullHandlingDecoder<Any> {
5353
return ConfigFailure.DataClassWithoutConstructor(klass).invalid()
5454
}
5555

56-
val constructor = klass.constructors.first()
57-
58-
// we have a special case, which is a data class with a single field with the name 'value'.
59-
// we call this a "value type" and we can instantiate a value directly into this data class
60-
// without needing nested config, if the node is a primitive type
61-
62-
// try for the value type
63-
if (constructor.parameters.size == 1 && constructor.parameters[0].name == "value" && node is PrimitiveNode) {
64-
return context.decoder(constructor.parameters[0])
65-
.flatMap { it.decode(node, constructor.parameters[0].type, context) }
66-
.map { constructor.parameters[0] to it }
67-
.mapInvalid { ConfigFailure.ValueTypeFailure(klass, constructor.parameters[0], it) }
68-
.flatMap { construct(type, constructor, mapOf(it)) }
69-
}
70-
71-
data class Arg(val parameter: KParameter,
56+
data class Arg(val constructor: KFunction<Any>,
57+
val parameter: KParameter,
7258
val configName: String, // the config value name that was used
7359
val value: Any?)
7460

75-
// create a map of parameter to value. in the case of defaults, we skip the parameter completely.
76-
val args: ValidatedNel<ConfigFailure, List<Arg>> = constructor.parameters.mapNotNull { param ->
77-
78-
var name = "<<undefined>>"
79-
80-
// try each parameter mapper in turn to find the node
81-
val n = context.paramMappers.fold<ParameterMapper, Node>(Undefined) { n, mapper ->
82-
if (n.isDefined) n else {
83-
name = mapper.map(param)
84-
node.atKey(name)
85-
}
61+
val argsList = klass.constructors.map { constructor ->
62+
63+
// try for the value type
64+
// we have a special case, which is a data class with a single field with the name 'value'.
65+
// we call this a "value type" and we can instantiate a value directly into this data class
66+
// without needing nested config, if the node is a primitive type
67+
if (constructor.parameters.size == 1 && constructor.parameters[0].name == "value" && node is PrimitiveNode) {
68+
return context.decoder(constructor.parameters[0])
69+
.flatMap { it.decode(node, constructor.parameters[0].type, context) }
70+
.map { constructor.parameters[0] to it }
71+
.mapInvalid { ConfigFailure.ValueTypeFailure(klass, constructor.parameters[0], it) }
72+
.flatMap { construct(type, constructor, mapOf(it)) }
8673
}
8774

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

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

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

112-
construct(type, constructor, args.value.map { it.parameter to it.value }.toMap())
113-
}
88+
when {
89+
// if we have no value for this parameter at all, and it is optional we can skip it, and
90+
// kotlin will use the default
91+
param.isOptional && n is Undefined -> null
92+
else -> context.decoder(param)
93+
.flatMap { it.decode(n, param.type, context) }
94+
.map { Arg(constructor, param, name, it) }
95+
.mapInvalid { ConfigFailure.ParamFailure(param, it) }
96+
}
97+
}.sequence()
98+
args
11499
}
100+
val firstValidOrLastInvalidArgs = argsList.firstOrNull{ it is Validated.Valid } ?:
101+
argsList.last { it is Validated.Invalid }
102+
return when (firstValidOrLastInvalidArgs) {
103+
// in invalid we wrap in an error containing each individual error
104+
is Validated.Invalid -> ConfigFailure.DataClassFieldErrors(
105+
firstValidOrLastInvalidArgs.error, type, node.pos).invalid()
106+
is Validated.Valid -> {
107+
108+
// in strict mode we throw an error if not all config values were used for the class
109+
if (node is MapNode) {
110+
if (context.mode == DecodeMode.Strict && firstValidOrLastInvalidArgs.value.size != node.size) {
111+
val unusedValues = node.map.keys.minus(firstValidOrLastInvalidArgs.value.map { it.configName })
112+
return ConfigFailure.UnusedConfigValues(unusedValues.toList()).invalid()
113+
}
114+
}
115+
116+
return construct(type, firstValidOrLastInvalidArgs.value.first().constructor,
117+
firstValidOrLastInvalidArgs.value.map { it.parameter to it.value }.toMap())
118+
}
119+
}
115120
}
116121

117122
private fun <A> construct(

hoplite-core/src/test/kotlin/com/sksamuel/hoplite/WithoutDefaultsRegistryTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class WithoutDefaultsRegistryTest : FunSpec() {
1010
init {
1111
test("default registry throws no error") {
1212
val loader = ConfigLoader {
13-
addMapSource(mapOf("custom_value" to "\${PATH}"))
13+
addMapSource(mapOf("custom_value" to "\${PATH}", "PATH" to "\${PATH}"))
1414
}
1515
val e = loader.loadConfig<Config>()
1616
e as Validated.Valid<Config>
@@ -41,14 +41,14 @@ class WithoutDefaultsRegistryTest : FunSpec() {
4141
test("empty preprocessors registry throws error") {
4242
val loader = ConfigLoader {
4343
withDefaultPreprocessors(false)
44-
addMapSource(mapOf("custom_value" to "\${PATH}"))
44+
addMapSource(mapOf("custom_value" to "\${PATH}", "PATH" to "\${PATH}"))
4545
}
4646
val e = loader.loadConfig<Config>()
4747
e as Validated.Valid<Config>
4848
e.value.customValue shouldBe "\${PATH}"
4949
}
5050

5151
}
52-
52+
// if your env vars is not "PATH" and is "Path" auto inject doesn't work
5353
data class Config(val PATH: String, val customValue: String)
5454
}

hoplite-core/src/test/kotlin/com/sksamuel/hoplite/decoder/DataClassDecoderTest.kt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import java.time.YearMonth
1717
import java.time.ZoneOffset
1818
import kotlin.reflect.full.createType
1919

20+
enum class FooEnum { FIRST, SECOND, THIRD }
2021
class DataClassDecoderTest : StringSpec() {
2122
init {
2223
"convert basic data class" {
@@ -132,5 +133,73 @@ class DataClassDecoderTest : StringSpec() {
132133
DecoderContext(defaultDecoderRegistry(), defaultParamMappers(), emptyList())
133134
) shouldBe Foo("value", "default b", false).valid()
134135
}
136+
137+
"supports single param value constructor" {
138+
data class Foo(val value: FooEnum)
139+
140+
val node = StringNode("SECOND", Pos.NoPos)
141+
142+
DataClassDecoder().decode(
143+
node,
144+
Foo::class.createType(),
145+
DecoderContext(defaultDecoderRegistry(), defaultParamMappers(), emptyList())
146+
) shouldBe Foo(FooEnum.SECOND).valid()
147+
}
148+
149+
"supports multiple constructors with single param value constructor" {
150+
data class Foo(val a: FooEnum, val b: String?, val c: Boolean?){
151+
constructor(value: FooEnum) : this( value, null, null)
152+
}
153+
154+
val node = StringNode("THIRD", Pos.NoPos)
155+
156+
DataClassDecoder().decode(
157+
node,
158+
Foo::class.createType(),
159+
DecoderContext(defaultDecoderRegistry(), defaultParamMappers(), emptyList())
160+
) shouldBe Foo(FooEnum.THIRD).valid()
161+
}
162+
163+
"calls multiple param constructors with type that has single param value constructor" {
164+
data class Foo(val a: FooEnum, val b: String?, val c: Boolean?){
165+
constructor(value: FooEnum) : this( value, null, null)
166+
}
167+
168+
val node = MapNode(
169+
mapOf(
170+
"a" to StringNode("FIRST", Pos.NoPos),
171+
"b" to StringNode("MultiParamCallExpected", Pos.NoPos),
172+
"c" to BooleanNode(false, Pos.NoPos)
173+
),
174+
Pos.NoPos
175+
)
176+
177+
DataClassDecoder().decode(
178+
node,
179+
Foo::class.createType(),
180+
DecoderContext(defaultDecoderRegistry(), defaultParamMappers(), emptyList())
181+
) shouldBe Foo(FooEnum.FIRST, "MultiParamCallExpected", false).valid()
182+
}
183+
184+
"calls partial param constructors with type that has single param value constructor" {
185+
data class Foo(val a: FooEnum, val b: String?, val c: Boolean?){
186+
constructor(value: FooEnum) : this( value, null, null)
187+
constructor(value: FooEnum, c: Boolean) : this( value, null, c)
188+
}
189+
190+
val node = MapNode(
191+
mapOf(
192+
"a" to StringNode("THIRD", Pos.NoPos),
193+
"c" to BooleanNode(true, Pos.NoPos)
194+
),
195+
Pos.NoPos
196+
)
197+
198+
DataClassDecoder().decode(
199+
node,
200+
Foo::class.createType(),
201+
DecoderContext(defaultDecoderRegistry(), defaultParamMappers(), emptyList())
202+
) shouldBe Foo(FooEnum.THIRD, true).valid()
203+
}
135204
}
136205
}

0 commit comments

Comments
 (0)