Skip to content

Commit e4e8352

Browse files
committed
🐛 preserve overflow merge value shape
1 parent 5a9b7fe commit e4e8352

3 files changed

Lines changed: 33 additions & 27 deletions

File tree

src/qs_codec/utils/utils.py

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- Several routines use an object-identity `visited` set to avoid infinite recursion when user inputs contain cycles.
1818
"""
1919

20+
import copy
2021
import typing as t
2122
from collections import deque
2223
from collections.abc import Mapping as ABCMapping
@@ -46,6 +47,13 @@ def _numeric_key_pairs(mapping: t.Mapping[t.Any, t.Any]) -> t.List[t.Tuple[int,
4647
return pairs
4748

4849

50+
def _copy_overflow_append_value(value: t.Any) -> t.Any:
51+
"""Copy container values before storing them in an overflow append slot."""
52+
if isinstance(value, (ABCMapping, list, tuple)):
53+
return copy.copy(value)
54+
return value
55+
56+
4957
@dataclass
5058
class _MergeFrame:
5159
target: t.Any
@@ -523,6 +531,14 @@ def combine(
523531
"""
524532
Concatenate two values, treating non-sequences as singletons.
525533
534+
Normal list/tuple inputs are flattened into the combined result. When
535+
``a`` is already an :class:`OverflowDict`, however, ``b`` is appended as
536+
one value at the next numeric key, even if ``b`` is a list, tuple, or
537+
another :class:`OverflowDict`. This preserves qs parity for duplicate
538+
values after list-limit overflow: a later comma-split or bracket-array
539+
payload remains a nested value instead of being flattened into the
540+
overflowed container.
541+
526542
If `list_limit` is exceeded, converts the list to an `OverflowDict`
527543
(a dict with numeric keys) to prevent memory exhaustion.
528544
When `options` is provided, its ``list_limit`` controls when a list is
@@ -537,33 +553,15 @@ def combine(
537553
list to :class:`OverflowDict`.
538554
"""
539555
if Utils.is_overflow(a):
540-
# a is already an OverflowDict. Append b to a *copy* at the next numeric index.
541-
# We assume sequential keys; len(a_copy) gives the next index.
556+
# a is already an OverflowDict. Append b as one value at the next numeric index.
542557
orig_a = t.cast(OverflowDict, a)
543558
a_copy = orig_a.__class__({k: v for k, v in orig_a.items() if not isinstance(v, Undefined)})
544559
# Use max key + 1 to handle sparse dicts safely, rather than len(a)
545560
key_pairs = _numeric_key_pairs(a_copy)
546561
idx = (max(key for key, _ in key_pairs) + 1) if key_pairs else 0
547562

548-
if isinstance(orig_a, CommaOverflowDict):
549-
if not isinstance(b, Undefined):
550-
a_copy[str(idx)] = b
551-
elif isinstance(b, (list, tuple)):
552-
for item in b:
553-
if not isinstance(item, Undefined):
554-
a_copy[str(idx)] = item
555-
idx += 1
556-
elif Utils.is_overflow(b):
557-
b = t.cast(OverflowDict, b)
558-
# Iterate in numeric key order to preserve list semantics
559-
for _, k in sorted(_numeric_key_pairs(b), key=lambda item: item[0]):
560-
val = b[k]
561-
if not isinstance(val, Undefined):
562-
a_copy[str(idx)] = val
563-
idx += 1
564-
else:
565-
if not isinstance(b, Undefined):
566-
a_copy[str(idx)] = b
563+
if not isinstance(b, Undefined):
564+
a_copy[str(idx)] = _copy_overflow_append_value(b)
567565
return a_copy
568566

569567
# Normal combination: flatten lists/tuples

tests/unit/decode_test.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1654,6 +1654,12 @@ def test_mapping_bracket_comma_list_over_zero_limit_raises(self) -> None:
16541654
},
16551655
id="overflow-comma-list-then-overflow-comma-list",
16561656
),
1657+
pytest.param(
1658+
"a[]=1&a[]=2&a[]=3,4",
1659+
DecodeOptions(comma=True, list_limit=1),
1660+
{"a": {"0": "1", "1": "2", "2": [["3", "4"]]}},
1661+
id="bracket-overflow-then-comma-list",
1662+
),
16571663
],
16581664
)
16591665
def test_comma_overflow_duplicates_keep_overflow_values_nested(

tests/unit/utils_test.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -775,7 +775,7 @@ def test_combine_overflow_dict_with_overflow_dict(self) -> None:
775775
combined = Utils.combine(a, b)
776776
assert isinstance(combined, OverflowDict)
777777
assert combined["0"] == "x"
778-
assert combined["1"] == "y"
778+
assert combined["1"] == {"0": "y"}
779779
assert len(combined) == 2
780780

781781
def test_compact_removes_undefined_entries_and_avoids_cycles(self) -> None:
@@ -1149,12 +1149,13 @@ def test_combine_list_with_overflow_dict(self) -> None:
11491149
result = Utils.combine(a, b)
11501150
assert result == ["start", "x", "y"]
11511151

1152-
def test_combine_skips_undefined_in_overflow_dict_append(self) -> None:
1152+
def test_combine_overflow_dict_appends_list_as_single_value(self) -> None:
11531153
a = OverflowDict({"0": "x"})
11541154
b = ["y", Undefined(), "z"]
11551155
result = Utils.combine(a, b)
11561156
assert isinstance(result, OverflowDict)
1157-
assert result == {"0": "x", "1": "y", "2": "z"}
1157+
assert result == {"0": "x", "1": ["y", Undefined(), "z"]}
1158+
assert result["1"] is not b
11581159

11591160
def test_combine_skips_undefined_in_list_flattening(self) -> None:
11601161
a = ["x", Undefined()]
@@ -1178,13 +1179,14 @@ def test_combine_overflow_dict_skips_existing_undefined_and_ignores_non_numeric_
11781179
assert result["skip"] == "keep"
11791180
assert "1" in a # Original should remain unchanged
11801181

1181-
def test_combine_overflow_dict_source_skips_non_numeric_keys(self) -> None:
1182+
def test_combine_overflow_dict_appends_overflow_source_as_single_value(self) -> None:
11821183
a = OverflowDict({"0": "x"})
11831184
b = OverflowDict({"foo": "bar", "1": "y", "0": "z"})
11841185
result = Utils.combine(a, b)
11851186
assert isinstance(result, OverflowDict)
1186-
assert result == {"0": "x", "1": "z", "2": "y"}
1187-
assert "foo" not in result
1187+
assert result == {"0": "x", "1": {"foo": "bar", "1": "y", "0": "z"}}
1188+
assert isinstance(result["1"], OverflowDict)
1189+
assert result["1"] is not b
11881190

11891191
def test_merge_overflow_dict_source_preserves_non_numeric_keys(self) -> None:
11901192
target = "a"

0 commit comments

Comments
 (0)