Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -140,18 +143,30 @@ internal object Encoder {
return values
}

var effectiveCommaLength: Int? = null

val objKeys: List<Any?> =
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()))
}
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -253,6 +277,7 @@ internal object Encoder {
prefix = keyPrefix,
generateArrayPrefix = generateArrayPrefix,
commaRoundTrip = commaRoundTrip,
commaCompactNulls = commaCompactNulls,
allowEmptyLists = allowEmptyLists,
strictNullHandling = strictNullHandling,
skipNulls = skipNulls,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -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<Any?>) = apply {
this.sort = { a, b -> comparator.compare(a, b) }
Expand Down Expand Up @@ -327,6 +337,7 @@ data class EncodeOptions(
skipNulls = skipNulls,
strictNullHandling = strictNullHandling,
commaRoundTrip = commaRoundTrip,
commaCompactNulls = commaCompactNulls,
sort = sort,
)
}
Expand Down
3 changes: 3 additions & 0 deletions qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/qs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class EncodeOptionsSpec :
skipNulls = true,
strictNullHandling = true,
commaRoundTrip = true,
commaCompactNulls = true,
)

val newOptions = options.copy()
Expand All @@ -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
}

Expand All @@ -69,6 +71,7 @@ class EncodeOptionsSpec :
skipNulls = true,
strictNullHandling = true,
commaRoundTrip = true,
commaCompactNulls = true,
)

val newOptions =
Expand All @@ -87,6 +90,7 @@ class EncodeOptionsSpec :
skipNulls = false,
strictNullHandling = false,
commaRoundTrip = false,
commaCompactNulls = false,
filter = FunctionFilter { _: String, _: Any? -> emptyMap<String, Any?>() },
)

Expand All @@ -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") {
Expand Down
Loading