feat: delimited multi-value query params — QueryParameters.split and @Query(delimiter)#56
Merged
Merged
Conversation
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.
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".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.parsesplits the raw query only on&, so a single pair whose value packs several items behind an in-value delimiter is opaque: for?roles=admin,userthe caller gets back the whole"admin,user"string. Repeated pairs (x=1&x=2) already flatten viagetAll, but in-value delimiters (,,|,;, …) do not.Reads every pair for
namein appearance order and splits each decoded value ondelimiter, so repeated pairs and in-value delimiters both flatten into one list.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 plainmapin whatever error style they prefer.2. Binder —
@Query(delimiter)(write side)An iterable
@Queryfield 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; OpenAPIstyle: form, explode: false).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, `'