Skip to content

Commit 1107d04

Browse files
authored
✨ add EncodeOptions.commaCompactNulls (#49)
1 parent 6ffd2cb commit 1107d04

File tree

6 files changed

+116
-11
lines changed

6 files changed

+116
-11
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,12 @@ QS.encode(
795795
// => "a=b,c"
796796
```
797797

798+
**Note:** When `ListFormat.COMMA` is selected, you can also set `EncodeOptions.commaRoundTrip` to
799+
`true` or `false` to append `[]` on single-element lists so they round-trip through decoding. Set
800+
`EncodeOptions.commaCompactNulls` to `true` alongside the comma format when you'd like to drop
801+
`null` entries instead of preserving empty slots (for example, `listOf("one", null, "two")`
802+
becomes `one,two`).
803+
798804
### Nested maps
799805

800806
Kotlin:

qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Encoder.kt

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ internal object Encoder {
2525
* @param prefix An optional prefix for the encoded string.
2626
* @param generateArrayPrefix A generator for array prefixes.
2727
* @param commaRoundTrip If true, uses comma for array encoding.
28+
* @param commaCompactNulls If true, compacts nulls in comma-separated lists.
2829
* @param allowEmptyLists If true, allows empty lists in the output.
2930
* @param strictNullHandling If true, handles nulls strictly.
3031
* @param skipNulls If true, skips null values in the output.
@@ -47,6 +48,7 @@ internal object Encoder {
4748
prefix: String? = null,
4849
generateArrayPrefix: ListFormatGenerator? = null,
4950
commaRoundTrip: Boolean? = null,
51+
commaCompactNulls: Boolean = false,
5052
allowEmptyLists: Boolean = false,
5153
strictNullHandling: Boolean = false,
5254
skipNulls: Boolean = false,
@@ -65,8 +67,9 @@ internal object Encoder {
6567
val prefix: String = prefix ?: if (addQueryPrefix) "?" else ""
6668
val generateArrayPrefix: ListFormatGenerator =
6769
generateArrayPrefix ?: ListFormat.INDICES.generator
68-
val commaRoundTrip: Boolean =
69-
commaRoundTrip ?: (generateArrayPrefix == ListFormat.COMMA.generator)
70+
val isCommaGenerator = generateArrayPrefix == ListFormat.COMMA.generator
71+
val commaRoundTrip: Boolean = commaRoundTrip ?: isCommaGenerator
72+
val compactNulls = commaCompactNulls && isCommaGenerator
7073

7174
var obj: Any? = data
7275

@@ -140,18 +143,30 @@ internal object Encoder {
140143
return values
141144
}
142145

146+
var effectiveCommaLength: Int? = null
147+
143148
val objKeys: List<Any?> =
144149
when {
145-
generateArrayPrefix == ListFormat.COMMA.generator && obj is Iterable<*> -> {
146-
// we need to join elements in
147-
if (encodeValuesOnly && encoder != null) {
148-
obj = obj.map { el -> el?.let { encoder(it.toString(), null, null) } ?: "" }
149-
}
150+
isCommaGenerator && obj is Iterable<*> -> {
151+
// materialize once for reuse
152+
val items = obj.toList()
153+
val filtered = if (compactNulls) items.filterNotNull() else items
150154

151-
if (obj.iterator().hasNext()) {
152-
val objKeysValue = obj.joinToString(",") { el -> el?.toString() ?: "" }
155+
effectiveCommaLength = filtered.size
153156

154-
listOf(mapOf("value" to objKeysValue.ifEmpty { null }))
157+
val joinSource =
158+
if (encodeValuesOnly && encoder != null) {
159+
filtered.map { el ->
160+
el?.let { encoder(it.toString(), null, null) } ?: ""
161+
}
162+
} else {
163+
filtered.map { el -> el?.toString() ?: "" }
164+
}
165+
166+
if (joinSource.isNotEmpty()) {
167+
val joined = joinSource.joinToString(",")
168+
169+
listOf(mapOf("value" to joined.ifEmpty { null }))
155170
} else {
156171
listOf(mapOf("value" to Undefined.Companion()))
157172
}
@@ -180,7 +195,16 @@ internal object Encoder {
180195
val encodedPrefix: String = if (encodeDotInKeys) prefix.replace(".", "%2E") else prefix
181196

182197
val adjustedPrefix: String =
183-
if ((commaRoundTrip && obj is Iterable<*> && obj.count() == 1)) "$encodedPrefix[]"
198+
if (
199+
commaRoundTrip &&
200+
obj is Iterable<*> &&
201+
(if (isCommaGenerator && effectiveCommaLength != null) {
202+
effectiveCommaLength == 1
203+
} else {
204+
obj.count() == 1
205+
})
206+
)
207+
"$encodedPrefix[]"
184208
else encodedPrefix
185209

186210
if (allowEmptyLists && obj is Iterable<*> && !obj.iterator().hasNext()) {
@@ -253,6 +277,7 @@ internal object Encoder {
253277
prefix = keyPrefix,
254278
generateArrayPrefix = generateArrayPrefix,
255279
commaRoundTrip = commaRoundTrip,
280+
commaCompactNulls = commaCompactNulls,
256281
allowEmptyLists = allowEmptyLists,
257282
strictNullHandling = strictNullHandling,
258283
skipNulls = skipNulls,

qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/EncodeOptions.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ data class EncodeOptions(
132132
*/
133133
val commaRoundTrip: Boolean? = null,
134134

135+
/**
136+
* When listFormat is set to ListFormat.COMMA, drop `null` items before joining instead of
137+
* preserving empty slots.
138+
*/
139+
val commaCompactNulls: Boolean = false,
140+
135141
/** Set a Sorter to affect the order of parameter keys. */
136142
val sort: Sorter? = null,
137143
) {
@@ -220,6 +226,7 @@ data class EncodeOptions(
220226
private var skipNulls: Boolean = false
221227
private var strictNullHandling: Boolean = false
222228
private var commaRoundTrip: Boolean? = null
229+
private var commaCompactNulls: Boolean = false
223230
private var sort: Sorter? = null
224231

225232
/** Provide a Kotlin [ValueEncoder]. Ignored when [encode] is `false`. */
@@ -300,6 +307,9 @@ data class EncodeOptions(
300307
/** With COMMA listFormat, append `[]` on single-item lists to allow round trip. */
301308
fun commaRoundTrip(value: Boolean?) = apply { this.commaRoundTrip = value }
302309

310+
/** With COMMA listFormat, drop `null` entries before joining for more compact payloads. */
311+
fun commaCompactNulls(value: Boolean) = apply { this.commaCompactNulls = value }
312+
303313
/** Java-friendly key sorter; adapted to [Sorter]. */
304314
fun sort(comparator: java.util.Comparator<Any?>) = apply {
305315
this.sort = { a, b -> comparator.compare(a, b) }
@@ -327,6 +337,7 @@ data class EncodeOptions(
327337
skipNulls = skipNulls,
328338
strictNullHandling = strictNullHandling,
329339
commaRoundTrip = commaRoundTrip,
340+
commaCompactNulls = commaCompactNulls,
330341
sort = sort,
331342
)
332343
}

qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/qs.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ fun encode(data: Any?, options: EncodeOptions? = null): String {
154154
commaRoundTrip =
155155
options.getListFormat.generator == ListFormat.COMMA.generator &&
156156
options.commaRoundTrip == true,
157+
commaCompactNulls =
158+
options.getListFormat.generator == ListFormat.COMMA.generator &&
159+
options.commaCompactNulls,
157160
allowEmptyLists = options.allowEmptyLists,
158161
strictNullHandling = options.strictNullHandling,
159162
skipNulls = options.skipNulls,

qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,61 @@ class QsParserSpec :
948948
"a=%2C&b=&c=c%2Cd%25"
949949
}
950950

951+
it("should drop null entries when commaCompactNulls is enabled") {
952+
val value = mapOf("a" to mapOf("b" to listOf("one", null, "two", null, "three")))
953+
954+
encode(value, EncodeOptions(encode = false, listFormat = ListFormat.COMMA)) shouldBe
955+
"a[b]=one,,two,,three"
956+
957+
encode(
958+
value,
959+
EncodeOptions(
960+
encode = false,
961+
listFormat = ListFormat.COMMA,
962+
commaCompactNulls = true,
963+
),
964+
) shouldBe "a[b]=one,two,three"
965+
}
966+
967+
it("should omit key when commaCompactNulls strips all values") {
968+
val value = mapOf("a" to listOf(null, null))
969+
970+
encode(value, EncodeOptions(encode = false, listFormat = ListFormat.COMMA)) shouldBe
971+
"a=," // baseline behaviour keeps empty slots
972+
973+
encode(
974+
value,
975+
EncodeOptions(
976+
encode = false,
977+
listFormat = ListFormat.COMMA,
978+
commaCompactNulls = true,
979+
),
980+
) shouldBe ""
981+
}
982+
983+
it("should preserve round-trip marker after compacting nulls") {
984+
val value = mapOf("a" to listOf(null, "foo"))
985+
986+
encode(
987+
value,
988+
EncodeOptions(
989+
encode = false,
990+
listFormat = ListFormat.COMMA,
991+
commaRoundTrip = true,
992+
),
993+
) shouldBe "a=,foo"
994+
995+
encode(
996+
value,
997+
EncodeOptions(
998+
encode = false,
999+
listFormat = ListFormat.COMMA,
1000+
commaRoundTrip = true,
1001+
commaCompactNulls = true,
1002+
),
1003+
) shouldBe "a[]=foo"
1004+
}
1005+
9511006
it("should stringify nested array values with dots notation") {
9521007
val value = mapOf("a" to mapOf("b" to listOf("c", "d")))
9531008
val options = EncodeOptions(allowDots = true, encodeValuesOnly = true)

qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/EncodeOptionsSpec.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class EncodeOptionsSpec :
3131
skipNulls = true,
3232
strictNullHandling = true,
3333
commaRoundTrip = true,
34+
commaCompactNulls = true,
3435
)
3536

3637
val newOptions = options.copy()
@@ -49,6 +50,7 @@ class EncodeOptionsSpec :
4950
newOptions.skipNulls shouldBe true
5051
newOptions.strictNullHandling shouldBe true
5152
newOptions.commaRoundTrip shouldBe true
53+
newOptions.commaCompactNulls shouldBe true
5254
newOptions shouldBe options
5355
}
5456

@@ -69,6 +71,7 @@ class EncodeOptionsSpec :
6971
skipNulls = true,
7072
strictNullHandling = true,
7173
commaRoundTrip = true,
74+
commaCompactNulls = true,
7275
)
7376

7477
val newOptions =
@@ -87,6 +90,7 @@ class EncodeOptionsSpec :
8790
skipNulls = false,
8891
strictNullHandling = false,
8992
commaRoundTrip = false,
93+
commaCompactNulls = false,
9094
filter = FunctionFilter { _: String, _: Any? -> emptyMap<String, Any?>() },
9195
)
9296

@@ -104,6 +108,7 @@ class EncodeOptionsSpec :
104108
newOptions.skipNulls shouldBe false
105109
newOptions.strictNullHandling shouldBe false
106110
newOptions.commaRoundTrip shouldBe false
111+
newOptions.commaCompactNulls shouldBe false
107112
}
108113

109114
it("builder produces java-friendly configuration") {

0 commit comments

Comments
 (0)