From 9156b4bc1f6d9a93238e9beeb515d06f13321c9e Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 3 Nov 2025 22:40:43 +0000 Subject: [PATCH 1/3] :sparkles: add `EncodeOptions.commaCompactNulls` option to compact nulls in comma-separated lists --- .../techouse/qskotlin/internal/Encoder.kt | 47 ++++++++++++++----- .../techouse/qskotlin/models/EncodeOptions.kt | 11 +++++ .../kotlin/io/github/techouse/qskotlin/qs.kt | 3 ++ 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Encoder.kt b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Encoder.kt index 617fc81..6031dfe 100644 --- a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Encoder.kt +++ b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Encoder.kt @@ -25,6 +25,7 @@ internal object Encoder { * @param prefix An optional prefix for the encoded string. * @param generateArrayPrefix A generator for array prefixes. * @param commaRoundTrip If true, uses comma for array encoding. + * @param commaCompactNulls If true, compacts nulls in comma-separated lists. * @param allowEmptyLists If true, allows empty lists in the output. * @param strictNullHandling If true, handles nulls strictly. * @param skipNulls If true, skips null values in the output. @@ -47,6 +48,7 @@ internal object Encoder { prefix: String? = null, generateArrayPrefix: ListFormatGenerator? = null, commaRoundTrip: Boolean? = null, + commaCompactNulls: Boolean = false, allowEmptyLists: Boolean = false, strictNullHandling: Boolean = false, skipNulls: Boolean = false, @@ -65,8 +67,9 @@ internal object Encoder { val prefix: String = prefix ?: if (addQueryPrefix) "?" else "" val generateArrayPrefix: ListFormatGenerator = generateArrayPrefix ?: ListFormat.INDICES.generator - val commaRoundTrip: Boolean = - commaRoundTrip ?: (generateArrayPrefix == ListFormat.COMMA.generator) + val isCommaGenerator = generateArrayPrefix == ListFormat.COMMA.generator + val commaRoundTrip: Boolean = commaRoundTrip ?: isCommaGenerator + val compactNulls = commaCompactNulls && isCommaGenerator var obj: Any? = data @@ -140,18 +143,30 @@ internal object Encoder { return values } + var effectiveCommaLength: Int? = null + val objKeys: List = when { - generateArrayPrefix == ListFormat.COMMA.generator && obj is Iterable<*> -> { - // we need to join elements in - if (encodeValuesOnly && encoder != null) { - obj = obj.map { el -> el?.let { encoder(it.toString(), null, null) } ?: "" } - } + isCommaGenerator && obj is Iterable<*> -> { + // materialize once for reuse + val items = obj.toList() + val filtered = if (compactNulls) items.filterNotNull() else items - if (obj.iterator().hasNext()) { - val objKeysValue = obj.joinToString(",") { el -> el?.toString() ?: "" } + effectiveCommaLength = filtered.size - listOf(mapOf("value" to objKeysValue.ifEmpty { null })) + val joinSource = + if (encodeValuesOnly && encoder != null) { + filtered.map { el -> + el?.let { encoder(it.toString(), null, null) } ?: "" + } + } else { + filtered.map { el -> el?.toString() ?: "" } + } + + if (joinSource.isNotEmpty()) { + val joined = joinSource.joinToString(",") + + listOf(mapOf("value" to joined.ifEmpty { null })) } else { listOf(mapOf("value" to Undefined.Companion())) } @@ -180,7 +195,16 @@ internal object Encoder { val encodedPrefix: String = if (encodeDotInKeys) prefix.replace(".", "%2E") else prefix val adjustedPrefix: String = - if ((commaRoundTrip && obj is Iterable<*> && obj.count() == 1)) "$encodedPrefix[]" + if ( + commaRoundTrip && + obj is Iterable<*> && + (if (isCommaGenerator && effectiveCommaLength != null) { + effectiveCommaLength == 1 + } else { + obj.count() == 1 + }) + ) + "$encodedPrefix[]" else encodedPrefix if (allowEmptyLists && obj is Iterable<*> && !obj.iterator().hasNext()) { @@ -253,6 +277,7 @@ internal object Encoder { prefix = keyPrefix, generateArrayPrefix = generateArrayPrefix, commaRoundTrip = commaRoundTrip, + commaCompactNulls = commaCompactNulls, allowEmptyLists = allowEmptyLists, strictNullHandling = strictNullHandling, skipNulls = skipNulls, diff --git a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/EncodeOptions.kt b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/EncodeOptions.kt index dd36184..b9cdc43 100644 --- a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/EncodeOptions.kt +++ b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/EncodeOptions.kt @@ -132,6 +132,12 @@ data class EncodeOptions( */ val commaRoundTrip: Boolean? = null, + /** + * When listFormat is set to ListFormat.COMMA, drop `null` items before joining instead of + * preserving empty slots. + */ + val commaCompactNulls: Boolean = false, + /** Set a Sorter to affect the order of parameter keys. */ val sort: Sorter? = null, ) { @@ -220,6 +226,7 @@ data class EncodeOptions( private var skipNulls: Boolean = false private var strictNullHandling: Boolean = false private var commaRoundTrip: Boolean? = null + private var commaCompactNulls: Boolean = false private var sort: Sorter? = null /** Provide a Kotlin [ValueEncoder]. Ignored when [encode] is `false`. */ @@ -300,6 +307,9 @@ data class EncodeOptions( /** With COMMA listFormat, append `[]` on single-item lists to allow round trip. */ fun commaRoundTrip(value: Boolean?) = apply { this.commaRoundTrip = value } + /** With COMMA listFormat, drop `null` entries before joining for more compact payloads. */ + fun commaCompactNulls(value: Boolean) = apply { this.commaCompactNulls = value } + /** Java-friendly key sorter; adapted to [Sorter]. */ fun sort(comparator: java.util.Comparator) = apply { this.sort = { a, b -> comparator.compare(a, b) } @@ -327,6 +337,7 @@ data class EncodeOptions( skipNulls = skipNulls, strictNullHandling = strictNullHandling, commaRoundTrip = commaRoundTrip, + commaCompactNulls = commaCompactNulls, sort = sort, ) } diff --git a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/qs.kt b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/qs.kt index f6e4ac6..a5aa2ae 100644 --- a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/qs.kt +++ b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/qs.kt @@ -154,6 +154,9 @@ fun encode(data: Any?, options: EncodeOptions? = null): String { commaRoundTrip = options.getListFormat.generator == ListFormat.COMMA.generator && options.commaRoundTrip == true, + commaCompactNulls = + options.getListFormat.generator == ListFormat.COMMA.generator && + options.commaCompactNulls, allowEmptyLists = options.allowEmptyLists, strictNullHandling = options.strictNullHandling, skipNulls = options.skipNulls, From c813b582e941cf77808477d1dfb32eb3fec1eabf Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 3 Nov 2025 22:40:58 +0000 Subject: [PATCH 2/3] :white_check_mark: add tests for `commaCompactNulls` option in encoding behavior --- .../techouse/qskotlin/unit/QsParserSpec.kt | 55 +++++++++++++++++++ .../qskotlin/unit/models/EncodeOptionsSpec.kt | 5 ++ 2 files changed, 60 insertions(+) diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt index f19e472..c6eb9c8 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt @@ -948,6 +948,61 @@ class QsParserSpec : "a=%2C&b=&c=c%2Cd%25" } + it("should drop null entries when commaCompactNulls is enabled") { + val value = mapOf("a" to mapOf("b" to listOf("one", null, "two", null, "three"))) + + encode(value, EncodeOptions(encode = false, listFormat = ListFormat.COMMA)) shouldBe + "a[b]=one,,two,,three" + + encode( + value, + EncodeOptions( + encode = false, + listFormat = ListFormat.COMMA, + commaCompactNulls = true, + ), + ) shouldBe "a[b]=one,two,three" + } + + it("should omit key when commaCompactNulls strips all values") { + val value = mapOf("a" to listOf(null, null)) + + encode(value, EncodeOptions(encode = false, listFormat = ListFormat.COMMA)) shouldBe + "a=," // baseline behaviour keeps empty slots + + encode( + value, + EncodeOptions( + encode = false, + listFormat = ListFormat.COMMA, + commaCompactNulls = true, + ), + ) shouldBe "" + } + + it("should preserve round-trip marker after compacting nulls") { + val value = mapOf("a" to listOf(null, "foo")) + + encode( + value, + EncodeOptions( + encode = false, + listFormat = ListFormat.COMMA, + commaRoundTrip = true, + ), + ) shouldBe "a=,foo" + + encode( + value, + EncodeOptions( + encode = false, + listFormat = ListFormat.COMMA, + commaRoundTrip = true, + commaCompactNulls = true, + ), + ) shouldBe "a[]=foo" + } + it("should stringify nested array values with dots notation") { val value = mapOf("a" to mapOf("b" to listOf("c", "d"))) val options = EncodeOptions(allowDots = true, encodeValuesOnly = true) diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/EncodeOptionsSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/EncodeOptionsSpec.kt index b43eb8b..be1577d 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/EncodeOptionsSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/EncodeOptionsSpec.kt @@ -31,6 +31,7 @@ class EncodeOptionsSpec : skipNulls = true, strictNullHandling = true, commaRoundTrip = true, + commaCompactNulls = true, ) val newOptions = options.copy() @@ -49,6 +50,7 @@ class EncodeOptionsSpec : newOptions.skipNulls shouldBe true newOptions.strictNullHandling shouldBe true newOptions.commaRoundTrip shouldBe true + newOptions.commaCompactNulls shouldBe true newOptions shouldBe options } @@ -69,6 +71,7 @@ class EncodeOptionsSpec : skipNulls = true, strictNullHandling = true, commaRoundTrip = true, + commaCompactNulls = true, ) val newOptions = @@ -87,6 +90,7 @@ class EncodeOptionsSpec : skipNulls = false, strictNullHandling = false, commaRoundTrip = false, + commaCompactNulls = false, filter = FunctionFilter { _: String, _: Any? -> emptyMap() }, ) @@ -104,6 +108,7 @@ class EncodeOptionsSpec : newOptions.skipNulls shouldBe false newOptions.strictNullHandling shouldBe false newOptions.commaRoundTrip shouldBe false + newOptions.commaCompactNulls shouldBe false } it("builder produces java-friendly configuration") { From 465efe66e488def2938a035df3799f4b95798102 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 3 Nov 2025 22:41:42 +0000 Subject: [PATCH 3/3] :memo: update README to include notes on `commaRoundTrip` and `commaCompactNulls` options --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index c19ff6a..2f46ff4 100644 --- a/README.md +++ b/README.md @@ -795,6 +795,12 @@ QS.encode( // => "a=b,c" ``` +**Note:** When `ListFormat.COMMA` is selected, you can also set `EncodeOptions.commaRoundTrip` to +`true` or `false` to append `[]` on single-element lists so they round-trip through decoding. Set +`EncodeOptions.commaCompactNulls` to `true` alongside the comma format when you'd like to drop +`null` entries instead of preserving empty slots (for example, `listOf("one", null, "two")` +becomes `one,two`). + ### Nested maps Kotlin: