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
19 changes: 13 additions & 6 deletions lib/src/extensions/decode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ extension _$Decode on QS {
// Duplicate key policy: combine/first/last (default: combine).
final bool existing = obj.containsKey(key);
if (existing && options.duplicates == Duplicates.combine) {
obj[key] = Utils.combine(obj[key], val);
obj[key] = Utils.combine(obj[key], val, listLimit: options.listLimit);
} else if (!existing || options.duplicates == Duplicates.last) {
obj[key] = val;
}
Expand All @@ -209,7 +209,8 @@ extension _$Decode on QS {
/// - When `parseLists` is false, numeric segments are treated as string keys.
/// - When `allowEmptyLists` is true, an empty string (or `null` under
/// `strictNullHandling`) under a `[]` segment yields an empty list.
/// - `listLimit` applies to explicit numeric indices as an upper bound.
/// - `listLimit` applies to explicit numeric indices and list growth via `[]`;
/// when exceeded, lists are converted into maps with string indices.
/// - A negative `listLimit` disables numeric‑index parsing (bracketed numbers become map keys).
/// Empty‑bracket pushes (`[]`) still create lists here; this method does not enforce
/// `throwOnLimitExceeded` for that path. Comma‑split growth (if any) has already been
Expand Down Expand Up @@ -263,10 +264,16 @@ extension _$Decode on QS {
// Anonymous list segment `[]` — either an empty list (when allowed) or a
// single-element list with the leaf combined in.
if (root == '[]' && options.parseLists) {
obj = options.allowEmptyLists &&
(leaf == '' || (options.strictNullHandling && leaf == null))
? List<dynamic>.empty(growable: true)
: Utils.combine([], leaf);
if (Utils.isOverflow(leaf)) {
// leaf can already be overflow (e.g. duplicates combine/listLimit),
// so preserve it instead of re-wrapping into a list.
obj = leaf;
} else {
obj = options.allowEmptyLists &&
(leaf == '' || (options.strictNullHandling && leaf == null))
? List<dynamic>.empty(growable: true)
: Utils.combine([], leaf, listLimit: options.listLimit);
}
} else {
obj = <String, dynamic>{};
// Normalize bracketed segments ("[k]"). Note: depending on how key decoding is configured,
Expand Down
7 changes: 5 additions & 2 deletions lib/src/models/decode_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,13 @@ final class DecodeOptions with EquatableMixin {
/// `a[]=` without coercing or discarding them.
final bool allowEmptyLists;

/// Maximum list index that will be honored when decoding bracket indices.
/// Maximum list size/index that will be honored when decoding bracket lists.
///
/// Keys like `a[9999999]` can cause excessively large sparse lists; above
/// this limit, indices are treated as string map keys instead.
/// this limit, indices are treated as string map keys instead. The same
/// limit also applies to empty-bracket pushes (`a[]`) and duplicate combines:
/// once growth exceeds the limit, the list is converted to a map with string
/// indices to preserve values (matching Node `qs` arrayLimit semantics).
///
/// **Negative values:** passing a negative `listLimit` (e.g. `-1`) disables
/// numeric‑index parsing entirely — any bracketed number like `a[0]` or
Expand Down
13 changes: 0 additions & 13 deletions lib/src/qs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ final class QS {
/// * a query [String] (e.g. `"a=1&b[c]=2"`), or
/// * a pre-tokenized `Map<String, dynamic>` produced by a custom tokenizer.
/// - When `input` is `null` or the empty string, `{}` is returned.
/// - If [DecodeOptions.parseLists] is `true` and the number of top‑level
/// parameters exceeds [DecodeOptions.listLimit], list parsing is
/// temporarily disabled for this call to bound memory (mirrors Node `qs`).
/// - Throws [ArgumentError] if `input` is neither a `String` nor a
/// `Map<String, dynamic>`.
///
Expand Down Expand Up @@ -67,16 +64,6 @@ final class QS {
? _$Decode._parseQueryStringValues(input, options)
: input;

// Guardrail: if the top-level parameter count is large, temporarily disable
// list parsing to keep memory bounded (matches Node `qs`). Only apply for
// raw string inputs, not for pre-tokenized maps.
if (input is String &&
options.parseLists &&
options.listLimit > 0 &&
(tempObj?.length ?? 0) > options.listLimit) {
options = options.copyWith(parseLists: false);
}

Map<String, dynamic> obj = {};

// Merge each parsed key into the accumulator using the same rules as Node `qs`.
Expand Down
135 changes: 122 additions & 13 deletions lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,42 @@ part 'constants/hex_table.dart';
final class Utils {
static const int _segmentLimit = 1024;

/// Tracks array-overflow objects (from listLimit) without mutating user data.
static final Expando<int> _overflowIndex = Expando<int>('qsOverflowIndex');

/// Marks a map as an overflow container with the given max index.
@internal
@visibleForTesting
static Map<String, dynamic> markOverflow(
Map<String, dynamic> obj,
int maxIndex,
) {
_overflowIndex[obj] = maxIndex;
return obj;
}

/// Returns `true` if the given object is marked as an overflow container.
@internal
static bool isOverflow(dynamic obj) =>
obj is Map && _overflowIndex[obj] != null;

/// Returns the tracked max numeric index for an overflow map, or -1 if absent.
static int _getOverflowIndex(Map obj) => _overflowIndex[obj] ?? -1;

/// Updates the tracked max numeric index for an overflow map.
static void _setOverflowIndex(Map obj, int maxIndex) {
_overflowIndex[obj] = maxIndex;
}

/// Returns the larger of the current max and the parsed numeric key (if any).
static int _updateOverflowMax(int current, String key) {
final int? parsed = int.tryParse(key);
if (parsed == null || parsed < 0) {
return current;
}
return parsed > current ? parsed : current;
}

/// Deeply merges `source` into `target` while preserving insertion order
/// and list semantics used by `qs`.
///
Expand Down Expand Up @@ -127,12 +163,17 @@ final class Utils {
}
} else if (target is Map) {
if (source is Iterable) {
target = <String, dynamic>{
for (final MapEntry entry in target.entries)
entry.key.toString(): entry.value,
final Map<String, dynamic> sourceMap = {
for (final (int i, dynamic item) in source.indexed)
if (item is! Undefined) i.toString(): item
};
return merge(target, sourceMap, options);
}
if (isOverflow(target)) {
final int newIndex = _getOverflowIndex(target) + 1;
target[newIndex.toString()] = source;
_setOverflowIndex(target, newIndex);
return target;
}
} else if (source != null) {
if (target is! Iterable && source is Iterable) {
Expand All @@ -146,11 +187,40 @@ final class Utils {

if (target == null || target is! Map) {
if (target is Iterable) {
return Map<String, dynamic>.of({
final Map<String, dynamic> mergeTarget = {
for (final (int i, dynamic item) in target.indexed)
if (item is! Undefined) i.toString(): item,
...source,
});
};
for (final MapEntry entry in source.entries) {
final String key = entry.key.toString();
if (mergeTarget.containsKey(key)) {
mergeTarget[key] = merge(
mergeTarget[key],
entry.value,
options,
);
} else {
mergeTarget[key] = entry.value;
}
}
return mergeTarget;
}

if (isOverflow(source)) {
final int sourceMax = _getOverflowIndex(source);
final Map<String, dynamic> result = {
if (target != null) '0': target,
};
for (final MapEntry entry in source.entries) {
final String key = entry.key.toString();
final int? oldIndex = int.tryParse(key);
if (oldIndex == null) {
result[key] = entry.value;
} else {
result[(oldIndex + 1).toString()] = entry.value;
}
}
return markOverflow(result, sourceMax + 1);
}

return [
Expand All @@ -165,6 +235,9 @@ final class Utils {
];
}

final bool targetOverflow = isOverflow(target);
int? overflowMax = targetOverflow ? _getOverflowIndex(target) : null;

Map<String, dynamic> mergeTarget = target is Iterable && source is! Iterable
? {
for (final (int i, dynamic item) in (target as Iterable).indexed)
Expand All @@ -176,6 +249,9 @@ final class Utils {
};

for (final MapEntry entry in source.entries) {
if (overflowMax != null) {
overflowMax = _updateOverflowMax(overflowMax, entry.key.toString());
}
mergeTarget.update(
entry.key.toString(),
(value) => merge(
Expand All @@ -186,6 +262,9 @@ final class Utils {
ifAbsent: () => entry.value,
);
}
if (overflowMax != null) {
markOverflow(mergeTarget, overflowMax);
}
return mergeTarget;
}

Expand Down Expand Up @@ -577,17 +656,47 @@ final class Utils {
return root;
}

/// Concatenates two values as a typed `List<T>`, spreading iterables.
/// Concatenates two values, spreading iterables.
///
/// When [listLimit] is provided and exceeded, returns a map with string keys.
/// Any throwing behavior is enforced earlier during parsing, matching Node `qs`.
///
/// **Note:** If [a] is already an overflow object, this method mutates [a]
/// in place by appending entries from [b].
///
/// Examples:
/// ```dart
/// combine&lt;int&gt;([1,2], 3); // [1,2,3]
/// combine&lt;String&gt;('a', ['b','c']); // ['a','b','c']
/// combine([1,2], 3); // [1,2,3]
/// combine('a', ['b','c']); // ['a','b','c']
/// ```
static List<T> combine<T>(dynamic a, dynamic b) => <T>[
if (a is Iterable<T>) ...a else a,
if (b is Iterable<T>) ...b else b,
];
static dynamic combine(dynamic a, dynamic b, {int? listLimit}) {
if (isOverflow(a)) {
int newIndex = _getOverflowIndex(a);
if (b is Iterable) {
for (final item in b) {
newIndex++;
a[newIndex.toString()] = item;
}
} else {
newIndex++;
a[newIndex.toString()] = b;
}
_setOverflowIndex(a, newIndex);
return a;
}

final List<dynamic> result = <dynamic>[
if (a is Iterable) ...a else a,
if (b is Iterable) ...b else b,
];

if (listLimit != null && listLimit >= 0 && result.length > listLimit) {
final Map<String, dynamic> overflow = createIndexMap(result);
return markOverflow(overflow, result.length - 1);
}

return result;
}

/// Applies `fn` to a scalar or maps it over an iterable, returning the result.
///
Expand Down
2 changes: 1 addition & 1 deletion test/comparison/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"author": "Klemen Tusar",
"license": "BSD-3-Clause",
"dependencies": {
"qs": "^6.12.1"
"qs": "^6.14.1"
}
}
Loading