Skip to content

feat: delimited multi-value query params — QueryParameters.split and @Query(delimiter)#56

Merged
OmarAlJarrah merged 3 commits into
mainfrom
feat/query-value-split
Jul 10, 2026
Merged

feat: delimited multi-value query params — QueryParameters.split and @Query(delimiter)#56
OmarAlJarrah merged 3 commits into
mainfrom
feat/query-value-split

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Adds first-class support for delimited multi-value query parameters on both sides of the library — reading a delimited value apart in the core, and writing one from an annotated field in the binder. The two halves are inverses of each other.

1. Core — QueryParameters.split (read side)

QueryParameters.parse splits the raw query only on &, so a single pair whose value packs several items behind an in-value delimiter is opaque: for ?roles=admin,user the caller gets back the whole "admin,user" string. Repeated pairs (x=1&x=2) already flatten via getAll, but in-value delimiters (,, |, ;, …) do not.

public fun split(name: String, delimiter: Char): List<String>

Reads every pair for name in appearance order and splits each decoded value on delimiter, so repeated pairs and in-value delimiters both flatten into one list.

val q = Url.parseOrThrow("https://h/?roles=admin,user&perm=read|write&id=1,2&id=3").queryParameters
q.split("roles", ',')                  // ["admin", "user"]
q.split("perm", '|')                   // ["read", "write"]
q.split("id", ',')                     // ["1", "2", "3"]   — pairs and delimiters both flatten
q.split("id", ',').map(String::toInt)  // [1, 2, 3]         — conversion is just map()

Splitting is derived on demand (the snapshot is never mutated and round-trips unchanged), literal, and lossless — no trimming, no empty-token dropping (a,,b["a", "", "b"], x=[""], trailing empties kept). It operates on the decoded value, so a percent-encoded delimiter (a%2Cb) still splits. There is no typed-conversion layer: splitting cannot fail, so callers convert with a plain map in whatever error style they prefer.

2. Binder — @Query(delimiter) (write side)

An iterable @Query field always fanned out into repeated params (roles=admin&roles=user), with no way to emit the single delimited value many APIs expect (?fields=id,name,email; OpenAPI style: form, explode: false).

class Search(
    @Query("roles", ',') val roles: List<Role>,   // -> roles=admin,user
    @Query("tags")       val tags: List<String>,  // -> tags=a&tags=b   (unchanged)
)

When the delimiter is set on an iterable field, the non-null elements' scalar text joins on the delimiter into one parameter value. The default (a NUL sentinel, `'

QueryParameters.parse splits the raw query only on '&', so a single pair
whose value packs several items behind an in-value delimiter (roles=admin,user)
was opaque: getAll returned the whole "admin,user" string with no first-class
way to say "this value is a comma-delimited list".

Add split(name, delimiter): List<String>, a lazy read-time accessor that reads
every pair for a name and splits each decoded value on the given delimiter,
flattening repeated pairs and in-value delimiters into one list. Splitting is
literal and lossless (no trimming, empty tokens kept) and operates on decoded
values, so a percent-encoded delimiter still splits. It never fails; callers
convert with a plain map, e.g. split("id", ',').map(String::toInt).

Pairs with no '=' contribute nothing (there is no value to partition) and the
snapshot is never mutated, so existing round-tripping is unchanged. Adds the
full edge-case test matrix and a README recipe.
An iterable @query member always fanned out into repeated params
(roles=admin&roles=user), with no way to emit the single delimited value
many APIs expect (roles=admin,user; OpenAPI form / explode:false).

Add an optional delimiter to @query. When set on an iterable field, the
non-null elements' scalar text joins on the delimiter into one parameter
value; an empty or all-null collection emits no parameter, and a scalar
field ignores the delimiter. The default NUL sentinel preserves today's
fan-out, and @QueryMap never joins.

This is the write-side inverse of QueryParameters.split: a field written
with delimiter ',' reads back with split(name, ','). A join is lossy only
when an element's text contains the delimiter, the same caveat split carries.

Factor the shared "drop nulls, render each element to scalar text" step into
scalarTokens, reused by the join and the catch-all path expansion.
@OmarAlJarrah OmarAlJarrah changed the title feat: add QueryParameters.split for delimited multi-value query params feat: delimited multi-value query params — QueryParameters.split and @Query(delimiter) Jul 10, 2026
 joins

Add binder tests pinning two previously-uncovered behaviors of a delimited @query join: an empty-string element is retained and joins to a token (distinct from a null element, which is dropped, and an empty list, which emits no parameter), and the join is honored when a delimited list is nested inside a scoped @query object, not only as a direct root leaf.

Also correct the QueryParameters.split KDoc: it computes eagerly on each call, so describe it as "derived on demand" rather than "lazy".
@OmarAlJarrah OmarAlJarrah merged commit cce1fb7 into main Jul 10, 2026
12 checks passed
@OmarAlJarrah OmarAlJarrah deleted the feat/query-value-split branch July 10, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant