From 1ac2a9955163d7926c87b2fac49c769cfacfda7e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 7 Jul 2026 17:12:42 +0300 Subject: [PATCH 1/2] refactor: remove speculative abstraction from kuri-bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three simplifications in the reflection binder, all internal: - Collapse the UrlBinder/UriBinder interfaces (one implementer each, never referenced as a type — introduced only for a hypothetical future codegen backend) and their two near-identical ReflectiveBinder wrappers. KuriBind now drives a single shared BindingExecutor directly, dropping two redundant executor instances and two dead `check(bound === base)` postconditions. - Fold the duplicated iterable-fan-out query logic (applyQuery / applyQueryMapEntry) into one addQueryValues helper. - Merge the thin PlanCache wrapper into PlanCompiler, matching the inline ConcurrentHashMap-cache idiom the member scanner already uses, and dedupe the `type != Any::class` precondition to a single site. Two files are renamed to match their sole remaining declaration (Binder.kt -> Profile.kt, ReflectiveBinder.kt -> BindingExecutor.kt). One end-to-end test is retargeted from the removed ReflectiveUrlBinder class to BindingExecutor, preserving all its assertions. Pure refactor: binding behavior unchanged, public API unchanged. --- .../kotlin/org/dexpace/kuri/bind/Binder.kt | 29 ------- .../kotlin/org/dexpace/kuri/bind/Profile.kt | 8 ++ .../kotlin/org/dexpace/kuri/bind/KuriBind.kt | 26 +++---- ...ReflectiveBinder.kt => BindingExecutor.kt} | 77 +++++-------------- .../dexpace/kuri/bind/internal/PlanCache.kt | 35 --------- .../kuri/bind/internal/PlanCompiler.kt | 52 +++++++++---- .../bind/internal/ReflectiveBinderTest.kt | 34 ++++---- 7 files changed, 98 insertions(+), 163 deletions(-) delete mode 100644 kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Binder.kt create mode 100644 kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Profile.kt rename kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/{ReflectiveBinder.kt => BindingExecutor.kt} (90%) delete mode 100644 kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCache.kt diff --git a/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Binder.kt b/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Binder.kt deleted file mode 100644 index 7e21c5e..0000000 --- a/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Binder.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2026 dexpace and Omar Aljarrah - * SPDX-License-Identifier: MIT - */ -package org.dexpace.kuri.bind - -import org.dexpace.kuri.Uri -import org.dexpace.kuri.Url - -/** Which profile a bind targets. Fixed by the entry point / base-builder type. */ -internal enum class Profile { URL, URI } - -/** Binds an annotated object onto a `Url.Builder`. Implemented by reflection now; by codegen later. */ -internal interface UrlBinder { - fun bind( - base: Url.Builder, - target: Any, - options: BindOptions, - ): Url.Builder -} - -/** Binds an annotated object onto a `Uri.Builder`. */ -internal interface UriBinder { - fun bind( - base: Uri.Builder, - target: Any, - options: BindOptions, - ): Uri.Builder -} diff --git a/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Profile.kt b/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Profile.kt new file mode 100644 index 0000000..bb46941 --- /dev/null +++ b/kuri-bind/src/commonMain/kotlin/org/dexpace/kuri/bind/Profile.kt @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * SPDX-License-Identifier: MIT + */ +package org.dexpace.kuri.bind + +/** Which profile a bind targets. Fixed by the entry point / base-builder type. */ +internal enum class Profile { URL, URI } diff --git a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/KuriBind.kt b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/KuriBind.kt index 07f1486..c027460 100644 --- a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/KuriBind.kt +++ b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/KuriBind.kt @@ -6,11 +6,11 @@ package org.dexpace.kuri.bind import org.dexpace.kuri.Uri import org.dexpace.kuri.Url +import org.dexpace.kuri.bind.internal.BindingExecutor import org.dexpace.kuri.bind.internal.KotlinReflectMemberScanner -import org.dexpace.kuri.bind.internal.PlanCache import org.dexpace.kuri.bind.internal.PlanCompiler -import org.dexpace.kuri.bind.internal.ReflectiveUriBinder -import org.dexpace.kuri.bind.internal.ReflectiveUrlBinder +import org.dexpace.kuri.bind.internal.UriBuilderSink +import org.dexpace.kuri.bind.internal.UrlBuilderSink import kotlin.reflect.full.hasAnnotation import org.dexpace.kuri.bind.Uri as UriMarker import org.dexpace.kuri.bind.Url as UrlMarker @@ -43,11 +43,11 @@ import org.dexpace.kuri.bind.Url as UrlMarker * All methods are stateless and safe to call concurrently. */ public object KuriBind { - // One profile-agnostic plan cache backs both binders: a plan describes a `Url` and a `Uri` bind - // alike, so sharing it means each root type is compiled exactly once (see PlanCache). - private val cache = PlanCache(PlanCompiler(KotlinReflectMemberScanner())) - private val urlBinder = ReflectiveUrlBinder(cache) - private val uriBinder = ReflectiveUriBinder(cache) + // One shared executor backs every bind through either profile: its [PlanCompiler] caches a compiled + // plan per root type, and a plan describes a `Url` and a `Uri` bind alike, so each root type is + // compiled exactly once regardless of profile. The executor is stateless; each `bindInto` overload + // projects its accumulated result through the sink for the profile it targets. + private val executor = BindingExecutor(PlanCompiler(KotlinReflectMemberScanner())) /** * Binds [target] onto [base], returning the same builder so calls can chain. @@ -73,9 +73,8 @@ public object KuriBind { options: BindOptions = BindOptions.DEFAULT, ): Url.Builder { requireRoot(target, Profile.URL) - val bound = urlBinder.bind(base, target, options) - check(bound === base) { "url binder must return the same base builder it was given" } - return bound + executor.execute(target, options).projectInto(UrlBuilderSink(base)) + return base } /** @@ -96,9 +95,8 @@ public object KuriBind { options: BindOptions = BindOptions.DEFAULT, ): Uri.Builder { requireRoot(target, Profile.URI) - val bound = uriBinder.bind(base, target, options) - check(bound === base) { "uri binder must return the same base builder it was given" } - return bound + executor.execute(target, options).projectInto(UriBuilderSink(base)) + return base } /** diff --git a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinder.kt b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt similarity index 90% rename from kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinder.kt rename to kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt index 7ce910f..3f228ac 100644 --- a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinder.kt +++ b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt @@ -4,12 +4,8 @@ */ package org.dexpace.kuri.bind.internal -import org.dexpace.kuri.Uri -import org.dexpace.kuri.Url import org.dexpace.kuri.bind.BindOptions import org.dexpace.kuri.bind.KuriBindException -import org.dexpace.kuri.bind.UriBinder -import org.dexpace.kuri.bind.UrlBinder import java.util.Collections import java.util.IdentityHashMap @@ -24,7 +20,7 @@ import java.util.IdentityHashMap * the graph traversal itself. */ internal class BindingExecutor( - private val cache: PlanCache, + private val compiler: PlanCompiler, ) { /** Executes [target]'s plan into a fresh [ComponentSink], honoring [options]. */ fun execute( @@ -37,7 +33,7 @@ internal class BindingExecutor( // merge scope are suppressed for the entire walk; the flag rides along in [WalkContext]. The // root plan is fetched once here and threaded into [walk] (each merge step re-fetches its own // child plan), so the root type is not looked up twice. - val rootPlan = cache.planFor(target::class) + val rootPlan = compiler.planFor(target::class) val ctx = WalkContext(sink, options, active, depth = 0, pathTemplated = rootPlan.template != null) walk(target, rootPlan, ctx) return sink @@ -95,7 +91,7 @@ internal class BindingExecutor( value: Any, ctx: WalkContext, ) { - if ((step.op == LeafOp.QUERY || step.op == LeafOp.PATH) && runtimeValueIsBindable(value, cache)) { + if ((step.op == LeafOp.QUERY || step.op == LeafOp.PATH) && runtimeValueIsBindable(value, compiler)) { applyRecurse(BindStep.Recurse(step.member, scopeFor(step.op)), value, ctx) } else { LeafBinder.apply(step, value, ctx.sink) @@ -117,7 +113,7 @@ internal class BindingExecutor( ctx: WalkContext, ) { when (step.scope) { - Scope.MERGE -> walk(value, cache.planFor(value::class), ctx.deeper()) + Scope.MERGE -> walk(value, compiler.planFor(value::class), ctx.deeper()) Scope.QUERY -> recurseScoped(value, ctx, LeafOp.QUERY) // A templated root owns the path (see [applyLeafStep]); skip scoped path recursion too. Scope.PATH -> if (!ctx.pathTemplated) recurseScoped(value, ctx, LeafOp.PATH) @@ -142,7 +138,7 @@ internal class BindingExecutor( op: LeafOp, ) { require(op == LeafOp.QUERY || op == LeafOp.PATH) { "scoped op must be QUERY or PATH: $op" } - val plan = cache.planFor(nested::class) + val plan = compiler.planFor(nested::class) // A scoped `@Query` object contributes every query leaf it declares — both its `@Query` // params and its `@QueryMap` entries; a scoped `@Path` object contributes its path segments. val leaves = @@ -180,38 +176,6 @@ private class WalkContext( fun deeper(): WalkContext = WalkContext(sink, options, active, depth + 1, pathTemplated) } -/** Binds an annotated object onto a `Url.Builder` by reflection. */ -internal class ReflectiveUrlBinder( - cache: PlanCache, -) : UrlBinder { - private val executor = BindingExecutor(cache) - - override fun bind( - base: Url.Builder, - target: Any, - options: BindOptions, - ): Url.Builder { - executor.execute(target, options).projectInto(UrlBuilderSink(base)) - return base - } -} - -/** Binds an annotated object onto a `Uri.Builder` by reflection. */ -internal class ReflectiveUriBinder( - cache: PlanCache, -) : UriBinder { - private val executor = BindingExecutor(cache) - - override fun bind( - base: Uri.Builder, - target: Any, - options: BindOptions, - ): Uri.Builder { - executor.execute(target, options).projectInto(UriBuilderSink(base)) - return base - } -} - /** * Resolves a root `@PathTemplate` against its holes and emits the filled path in template order. * @@ -255,7 +219,7 @@ private object LeafBinder { LeafOp.PORT -> sink.setPort(convertPort(value, path), path) LeafOp.FRAGMENT -> sink.setFragment(scalarText(value), path) LeafOp.PATH -> applyPath(value, sink) - LeafOp.QUERY -> applyQuery(step.queryName ?: path, value, sink) + LeafOp.QUERY -> addQueryValues(step.queryName ?: path, value, sink) LeafOp.QUERY_MAP -> applyQueryMap(value, sink, path) // Userinfo leaves are paired into one contribution by UserInfoBinder, never applied here. LeafOp.USERINFO, LeafOp.USERNAME, LeafOp.PASSWORD -> Unit @@ -276,19 +240,6 @@ private object LeafBinder { sink: ComponentSink, ) = forEachScoped(value) { sink.addPathSegment(scalarText(it)) } - private fun applyQuery( - name: String, - value: Any, - sink: ComponentSink, - ) { - val iterable = asIterableOrNull(value) - if (iterable != null) { - iterable.forEach { sink.addQueryParameter(name, it?.let(::scalarText)) } - } else { - sink.addQueryParameter(name, scalarText(value)) - } - } - private fun applyQueryMapEntry( key: Any?, value: Any?, @@ -297,6 +248,18 @@ private object LeafBinder { ) { val name = key?.let(::scalarText) ?: return if (name.isEmpty()) throw KuriBindException("query parameter name must not be empty", path) + addQueryValues(name, value, sink) + } + + // Contributes a `@Query` value under [name]: a collection fans out into one parameter per element + // (a null element yields a valueless parameter), any other value becomes a single scalar parameter. + // The direct `@Query` leaf and each `@QueryMap` entry share this; they differ only in how [name] is + // derived. A non-null [value] takes the same paths (`value?.let(::x)` collapses to `x(value)`). + private fun addQueryValues( + name: String, + value: Any?, + sink: ComponentSink, + ) { val iterable = value?.let(::asIterableOrNull) if (iterable != null) { iterable.forEach { sink.addQueryParameter(name, it?.let(::scalarText)) } @@ -393,11 +356,11 @@ private fun forEachScoped( */ private fun runtimeValueIsBindable( value: Any, - cache: PlanCache, + compiler: PlanCompiler, ): Boolean { val scalarLike = value is CharSequence || value is Number || value is Char || value is Boolean || value is Enum<*> if (scalarLike || asIterableOrNull(value) != null || asMapOrNull(value) != null) return false - return cache.planFor(value::class).steps.isNotEmpty() + return compiler.planFor(value::class).steps.isNotEmpty() } /** The recursion scope matching a scalar-scoped leaf op. */ diff --git a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCache.kt b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCache.kt deleted file mode 100644 index fe8a4a7..0000000 --- a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCache.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2026 dexpace and Omar Aljarrah - * SPDX-License-Identifier: MIT - */ -package org.dexpace.kuri.bind.internal - -import java.util.concurrent.ConcurrentHashMap -import kotlin.reflect.KClass - -/** - * Compiles each type's [TypePlan] once and caches it, keyed by type; safe for concurrent binds. - * - * The cache is profile-agnostic: a plan describes both a `Url` and a `Uri` binding, since the profile - * only narrows which components project later (see [PlanCompiler]). One shared cache therefore backs - * both [ReflectiveUrlBinder] and [ReflectiveUriBinder], which differ only by the sink they project - * through — so a type compiled for a URL bind is reused verbatim for a URI bind. - */ -internal class PlanCache( - private val compiler: PlanCompiler, -) { - private val plans = ConcurrentHashMap, TypePlan>() - - /** - * The compiled plan for [type], computing and caching it on first use (compile-once per type). - * - * @throws KuriBindException when [type] cannot be compiled into a valid plan (delegated to - * [PlanCompiler.compile]). - */ - fun planFor(type: KClass<*>): TypePlan { - require(type != Any::class) { "cannot bind Any" } - val plan = plans.getOrPut(type) { compiler.compile(type) } - check(plan.type == type) { "plan cache returned a plan for ${plan.type} under key $type" } - return plan - } -} diff --git a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCompiler.kt b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCompiler.kt index 8515449..f41264b 100644 --- a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCompiler.kt +++ b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/PlanCompiler.kt @@ -15,6 +15,7 @@ import org.dexpace.kuri.bind.QueryMap import org.dexpace.kuri.bind.Scheme import org.dexpace.kuri.bind.UserInfo import org.dexpace.kuri.bind.Username +import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KClass import kotlin.reflect.full.findAnnotation import org.dexpace.kuri.bind.PathTemplate as PathTemplateAnn @@ -22,10 +23,33 @@ import org.dexpace.kuri.bind.Uri as UriMarker import org.dexpace.kuri.bind.Url as UrlMarker import org.dexpace.kuri.host.Host as KuriHost -/** Compiles a type into a validated [TypePlan], resolving any root `@PathTemplate` against its holes. */ +/** + * Compiles a type into a validated [TypePlan], resolving any root `@PathTemplate` against its holes, + * and caches the result so each type compiles at most once. + * + * The cache is profile-agnostic: a plan describes both a `Url` and a `Uri` binding, since the profile + * only narrows which components project later. One [PlanCompiler] therefore backs every bind through + * both profiles, and a type compiled for a URL bind is reused verbatim for a URI bind. Concurrent binds + * are safe: [planFor] memoizes through a [ConcurrentHashMap], and a rare double-compile under a race is + * harmless because compilation is pure and both racers produce an equal plan. + */ internal class PlanCompiler( private val scanner: MemberScanner, ) { + private val plans = ConcurrentHashMap, TypePlan>() + + /** + * The compiled plan for [type], computing and caching it on first use (compile-once per type). + * + * @throws KuriBindException when [type] cannot be compiled into a valid plan (delegated to + * [compile]). + */ + fun planFor(type: KClass<*>): TypePlan { + val plan = plans.getOrPut(type) { compile(type) } + check(plan.type == type) { "plan cache returned a plan for ${plan.type} under key $type" } + return plan + } + // The plan is profile-agnostic: the same steps describe a `Url` and a `Uri` binding, since the // profile only narrows which components project later. So `compile` takes just the type. // @@ -178,18 +202,6 @@ internal class PlanCompiler( private fun hasBindingMembers(type: KClass<*>): Boolean = scanner.scan(type).any { member -> member.annotations.any { it.isBindingMarker() } } - - private fun bindingAnnotation(member: ScannedMember): Annotation? { - val markers = member.annotations.filter { it.isBindingMarker() } - if (markers.size > 1) { - throw KuriBindException( - "member '${member.name}' has more than one binding annotation: " + - markers.joinToString { it.annotationClass.simpleName ?: "?" }, - member.name, - ) - } - return markers.singleOrNull() - } } // Enforces a bijection between the template's holes and the named-`@Path` providers reachable through @@ -242,6 +254,20 @@ private fun mergeStep(member: ScannedMember): BindStep { return BindStep.Recurse(member, Scope.MERGE) } +// The single binding marker on [member], or `null` when it carries none; more than one is a +// misconfiguration rejected at compile. Pure (it only inspects the member's annotations), so top-level. +private fun bindingAnnotation(member: ScannedMember): Annotation? { + val markers = member.annotations.filter { it.isBindingMarker() } + if (markers.size > 1) { + throw KuriBindException( + "member '${member.name}' has more than one binding annotation: " + + markers.joinToString { it.annotationClass.simpleName ?: "?" }, + member.name, + ) + } + return markers.singleOrNull() +} + private fun Annotation.isBindingMarker(): Boolean = when (this) { is Scheme, is UserInfo, is Username, is Password, is Host, is Port, diff --git a/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinderTest.kt b/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinderTest.kt index 90ed485..1bc3c46 100644 --- a/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinderTest.kt +++ b/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinderTest.kt @@ -67,37 +67,41 @@ private class SelfRef( ) class ReflectiveBinderTest { - private val binder = ReflectiveUrlBinder(PlanCache(PlanCompiler(KotlinReflectMemberScanner()))) + private val executor = BindingExecutor(PlanCompiler(KotlinReflectMemberScanner())) + + // Exercises the reflective walk end to end into a URL: this is exactly what the `@Url` entry point + // does after root validation — run the executor, then project the accumulated components onto a + // fresh Url.Builder through the URL sink. + private fun bind( + target: Any, + options: BindOptions = BindOptions.DEFAULT, + ): Url.Builder { + val base = Url.Builder() + executor.execute(target, options).projectInto(UrlBuilderSink(base)) + return base + } @Test fun `binds a templated request end to end`() { val target = Req("https", "h", "shoes", listOf("a", "b"), "x y") - val url = binder.bind(Url.Builder(), target, BindOptions.DEFAULT).build() - assertEquals("https://h/search/shoes/a/b?q=x%20y", url.toString()) + assertEquals("https://h/search/shoes/a/b?q=x%20y", bind(target).build().toString()) } @Test fun `combines sibling username and password into one userinfo`() { - val url = binder.bind(Url.Builder(), Creds("https", "h", "bob", "s3cret"), BindOptions.DEFAULT).build() + val url = bind(Creds("https", "h", "bob", "s3cret")).build() assertEquals("https://bob:s3cret@h/", url.toString()) } @Test fun `recurses a scoped query into the nested object's query members`() { val target = ScopedSearch("https", "h", QueryFilters("red", "large")) - val url = binder.bind(Url.Builder(), target, BindOptions.DEFAULT).build() - assertEquals("https://h/?color=red&size=large", url.toString()) + assertEquals("https://h/?color=red&size=large", bind(target).build().toString()) } @Test fun `merges a nested @Url sub-object host and port into the parent`() { - val url = - binder - .bind( - Url.Builder(), - MergedCall("https", HostPortEndpoint("h", 8443)), - BindOptions.DEFAULT, - ).build() + val url = bind(MergedCall("https", HostPortEndpoint("h", 8443))).build() assertEquals("https://h:8443/", url.toString()) } @@ -105,7 +109,7 @@ class ReflectiveBinderTest { fun `fails when the bind depth is exceeded`() { val deep = Chain(Chain(Chain(null))) assertFailsWith { - binder.bind(Url.Builder(), deep, BindOptions(maxDepth = 1)) + bind(deep, BindOptions(maxDepth = 1)) } } @@ -114,7 +118,7 @@ class ReflectiveBinderTest { val node = SelfRef("h", null) node.self = node assertFailsWith { - binder.bind(Url.Builder(), node, BindOptions.DEFAULT) + bind(node) } } } From 0229fae17cc45a06954c69d1d30f3c58e422bc22 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 8 Jul 2026 14:45:10 +0300 Subject: [PATCH 2/2] refactor: rename ReflectiveBinderTest to match BindingExecutor The reflective binder wrappers were removed along with the ReflectiveBinder.kt file, but the end-to-end test kept the old name for a type that no longer exists. Rename the file and class to BindingExecutorTest, matching the class it actually exercises, and reword the addQueryValues comment to explain why value is nullable rather than restating the null-safe call mechanics. --- .../kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt | 3 ++- .../{ReflectiveBinderTest.kt => BindingExecutorTest.kt} | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/{ReflectiveBinderTest.kt => BindingExecutorTest.kt} (99%) diff --git a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt index 3f228ac..b365686 100644 --- a/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt +++ b/kuri-bind/src/jvmMain/kotlin/org/dexpace/kuri/bind/internal/BindingExecutor.kt @@ -254,7 +254,8 @@ private object LeafBinder { // Contributes a `@Query` value under [name]: a collection fans out into one parameter per element // (a null element yields a valueless parameter), any other value becomes a single scalar parameter. // The direct `@Query` leaf and each `@QueryMap` entry share this; they differ only in how [name] is - // derived. A non-null [value] takes the same paths (`value?.let(::x)` collapses to `x(value)`). + // derived. [value] is nullable only so a `@QueryMap` entry's null value can pass through as a + // valueless parameter; the direct `@Query` path always reads a non-null value first. private fun addQueryValues( name: String, value: Any?, diff --git a/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinderTest.kt b/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/BindingExecutorTest.kt similarity index 99% rename from kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinderTest.kt rename to kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/BindingExecutorTest.kt index 1bc3c46..e429e8c 100644 --- a/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/ReflectiveBinderTest.kt +++ b/kuri-bind/src/jvmTest/kotlin/org/dexpace/kuri/bind/internal/BindingExecutorTest.kt @@ -66,7 +66,7 @@ private class SelfRef( @BindUrl var self: SelfRef?, ) -class ReflectiveBinderTest { +class BindingExecutorTest { private val executor = BindingExecutor(PlanCompiler(KotlinReflectMemberScanner())) // Exercises the reflective walk end to end into a URL: this is exactly what the `@Url` entry point