Skip to content

Commit 031439a

Browse files
authored
🐛 ensure consistent string key usage for maps and correct numeric key handling in Decoder and test cases (#10)
1 parent ca12138 commit 031439a

File tree

8 files changed

+107
-103
lines changed

8 files changed

+107
-103
lines changed

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

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -198,45 +198,42 @@ internal object Decoder {
198198
else -> Utils.combine<Any?>(emptyList<Any?>(), leaf)
199199
}
200200
} else {
201-
val mutableObj = LinkedHashMap<Any, Any?>(1)
201+
// Always build *string-keyed* maps here
202+
val mutableObj = LinkedHashMap<String, Any?>(1)
203+
202204
val cleanRoot =
203205
if (root.startsWith("[") && root.endsWith("]")) {
204206
root.substring(1, root.length - 1)
205207
} else root
206208

207209
val decodedRoot =
208-
if (options.getDecodeDotInKeys) {
209-
cleanRoot.replace("%2E", ".")
210-
} else cleanRoot
210+
if (options.getDecodeDotInKeys) cleanRoot.replace("%2E", ".") else cleanRoot
211211

212-
val index: Int? =
213-
if (decodedRoot.isNotEmpty() && decodedRoot.toIntOrNull() != null)
214-
decodedRoot.toInt()
215-
else null
212+
val isPureNumeric = decodedRoot.isNotEmpty() && decodedRoot.all { it.isDigit() }
213+
val idx: Int? = if (isPureNumeric) decodedRoot.toInt() else null
214+
val isBracketedNumeric =
215+
idx != null && root != decodedRoot && idx.toString() == decodedRoot
216216

217217
when {
218-
!options.parseLists && decodedRoot == "" -> {
219-
mutableObj[0] = leaf
218+
// If list parsing is disabled OR listLimit < 0: always make a map with string
219+
// key
220+
!options.parseLists || options.listLimit < 0 -> {
221+
val keyForMap = if (decodedRoot == "") "0" else decodedRoot
222+
mutableObj[keyForMap] = leaf
220223
obj = mutableObj
221224
}
222225

223-
index != null &&
224-
index >= 0 &&
225-
root != decodedRoot &&
226-
index.toString() == decodedRoot &&
227-
options.parseLists &&
228-
index <= options.listLimit -> {
229-
val list = MutableList<Any?>(index + 1) { Undefined.Companion() }
230-
list[index] = leaf
226+
// Proper list index (e.g., "[3]") and allowed by listLimit -> build a list
227+
isBracketedNumeric && idx >= 0 && idx <= options.listLimit -> {
228+
val list = MutableList<Any?>(idx + 1) { Undefined.Companion() }
229+
list[idx] = leaf
231230
obj = list
232231
}
233232

233+
// Otherwise, treat it as a map with *string* key (even if numeric)
234234
else -> {
235-
if (index != null) {
236-
mutableObj[index] = leaf
237-
} else {
238-
mutableObj[decodedRoot] = leaf
239-
}
235+
val keyForMap = decodedRoot
236+
mutableObj[keyForMap] = leaf
240237
obj = mutableObj
241238
}
242239
}

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

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,12 @@ import io.github.techouse.qskotlin.enums.Format
44
import io.github.techouse.qskotlin.enums.Formatter
55
import io.github.techouse.qskotlin.enums.ListFormat
66
import io.github.techouse.qskotlin.enums.ListFormatGenerator
7-
import io.github.techouse.qskotlin.models.DateSerializer
8-
import io.github.techouse.qskotlin.models.Filter
9-
import io.github.techouse.qskotlin.models.FunctionFilter
10-
import io.github.techouse.qskotlin.models.IterableFilter
11-
import io.github.techouse.qskotlin.models.Sorter
12-
import io.github.techouse.qskotlin.models.Undefined
13-
import io.github.techouse.qskotlin.models.ValueEncoder
14-
import io.github.techouse.qskotlin.models.WeakWrapper
7+
import io.github.techouse.qskotlin.models.*
158
import java.nio.charset.Charset
169
import java.nio.charset.StandardCharsets
1710
import java.time.Instant
1811
import java.time.LocalDateTime
19-
import java.util.WeakHashMap
20-
import kotlin.collections.get
12+
import java.util.*
2113

2214
/** A helper object for encoding data into a query string format. */
2315
internal object Encoder {

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,21 @@ internal object Utils {
3737
is Iterable<*> ->
3838
when {
3939
target.any { it is Undefined } -> {
40-
val mutableTarget: MutableMap<Any, Any?> =
41-
target.withIndex().associate { it.index to it.value }.toMutableMap()
40+
val mutableTarget: MutableMap<String, Any?> =
41+
target
42+
.withIndex()
43+
.associate { it.index.toString() to it.value }
44+
.toMutableMap()
4245

4346
when (source) {
4447
is Iterable<*> ->
4548
source.forEachIndexed { i, item ->
4649
if (item !is Undefined) {
47-
mutableTarget[i] = item
50+
mutableTarget[i.toString()] = item
4851
}
4952
}
5053

51-
else -> mutableTarget[mutableTarget.size] = source
54+
else -> mutableTarget[mutableTarget.size.toString()] = source
5255
}
5356

5457
when {
@@ -117,7 +120,7 @@ internal object Utils {
117120
is Iterable<*> -> {
118121
source.forEachIndexed { i, item ->
119122
if (item !is Undefined) {
120-
mutableTarget[i] = item
123+
mutableTarget[i.toString()] = item
121124
}
122125
}
123126
}
@@ -146,16 +149,16 @@ internal object Utils {
146149
if (target == null || target !is Map<*, *>) {
147150
return when (target) {
148151
is Iterable<*> -> {
149-
val mutableTarget: MutableMap<Any, Any?> =
152+
val mutableTarget: MutableMap<String, Any?> =
150153
target
151154
.withIndex()
152-
.associate { it.index to it.value }
155+
.associate { it.index.toString() to it.value }
153156
.filterValues { it !is Undefined }
154157
.toMutableMap()
155158

156159
@Suppress("UNCHECKED_CAST")
157160
(source as Map<Any, Any?>).forEach { (key, value) ->
158-
mutableTarget[key] = value
161+
mutableTarget[key.toString()] = value
159162
}
160163
mutableTarget
161164
}
@@ -183,10 +186,9 @@ internal object Utils {
183186
target is Iterable<*> && source !is Iterable<*> ->
184187
target
185188
.withIndex()
186-
.associate { it.index to it.value }
189+
.associate { it.index.toString() to it.value }
187190
.filterValues { it !is Undefined }
188191
.toMutableMap()
189-
190192
else -> (target as Map<Any, Any?>).toMutableMap()
191193
}
192194

qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/fixtures/data/EmptyTestCases.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,12 @@ internal val EmptyTestCases: List<Map<String, Any>> =
178178
"indices" to "[0]=a&[1]=b& [0]=1",
179179
"repeat" to "=a&=b& =1",
180180
),
181-
"noEmptyKeys" to mapOf(0 to "a", 1 to "b", " " to listOf("1")),
181+
"noEmptyKeys" to mapOf("0" to "a", "1" to "b", " " to listOf("1")),
182182
),
183183
mapOf(
184184
"input" to "[0]=a&[1]=b&a[0]=1&a[1]=2",
185185
"withEmptyKeys" to mapOf("" to listOf("a", "b"), "a" to listOf("1", "2")),
186-
"noEmptyKeys" to mapOf(0 to "a", 1 to "b", "a" to listOf("1", "2")),
186+
"noEmptyKeys" to mapOf("0" to "a", "1" to "b", "a" to listOf("1", "2")),
187187
"stringifyOutput" to
188188
mapOf(
189189
"brackets" to "[]=a&[]=b&a[]=1&a[]=2",
@@ -207,6 +207,6 @@ internal val EmptyTestCases: List<Map<String, Any>> =
207207
"withEmptyKeys" to mapOf("" to listOf("a", "b")),
208208
"stringifyOutput" to
209209
mapOf("brackets" to "[]=a&[]=b", "indices" to "[0]=a&[1]=b", "repeat" to "=a&=b"),
210-
"noEmptyKeys" to mapOf(0 to "a", 1 to "b"),
210+
"noEmptyKeys" to mapOf("0" to "a", "1" to "b"),
211211
),
212212
)

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

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class DecodeSpec :
7474
}
7575

7676
it("parses a simple string") {
77-
decode("0=foo") shouldBe mapOf(0 to "foo")
77+
decode("0=foo") shouldBe mapOf("0" to "foo")
7878
decode("foo=c++") shouldBe mapOf("foo" to "c ")
7979
decode("a[>=]=23") shouldBe mapOf("a" to mapOf(">=" to "23"))
8080
decode("a[<=>]==23") shouldBe mapOf("a" to mapOf("<=>" to "=23"))
@@ -304,25 +304,25 @@ class DecodeSpec :
304304
decode("a[1]=c&a[0]=b") shouldBe mapOf("a" to listOf("b", "c"))
305305
decode("a[1]=c", DecodeOptions(listLimit = 20)) shouldBe mapOf("a" to listOf("c"))
306306
decode("a[1]=c", DecodeOptions(listLimit = 0)) shouldBe
307-
mapOf("a" to mapOf(1 to "c"))
307+
mapOf("a" to mapOf("1" to "c"))
308308
decode("a[1]=c") shouldBe mapOf("a" to listOf("c"))
309309
decode("a[0]=b&a[2]=c", DecodeOptions(parseLists = false)) shouldBe
310-
mapOf("a" to mapOf(0 to "b", 2 to "c"))
310+
mapOf("a" to mapOf("0" to "b", "2" to "c"))
311311
decode("a[0]=b&a[2]=c", DecodeOptions(parseLists = true)) shouldBe
312312
mapOf("a" to listOf("b", "c"))
313313
decode("a[1]=b&a[15]=c", DecodeOptions(parseLists = false)) shouldBe
314-
mapOf("a" to mapOf(1 to "b", 15 to "c"))
314+
mapOf("a" to mapOf("1" to "b", "15" to "c"))
315315
decode("a[1]=b&a[15]=c", DecodeOptions(parseLists = true)) shouldBe
316316
mapOf("a" to listOf("b", "c"))
317317
}
318318

319319
it("limits specific list indices to listLimit") {
320320
decode("a[20]=a", DecodeOptions(listLimit = 20)) shouldBe mapOf("a" to listOf("a"))
321321
decode("a[21]=a", DecodeOptions(listLimit = 20)) shouldBe
322-
mapOf("a" to mapOf(21 to "a"))
322+
mapOf("a" to mapOf("21" to "a"))
323323

324324
decode("a[20]=a") shouldBe mapOf("a" to listOf("a"))
325-
decode("a[21]=a") shouldBe mapOf("a" to mapOf(21 to "a"))
325+
decode("a[21]=a") shouldBe mapOf("a" to mapOf("21" to "a"))
326326
}
327327

328328
it("supports keys that begin with a number") {
@@ -351,15 +351,15 @@ class DecodeSpec :
351351

352352
it("transforms lists to maps") {
353353
decode("foo[0]=bar&foo[bad]=baz") shouldBe
354-
mapOf("foo" to mapOf(0 to "bar", "bad" to "baz"))
354+
mapOf("foo" to mapOf("0" to "bar", "bad" to "baz"))
355355
decode("foo[bad]=baz&foo[0]=bar") shouldBe
356-
mapOf("foo" to mapOf("bad" to "baz", 0 to "bar"))
356+
mapOf("foo" to mapOf("bad" to "baz", "0" to "bar"))
357357
decode("foo[bad]=baz&foo[]=bar") shouldBe
358-
mapOf("foo" to mapOf("bad" to "baz", 0 to "bar"))
358+
mapOf("foo" to mapOf("bad" to "baz", "0" to "bar"))
359359
decode("foo[]=bar&foo[bad]=baz") shouldBe
360-
mapOf("foo" to mapOf(0 to "bar", "bad" to "baz"))
360+
mapOf("foo" to mapOf("0" to "bar", "bad" to "baz"))
361361
decode("foo[bad]=baz&foo[]=bar&foo[]=foo") shouldBe
362-
mapOf("foo" to mapOf("bad" to "baz", 0 to "bar", 1 to "foo"))
362+
mapOf("foo" to mapOf("bad" to "baz", "0" to "bar", "1" to "foo"))
363363
decode("foo[0][a]=a&foo[0][b]=b&foo[1][a]=aa&foo[1][b]=bb") shouldBe
364364
mapOf(
365365
"foo" to
@@ -387,13 +387,13 @@ class DecodeSpec :
387387
DecodeOptions(allowDots = true),
388388
) shouldBe mapOf("foo" to listOf(mapOf("baz" to listOf("15", "16"), "bar" to "2")))
389389
decode("foo.bad=baz&foo[0]=bar", DecodeOptions(allowDots = true)) shouldBe
390-
mapOf("foo" to mapOf("bad" to "baz", 0 to "bar"))
390+
mapOf("foo" to mapOf("bad" to "baz", "0" to "bar"))
391391
decode("foo.bad=baz&foo[]=bar", DecodeOptions(allowDots = true)) shouldBe
392-
mapOf("foo" to mapOf("bad" to "baz", 0 to "bar"))
392+
mapOf("foo" to mapOf("bad" to "baz", "0" to "bar"))
393393
decode("foo[]=bar&foo.bad=baz", DecodeOptions(allowDots = true)) shouldBe
394-
mapOf("foo" to mapOf(0 to "bar", "bad" to "baz"))
394+
mapOf("foo" to mapOf("0" to "bar", "bad" to "baz"))
395395
decode("foo.bad=baz&foo[]=bar&foo[]=foo", DecodeOptions(allowDots = true)) shouldBe
396-
mapOf("foo" to mapOf("bad" to "baz", 0 to "bar", 1 to "foo"))
396+
mapOf("foo" to mapOf("bad" to "baz", "0" to "bar", "1" to "foo"))
397397
decode(
398398
"foo[0].a=a&foo[0].b=b&foo[1].a=aa&foo[1].b=bb",
399399
DecodeOptions(allowDots = true),
@@ -406,7 +406,7 @@ class DecodeSpec :
406406

407407
it("correctly prunes undefined values when converting a list to a map") {
408408
decode("a[2]=b&a[99999999]=c") shouldBe
409-
mapOf("a" to mapOf(2 to "b", 99999999 to "c"))
409+
mapOf("a" to mapOf("2" to "b", "99999999" to "c"))
410410
}
411411

412412
it("supports malformed uri characters") {
@@ -482,9 +482,9 @@ class DecodeSpec :
482482
}
483483

484484
it("continues parsing when no parent is found") {
485-
decode("[]=&a=b") shouldBe mapOf(0 to "", "a" to "b")
485+
decode("[]=&a=b") shouldBe mapOf("0" to "", "a" to "b")
486486
decode("[]&a=b", DecodeOptions(strictNullHandling = true)) shouldBe
487-
mapOf(0 to null, "a" to "b")
487+
mapOf("0" to null, "a" to "b")
488488
decode("[foo]=bar") shouldBe mapOf("foo" to "bar")
489489
}
490490

@@ -519,25 +519,25 @@ class DecodeSpec :
519519

520520
it("allows overriding list limit") {
521521
decode("a[0]=b", DecodeOptions(listLimit = -1)) shouldBe
522-
mapOf("a" to mapOf(0 to "b"))
522+
mapOf("a" to mapOf("0" to "b"))
523523
decode("a[0]=b", DecodeOptions(listLimit = 0)) shouldBe mapOf("a" to listOf("b"))
524524

525525
decode("a[-1]=b", DecodeOptions(listLimit = -1)) shouldBe
526-
mapOf("a" to mapOf(-1 to "b"))
526+
mapOf("a" to mapOf("-1" to "b"))
527527
decode("a[-1]=b", DecodeOptions(listLimit = 0)) shouldBe
528-
mapOf("a" to mapOf(-1 to "b"))
528+
mapOf("a" to mapOf("-1" to "b"))
529529

530530
decode("a[0]=b&a[1]=c", DecodeOptions(listLimit = -1)) shouldBe
531-
mapOf("a" to mapOf(0 to "b", 1 to "c"))
531+
mapOf("a" to mapOf("0" to "b", "1" to "c"))
532532
decode("a[0]=b&a[1]=c", DecodeOptions(listLimit = 0)) shouldBe
533-
mapOf("a" to mapOf(0 to "b", 1 to "c"))
533+
mapOf("a" to mapOf("0" to "b", "1" to "c"))
534534
}
535535

536536
it("allows disabling list parsing") {
537537
decode("a[0]=b&a[1]=c", DecodeOptions(parseLists = false)) shouldBe
538-
mapOf("a" to mapOf(0 to "b", 1 to "c"))
538+
mapOf("a" to mapOf("0" to "b", "1" to "c"))
539539
decode("a[]=b", DecodeOptions(parseLists = false)) shouldBe
540-
mapOf("a" to mapOf(0 to "b"))
540+
mapOf("a" to mapOf("0" to "b"))
541541
}
542542

543543
it("allows for query string prefix") {
@@ -725,7 +725,7 @@ class DecodeSpec :
725725

726726
val expectedList = mutableMapOf<Any, Any?>()
727727
expectedList["a"] = mutableMapOf<Any, Any?>()
728-
(expectedList["a"] as MutableMap<Any, Any?>)[0] = "b"
728+
(expectedList["a"] as MutableMap<Any, Any?>)["0"] = "b"
729729
(expectedList["a"] as MutableMap<Any, Any?>)["c"] = "d"
730730
decode("a[]=b&a[c]=d") shouldBe expectedList
731731
}
@@ -1032,7 +1032,17 @@ class DecodeSpec :
10321032
"a[1]=1&a[2]=2&a[3]=3&a[4]=4&a[5]=5&a[6]=6",
10331033
DecodeOptions(listLimit = 5),
10341034
) shouldBe
1035-
mapOf("a" to mapOf(1 to "1", 2 to "2", 3 to "3", 4 to "4", 5 to "5", 6 to "6"))
1035+
mapOf(
1036+
"a" to
1037+
mapOf(
1038+
"1" to "1",
1039+
"2" to "2",
1040+
"3" to "3",
1041+
"4" to "4",
1042+
"5" to "5",
1043+
"6" to "6",
1044+
)
1045+
)
10361046
}
10371047

10381048
it("handles list limit of zero correctly") {

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,21 +179,21 @@ class ExampleSpec :
179179
}
180180

181181
it("converts high indices to Map keys") {
182-
decode("a[100]=b") shouldBe mapOf("a" to mapOf(100 to "b"))
182+
decode("a[100]=b") shouldBe mapOf("a" to mapOf("100" to "b"))
183183
}
184184

185185
it("can override list limit") {
186186
decode("a[1]=b", DecodeOptions(listLimit = 0)) shouldBe
187-
mapOf("a" to mapOf(1 to "b"))
187+
mapOf("a" to mapOf("1" to "b"))
188188
}
189189

190190
it("can disable list parsing entirely") {
191191
decode("a[]=b", DecodeOptions(parseLists = false)) shouldBe
192-
mapOf("a" to mapOf(0 to "b"))
192+
mapOf("a" to mapOf("0" to "b"))
193193
}
194194

195195
it("merges mixed notations into Map") {
196-
decode("a[0]=b&a[b]=c") shouldBe mapOf("a" to mapOf(0 to "b", "b" to "c"))
196+
decode("a[0]=b&a[b]=c") shouldBe mapOf("a" to mapOf("0" to "b", "b" to "c"))
197197
}
198198

199199
it("can create lists of Maps") {

0 commit comments

Comments
 (0)