From 9dc6ca23fbb4946f86efd438ea74d80f3563d674 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 18:16:54 +0000 Subject: [PATCH 01/42] feat: add validation for DecodeOptions and EncodeOptions to ensure proper settings --- lib/src/qs.dart | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/lib/src/qs.dart b/lib/src/qs.dart index 4f79f4e..06a584c 100644 --- a/lib/src/qs.dart +++ b/lib/src/qs.dart @@ -45,6 +45,7 @@ final class QS { static Map decode(dynamic input, [DecodeOptions? options]) { options ??= const DecodeOptions(); // Default to the library's safe, Node-`qs` compatible settings. + _validateDecodeOptions(options); // Fail fast on unsupported input shapes to avoid ambiguous behavior. if (!(input is String? || input is Map?)) { @@ -102,6 +103,7 @@ final class QS { static String encode(Object? object, [EncodeOptions? options]) { options ??= const EncodeOptions(); // Use default encoding settings unless overridden by the caller. + _validateEncodeOptions(options); // Normalize supported inputs into a mutable map we can traverse. Map obj = switch (object) { @@ -210,3 +212,31 @@ final class QS { return out.toString(); } } + +void _validateDecodeOptions(DecodeOptions options) { + final Encoding charset = options.charset; + if (charset != utf8 && charset != latin1) { + throw ArgumentError.value(charset, 'charset', 'Invalid charset'); + } + + if (options.decodeDotInKeys && !options.allowDots) { + throw ArgumentError('decodeDotInKeys requires allowDots to be true'); + } + + final num limit = options.parameterLimit; + if (limit.isNaN || (limit.isFinite && limit <= 0)) { + throw ArgumentError('Parameter limit must be a positive number.'); + } +} + +void _validateEncodeOptions(EncodeOptions options) { + final Encoding charset = options.charset; + if (charset != utf8 && charset != latin1) { + throw ArgumentError.value(charset, 'charset', 'Invalid charset'); + } + + final dynamic filter = options.filter; + if (filter != null && filter is! Function && filter is! Iterable) { + throw ArgumentError.value(filter, 'filter', 'Invalid filter'); + } +} From 06fa2a0cf2b8f7667ce6c0552485aa95eb1a08a4 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 18:16:59 +0000 Subject: [PATCH 02/42] fix: enhance decoding logic for ByteBuffer to handle malformed UTF-8 strings --- lib/src/utils.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 8917815..641f335 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -433,7 +433,9 @@ final class Utils { } final String? str = value is ByteBuffer - ? charset.decode(value.asUint8List()) + ? (charset == utf8 + ? utf8.decode(value.asUint8List(), allowMalformed: true) + : latin1.decode(value.asUint8List())) : value?.toString(); if (str?.isEmpty ?? true) { From e93edd51775270816a7957d96fb64bed4e877fe4 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 18:17:06 +0000 Subject: [PATCH 03/42] feat: add throwOnLimitExceeded option to DecodeOptions for stricter limit handling --- lib/src/models/decode_options.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index e9bb8b7..6981c00 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -273,6 +273,7 @@ final class DecodeOptions with EquatableMixin { bool? parseLists, bool? strictNullHandling, bool? strictDepth, + bool? throwOnLimitExceeded, Decoder? decoder, LegacyDecoder? legacyDecoder, }) => @@ -294,6 +295,7 @@ final class DecodeOptions with EquatableMixin { parseLists: parseLists ?? this.parseLists, strictNullHandling: strictNullHandling ?? this.strictNullHandling, strictDepth: strictDepth ?? this.strictDepth, + throwOnLimitExceeded: throwOnLimitExceeded ?? this.throwOnLimitExceeded, decoder: decoder ?? _decoder, legacyDecoder: legacyDecoder ?? _legacyDecoder, ); @@ -315,6 +317,7 @@ final class DecodeOptions with EquatableMixin { ' parameterLimit: $parameterLimit,\n' ' parseLists: $parseLists,\n' ' strictDepth: $strictDepth,\n' + ' throwOnLimitExceeded: $throwOnLimitExceeded,\n' ' strictNullHandling: $strictNullHandling\n' ')'; From 121c8c7dc9add46c5ff92863825a9752cb2e249f Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 18:17:11 +0000 Subject: [PATCH 04/42] test: add tests for throwOnLimitExceeded in DecodeOptions copyWith method --- test/unit/models/decode_options_test.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index 872954d..0a6c496 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -96,6 +96,21 @@ void main() { expect(newOptions.strictNullHandling, isFalse); }); + test('copyWith preserves throwOnLimitExceeded', () { + const DecodeOptions options = DecodeOptions(throwOnLimitExceeded: true); + final DecodeOptions newOptions = options.copyWith(); + + expect(newOptions.throwOnLimitExceeded, isTrue); + }); + + test('copyWith overrides throwOnLimitExceeded', () { + const DecodeOptions options = DecodeOptions(throwOnLimitExceeded: false); + final DecodeOptions newOptions = + options.copyWith(throwOnLimitExceeded: true); + + expect(newOptions.throwOnLimitExceeded, isTrue); + }); + test('toString', () { final DecodeOptions options = const DecodeOptions( allowDots: true, @@ -134,6 +149,7 @@ void main() { ' parameterLimit: 100,\n' ' parseLists: false,\n' ' strictDepth: false,\n' + ' throwOnLimitExceeded: false,\n' ' strictNullHandling: true\n' ')', ), From 88400690375bf6a2607d7e1999e25c594ae52491 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 18:48:12 +0000 Subject: [PATCH 05/42] feat: implement _EncodeFrame and refactor encoding logic for improved structure and readability refactor: simplify DecodeOptions and EncodeOptions constructors by removing redundant assertions feat: add _MergeFrame and enhance merge utility for better handling of complex data structures --- lib/src/extensions/encode.dart | 522 ++++++++++++++++++----------- lib/src/models/decode_options.dart | 14 +- lib/src/models/encode_options.dart | 12 +- lib/src/utils.dart | 433 ++++++++++++++++-------- 4 files changed, 607 insertions(+), 374 deletions(-) diff --git a/lib/src/extensions/encode.dart b/lib/src/extensions/encode.dart index 2b4d3bf..6cba0b2 100644 --- a/lib/src/extensions/encode.dart +++ b/lib/src/extensions/encode.dart @@ -1,5 +1,63 @@ part of '../qs.dart'; +final class _EncodeFrame { + _EncodeFrame({ + required this.object, + required this.undefined, + required this.sideChannel, + required this.prefix, + required this.generateArrayPrefix, + required this.commaRoundTrip, + required this.commaCompactNulls, + required this.allowEmptyLists, + required this.strictNullHandling, + required this.skipNulls, + required this.encodeDotInKeys, + required this.encoder, + required this.serializeDate, + required this.sort, + required this.filter, + required this.allowDots, + required this.format, + required this.formatter, + required this.encodeValuesOnly, + required this.charset, + required this.onResult, + }); + + dynamic object; + final bool undefined; + final WeakMap sideChannel; + final String prefix; + final ListFormatGenerator generateArrayPrefix; + final bool commaRoundTrip; + final bool commaCompactNulls; + final bool allowEmptyLists; + final bool strictNullHandling; + final bool skipNulls; + final bool encodeDotInKeys; + final Encoder? encoder; + final DateSerializer? serializeDate; + final Sorter? sort; + final dynamic filter; + final bool allowDots; + final Format format; + final Formatter formatter; + final bool encodeValuesOnly; + final Encoding charset; + final void Function(List result) onResult; + + bool prepared = false; + bool tracked = false; + Object? trackedObject; + List objKeys = const []; + int index = 0; + List? seqList; + int? commaEffectiveLength; + String? adjustedPrefix; + List values = []; +} + /// Encoding engine used by [QS.encode]. /// /// This module mirrors the shape and behavior of the Node `qs` encoder: @@ -16,10 +74,6 @@ part of '../qs.dart'; /// - *prefix*: current key path being built (e.g., `user[address]`), with optional `?` prefix. extension _$Encode on QS { - // Side-channel anchor used to thread cycle-detection state through recursion. - // We store nested WeakMaps under this key to walk back up the call stack. - static const Map _sentinel = {}; - /// Core encoder (recursive). /// /// Returns either a `String` (single key=value) or `List` fragments, which the @@ -76,164 +130,232 @@ extension _$Encode on QS { identical(generateArrayPrefix, ListFormat.comma.generator); formatter ??= format.formatter; - dynamic obj = object; - - WeakMap? tmpSc = sideChannel; - int step = 0; - bool findFlag = false; - - // Walk the nested WeakMap chain to see if the current object already appeared - // in the traversal path. If so, either throw (direct cycle) or stop descending. - while ((tmpSc = tmpSc?.get(_sentinel)) != null && !findFlag) { - // Where object last appeared in the ref tree - final int? pos = tmpSc?.get(object) as int?; - step += 1; - if (pos != null) { - if (pos == step) { - throw RangeError('Cyclic object value'); - } else { - findFlag = true; // Break while + List? result; + final List<_EncodeFrame> stack = [ + _EncodeFrame( + object: object, + undefined: undefined, + sideChannel: sideChannel, + prefix: prefix, + generateArrayPrefix: generateArrayPrefix, + commaRoundTrip: commaRoundTrip, + commaCompactNulls: commaCompactNulls, + allowEmptyLists: allowEmptyLists, + strictNullHandling: strictNullHandling, + skipNulls: skipNulls, + encodeDotInKeys: encodeDotInKeys, + encoder: encoder, + serializeDate: serializeDate, + sort: sort, + filter: filter, + allowDots: allowDots, + format: format, + formatter: formatter, + encodeValuesOnly: encodeValuesOnly, + charset: charset, + onResult: (List value) => result = value, + ), + ]; + + while (stack.isNotEmpty) { + final _EncodeFrame frame = stack.last; + + if (!frame.prepared) { + dynamic obj = frame.object; + final bool trackObject = + obj is Map || (obj is Iterable && obj is! String); + if (trackObject) { + if (frame.sideChannel.contains(obj)) { + throw RangeError('Cyclic object value'); + } + frame.sideChannel[obj] = true; + frame.tracked = true; + frame.trackedObject = obj as Object; } - } - if (tmpSc?.get(_sentinel) == null) { - step = 0; - } - } - // Apply filter hook first. For dates, serialize them before any list/comma handling. - if (filter is Function) { - obj = filter.call(prefix, obj); - } else if (obj is DateTime) { - obj = switch (serializeDate) { - null => obj.toIso8601String(), - _ => serializeDate(obj), - }; - } else if (identical(generateArrayPrefix, ListFormat.comma.generator) && - obj is Iterable) { - obj = Utils.apply( - obj, - (value) => value is DateTime - ? (serializeDate?.call(value) ?? value.toIso8601String()) - : value, - ); - } + // Apply filter hook first. For dates, serialize them before any list/comma handling. + if (frame.filter is Function) { + obj = frame.filter.call(frame.prefix, obj); + } else if (obj is DateTime) { + obj = switch (frame.serializeDate) { + null => obj.toIso8601String(), + _ => frame.serializeDate!(obj), + }; + } else if (identical( + frame.generateArrayPrefix, ListFormat.comma.generator) && + obj is Iterable) { + obj = Utils.apply( + obj, + (value) => value is DateTime + ? (frame.serializeDate?.call(value) ?? value.toIso8601String()) + : value, + ); + } - // Present-but-null handling: - // - If the value is *present* and null and strictNullHandling is on, emit only the key. - // - Otherwise, treat null as an empty string. - if (!undefined && obj == null) { - if (strictNullHandling) { - return encoder != null && !encodeValuesOnly ? encoder(prefix) : prefix; - } + // Present-but-null handling: + // - If the value is *present* and null and strictNullHandling is on, emit only the key. + // - Otherwise, treat null as an empty string. + if (!frame.undefined && obj == null) { + if (frame.strictNullHandling) { + final String keyOnly = + frame.encoder != null && !frame.encodeValuesOnly + ? frame.encoder!(frame.prefix) + : frame.prefix; + if (frame.tracked) { + frame.sideChannel.remove(frame.trackedObject ?? frame.object); + } + stack.removeLast(); + frame.onResult([keyOnly]); + continue; + } + obj = ''; + } - obj = ''; - } + // Fast path for primitives and byte buffers → return a single key=value fragment. + if (Utils.isNonNullishPrimitive(obj, frame.skipNulls) || + obj is ByteBuffer) { + late final String fragment; + if (frame.encoder != null) { + final String keyValue = frame.encodeValuesOnly + ? frame.prefix + : frame.encoder!(frame.prefix); + fragment = + '${frame.formatter(keyValue)}=${frame.formatter(frame.encoder!(obj))}'; + } else { + fragment = + '${frame.formatter(frame.prefix)}=${frame.formatter(obj.toString())}'; + } + if (frame.tracked) { + frame.sideChannel.remove(frame.trackedObject ?? frame.object); + } + stack.removeLast(); + frame.onResult([fragment]); + continue; + } - // Fast path for primitives and byte buffers → return a single key=value fragment. - if (Utils.isNonNullishPrimitive(obj, skipNulls) || obj is ByteBuffer) { - if (encoder != null) { - final String keyValue = encodeValuesOnly ? prefix : encoder(prefix); - return ['${formatter(keyValue)}=${formatter(encoder(obj))}']; - } - return ['${formatter(prefix)}=${formatter(obj.toString())}']; - } + // Collect per-branch fragments; empty list signifies "emit nothing" for this path. + if (frame.undefined) { + if (frame.tracked) { + frame.sideChannel.remove(frame.trackedObject ?? frame.object); + } + stack.removeLast(); + frame.onResult(const []); + continue; + } - // Collect per-branch fragments; empty list signifies "emit nothing" for this path. - final List values = []; + // Cache list form once for non-Map, non-String iterables to avoid repeated enumeration + List? seqList; + int? commaEffectiveLength; + final bool isSeq = obj is Iterable && obj is! String && obj is! Map; + if (isSeq) { + seqList = obj is List ? obj : obj.toList(growable: false); + } - if (undefined) { - return values; - } + late final List objKeys; + // Determine the set of keys/indices to traverse at this depth: + // - For `.comma` lists we join values in-place. + // - If `filter` is Iterable, it constrains the key set. + // - Otherwise derive keys from Map/Iterable, and optionally sort them. + if (identical(frame.generateArrayPrefix, ListFormat.comma.generator) && + obj is Iterable) { + final Iterable iterableObj = obj; + final List commaItems = iterableObj is List + ? List.from(iterableObj) + : iterableObj.toList(growable: false); + + final List filteredItems = frame.commaCompactNulls + ? commaItems.where((dynamic item) => item != null).toList() + : commaItems; + + commaEffectiveLength = filteredItems.length; + + final Iterable joinIterable = frame.encodeValuesOnly && + frame.encoder != null + ? (Utils.apply(filteredItems, frame.encoder!) as Iterable) + : filteredItems; + + final List joinList = joinIterable is List + ? List.from(joinIterable) + : joinIterable.toList(growable: false); + + if (joinList.isNotEmpty) { + final String objKeysValue = + joinList.map((e) => e != null ? e.toString() : '').join(','); + + objKeys = [ + { + 'value': objKeysValue.isNotEmpty ? objKeysValue : null, + }, + ]; + } else { + objKeys = [ + {'value': const Undefined()}, + ]; + } + } else if (frame.filter is Iterable) { + objKeys = List.of(frame.filter); + } else { + late final Iterable keys; + if (obj is Map) { + keys = obj.keys; + } else if (seqList != null) { + keys = + List.generate(seqList.length, (i) => i, growable: false); + } else { + keys = const []; + } + objKeys = frame.sort != null + ? (keys.toList()..sort(frame.sort)) + : keys.toList(); + } - // Cache list form once for non-Map, non-String iterables to avoid repeated enumeration - List? seqList_; - int? commaEffectiveLength; - final bool isSeq_ = obj is Iterable && obj is! String && obj is! Map; - if (isSeq_) { - if (obj is List) { - seqList_ = obj; - } else { - seqList_ = obj.toList(growable: false); - } - } + // Key-path formatting: + // - Optionally encode literal dots. + // - Under `.comma` with single-element lists and round-trip enabled, append []. + final String encodedPrefix = frame.encodeDotInKeys + ? frame.prefix.replaceAll('.', '%2E') + : frame.prefix; + + final bool shouldAppendRoundTripMarker = (frame.commaRoundTrip == + true) && + seqList != null && + (identical(frame.generateArrayPrefix, ListFormat.comma.generator) && + commaEffectiveLength != null + ? commaEffectiveLength == 1 + : seqList.length == 1); + + final String adjustedPrefix = + shouldAppendRoundTripMarker ? '$encodedPrefix[]' : encodedPrefix; + + // Emit `key[]` when an empty list is allowed, to preserve shape on round-trip. + if (frame.allowEmptyLists && seqList != null && seqList.isEmpty) { + if (frame.tracked) { + frame.sideChannel.remove(frame.trackedObject ?? frame.object); + } + stack.removeLast(); + frame.onResult(['$adjustedPrefix[]']); + continue; + } - late final List objKeys; - // Determine the set of keys/indices to traverse at this depth: - // - For `.comma` lists we join values in-place. - // - If `filter` is Iterable, it constrains the key set. - // - Otherwise derive keys from Map/Iterable, and optionally sort them. - if (identical(generateArrayPrefix, ListFormat.comma.generator) && - obj is Iterable) { - final Iterable iterableObj = obj; - final List commaItems = iterableObj is List - ? List.from(iterableObj) - : iterableObj.toList(growable: false); - - final List filteredItems = commaCompactNulls - ? commaItems.where((dynamic item) => item != null).toList() - : commaItems; - - commaEffectiveLength = filteredItems.length; - - final Iterable joinIterable = encodeValuesOnly && encoder != null - ? (Utils.apply(filteredItems, encoder) as Iterable) - : filteredItems; - - final List joinList = joinIterable is List - ? List.from(joinIterable) - : joinIterable.toList(growable: false); - - if (joinList.isNotEmpty) { - final String objKeysValue = - joinList.map((e) => e != null ? e.toString() : '').join(','); - - objKeys = [ - { - 'value': objKeysValue.isNotEmpty ? objKeysValue : null, - }, - ]; - } else { - objKeys = [ - {'value': const Undefined()}, - ]; - } - } else if (filter is Iterable) { - objKeys = List.of(filter); - } else { - late final Iterable keys; - if (obj is Map) { - keys = obj.keys; - } else if (seqList_ != null) { - keys = List.generate(seqList_.length, (i) => i, growable: false); - } else { - keys = const []; + frame.object = obj; + frame.prepared = true; + frame.objKeys = objKeys; + frame.seqList = seqList; + frame.commaEffectiveLength = commaEffectiveLength; + frame.adjustedPrefix = adjustedPrefix; + continue; } - objKeys = sort != null ? (keys.toList()..sort(sort)) : keys.toList(); - } - // Key-path formatting: - // - Optionally encode literal dots. - // - Under `.comma` with single-element lists and round-trip enabled, append []. - final String encodedPrefix = - encodeDotInKeys ? prefix.replaceAll('.', '%2E') : prefix; - - final bool shouldAppendRoundTripMarker = (commaRoundTrip == true) && - seqList_ != null && - (identical(generateArrayPrefix, ListFormat.comma.generator) && - commaEffectiveLength != null - ? commaEffectiveLength == 1 - : seqList_.length == 1); - - final String adjustedPrefix = - shouldAppendRoundTripMarker ? '$encodedPrefix[]' : encodedPrefix; - - // Emit `key[]` when an empty list is allowed, to preserve shape on round-trip. - if (allowEmptyLists && seqList_ != null && seqList_.isEmpty) { - return '$adjustedPrefix[]'; - } + if (frame.index >= frame.objKeys.length) { + if (frame.tracked) { + frame.sideChannel.remove(frame.trackedObject ?? frame.object); + } + stack.removeLast(); + frame.onResult(frame.values); + continue; + } - for (int i = 0; i < objKeys.length; i++) { - final key = objKeys[i]; + final key = frame.objKeys[frame.index++]; late final dynamic value; late final bool valueUndefined; @@ -245,13 +367,13 @@ extension _$Encode on QS { } else { // Resolve value for the current key/index. try { - if (obj is Map) { - value = obj[key]; - valueUndefined = !obj.containsKey(key); - } else if (seqList_ != null) { + if (frame.object is Map) { + value = frame.object[key]; + valueUndefined = !(frame.object as Map).containsKey(key); + } else if (frame.seqList != null) { final int? idx = key is int ? key : int.tryParse(key.toString()); - if (idx != null && idx >= 0 && idx < seqList_.length) { - value = seqList_[idx]; + if (idx != null && idx >= 0 && idx < frame.seqList!.length) { + value = frame.seqList![idx]; valueUndefined = false; } else { value = null; @@ -260,7 +382,7 @@ extension _$Encode on QS { } else { // Best-effort dynamic indexer for user-defined classes that expose `operator []`. // If it throws (no indexer / wrong type), we fall through to the catch and mark undefined. - value = obj[key]; + value = (frame.object as dynamic)[key]; valueUndefined = false; } } catch (_) { @@ -269,64 +391,58 @@ extension _$Encode on QS { } } - if (skipNulls && value == null) { + if (frame.skipNulls && value == null) { continue; } // Build the next key path segment using either bracket or dot notation. - final String encodedKey = allowDots && encodeDotInKeys + final String encodedKey = frame.allowDots && frame.encodeDotInKeys ? key.toString().replaceAll('.', '%2E') : key.toString(); final bool isCommaSentinel = key is Map && key.containsKey('value'); final String keyPrefix = (isCommaSentinel && - identical(generateArrayPrefix, ListFormat.comma.generator)) - ? adjustedPrefix - : (seqList_ != null - ? generateArrayPrefix(adjustedPrefix, encodedKey) - : '$adjustedPrefix${allowDots ? '.$encodedKey' : '[$encodedKey]'}'); - - // Thread cycle-detection state into recursive calls without keeping strong references. - sideChannel[object] = step; - final WeakMap valueSideChannel = WeakMap(); - valueSideChannel.add(key: _sentinel, value: sideChannel); - - final encoded = _encode( - value, - undefined: valueUndefined, - prefix: keyPrefix, - generateArrayPrefix: generateArrayPrefix, - commaRoundTrip: commaRoundTrip, - commaCompactNulls: commaCompactNulls, - allowEmptyLists: allowEmptyLists, - strictNullHandling: strictNullHandling, - skipNulls: skipNulls, - encodeDotInKeys: encodeDotInKeys, - encoder: identical(generateArrayPrefix, ListFormat.comma.generator) && - encodeValuesOnly && - seqList_ != null - ? null - : encoder, - serializeDate: serializeDate, - filter: filter, - sort: sort, - allowDots: allowDots, - format: format, - formatter: formatter, - encodeValuesOnly: encodeValuesOnly, - charset: charset, - sideChannel: valueSideChannel, + identical(frame.generateArrayPrefix, ListFormat.comma.generator)) + ? frame.adjustedPrefix! + : (frame.seqList != null + ? frame.generateArrayPrefix(frame.adjustedPrefix!, encodedKey) + : '${frame.adjustedPrefix!}${frame.allowDots ? '.$encodedKey' : '[$encodedKey]'}'); + + stack.add( + _EncodeFrame( + object: value, + undefined: valueUndefined, + sideChannel: frame.sideChannel, + prefix: keyPrefix, + generateArrayPrefix: frame.generateArrayPrefix, + commaRoundTrip: frame.commaRoundTrip, + commaCompactNulls: frame.commaCompactNulls, + allowEmptyLists: frame.allowEmptyLists, + strictNullHandling: frame.strictNullHandling, + skipNulls: frame.skipNulls, + encodeDotInKeys: frame.encodeDotInKeys, + encoder: identical( + frame.generateArrayPrefix, ListFormat.comma.generator) && + frame.encodeValuesOnly && + frame.seqList != null + ? null + : frame.encoder, + serializeDate: frame.serializeDate, + sort: frame.sort, + filter: frame.filter, + allowDots: frame.allowDots, + format: frame.format, + formatter: frame.formatter, + encodeValuesOnly: frame.encodeValuesOnly, + charset: frame.charset, + onResult: (List encoded) { + frame.values.addAll(encoded); + }, + ), ); - - // Flatten nested results (each recursion returns a list of fragments or a single fragment). - if (encoded is Iterable) { - values.addAll(encoded); - } else { - values.add(encoded); - } } - return values; + return result ?? const []; } } diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 6981c00..eeabd5c 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -73,19 +73,7 @@ final class DecodeOptions with EquatableMixin { }) : allowDots = allowDots ?? (decodeDotInKeys ?? false), decodeDotInKeys = decodeDotInKeys ?? false, _decoder = decoder, - _legacyDecoder = legacyDecoder, - assert( - charset == utf8 || charset == latin1, - 'Invalid charset', - ), - assert( - !(decodeDotInKeys ?? false) || allowDots != false, - 'decodeDotInKeys requires allowDots to be true', - ), - assert( - parameterLimit > 0, - 'Parameter limit must be positive', - ); + _legacyDecoder = legacyDecoder; /// When `true`, decode dot notation in keys: `a.b=c` → `{a: {b: "c"}}`. /// diff --git a/lib/src/models/encode_options.dart b/lib/src/models/encode_options.dart index b4fab9e..8635345 100644 --- a/lib/src/models/encode_options.dart +++ b/lib/src/models/encode_options.dart @@ -1,4 +1,4 @@ -import 'dart:convert' show Encoding, utf8, latin1; +import 'dart:convert' show Encoding, utf8; import 'package:equatable/equatable.dart'; import 'package:qs_dart/src/enums/format.dart'; @@ -55,15 +55,7 @@ final class EncodeOptions with EquatableMixin { (indices == false ? ListFormat.repeat : null) ?? ListFormat.indices, _serializeDate = serializeDate, - _encoder = encoder, - assert( - charset == utf8 || charset == latin1, - 'Invalid charset', - ), - assert( - filter == null || filter is Function || filter is Iterable, - 'Invalid filter', - ); + _encoder = encoder; /// Set to `true` to add a question mark `?` prefix to the encoded output. final bool addQueryPrefix; diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 641f335..3d4f896 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -12,6 +12,37 @@ import 'package:qs_dart/src/models/undefined.dart'; part 'constants/hex_table.dart'; +enum _MergePhase { + start, + mapIter, + listIter, +} + +final class _MergeFrame { + _MergeFrame({ + required this.target, + required this.source, + required this.options, + required this.onResult, + }); + + dynamic target; + dynamic source; + final DecodeOptions? options; + final void Function(dynamic result) onResult; + + _MergePhase phase = _MergePhase.start; + + Map? mergeTarget; + Iterator>? mapIterator; + int? overflowMax; + + SplayTreeMap? indexedTarget; + List? sourceList; + int listIndex = 0; + bool targetIsSet = false; +} + /// Internal utilities and helpers used by the library. /// /// This class gathers low-level building blocks used by the public @@ -92,180 +123,286 @@ final class Utils { dynamic source, [ DecodeOptions? options = const DecodeOptions(), ]) { - if (source == null) { - return target; - } + dynamic result; + final List<_MergeFrame> stack = [ + _MergeFrame( + target: target, + source: source, + options: options, + onResult: (dynamic value) => result = value, + ), + ]; - if (source is! Map) { - if (target is Iterable) { - if (target.any((el) => el is Undefined)) { - // use a SplayTreeMap to keep the keys in order - final SplayTreeMap target_ = _toIndexedTreeMap(target); + while (stack.isNotEmpty) { + final _MergeFrame frame = stack.last; - if (source is Iterable) { - for (final (int i, dynamic item) in source.indexed) { - if (item is! Undefined) { - target_[i] = item; - } - } - } else { - target_[target_.length] = source; - } + if (frame.phase == _MergePhase.start) { + final dynamic currentTarget = frame.target; + final dynamic currentSource = frame.source; - target = options?.parseLists == false && - target_.values.any((el) => el is Undefined) - ? SplayTreeMap.from({ - for (final MapEntry entry in target_.entries) - if (entry.value is! Undefined) entry.key: entry.value, - }) - : target is Set - ? target_.values.toSet() - : target_.values.toList(); - } else { - if (source is Iterable) { - // check if source is a list of maps and target is a list of maps - if (target.every((el) => el is Map || el is Undefined) && - source.every((el) => el is Map || el is Undefined)) { - // loop through the target list and merge the maps - // then loop through the source list and add any new maps + if (currentSource == null) { + stack.removeLast(); + frame.onResult(currentTarget); + continue; + } + + if (currentSource is! Map) { + if (currentTarget is Iterable) { + if (currentTarget.any((el) => el is Undefined)) { final SplayTreeMap target_ = - _toIndexedTreeMap(target); - for (final (int i, dynamic item) in source.indexed) { - target_.update( - i, - (value) => merge(value, item, options), - ifAbsent: () => item, + _toIndexedTreeMap(currentTarget); + + if (currentSource is Iterable) { + for (final (int i, dynamic item) in currentSource.indexed) { + if (item is! Undefined) { + target_[i] = item; + } + } + } else { + target_[target_.length] = currentSource; + } + + if (frame.options?.parseLists == false && + target_.values.any((el) => el is Undefined)) { + stack.removeLast(); + frame.onResult( + SplayTreeMap.from({ + for (final MapEntry entry in target_.entries) + if (entry.value is! Undefined) entry.key: entry.value, + }), ); + continue; } - if (target is Set) { - target = target_.values.toSet(); - } else { - target = target_.values.toList(); + + stack.removeLast(); + frame.onResult( + currentTarget is Set + ? target_.values.toSet() + : target_.values.toList(), + ); + continue; + } + + if (currentSource is Iterable) { + final bool targetMaps = + currentTarget.every((el) => el is Map || el is Undefined); + final bool sourceMaps = + currentSource.every((el) => el is Map || el is Undefined); + + if (targetMaps && sourceMaps) { + frame.indexedTarget = _toIndexedTreeMap(currentTarget); + frame.sourceList = currentSource is List + ? currentSource + : currentSource.toList(growable: false); + frame.targetIsSet = currentTarget is Set; + frame.listIndex = 0; + frame.phase = _MergePhase.listIter; + continue; } - } else { - if (target is Set) { - target = Set.of(target) - ..addAll(source.whereNotType()); - } else { - target = List.of(target) - ..addAll(source.whereNotType()); + + stack.removeLast(); + frame.onResult( + currentTarget is Set + ? (Set.of(currentTarget) + ..addAll(currentSource.whereNotType())) + : (List.of(currentTarget) + ..addAll(currentSource.whereNotType())), + ); + continue; + } + + if (currentSource != null) { + if (currentTarget is List) { + currentTarget.add(currentSource); + stack.removeLast(); + frame.onResult(currentTarget); + continue; } + if (currentTarget is Set) { + currentTarget.add(currentSource); + stack.removeLast(); + frame.onResult(currentTarget); + continue; + } + stack.removeLast(); + frame.onResult([currentTarget, currentSource]); + continue; } - } else if (source != null) { - if (target is List) { - target.add(source); - } else if (target is Set) { - target.add(source); - } else { - target = [target, source]; + } else if (currentTarget is Map) { + if (currentSource is Iterable) { + final Map sourceMap = { + for (final (int i, dynamic item) in currentSource.indexed) + if (item is! Undefined) i.toString(): item, + }; + frame.source = sourceMap; + frame.phase = _MergePhase.start; + continue; + } + if (isOverflow(currentTarget)) { + final int newIndex = _getOverflowIndex(currentTarget) + 1; + currentTarget[newIndex.toString()] = currentSource; + _setOverflowIndex(currentTarget, newIndex); + stack.removeLast(); + frame.onResult(currentTarget); + continue; + } + } else if (currentSource != null) { + if (currentTarget is! Iterable && currentSource is Iterable) { + stack.removeLast(); + frame.onResult( + [currentTarget, ...currentSource.whereNotType()], + ); + continue; } + stack.removeLast(); + frame.onResult([currentTarget, currentSource]); + continue; } + + stack.removeLast(); + frame.onResult(currentTarget); + continue; } - } else if (target is Map) { - if (source is Iterable) { - final Map 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) { - return [target, ...source.whereNotType()]; - } - return [target, source]; - } - return target; - } + if (currentTarget == null || currentTarget is! Map) { + if (currentTarget is Iterable) { + final Map mergeTarget = { + for (final (int i, dynamic item) in currentTarget.indexed) + if (item is! Undefined) i.toString(): item, + }; + frame.mergeTarget = mergeTarget; + frame.mapIterator = currentSource.entries.iterator; + frame.overflowMax = null; + frame.phase = _MergePhase.mapIter; + continue; + } - if (target == null || target is! Map) { - if (target is Iterable) { - final Map mergeTarget = { - for (final (int i, dynamic item) in target.indexed) - if (item is! Undefined) i.toString(): item, - }; - 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; + if (isOverflow(currentSource)) { + final int sourceMax = _getOverflowIndex(currentSource); + final Map resultMap = { + if (currentTarget != null) '0': currentTarget, + }; + for (final MapEntry entry in currentSource.entries) { + final String key = entry.key.toString(); + final int? oldIndex = int.tryParse(key); + if (oldIndex == null) { + resultMap[key] = entry.value; + } else { + resultMap[(oldIndex + 1).toString()] = entry.value; + } + } + stack.removeLast(); + frame.onResult(markOverflow(resultMap, sourceMax + 1)); + continue; } + + stack.removeLast(); + frame.onResult( + [ + if (currentTarget is Iterable) + ...currentTarget.whereNotType() + else if (currentTarget != null) + currentTarget, + if (currentSource is Iterable) + ...(currentSource as Iterable).whereNotType() + else + currentSource, + ], + ); + continue; } - return mergeTarget; + + final bool targetOverflow = isOverflow(currentTarget); + final int? overflowMax = + targetOverflow ? _getOverflowIndex(currentTarget) : null; + + final Map mergeTarget = + currentTarget is Iterable && currentSource is! Iterable + ? { + for (final (int i, dynamic item) + in (currentTarget as Iterable).indexed) + if (item is! Undefined) i.toString(): item, + } + : { + for (final MapEntry entry in currentTarget.entries) + entry.key.toString(): entry.value, + }; + + frame.mergeTarget = mergeTarget; + frame.mapIterator = currentSource.entries.iterator; + frame.overflowMax = overflowMax; + frame.phase = _MergePhase.mapIter; + continue; } - if (isOverflow(source)) { - final int sourceMax = _getOverflowIndex(source); - final Map result = { - if (target != null) '0': target, - }; - for (final MapEntry entry in source.entries) { + if (frame.phase == _MergePhase.mapIter) { + if (frame.mapIterator!.moveNext()) { + final MapEntry entry = frame.mapIterator!.current; 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; + + if (frame.overflowMax != null) { + frame.overflowMax = _updateOverflowMax(frame.overflowMax!, key); } + + if (frame.mergeTarget!.containsKey(key)) { + final dynamic childTarget = frame.mergeTarget![key]; + stack.add( + _MergeFrame( + target: childTarget, + source: entry.value, + options: frame.options, + onResult: (dynamic value) { + frame.mergeTarget![key] = value; + }, + ), + ); + continue; + } + + frame.mergeTarget![key] = entry.value; + continue; } - return markOverflow(result, sourceMax + 1); - } - return [ - if (target is Iterable) - ...target.whereNotType() - else if (target != null) - target, - if (source is Iterable) - ...(source as Iterable).whereNotType() - else - source, - ]; - } + if (frame.overflowMax != null) { + markOverflow(frame.mergeTarget!, frame.overflowMax!); + } - final bool targetOverflow = isOverflow(target); - int? overflowMax = targetOverflow ? _getOverflowIndex(target) : null; + stack.removeLast(); + frame.onResult(frame.mergeTarget!); + continue; + } - Map mergeTarget = target is Iterable && source is! Iterable - ? { - for (final (int i, dynamic item) in (target as Iterable).indexed) - if (item is! Undefined) i.toString(): item - } - : { - for (final MapEntry entry in target.entries) - entry.key.toString(): entry.value - }; - - for (final MapEntry entry in source.entries) { - if (overflowMax != null) { - overflowMax = _updateOverflowMax(overflowMax, entry.key.toString()); + if (frame.listIndex >= frame.sourceList!.length) { + final resultList = frame.targetIsSet + ? frame.indexedTarget!.values.toSet() + : frame.indexedTarget!.values.toList(); + stack.removeLast(); + frame.onResult(resultList); + continue; } - mergeTarget.update( - entry.key.toString(), - (value) => merge( - value, - entry.value, - options, - ), - ifAbsent: () => entry.value, - ); - } - if (overflowMax != null) { - markOverflow(mergeTarget, overflowMax); + + final int idx = frame.listIndex++; + final dynamic item = frame.sourceList![idx]; + + if (frame.indexedTarget!.containsKey(idx)) { + final dynamic childTarget = frame.indexedTarget![idx]; + stack.add( + _MergeFrame( + target: childTarget, + source: item, + options: frame.options, + onResult: (dynamic value) { + frame.indexedTarget![idx] = value; + }, + ), + ); + continue; + } + + frame.indexedTarget![idx] = item; } - return mergeTarget; + + return result; } /// Converts an iterable to a zero-indexed [SplayTreeMap]. From a446970dc537a2986f5f6c5ec5495fc60d3ee6e7 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 18:48:18 +0000 Subject: [PATCH 06/42] test: enhance tests for DecodeOptions and EncodeOptions with runtime validation and deep nesting scenarios --- test/unit/decode_test.dart | 20 ++++++---- test/unit/encode_edge_cases_test.dart | 11 ++++++ test/unit/models/decode_options_test.dart | 45 ++++++++++++++++++++--- test/unit/models/encode_options_test.dart | 32 ++++++++++++++++ test/unit/uri_extension_test.dart | 8 +++- test/unit/utils_test.dart | 27 ++++++++++++++ 6 files changed, 128 insertions(+), 15 deletions(-) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index bf4e860..1adfb2f 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -17,7 +17,7 @@ void main() { expect( () => QS.decode( 'a=b&c=d', - DecodeOptions(parameterLimit: 0), + const DecodeOptions(parameterLimit: 0), ), throwsA(anyOf( isA(), @@ -1539,10 +1539,14 @@ void main() { }); group('charset', () { - test('throws an AssertionError when given an unknown charset', () { + test('throws when given an unknown charset', () { expect( () => QS.decode('a=b', DecodeOptions(charset: ShiftJIS())), - throwsA(isA()), + throwsA(anyOf( + isA(), + isA(), + isA(), + )), ); }); @@ -2344,8 +2348,8 @@ void main() { test('allowDots=false, decodeDotInKeys=true is invalid', () { expect( - () => QS.decode( - 'a%2Eb=c', DecodeOptions(allowDots: false, decodeDotInKeys: true)), + () => QS.decode('a%2Eb=c', + const DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), isA(), @@ -2501,7 +2505,7 @@ void main() { () { expect( () => QS.decode('a%5Bb%5D%5Bc%5D%2Ed=x', - DecodeOptions(allowDots: false, decodeDotInKeys: true)), + const DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), isA(), @@ -2796,8 +2800,8 @@ void main() { // Invalid combo: allowDots=false with decodeDotInKeys=true should throw expect( - () => QS.decode( - 'a[%2e]=x', DecodeOptions(allowDots: false, decodeDotInKeys: true)), + () => QS.decode('a[%2e]=x', + const DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), isA(), diff --git a/test/unit/encode_edge_cases_test.dart b/test/unit/encode_edge_cases_test.dart index d0e27ef..408b648 100644 --- a/test/unit/encode_edge_cases_test.dart +++ b/test/unit/encode_edge_cases_test.dart @@ -161,6 +161,17 @@ void main() { expect(occurrences, 2); expect(encoded.contains('c=2'), isTrue); }); + + test('encodes deep nesting without stack overflow', () { + const depth = 2048; + Map obj = {'leaf': 'x'}; + for (var i = 0; i < depth; i++) { + obj = {'a': obj}; + } + + final encoded = QS.encode(obj); + expect(encoded, contains('leaf')); + }); }); } diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index 0a6c496..183efcb 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -1,9 +1,9 @@ // ignore_for_file: deprecated_member_use_from_same_package import 'dart:convert'; -import 'package:qs_dart/src/enums/decode_kind.dart'; import 'package:qs_dart/src/enums/duplicates.dart'; import 'package:qs_dart/src/models/decode_options.dart'; +import 'package:qs_dart/src/qs.dart'; import 'package:test/test.dart'; void main() { @@ -158,9 +158,11 @@ void main() { }); group('DecodeOptions – allowDots / decodeDotInKeys interplay', () { - test('constructor: allowDots=false + decodeDotInKeys=true throws', () { + test('constructor: allowDots=false + decodeDotInKeys=true throws on use', + () { + final opts = const DecodeOptions(allowDots: false, decodeDotInKeys: true); expect( - () => DecodeOptions(allowDots: false, decodeDotInKeys: true), + () => QS.decode('a=b', opts), throwsA(anyOf( isA(), isA(), @@ -171,8 +173,9 @@ void main() { test('copyWith: making options inconsistent throws', () { final base = const DecodeOptions(decodeDotInKeys: true); + final invalid = base.copyWith(allowDots: false); expect( - () => base.copyWith(allowDots: false), + () => QS.decode('a=b', invalid), throwsA(anyOf( isA(), isA(), @@ -182,6 +185,24 @@ void main() { }); }); + group('DecodeOptions runtime validation', () { + test('throws for invalid charset', () { + final opts = const DecodeOptions(charset: _FakeEncoding()); + expect( + () => QS.decode('a=b', opts), + throwsA(isA()), + ); + }); + + test('throws for NaN parameterLimit', () { + final opts = const DecodeOptions(parameterLimit: double.nan); + expect( + () => QS.decode('a=b', opts), + throwsA(isA()), + ); + }); + }); + group( 'DecodeOptions.defaultDecode: KEY protects encoded dots prior to percent-decoding', () { @@ -339,8 +360,9 @@ void main() { 'copyWith to an inconsistent combination (allowDots=false with decodeDotInKeys=true) throws', () { final original = const DecodeOptions(decodeDotInKeys: true); + final invalid = original.copyWith(allowDots: false); expect( - () => original.copyWith(allowDots: false), + () => QS.decode('a=b', invalid), throwsA(anyOf( isA(), isA(), @@ -379,3 +401,16 @@ void main() { }); }); } + +class _FakeEncoding extends Encoding { + const _FakeEncoding(); + + @override + String get name => 'fake'; + + @override + Converter, String> get decoder => utf8.decoder; + + @override + Converter> get encoder => utf8.encoder; +} diff --git a/test/unit/models/encode_options_test.dart b/test/unit/models/encode_options_test.dart index 86efefd..0f2aead 100644 --- a/test/unit/models/encode_options_test.dart +++ b/test/unit/models/encode_options_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:qs_dart/src/enums/format.dart'; import 'package:qs_dart/src/enums/list_format.dart'; import 'package:qs_dart/src/models/encode_options.dart'; +import 'package:qs_dart/src/qs.dart'; import 'package:test/test.dart'; void main() { @@ -145,4 +146,35 @@ void main() { ); }); }); + + group('EncodeOptions runtime validation', () { + test('throws for invalid charset', () { + final opts = const EncodeOptions(charset: _FakeEncoding()); + expect( + () => QS.encode({'a': 'b'}, opts), + throwsA(isA()), + ); + }); + + test('throws for invalid filter', () { + final opts = const EncodeOptions(filter: 123); + expect( + () => QS.encode({'a': 'b'}, opts), + throwsA(isA()), + ); + }); + }); +} + +class _FakeEncoding extends Encoding { + const _FakeEncoding(); + + @override + String get name => 'fake'; + + @override + Converter, String> get decoder => utf8.decoder; + + @override + Converter> get encoder => utf8.encoder; } diff --git a/test/unit/uri_extension_test.dart b/test/unit/uri_extension_test.dart index 5289a71..9b2ab7e 100644 --- a/test/unit/uri_extension_test.dart +++ b/test/unit/uri_extension_test.dart @@ -1342,11 +1342,15 @@ void main() { }); group('charset', () { - test('throws an AssertionError when given an unknown charset', () { + test('throws when given an unknown charset', () { expect( () => Uri.parse('$testUrl?a=b') .queryParametersQs(DecodeOptions(charset: ShiftJIS())), - throwsA(isA()), + throwsA(anyOf( + isA(), + isA(), + isA(), + )), ); }); diff --git a/test/unit/utils_test.dart b/test/unit/utils_test.dart index 6af029d..dfb6207 100644 --- a/test/unit/utils_test.dart +++ b/test/unit/utils_test.dart @@ -1349,6 +1349,33 @@ void main() { expect(result.first, equals('seed')); expect(result.last, equals({'extra': 1})); }); + + test('merges deep maps without stack overflow', () { + const depth = 2048; + Map left = {}; + Map cursor = left; + for (var i = 0; i < depth; i++) { + final next = {}; + cursor['a'] = next; + cursor = next; + } + + Map right = {}; + Map rightCursor = right; + for (var i = 0; i < depth; i++) { + final next = {}; + rightCursor['a'] = next; + rightCursor = next; + } + rightCursor['leaf'] = 'x'; + + final merged = Utils.merge(left, right) as Map; + dynamic walk = merged; + for (var i = 0; i < depth; i++) { + walk = (walk as Map)['a']; + } + expect((walk as Map)['leaf'], equals('x')); + }); }); group('Utils.encode surrogate handling', () { From 040ea8911612511c099655f06912b5b45fc6b23b Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 18:55:02 +0000 Subject: [PATCH 07/42] refactor: move _EncodeFrame class to a separate file for better organization --- lib/src/extensions/encode.dart | 58 ------------------------------- lib/src/models/encode_frame.dart | 59 ++++++++++++++++++++++++++++++++ lib/src/qs.dart | 1 + 3 files changed, 60 insertions(+), 58 deletions(-) create mode 100644 lib/src/models/encode_frame.dart diff --git a/lib/src/extensions/encode.dart b/lib/src/extensions/encode.dart index 6cba0b2..148129c 100644 --- a/lib/src/extensions/encode.dart +++ b/lib/src/extensions/encode.dart @@ -1,63 +1,5 @@ part of '../qs.dart'; -final class _EncodeFrame { - _EncodeFrame({ - required this.object, - required this.undefined, - required this.sideChannel, - required this.prefix, - required this.generateArrayPrefix, - required this.commaRoundTrip, - required this.commaCompactNulls, - required this.allowEmptyLists, - required this.strictNullHandling, - required this.skipNulls, - required this.encodeDotInKeys, - required this.encoder, - required this.serializeDate, - required this.sort, - required this.filter, - required this.allowDots, - required this.format, - required this.formatter, - required this.encodeValuesOnly, - required this.charset, - required this.onResult, - }); - - dynamic object; - final bool undefined; - final WeakMap sideChannel; - final String prefix; - final ListFormatGenerator generateArrayPrefix; - final bool commaRoundTrip; - final bool commaCompactNulls; - final bool allowEmptyLists; - final bool strictNullHandling; - final bool skipNulls; - final bool encodeDotInKeys; - final Encoder? encoder; - final DateSerializer? serializeDate; - final Sorter? sort; - final dynamic filter; - final bool allowDots; - final Format format; - final Formatter formatter; - final bool encodeValuesOnly; - final Encoding charset; - final void Function(List result) onResult; - - bool prepared = false; - bool tracked = false; - Object? trackedObject; - List objKeys = const []; - int index = 0; - List? seqList; - int? commaEffectiveLength; - String? adjustedPrefix; - List values = []; -} - /// Encoding engine used by [QS.encode]. /// /// This module mirrors the shape and behavior of the Node `qs` encoder: diff --git a/lib/src/models/encode_frame.dart b/lib/src/models/encode_frame.dart new file mode 100644 index 0000000..71161c1 --- /dev/null +++ b/lib/src/models/encode_frame.dart @@ -0,0 +1,59 @@ +part of '../qs.dart'; + +final class _EncodeFrame { + _EncodeFrame({ + required this.object, + required this.undefined, + required this.sideChannel, + required this.prefix, + required this.generateArrayPrefix, + required this.commaRoundTrip, + required this.commaCompactNulls, + required this.allowEmptyLists, + required this.strictNullHandling, + required this.skipNulls, + required this.encodeDotInKeys, + required this.encoder, + required this.serializeDate, + required this.sort, + required this.filter, + required this.allowDots, + required this.format, + required this.formatter, + required this.encodeValuesOnly, + required this.charset, + required this.onResult, + }); + + dynamic object; + final bool undefined; + final WeakMap sideChannel; + final String prefix; + final ListFormatGenerator generateArrayPrefix; + final bool commaRoundTrip; + final bool commaCompactNulls; + final bool allowEmptyLists; + final bool strictNullHandling; + final bool skipNulls; + final bool encodeDotInKeys; + final Encoder? encoder; + final DateSerializer? serializeDate; + final Sorter? sort; + final dynamic filter; + final bool allowDots; + final Format format; + final Formatter formatter; + final bool encodeValuesOnly; + final Encoding charset; + final void Function(List result) onResult; + + bool prepared = false; + bool tracked = false; + Object? trackedObject; + List objKeys = const []; + int index = 0; + List? seqList; + int? commaEffectiveLength; + String? adjustedPrefix; + List values = []; +} diff --git a/lib/src/qs.dart b/lib/src/qs.dart index 06a584c..923dafb 100644 --- a/lib/src/qs.dart +++ b/lib/src/qs.dart @@ -17,6 +17,7 @@ export 'package:qs_dart/src/enums/decode_kind.dart'; part 'extensions/decode.dart'; part 'extensions/encode.dart'; +part 'models/encode_frame.dart'; /// # QS (Dart) /// From 556162681e082fa87cbd01c69aa35c0b22b2b8fa Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 18:57:52 +0000 Subject: [PATCH 08/42] docs: expand _EncodeFrame with additional properties for enhanced encoding capabilities --- lib/src/models/encode_frame.dart | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/lib/src/models/encode_frame.dart b/lib/src/models/encode_frame.dart index 71161c1..53a1d41 100644 --- a/lib/src/models/encode_frame.dart +++ b/lib/src/models/encode_frame.dart @@ -1,5 +1,10 @@ part of '../qs.dart'; +/// Internal encoder stack frame used by the iterative `_encode` traversal. +/// +/// Stores the current object, derived key paths, and accumulated child results +/// so the encoder can walk deep graphs without recursion while preserving +/// Node `qs` ordering and cycle detection behavior. final class _EncodeFrame { _EncodeFrame({ required this.object, @@ -25,35 +30,93 @@ final class _EncodeFrame { required this.onResult, }); + /// Current value being encoded at this stack level. dynamic object; + + /// Whether the value is "missing" rather than explicitly present (qs semantics). final bool undefined; + + /// Weak side-channel for cycle detection across the traversal path. final WeakMap sideChannel; + + /// Fully-qualified key path prefix for this frame. final String prefix; + + /// List key generator (indices/brackets/repeat/comma). final ListFormatGenerator generateArrayPrefix; + + /// Emit a round-trip marker for comma lists with a single element. final bool commaRoundTrip; + + /// Drop nulls before joining comma lists. final bool commaCompactNulls; + + /// Whether empty lists should emit `key[]`. final bool allowEmptyLists; + + /// Emit bare keys for explicit nulls (no `=`). final bool strictNullHandling; + + /// Skip keys whose values are null. final bool skipNulls; + + /// Encode literal dots in keys as `%2E`. final bool encodeDotInKeys; + + /// Optional value encoder (and key encoder when `encodeValuesOnly` is false). final Encoder? encoder; + + /// Optional serializer for DateTime values. final DateSerializer? serializeDate; + + /// Optional key sorter for deterministic ordering. final Sorter? sort; + + /// Filter hook or whitelist for keys at this level. final dynamic filter; + + /// Whether to use dot notation between segments. final bool allowDots; + + /// Output formatting mode. final Format format; + + /// Formatter applied to already-encoded tokens. final Formatter formatter; + + /// Encode values only (leave keys unencoded). final bool encodeValuesOnly; + + /// Declared charset (used by encoder/formatter hooks). final Encoding charset; + + /// Callback invoked with this frame's encoded fragments. final void Function(List result) onResult; + /// Whether this frame has been initialized (keys computed, prefix adjusted). bool prepared = false; + + /// Whether this frame registered a cycle-tracking entry. bool tracked = false; + + /// The object used for cycle tracking (after filter/date transforms). Object? trackedObject; + + /// Keys/indices to iterate at this level. List objKeys = const []; + + /// Current index into [objKeys]. int index = 0; + + /// Cached list form for iterable values (to avoid re-iteration). List? seqList; + + /// Effective comma list length after filtering nulls. int? commaEffectiveLength; + + /// Prefix after dot-encoding and comma round-trip adjustment. String? adjustedPrefix; + + /// Accumulated encoded fragments from child frames. List values = []; } From fd5a91b2431bec8ea16cad3d331363ffd1aa4785 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 19:32:16 +0000 Subject: [PATCH 09/42] refactor: replace _validateDecodeOptions function with validate method in DecodeOptions for improved encapsulation --- lib/src/models/decode_options.dart | 32 +++++++++++++++++++++++++++--- lib/src/qs.dart | 18 +---------------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index eeabd5c..5ea1cf6 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -17,7 +17,8 @@ import 'package:qs_dart/src/utils.dart'; /// - **Dot notation**: set [allowDots] to treat `a.b=c` like `{a: {b: "c"}}`. /// If you *explicitly* request dot decoding in keys via [decodeDotInKeys], /// [allowDots] is implied and will be treated as `true` unless you explicitly -/// set `allowDots: false` — which is an invalid combination and will throw at construction time. +/// set `allowDots: false` — which is an invalid combination and will throw +/// when validated/used. /// - **Charset handling**: [charset] selects UTF‑8 or Latin‑1 decoding. When /// [charsetSentinel] is `true`, a leading `utf8=✓` token (in either UTF‑8 or /// Latin‑1 form) can override [charset] as a compatibility escape hatch. @@ -79,7 +80,7 @@ final class DecodeOptions with EquatableMixin { /// /// If you set [decodeDotInKeys] to `true` and do not pass [allowDots], this /// flag defaults to `true`. Passing `allowDots: false` while - /// `decodeDotInKeys` is `true` is invalid and will throw at construction. + /// `decodeDotInKeys` is `true` is invalid and will throw when validated/used. final bool allowDots; /// When `true`, allow empty list values to be produced from inputs like @@ -128,7 +129,7 @@ final class DecodeOptions with EquatableMixin { /// /// This explicitly opts into dot‑notation handling and **implies** [allowDots]. /// Passing `decodeDotInKeys: true` while forcing `allowDots: false` is an - /// invalid combination and will throw *at construction time*. + /// invalid combination and will throw when validated/used. /// /// Note: inside bracket segments (e.g., `a[%2E]`), percent‑decoding naturally /// yields `"."`. Whether a `.` causes additional splitting is a parser concern @@ -203,6 +204,7 @@ final class DecodeOptions with EquatableMixin { Encoding? charset, DecodeKind kind = DecodeKind.value, }) { + validate(); if (_decoder != null) { return _decoder!(value, charset: charset, kind: kind); } @@ -288,6 +290,27 @@ final class DecodeOptions with EquatableMixin { legacyDecoder: legacyDecoder ?? _legacyDecoder, ); + /// Validates option invariants (used by [QS.decode] and direct decoder calls). + void validate() { + if (_validated[this] == true) return; + + final Encoding currentCharset = charset; + if (currentCharset != utf8 && currentCharset != latin1) { + throw ArgumentError.value(currentCharset, 'charset', 'Invalid charset'); + } + + if (decodeDotInKeys && !allowDots) { + throw ArgumentError('decodeDotInKeys requires allowDots to be true'); + } + + final num limit = parameterLimit; + if (limit.isNaN || (limit.isFinite && limit <= 0)) { + throw ArgumentError('Parameter limit must be a positive number.'); + } + + _validated[this] = true; + } + @override String toString() => 'DecodeOptions(\n' ' allowDots: $allowDots,\n' @@ -331,4 +354,7 @@ final class DecodeOptions with EquatableMixin { _decoder, _legacyDecoder, ]; + + static final Expando _validated = + Expando('qsDecodeOptionsValidated'); } diff --git a/lib/src/qs.dart b/lib/src/qs.dart index 923dafb..a2d5d66 100644 --- a/lib/src/qs.dart +++ b/lib/src/qs.dart @@ -46,7 +46,7 @@ final class QS { static Map decode(dynamic input, [DecodeOptions? options]) { options ??= const DecodeOptions(); // Default to the library's safe, Node-`qs` compatible settings. - _validateDecodeOptions(options); + options.validate(); // Fail fast on unsupported input shapes to avoid ambiguous behavior. if (!(input is String? || input is Map?)) { @@ -214,22 +214,6 @@ final class QS { } } -void _validateDecodeOptions(DecodeOptions options) { - final Encoding charset = options.charset; - if (charset != utf8 && charset != latin1) { - throw ArgumentError.value(charset, 'charset', 'Invalid charset'); - } - - if (options.decodeDotInKeys && !options.allowDots) { - throw ArgumentError('decodeDotInKeys requires allowDots to be true'); - } - - final num limit = options.parameterLimit; - if (limit.isNaN || (limit.isFinite && limit <= 0)) { - throw ArgumentError('Parameter limit must be a positive number.'); - } -} - void _validateEncodeOptions(EncodeOptions options) { final Encoding charset = options.charset; if (charset != utf8 && charset != latin1) { From f157c7ff11b2dc11b2add6a641d1eff29b869ba0 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 20:07:04 +0000 Subject: [PATCH 10/42] refactor: add validation in DecodeOptions constructor to ensure proper usage of decodeKey/decodeValue --- lib/src/models/decode_options.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 5ea1cf6..014134a 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -204,6 +204,7 @@ final class DecodeOptions with EquatableMixin { Encoding? charset, DecodeKind kind = DecodeKind.value, }) { + // Validate here to cover direct decodeKey/decodeValue usage; cached via Expando. validate(); if (_decoder != null) { return _decoder!(value, charset: charset, kind: kind); From 9b75955de8f83d963bd7d7e3d3ebb6d4e4674481 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 20:08:18 +0000 Subject: [PATCH 11/42] refactor: enhance UTF-8 decoding in Utils to allow for malformed sequences --- lib/src/utils.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 3d4f896..3d7838b 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -571,6 +571,7 @@ final class Utils { final String? str = value is ByteBuffer ? (charset == utf8 + // UTF-8 may contain malformed sequences; Latin-1 is 1:1 for all bytes. ? utf8.decode(value.asUint8List(), allowMalformed: true) : latin1.decode(value.asUint8List())) : value?.toString(); From 124961e58a9b2b8b6876a5fd418a0d1a24b8d517 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 20:10:04 +0000 Subject: [PATCH 12/42] refactor: simplify test description for allowDots and decodeDotInKeys interplay --- test/unit/models/decode_options_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index 183efcb..9fc51ca 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -158,8 +158,7 @@ void main() { }); group('DecodeOptions – allowDots / decodeDotInKeys interplay', () { - test('constructor: allowDots=false + decodeDotInKeys=true throws on use', - () { + test('allowDots=false + decodeDotInKeys=true throws on use', () { final opts = const DecodeOptions(allowDots: false, decodeDotInKeys: true); expect( () => QS.decode('a=b', opts), From 14ff67b893fe1ffa623fa1756d18c19f12066019 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 1 Feb 2026 21:08:43 +0000 Subject: [PATCH 13/42] refactor: introduce _MergePhase and _MergeFrame for improved merge handling --- lib/src/enums/merge_phase.dart | 16 +++++++++++++++ lib/src/models/encode_options.dart | 15 +++++++++++++- lib/src/models/merge_frame.dart | 30 +++++++++++++++++++++++++++ lib/src/qs.dart | 14 +------------ lib/src/utils.dart | 33 ++---------------------------- 5 files changed, 63 insertions(+), 45 deletions(-) create mode 100644 lib/src/enums/merge_phase.dart create mode 100644 lib/src/models/merge_frame.dart diff --git a/lib/src/enums/merge_phase.dart b/lib/src/enums/merge_phase.dart new file mode 100644 index 0000000..8dcaef9 --- /dev/null +++ b/lib/src/enums/merge_phase.dart @@ -0,0 +1,16 @@ +part of '../utils.dart'; + +/// Internal phases for the iterative merge walker. +/// +/// These drive the state machine used by [Utils.merge] to avoid recursion +/// while preserving `qs` merge semantics for maps and iterables. +enum _MergePhase { + /// Initial dispatch and shape normalization. + start, + + /// Iterating over map entries during a merge step. + mapIter, + + /// Iterating over list/set indices during a merge step. + listIter, +} diff --git a/lib/src/models/encode_options.dart b/lib/src/models/encode_options.dart index 8635345..953c2d1 100644 --- a/lib/src/models/encode_options.dart +++ b/lib/src/models/encode_options.dart @@ -1,4 +1,4 @@ -import 'dart:convert' show Encoding, utf8; +import 'dart:convert' show Encoding, latin1, utf8; import 'package:equatable/equatable.dart'; import 'package:qs_dart/src/enums/format.dart'; @@ -155,6 +155,19 @@ final class EncodeOptions with EquatableMixin { ? _serializeDate!.call(date) : date.toIso8601String(); + /// Validates the encoding options, throwing [ArgumentError] on invalid values. + void validate() { + final Encoding charset = this.charset; + if (charset != utf8 && charset != latin1) { + throw ArgumentError.value(charset, 'charset', 'Invalid charset'); + } + + final dynamic filter = this.filter; + if (filter != null && filter is! Function && filter is! Iterable) { + throw ArgumentError.value(filter, 'filter', 'Invalid filter'); + } + } + /// Returns a new [EncodeOptions] instance with updated values. EncodeOptions copyWith({ bool? addQueryPrefix, diff --git a/lib/src/models/merge_frame.dart b/lib/src/models/merge_frame.dart new file mode 100644 index 0000000..fd9e7d1 --- /dev/null +++ b/lib/src/models/merge_frame.dart @@ -0,0 +1,30 @@ +part of '../utils.dart'; + +/// Stack frame for the iterative [Utils.merge] traversal. +/// +/// Captures the current target/source pair plus intermediate iterators and +/// buffers so the merge can walk deeply nested structures without recursion. +final class _MergeFrame { + _MergeFrame({ + required this.target, + required this.source, + required this.options, + required this.onResult, + }); + + dynamic target; + dynamic source; + final DecodeOptions? options; + final void Function(dynamic result) onResult; + + _MergePhase phase = _MergePhase.start; + + Map? mergeTarget; + Iterator>? mapIterator; + int? overflowMax; + + SplayTreeMap? indexedTarget; + List? sourceList; + int listIndex = 0; + bool targetIsSet = false; +} diff --git a/lib/src/qs.dart b/lib/src/qs.dart index a2d5d66..0ad8f75 100644 --- a/lib/src/qs.dart +++ b/lib/src/qs.dart @@ -104,7 +104,7 @@ final class QS { static String encode(Object? object, [EncodeOptions? options]) { options ??= const EncodeOptions(); // Use default encoding settings unless overridden by the caller. - _validateEncodeOptions(options); + options.validate(); // Normalize supported inputs into a mutable map we can traverse. Map obj = switch (object) { @@ -213,15 +213,3 @@ final class QS { return out.toString(); } } - -void _validateEncodeOptions(EncodeOptions options) { - final Encoding charset = options.charset; - if (charset != utf8 && charset != latin1) { - throw ArgumentError.value(charset, 'charset', 'Invalid charset'); - } - - final dynamic filter = options.filter; - if (filter != null && filter is! Function && filter is! Iterable) { - throw ArgumentError.value(filter, 'filter', 'Invalid filter'); - } -} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 3d7838b..547ed88 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -11,37 +11,8 @@ import 'package:qs_dart/src/models/decode_options.dart'; import 'package:qs_dart/src/models/undefined.dart'; part 'constants/hex_table.dart'; - -enum _MergePhase { - start, - mapIter, - listIter, -} - -final class _MergeFrame { - _MergeFrame({ - required this.target, - required this.source, - required this.options, - required this.onResult, - }); - - dynamic target; - dynamic source; - final DecodeOptions? options; - final void Function(dynamic result) onResult; - - _MergePhase phase = _MergePhase.start; - - Map? mergeTarget; - Iterator>? mapIterator; - int? overflowMax; - - SplayTreeMap? indexedTarget; - List? sourceList; - int listIndex = 0; - bool targetIsSet = false; -} +part 'enums/merge_phase.dart'; +part 'models/merge_frame.dart'; /// Internal utilities and helpers used by the library. /// From 5bb177c848baf7d1b1938efabba87aea24282120 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 8 Feb 2026 23:06:47 +0000 Subject: [PATCH 14/42] refactor: enhance DecodeOptions with runtime validation and assertions for better error handling --- lib/src/models/decode_options.dart | 20 ++++++++++++++-- test/unit/decode_test.dart | 12 +++++----- test/unit/models/decode_options_test.dart | 29 +++++++++++++---------- 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 014134a..4b5209e 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -49,6 +49,9 @@ typedef Decoder = dynamic Function( typedef LegacyDecoder = dynamic Function(String? value, {Encoding? charset}); /// Options that configure the output of [QS.decode]. +/// +/// Invariants are asserted in debug builds and validated at runtime via +/// [validate] (used by decode entry points). final class DecodeOptions with EquatableMixin { const DecodeOptions({ bool? allowDots, @@ -74,7 +77,20 @@ final class DecodeOptions with EquatableMixin { }) : allowDots = allowDots ?? (decodeDotInKeys ?? false), decodeDotInKeys = decodeDotInKeys ?? false, _decoder = decoder, - _legacyDecoder = legacyDecoder; + _legacyDecoder = legacyDecoder, + assert( + charset == utf8 || charset == latin1, + 'Invalid charset', + ), + assert( + !(decodeDotInKeys ?? false) || + (allowDots ?? (decodeDotInKeys ?? false)), + 'decodeDotInKeys requires allowDots to be true', + ), + assert( + parameterLimit > 0, + 'Parameter limit must be a positive number.', + ); /// When `true`, decode dot notation in keys: `a.b=c` → `{a: {b: "c"}}`. /// @@ -305,7 +321,7 @@ final class DecodeOptions with EquatableMixin { } final num limit = parameterLimit; - if (limit.isNaN || (limit.isFinite && limit <= 0)) { + if (limit.isNaN || limit <= 0) { throw ArgumentError('Parameter limit must be a positive number.'); } diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 1adfb2f..5b0ac66 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -17,7 +17,7 @@ void main() { expect( () => QS.decode( 'a=b&c=d', - const DecodeOptions(parameterLimit: 0), + DecodeOptions(parameterLimit: 0), ), throwsA(anyOf( isA(), @@ -2348,8 +2348,8 @@ void main() { test('allowDots=false, decodeDotInKeys=true is invalid', () { expect( - () => QS.decode('a%2Eb=c', - const DecodeOptions(allowDots: false, decodeDotInKeys: true)), + () => QS.decode( + 'a%2Eb=c', DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), isA(), @@ -2505,7 +2505,7 @@ void main() { () { expect( () => QS.decode('a%5Bb%5D%5Bc%5D%2Ed=x', - const DecodeOptions(allowDots: false, decodeDotInKeys: true)), + DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), isA(), @@ -2800,8 +2800,8 @@ void main() { // Invalid combo: allowDots=false with decodeDotInKeys=true should throw expect( - () => QS.decode('a[%2e]=x', - const DecodeOptions(allowDots: false, decodeDotInKeys: true)), + () => QS.decode( + 'a[%2e]=x', DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), isA(), diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index 9fc51ca..e90d3a3 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -159,9 +159,11 @@ void main() { group('DecodeOptions – allowDots / decodeDotInKeys interplay', () { test('allowDots=false + decodeDotInKeys=true throws on use', () { - final opts = const DecodeOptions(allowDots: false, decodeDotInKeys: true); expect( - () => QS.decode('a=b', opts), + () => QS.decode( + 'a=b', + DecodeOptions(allowDots: false, decodeDotInKeys: true), + ), throwsA(anyOf( isA(), isA(), @@ -172,9 +174,8 @@ void main() { test('copyWith: making options inconsistent throws', () { final base = const DecodeOptions(decodeDotInKeys: true); - final invalid = base.copyWith(allowDots: false); expect( - () => QS.decode('a=b', invalid), + () => QS.decode('a=b', base.copyWith(allowDots: false)), throwsA(anyOf( isA(), isA(), @@ -186,18 +187,23 @@ void main() { group('DecodeOptions runtime validation', () { test('throws for invalid charset', () { - final opts = const DecodeOptions(charset: _FakeEncoding()); expect( - () => QS.decode('a=b', opts), - throwsA(isA()), + // ignore: prefer_const_constructors + () => QS.decode('a=b', DecodeOptions(charset: _FakeEncoding())), + throwsA(anyOf( + isA(), + isA(), + )), ); }); test('throws for NaN parameterLimit', () { - final opts = const DecodeOptions(parameterLimit: double.nan); expect( - () => QS.decode('a=b', opts), - throwsA(isA()), + () => QS.decode('a=b', DecodeOptions(parameterLimit: double.nan)), + throwsA(anyOf( + isA(), + isA(), + )), ); }); }); @@ -359,9 +365,8 @@ void main() { 'copyWith to an inconsistent combination (allowDots=false with decodeDotInKeys=true) throws', () { final original = const DecodeOptions(decodeDotInKeys: true); - final invalid = original.copyWith(allowDots: false); expect( - () => QS.decode('a=b', invalid), + () => QS.decode('a=b', original.copyWith(allowDots: false)), throwsA(anyOf( isA(), isA(), From be760ac7e387c2aa6a040a78890a2894f59c6808 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 8 Feb 2026 23:16:05 +0000 Subject: [PATCH 15/42] refactor: enhance merge handling in Utils by normalizing undefined values in maps --- lib/src/utils.dart | 72 +++++++++++++++++++++++++++++++-------- test/unit/utils_test.dart | 5 ++- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 547ed88..9cc586f 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -119,11 +119,47 @@ final class Utils { if (currentSource is! Map) { if (currentTarget is Iterable) { - if (currentTarget.any((el) => el is Undefined)) { + final bool sourceIsIterable = currentSource is Iterable; + bool hasHoles = false; + bool targetMaps = true; + bool targetHasMap = false; + for (final el in currentTarget) { + if (el is Undefined) { + hasHoles = true; + continue; + } + if (el is Map) { + targetHasMap = true; + continue; + } + targetMaps = false; + } + + bool sourceMaps = false; + bool sourceHasMap = false; + if (sourceIsIterable) { + sourceMaps = true; + for (final el in currentSource) { + if (el is Undefined) { + continue; + } + if (el is Map) { + sourceHasMap = true; + continue; + } + sourceMaps = false; + } + } + + final bool canMergeMapLists = + sourceIsIterable && targetMaps && sourceMaps; + final bool hasAnyMap = targetHasMap || sourceHasMap; + + if (hasHoles && !(canMergeMapLists && hasAnyMap)) { final SplayTreeMap target_ = _toIndexedTreeMap(currentTarget); - if (currentSource is Iterable) { + if (sourceIsIterable) { for (final (int i, dynamic item) in currentSource.indexed) { if (item is! Undefined) { target_[i] = item; @@ -135,13 +171,13 @@ final class Utils { if (frame.options?.parseLists == false && target_.values.any((el) => el is Undefined)) { + final Map normalized = { + for (final MapEntry entry in target_.entries) + if (entry.value is! Undefined) + entry.key.toString(): entry.value, + }; stack.removeLast(); - frame.onResult( - SplayTreeMap.from({ - for (final MapEntry entry in target_.entries) - if (entry.value is! Undefined) entry.key: entry.value, - }), - ); + frame.onResult(normalized); continue; } @@ -154,13 +190,8 @@ final class Utils { continue; } - if (currentSource is Iterable) { - final bool targetMaps = - currentTarget.every((el) => el is Map || el is Undefined); - final bool sourceMaps = - currentSource.every((el) => el is Map || el is Undefined); - - if (targetMaps && sourceMaps) { + if (sourceIsIterable) { + if (canMergeMapLists && hasAnyMap) { frame.indexedTarget = _toIndexedTreeMap(currentTarget); frame.sourceList = currentSource is List ? currentSource @@ -344,6 +375,17 @@ final class Utils { } if (frame.listIndex >= frame.sourceList!.length) { + if (frame.options?.parseLists == false && + frame.indexedTarget!.values.any((el) => el is Undefined)) { + final Map normalized = { + for (final MapEntry entry + in frame.indexedTarget!.entries) + if (entry.value is! Undefined) entry.key.toString(): entry.value, + }; + stack.removeLast(); + frame.onResult(normalized); + continue; + } final resultList = frame.targetIsSet ? frame.indexedTarget!.values.toSet() : frame.indexedTarget!.values.toList(); diff --git a/test/unit/utils_test.dart b/test/unit/utils_test.dart index dfb6207..c5c5c2b 100644 --- a/test/unit/utils_test.dart +++ b/test/unit/utils_test.dart @@ -1,6 +1,5 @@ // ignore_for_file: deprecated_member_use_from_same_package -import 'dart:collection'; import 'dart:convert' show latin1, utf8; import 'package:qs_dart/qs_dart.dart'; @@ -1303,8 +1302,8 @@ void main() { const DecodeOptions(parseLists: false), ); - final splay = result as SplayTreeMap; - expect(splay.isEmpty, isTrue); + final map = result as Map; + expect(map, isEmpty); }); test('combines non-iterable scalars into a list pair', () { From db247592b0894d996af26f5cd1015a4c0469a4d3 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 8 Feb 2026 23:23:29 +0000 Subject: [PATCH 16/42] refactor: rename _MergeFrame and _MergePhase to MergeFrame and MergePhase for improved clarity --- lib/src/enums/merge_phase.dart | 5 +++-- lib/src/models/merge_frame.dart | 13 +++++++++---- lib/src/utils.dart | 26 +++++++++++++------------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/lib/src/enums/merge_phase.dart b/lib/src/enums/merge_phase.dart index 8dcaef9..b55bc64 100644 --- a/lib/src/enums/merge_phase.dart +++ b/lib/src/enums/merge_phase.dart @@ -1,10 +1,11 @@ -part of '../utils.dart'; +import 'package:meta/meta.dart' show internal; /// Internal phases for the iterative merge walker. /// /// These drive the state machine used by [Utils.merge] to avoid recursion /// while preserving `qs` merge semantics for maps and iterables. -enum _MergePhase { +@internal +enum MergePhase { /// Initial dispatch and shape normalization. start, diff --git a/lib/src/models/merge_frame.dart b/lib/src/models/merge_frame.dart index fd9e7d1..0dc8d55 100644 --- a/lib/src/models/merge_frame.dart +++ b/lib/src/models/merge_frame.dart @@ -1,11 +1,16 @@ -part of '../utils.dart'; +import 'dart:collection' show SplayTreeMap; + +import 'package:meta/meta.dart' show internal; +import 'package:qs_dart/qs_dart.dart' show DecodeOptions; +import 'package:qs_dart/src/enums/merge_phase.dart'; /// Stack frame for the iterative [Utils.merge] traversal. /// /// Captures the current target/source pair plus intermediate iterators and /// buffers so the merge can walk deeply nested structures without recursion. -final class _MergeFrame { - _MergeFrame({ +@internal +final class MergeFrame { + MergeFrame({ required this.target, required this.source, required this.options, @@ -17,7 +22,7 @@ final class _MergeFrame { final DecodeOptions? options; final void Function(dynamic result) onResult; - _MergePhase phase = _MergePhase.start; + MergePhase phase = MergePhase.start; Map? mergeTarget; Iterator>? mapIterator; diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 9cc586f..c71f1a6 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -6,13 +6,13 @@ import 'dart:typed_data' show ByteBuffer; import 'package:meta/meta.dart' show internal, visibleForTesting; import 'package:qs_dart/src/enums/format.dart'; +import 'package:qs_dart/src/enums/merge_phase.dart'; import 'package:qs_dart/src/extensions/extensions.dart'; import 'package:qs_dart/src/models/decode_options.dart'; +import 'package:qs_dart/src/models/merge_frame.dart'; import 'package:qs_dart/src/models/undefined.dart'; part 'constants/hex_table.dart'; -part 'enums/merge_phase.dart'; -part 'models/merge_frame.dart'; /// Internal utilities and helpers used by the library. /// @@ -95,8 +95,8 @@ final class Utils { DecodeOptions? options = const DecodeOptions(), ]) { dynamic result; - final List<_MergeFrame> stack = [ - _MergeFrame( + final List stack = [ + MergeFrame( target: target, source: source, options: options, @@ -105,9 +105,9 @@ final class Utils { ]; while (stack.isNotEmpty) { - final _MergeFrame frame = stack.last; + final MergeFrame frame = stack.last; - if (frame.phase == _MergePhase.start) { + if (frame.phase == MergePhase.start) { final dynamic currentTarget = frame.target; final dynamic currentSource = frame.source; @@ -198,7 +198,7 @@ final class Utils { : currentSource.toList(growable: false); frame.targetIsSet = currentTarget is Set; frame.listIndex = 0; - frame.phase = _MergePhase.listIter; + frame.phase = MergePhase.listIter; continue; } @@ -237,7 +237,7 @@ final class Utils { if (item is! Undefined) i.toString(): item, }; frame.source = sourceMap; - frame.phase = _MergePhase.start; + frame.phase = MergePhase.start; continue; } if (isOverflow(currentTarget)) { @@ -275,7 +275,7 @@ final class Utils { frame.mergeTarget = mergeTarget; frame.mapIterator = currentSource.entries.iterator; frame.overflowMax = null; - frame.phase = _MergePhase.mapIter; + frame.phase = MergePhase.mapIter; continue; } @@ -333,11 +333,11 @@ final class Utils { frame.mergeTarget = mergeTarget; frame.mapIterator = currentSource.entries.iterator; frame.overflowMax = overflowMax; - frame.phase = _MergePhase.mapIter; + frame.phase = MergePhase.mapIter; continue; } - if (frame.phase == _MergePhase.mapIter) { + if (frame.phase == MergePhase.mapIter) { if (frame.mapIterator!.moveNext()) { final MapEntry entry = frame.mapIterator!.current; final String key = entry.key.toString(); @@ -349,7 +349,7 @@ final class Utils { if (frame.mergeTarget!.containsKey(key)) { final dynamic childTarget = frame.mergeTarget![key]; stack.add( - _MergeFrame( + MergeFrame( target: childTarget, source: entry.value, options: frame.options, @@ -400,7 +400,7 @@ final class Utils { if (frame.indexedTarget!.containsKey(idx)) { final dynamic childTarget = frame.indexedTarget![idx]; stack.add( - _MergeFrame( + MergeFrame( target: childTarget, source: item, options: frame.options, From 5a400fc2dba3b6a9784e5b76064dcd2e2e55db69 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 8 Feb 2026 23:27:55 +0000 Subject: [PATCH 17/42] refactor: rename _EncodeFrame to EncodeFrame for improved clarity and update references --- lib/src/extensions/encode.dart | 8 ++++---- lib/src/models/encode_frame.dart | 13 ++++++++++--- lib/src/qs.dart | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/src/extensions/encode.dart b/lib/src/extensions/encode.dart index 148129c..223b180 100644 --- a/lib/src/extensions/encode.dart +++ b/lib/src/extensions/encode.dart @@ -73,8 +73,8 @@ extension _$Encode on QS { formatter ??= format.formatter; List? result; - final List<_EncodeFrame> stack = [ - _EncodeFrame( + final List stack = [ + EncodeFrame( object: object, undefined: undefined, sideChannel: sideChannel, @@ -100,7 +100,7 @@ extension _$Encode on QS { ]; while (stack.isNotEmpty) { - final _EncodeFrame frame = stack.last; + final EncodeFrame frame = stack.last; if (!frame.prepared) { dynamic obj = frame.object; @@ -352,7 +352,7 @@ extension _$Encode on QS { : '${frame.adjustedPrefix!}${frame.allowDots ? '.$encodedKey' : '[$encodedKey]'}'); stack.add( - _EncodeFrame( + EncodeFrame( object: value, undefined: valueUndefined, sideChannel: frame.sideChannel, diff --git a/lib/src/models/encode_frame.dart b/lib/src/models/encode_frame.dart index 53a1d41..30daf33 100644 --- a/lib/src/models/encode_frame.dart +++ b/lib/src/models/encode_frame.dart @@ -1,12 +1,19 @@ -part of '../qs.dart'; +import 'dart:convert' show Encoding; + +import 'package:meta/meta.dart' show internal; +import 'package:qs_dart/src/enums/format.dart'; +import 'package:qs_dart/src/enums/list_format.dart'; +import 'package:qs_dart/src/models/encode_options.dart'; +import 'package:weak_map/weak_map.dart'; /// Internal encoder stack frame used by the iterative `_encode` traversal. /// /// Stores the current object, derived key paths, and accumulated child results /// so the encoder can walk deep graphs without recursion while preserving /// Node `qs` ordering and cycle detection behavior. -final class _EncodeFrame { - _EncodeFrame({ +@internal +final class EncodeFrame { + EncodeFrame({ required this.object, required this.undefined, required this.sideChannel, diff --git a/lib/src/qs.dart b/lib/src/qs.dart index 0ad8f75..d9a1a4d 100644 --- a/lib/src/qs.dart +++ b/lib/src/qs.dart @@ -7,6 +7,7 @@ import 'package:qs_dart/src/enums/list_format.dart'; import 'package:qs_dart/src/enums/sentinel.dart'; import 'package:qs_dart/src/extensions/extensions.dart'; import 'package:qs_dart/src/models/decode_options.dart'; +import 'package:qs_dart/src/models/encode_frame.dart'; import 'package:qs_dart/src/models/encode_options.dart'; import 'package:qs_dart/src/models/undefined.dart'; import 'package:qs_dart/src/utils.dart'; @@ -17,7 +18,6 @@ export 'package:qs_dart/src/enums/decode_kind.dart'; part 'extensions/decode.dart'; part 'extensions/encode.dart'; -part 'models/encode_frame.dart'; /// # QS (Dart) /// From 65f7c158773ec1e002acc29ca053b856b119290f Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 8 Feb 2026 23:30:24 +0000 Subject: [PATCH 18/42] refactor: add comment to clarify Expando usage in DecodeOptions validation --- lib/src/models/decode_options.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 4b5209e..452595c 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -372,6 +372,8 @@ final class DecodeOptions with EquatableMixin { _legacyDecoder, ]; + // Expando does not keep keys alive; cached flags vanish when the options + // instance is GC'd, so this avoids repeat validation without leaking. static final Expando _validated = Expando('qsDecodeOptionsValidated'); } From e0a0518c41b022156a9d25cc5ed08de0d79a2bcf Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 8 Feb 2026 23:35:27 +0000 Subject: [PATCH 19/42] refactor: add runtime validation for charset in encoding function --- lib/src/utils.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index c71f1a6..60d951f 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -572,6 +572,11 @@ final class Utils { Encoding charset = utf8, Format? format = Format.rfc3986, }) { + assert(charset == utf8 || charset == latin1, 'Invalid charset'); + if (charset != utf8 && charset != latin1) { + throw ArgumentError.value(charset, 'charset', 'Invalid charset'); + } + // these can not be encoded if (value is Iterable || value is Map || From d6750dd1cab339f441be69230b66ac5c4ef913f4 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 8 Feb 2026 23:44:13 +0000 Subject: [PATCH 20/42] refactor: update encoder documentation to clarify iterative stack-based approach --- lib/src/extensions/encode.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/extensions/encode.dart b/lib/src/extensions/encode.dart index 223b180..a8de0da 100644 --- a/lib/src/extensions/encode.dart +++ b/lib/src/extensions/encode.dart @@ -16,10 +16,10 @@ part of '../qs.dart'; /// - *prefix*: current key path being built (e.g., `user[address]`), with optional `?` prefix. extension _$Encode on QS { - /// Core encoder (recursive). + /// Core encoder (iterative, stack-based). /// - /// Returns either a `String` (single key=value) or `List` fragments, which the - /// top-level caller ultimately joins with the chosen delimiter. + /// Returns a `List` of encoded fragments; the top-level caller joins + /// them with the chosen delimiter. /// /// Parameters (most mirror Node `qs`): /// - [object]: The current value to encode (map/iterable/scalar/byte buffer/date). From 7cd71fc5c5db3288d394f8fcec98cdd045e331e6 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 8 Feb 2026 23:44:57 +0000 Subject: [PATCH 21/42] refactor: update import statement for DecodeOptions to use the correct path --- lib/src/models/merge_frame.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/models/merge_frame.dart b/lib/src/models/merge_frame.dart index 0dc8d55..350f41b 100644 --- a/lib/src/models/merge_frame.dart +++ b/lib/src/models/merge_frame.dart @@ -1,7 +1,7 @@ import 'dart:collection' show SplayTreeMap; import 'package:meta/meta.dart' show internal; -import 'package:qs_dart/qs_dart.dart' show DecodeOptions; +import 'package:qs_dart/src/models/decode_options.dart'; import 'package:qs_dart/src/enums/merge_phase.dart'; /// Stack frame for the iterative [Utils.merge] traversal. From 263f5ff50bc13cd88dbbc91ee807b55447d7a9ac Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 8 Feb 2026 23:47:54 +0000 Subject: [PATCH 22/42] refactor: enhance comment for UTF-8 decoding to clarify Node.js compatibility --- lib/src/utils.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 60d951f..dca04a8 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -589,7 +589,8 @@ final class Utils { final String? str = value is ByteBuffer ? (charset == utf8 - // UTF-8 may contain malformed sequences; Latin-1 is 1:1 for all bytes. + // Match Node Buffer.toString('utf8'): replace malformed sequences. + // Strict decoding would throw and break Node qs parity for raw byte buffers. ? utf8.decode(value.asUint8List(), allowMalformed: true) : latin1.decode(value.asUint8List())) : value?.toString(); From 93a82ba391cab8dbb6748db99176cbfef5283af0 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 8 Feb 2026 23:51:29 +0000 Subject: [PATCH 23/42] refactor: remove StateError from expected exceptions in decode tests --- test/unit/decode_test.dart | 5 ----- test/unit/models/decode_options_test.dart | 3 --- test/unit/uri_extension_test.dart | 1 - 3 files changed, 9 deletions(-) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 5b0ac66..98a7fee 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -21,7 +21,6 @@ void main() { ), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -1544,7 +1543,6 @@ void main() { () => QS.decode('a=b', DecodeOptions(charset: ShiftJIS())), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -2352,7 +2350,6 @@ void main() { 'a%2Eb=c', DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -2508,7 +2505,6 @@ void main() { DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -2804,7 +2800,6 @@ void main() { 'a[%2e]=x', DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), - isA(), isA(), )), ); diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index e90d3a3..afbea00 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -166,7 +166,6 @@ void main() { ), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -178,7 +177,6 @@ void main() { () => QS.decode('a=b', base.copyWith(allowDots: false)), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -369,7 +367,6 @@ void main() { () => QS.decode('a=b', original.copyWith(allowDots: false)), throwsA(anyOf( isA(), - isA(), isA(), ))); }); diff --git a/test/unit/uri_extension_test.dart b/test/unit/uri_extension_test.dart index 9b2ab7e..c34ba41 100644 --- a/test/unit/uri_extension_test.dart +++ b/test/unit/uri_extension_test.dart @@ -1348,7 +1348,6 @@ void main() { .queryParametersQs(DecodeOptions(charset: ShiftJIS())), throwsA(anyOf( isA(), - isA(), isA(), )), ); From cb1519e58a7532c6c6dd7a1e6fe3380e9e296275 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 00:04:48 +0000 Subject: [PATCH 24/42] refactor: create new merged lists and sets instead of mutating existing ones --- lib/src/utils.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index dca04a8..6802caa 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -215,15 +215,17 @@ final class Utils { if (currentSource != null) { if (currentTarget is List) { - currentTarget.add(currentSource); + final List merged = List.of(currentTarget) + ..add(currentSource); stack.removeLast(); - frame.onResult(currentTarget); + frame.onResult(merged); continue; } if (currentTarget is Set) { - currentTarget.add(currentSource); + final Set merged = Set.of(currentTarget) + ..add(currentSource); stack.removeLast(); - frame.onResult(currentTarget); + frame.onResult(merged); continue; } stack.removeLast(); From 7a42791b64248cd05784caf8129adba59e904673 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 00:06:26 +0000 Subject: [PATCH 25/42] refactor: streamline merging logic for lists and sets in Utils class --- lib/src/utils.dart | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 6802caa..5735e4f 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -213,25 +213,23 @@ final class Utils { continue; } - if (currentSource != null) { - if (currentTarget is List) { - final List merged = List.of(currentTarget) - ..add(currentSource); - stack.removeLast(); - frame.onResult(merged); - continue; - } - if (currentTarget is Set) { - final Set merged = Set.of(currentTarget) - ..add(currentSource); - stack.removeLast(); - frame.onResult(merged); - continue; - } + if (currentTarget is List) { + final List merged = List.of(currentTarget) + ..add(currentSource); stack.removeLast(); - frame.onResult([currentTarget, currentSource]); + frame.onResult(merged); continue; } + if (currentTarget is Set) { + final Set merged = Set.of(currentTarget) + ..add(currentSource); + stack.removeLast(); + frame.onResult(merged); + continue; + } + stack.removeLast(); + frame.onResult([currentTarget, currentSource]); + continue; } else if (currentTarget is Map) { if (currentSource is Iterable) { final Map sourceMap = { @@ -250,7 +248,7 @@ final class Utils { frame.onResult(currentTarget); continue; } - } else if (currentSource != null) { + } else { if (currentTarget is! Iterable && currentSource is Iterable) { stack.removeLast(); frame.onResult( From 4c1e8c48444ef49e6b665de7c9c82f7a6cbd86ec Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 00:08:04 +0000 Subject: [PATCH 26/42] refactor: add validation for merge phase in Utils class --- lib/src/utils.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 5735e4f..315e2a9 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -374,6 +374,10 @@ final class Utils { continue; } + if (frame.phase != MergePhase.listIter) { + throw StateError('Invalid merge phase: ${frame.phase}'); + } + if (frame.listIndex >= frame.sourceList!.length) { if (frame.options?.parseLists == false && frame.indexedTarget!.values.any((el) => el is Undefined)) { From 2e6d9be6e6616038a9331a45da516f630fa22425 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 00:09:23 +0000 Subject: [PATCH 27/42] refactor: optimize iterable handling in encoding extension --- lib/src/extensions/encode.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/extensions/encode.dart b/lib/src/extensions/encode.dart index a8de0da..1d36e62 100644 --- a/lib/src/extensions/encode.dart +++ b/lib/src/extensions/encode.dart @@ -202,7 +202,7 @@ extension _$Encode on QS { obj is Iterable) { final Iterable iterableObj = obj; final List commaItems = iterableObj is List - ? List.from(iterableObj) + ? iterableObj : iterableObj.toList(growable: false); final List filteredItems = frame.commaCompactNulls @@ -217,7 +217,7 @@ extension _$Encode on QS { : filteredItems; final List joinList = joinIterable is List - ? List.from(joinIterable) + ? joinIterable : joinIterable.toList(growable: false); if (joinList.isNotEmpty) { From e9d89825369934ad2012b179a67744ba821b0174 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 08:43:08 +0000 Subject: [PATCH 28/42] refactor: enhance encoding and decoding tests with additional cases --- test/unit/decode_test.dart | 18 ++++++ test/unit/encode_edge_cases_test.dart | 85 ++++++++++++++++++++++++++ test/unit/encode_test.dart | 13 ++++ test/unit/utils_test.dart | 86 +++++++++++++++++++++++++-- 4 files changed, 198 insertions(+), 4 deletions(-) diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 98a7fee..4b05881 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -2964,6 +2964,24 @@ void main() { })); }); + test('depth=0 keeps the original key intact (strictDepth ignored)', () { + final decoded = QS.decode( + 'a[b]=1', + const DecodeOptions(depth: 0, strictDepth: true), + ); + + expect(decoded, equals({'a[b]': '1'})); + }); + + test('depth=0 keeps the original key intact', () { + final decoded = QS.decode( + 'a[b]=1', + const DecodeOptions(depth: 0), + ); + + expect(decoded, equals({'a[b]': '1'})); + }); + test('parameterLimit < 1 coerces to zero and triggers argument error', () { expect( () => QS.decode( diff --git a/test/unit/encode_edge_cases_test.dart b/test/unit/encode_edge_cases_test.dart index 408b648..0785f05 100644 --- a/test/unit/encode_edge_cases_test.dart +++ b/test/unit/encode_edge_cases_test.dart @@ -35,6 +35,45 @@ class _Dyn extends MapBase { int get length => _store.length; } +class _WeirdMap extends MapBase { + final Map _store = {'real': 1}; + + @override + dynamic operator [](Object? key) { + if (key == 'ghost') return {'nested': 1}; + return _store[key]; + } + + @override + void operator []=(String key, dynamic value) => _store[key] = value; + + @override + void clear() => _store.clear(); + + @override + Iterable get keys => const ['ghost']; + + @override + dynamic remove(Object? key) => _store.remove(key); + + @override + bool containsKey(Object? key) => key != 'ghost' && _store.containsKey(key); + + @override + int get length => 1; +} + +class _Indexable { + _Indexable(this._store); + + final Map _store; + + dynamic operator [](Object? key) => _store[key]; + + @override + String toString() => 'indexable'; +} + void main() { group('encode edge cases', () { test('cycle detection: shared subobject visited twice without throwing', @@ -58,6 +97,36 @@ void main() { expect(encoded, 'nil'); }); + test('filter can null-out a map while strictNullHandling preserves the key', + () { + final encoded = QS.encode( + { + 'obj': {'k': 'v'} + }, + EncodeOptions( + strictNullHandling: true, + encode: false, + filter: (prefix, value) => prefix == 'obj' ? null : value, + ), + ); + + expect(encoded, 'obj'); + }); + + test('filter can collapse a map into a scalar', () { + final encoded = QS.encode( + { + 'obj': {'k': 'v'} + }, + EncodeOptions( + encode: false, + filter: (prefix, value) => prefix == 'obj' ? 'x' : value, + ), + ); + + expect(encoded, 'obj=x'); + }); + test('filter iterable branch on MapBase with throwing key access', () { final dyn = _Dyn(); // Encode the MapBase directly with a filter that forces lookups for both 'ok' @@ -67,6 +136,11 @@ void main() { expect(encoded, 'ok=42'); }); + test('undefined map entries still clear side-channel tracking', () { + final encoded = QS.encode({'root': _WeirdMap()}); + expect(encoded, isEmpty); + }); + test('comma list empty emits nothing but executes Undefined sentinel path', () { final encoded = QS.encode( @@ -136,6 +210,17 @@ void main() { expect(encoded, matches(pattern)); }); + test('custom objects encode via toString', () { + final encoded = QS.encode( + { + 'root': _Indexable({'root': 'ok'}), + }, + const EncodeOptions(encode: false), + ); + + expect(encoded, 'root=indexable'); + }); + test('cycle detection step reset path (multi-level shared object)', () { // Construct a deeper object graph where the same shared leaf appears in // branches of differing depth to exercise the while-loop step reset logic. diff --git a/test/unit/encode_test.dart b/test/unit/encode_test.dart index 3b33178..2de57f0 100644 --- a/test/unit/encode_test.dart +++ b/test/unit/encode_test.dart @@ -108,6 +108,19 @@ void main() { expect(result, equals('obj[prop]=test')); }); + + test('filter iterable exercises dynamic indexer path', () { + final result = QS.encode( + {'u': const Undefined()}, + const EncodeOptions( + encode: false, + filter: ['u'], + ), + ); + + expect(result, isEmpty); + }); + test('encodes a query string map', () { expect(QS.encode({'a': 'b'}), equals('a=b')); expect(QS.encode({'a': 1}), equals('a=1')); diff --git a/test/unit/utils_test.dart b/test/unit/utils_test.dart index c5c5c2b..fde784f 100644 --- a/test/unit/utils_test.dart +++ b/test/unit/utils_test.dart @@ -1,6 +1,7 @@ // ignore_for_file: deprecated_member_use_from_same_package -import 'dart:convert' show latin1, utf8; +import 'dart:convert' show Converter, Encoding, latin1, utf8; +import 'dart:typed_data' show Uint8List; import 'package:qs_dart/qs_dart.dart'; import 'package:qs_dart/src/utils.dart'; @@ -49,6 +50,26 @@ void main() { ); }); + test('encode throws for invalid charset', () { + expect( + () => Utils.encode('x', charset: const _FakeEncoding()), + throwsA(anyOf( + isA(), + isA(), + )), + ); + }); + + test('encode ByteBuffer uses utf8 decoding', () { + final buffer = Uint8List.fromList([0x68, 0x69]).buffer; + expect(Utils.encode(buffer, charset: utf8), equals('hi')); + }); + + test('encode ByteBuffer uses latin1 decoding', () { + final buffer = Uint8List.fromList([0xE4]).buffer; + expect(Utils.encode(buffer, charset: latin1), equals('%E4')); + }); + test('encode huge string', () { final String hugeString = 'a' * 1000000; expect(Utils.encode(hugeString), equals(hugeString)); @@ -1297,13 +1318,57 @@ void main() { test('normalizes to map when Undefined persists and parseLists is false', () { final result = Utils.merge( - [const Undefined()], - const [Undefined()], + [const Undefined(), 'keep'], + const [Undefined(), 'add'], + const DecodeOptions(parseLists: false), + ); + + final map = result as Map; + expect(map, equals({'1': 'add'})); + }); + + test('overflow merge keeps non-numeric keys', () { + final overflow = Utils.markOverflow({'foo': 'bar', '0': 'x'}, 0); + final result = Utils.merge(null, overflow); + + expect(result, isA>()); + final map = result as Map; + expect(map['foo'], equals('bar')); + expect(map['1'], equals('x')); + }); + + test('wraps scalar targets into list with map element when merging maps', + () { + final result = Utils.merge('seed', {'a': 'b'}); + expect(result, isA()); + final list = result as List; + expect(list.first, equals('seed')); + final map = list[1] as Map; + expect(map, equals({'a': 'b'})); + }); + + test('list-merge normalizes to map when Undefined persists', () { + final result = Utils.merge( + [ + {'a': 1}, + const Undefined(), + {'b': 2}, + ], + [ + {'a': 2} + ], const DecodeOptions(parseLists: false), ); + expect(result, isA>()); final map = result as Map; - expect(map, isEmpty); + expect(map.containsKey('1'), isFalse); + expect( + map['0'], + equals({ + 'a': [1, 2] + })); + expect(map['2'], equals({'b': 2})); }); test('combines non-iterable scalars into a list pair', () { @@ -1432,3 +1497,16 @@ void main() { }); }); } + +class _FakeEncoding extends Encoding { + const _FakeEncoding(); + + @override + String get name => 'fake'; + + @override + Converter, String> get decoder => utf8.decoder; + + @override + Converter> get encoder => utf8.encoder; +} From d9cbe6346f869aef76ef62f626f0ba94b03644f2 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 09:18:14 +0000 Subject: [PATCH 29/42] refactor: enhance merging logic to handle Undefined values in Utils class --- lib/src/utils.dart | 9 +++++++++ test/unit/utils_test.dart | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 315e2a9..6a261ff 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -403,6 +403,15 @@ final class Utils { if (frame.indexedTarget!.containsKey(idx)) { final dynamic childTarget = frame.indexedTarget![idx]; + if (childTarget is Undefined) { + if (item is! Undefined) { + frame.indexedTarget![idx] = item; + } + continue; + } + if (item is Undefined) { + continue; + } stack.add( MergeFrame( target: childTarget, diff --git a/test/unit/utils_test.dart b/test/unit/utils_test.dart index fde784f..e5466b7 100644 --- a/test/unit/utils_test.dart +++ b/test/unit/utils_test.dart @@ -1371,6 +1371,25 @@ void main() { expect(map['2'], equals({'b': 2})); }); + test('list-merge replaces holes when merging maps by index', () { + final result = Utils.merge( + [ + const Undefined(), + {'a': 1} + ], + [ + {'a': 2} + ], + ); + + expect( + result, + equals([ + {'a': 2}, + {'a': 1} + ])); + }); + test('combines non-iterable scalars into a list pair', () { expect(Utils.merge('left', 'right'), equals(['left', 'right'])); }); From adb8e921e270c7f0ae69cf6ca4fd3e5618484968 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 09:21:09 +0000 Subject: [PATCH 30/42] refactor: change result variable declaration to late initialization in Utils class --- lib/src/utils.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 6a261ff..900f139 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -94,7 +94,7 @@ final class Utils { dynamic source, [ DecodeOptions? options = const DecodeOptions(), ]) { - dynamic result; + late dynamic result; final List stack = [ MergeFrame( target: target, From 15cb9c5b0646cb2e8098f8cd7c50f5a20afca207 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 09:32:31 +0000 Subject: [PATCH 31/42] refactor: replace _FakeEncoding with FakeEncoding in tests and add fake_encoding.dart --- test/support/fake_encoding.dart | 15 +++++++++++++++ test/unit/models/decode_options_test.dart | 17 +++-------------- test/unit/models/encode_options_test.dart | 17 +++-------------- test/unit/utils_test.dart | 19 ++++--------------- 4 files changed, 25 insertions(+), 43 deletions(-) create mode 100644 test/support/fake_encoding.dart diff --git a/test/support/fake_encoding.dart b/test/support/fake_encoding.dart new file mode 100644 index 0000000..99fa75e --- /dev/null +++ b/test/support/fake_encoding.dart @@ -0,0 +1,15 @@ +import 'dart:convert' show Converter, Encoding, utf8; + +/// Shared fake encoding used by charset validation tests. +class FakeEncoding extends Encoding { + const FakeEncoding(); + + @override + String get name => 'fake'; + + @override + Converter, String> get decoder => utf8.decoder; + + @override + Converter> get encoder => utf8.encoder; +} diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index afbea00..63c0463 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -6,6 +6,8 @@ import 'package:qs_dart/src/models/decode_options.dart'; import 'package:qs_dart/src/qs.dart'; import 'package:test/test.dart'; +import '../../support/fake_encoding.dart'; + void main() { group('DecodeOptions', () { test('copyWith no modifications', () { @@ -187,7 +189,7 @@ void main() { test('throws for invalid charset', () { expect( // ignore: prefer_const_constructors - () => QS.decode('a=b', DecodeOptions(charset: _FakeEncoding())), + () => QS.decode('a=b', DecodeOptions(charset: FakeEncoding())), throwsA(anyOf( isA(), isA(), @@ -402,16 +404,3 @@ void main() { }); }); } - -class _FakeEncoding extends Encoding { - const _FakeEncoding(); - - @override - String get name => 'fake'; - - @override - Converter, String> get decoder => utf8.decoder; - - @override - Converter> get encoder => utf8.encoder; -} diff --git a/test/unit/models/encode_options_test.dart b/test/unit/models/encode_options_test.dart index 0f2aead..320a5b9 100644 --- a/test/unit/models/encode_options_test.dart +++ b/test/unit/models/encode_options_test.dart @@ -6,6 +6,8 @@ import 'package:qs_dart/src/models/encode_options.dart'; import 'package:qs_dart/src/qs.dart'; import 'package:test/test.dart'; +import '../../support/fake_encoding.dart'; + void main() { group('EncodeOptions', () { test('copyWith no modifications', () { @@ -149,7 +151,7 @@ void main() { group('EncodeOptions runtime validation', () { test('throws for invalid charset', () { - final opts = const EncodeOptions(charset: _FakeEncoding()); + final opts = const EncodeOptions(charset: FakeEncoding()); expect( () => QS.encode({'a': 'b'}, opts), throwsA(isA()), @@ -165,16 +167,3 @@ void main() { }); }); } - -class _FakeEncoding extends Encoding { - const _FakeEncoding(); - - @override - String get name => 'fake'; - - @override - Converter, String> get decoder => utf8.decoder; - - @override - Converter> get encoder => utf8.encoder; -} diff --git a/test/unit/utils_test.dart b/test/unit/utils_test.dart index e5466b7..f99d8b0 100644 --- a/test/unit/utils_test.dart +++ b/test/unit/utils_test.dart @@ -1,12 +1,14 @@ // ignore_for_file: deprecated_member_use_from_same_package -import 'dart:convert' show Converter, Encoding, latin1, utf8; +import 'dart:convert' show latin1, utf8; import 'dart:typed_data' show Uint8List; import 'package:qs_dart/qs_dart.dart'; import 'package:qs_dart/src/utils.dart'; import 'package:test/test.dart'; +import '../support/fake_encoding.dart'; + import '../fixtures/dummy_enum.dart'; void main() { @@ -52,7 +54,7 @@ void main() { test('encode throws for invalid charset', () { expect( - () => Utils.encode('x', charset: const _FakeEncoding()), + () => Utils.encode('x', charset: const FakeEncoding()), throwsA(anyOf( isA(), isA(), @@ -1516,16 +1518,3 @@ void main() { }); }); } - -class _FakeEncoding extends Encoding { - const _FakeEncoding(); - - @override - String get name => 'fake'; - - @override - Converter, String> get decoder => utf8.decoder; - - @override - Converter> get encoder => utf8.encoder; -} From 1dd3ac9c56bfdf5d5ec25071c6944896df5a7080 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 09:44:10 +0000 Subject: [PATCH 32/42] chore: update CHANGELOG for 1.7.0-wip with new features, fixes, and enhancements --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ae7ce..d46d668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 1.7.0-wip + +* [FEAT] add `DecodeOptions.throwOnLimitExceeded` for strict limit enforcement on parameter, list, and depth overflows +* [FIX] tolerate malformed UTF-8 when encoding `ByteBuffer` values to match Node `Buffer.toString('utf8')` +* [FIX] improve merge semantics for lists/maps (hole replacement, undefined normalization under `parseLists=false`, and non-mutating list/set merges) +* [FIX] add runtime validation and DecodeOptions constructor asserts for invalid charsets and inconsistent dot options +* [CHORE] refactor encode/merge internals to stack-based frames (`EncodeFrame`, `MergeFrame`, `MergePhase`) for deep-nesting safety +* [CHORE] expand coverage for encode/decode/merge edge cases and add a shared `FakeEncoding` test helper + ## 1.6.1 * [FIX] prevent `DecodeOptions.listLimit` bypass in bracket notation to mitigate potential DoS via memory exhaustion From 8fbc9d0792b03f211a347c964b414633483b2e37 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 09:45:48 +0000 Subject: [PATCH 33/42] docs: enhance AI assistant guide with additional details on encoding and merging semantics --- .github/copilot-instructions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 714dfd7..570a8f1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,8 +19,10 @@ Purpose: Provide just enough project context so an AI assistant can make correct * Decode limits: default `depth=5`, `parameterLimit=1000`, `listLimit=20`; exceeding may coerce indices into object keys or (with strict flags) throw. * List vs Map merging mimics Node: duplicate keys accumulate to lists unless `duplicates` option changes strategy. * `Undefined` entries are placeholders stripped by `Utils.compact` post‑decode / during encode pruning; never serialize `Undefined` itself. +* `Utils.encode` uses `allowMalformed: true` when decoding `ByteBuffer` as UTF‑8 to match Node `Buffer.toString('utf8')`. * Charset sentinel: when `charsetSentinel=true`, `utf8=✓` token (encoded differently per charset) overrides provided `charset` and is omitted from output. * `allowDots` & `decodeDotInKeys`: invalid combination (`decodeDotInKeys: true` with `allowDots: false`) must throw (constructor asserts). Preserve that invariant. +* Merge semantics: list holes are treated as missing values when merging list‑of‑maps by index (no `[Undefined, map]` pair at an index); `parseLists=false` normalizes list results into string‑key maps. * Negative `listLimit` disables numeric indexing; with `throwOnLimitExceeded` certain pushes must throw `RangeError` (match existing patterns in decode logic—consult decode part file before altering behavior). * Encoding pipeline can inject custom encoder/decoder hooks; preserve argument order and named params (`charset`, `format`, `kind`). From fcdc5981332f9823285070a3f449b7e68a70f968 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 09:53:40 +0000 Subject: [PATCH 34/42] fix: decode ByteBuffer via charset when no custom encoder is provided --- lib/src/extensions/encode.dart | 10 +++++++++- test/unit/encode_test.dart | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/src/extensions/encode.dart b/lib/src/extensions/encode.dart index 1d36e62..96530d2 100644 --- a/lib/src/extensions/encode.dart +++ b/lib/src/extensions/encode.dart @@ -164,8 +164,16 @@ extension _$Encode on QS { fragment = '${frame.formatter(keyValue)}=${frame.formatter(frame.encoder!(obj))}'; } else { + final String valueString = obj is ByteBuffer + ? (frame.charset == utf8 + ? utf8.decode( + obj.asUint8List(), + allowMalformed: true, + ) + : latin1.decode(obj.asUint8List())) + : obj.toString(); fragment = - '${frame.formatter(frame.prefix)}=${frame.formatter(obj.toString())}'; + '${frame.formatter(frame.prefix)}=${frame.formatter(valueString)}'; } if (frame.tracked) { frame.sideChannel.remove(frame.trackedObject ?? frame.object); diff --git a/test/unit/encode_test.dart b/test/unit/encode_test.dart index 2de57f0..e7c4d3d 100644 --- a/test/unit/encode_test.dart +++ b/test/unit/encode_test.dart @@ -3192,6 +3192,16 @@ void main() { expect(QS.encode({'a': utf8.encode('test').buffer}), equals('a=test')); }); + test('encodes a buffer value without encoding', () { + expect( + QS.encode( + {'a': latin1.encode('ä').buffer}, + const EncodeOptions(encode: false, charset: latin1), + ), + equals('a=ä'), + ); + }); + test('encodes a date value', () { final DateTime now = DateTime.now(); final String str = 'a=${Uri.encodeComponent(now.toIso8601String())}'; From 67c0c84a19ea2213bd3b3dacbda02cbca7b0a3c1 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 09:57:25 +0000 Subject: [PATCH 35/42] chore: updated the runtime validation to use ArgumentError.value(...) for parameterLimit --- lib/src/models/decode_options.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 452595c..6607224 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -322,7 +322,11 @@ final class DecodeOptions with EquatableMixin { final num limit = parameterLimit; if (limit.isNaN || limit <= 0) { - throw ArgumentError('Parameter limit must be a positive number.'); + throw ArgumentError.value( + limit, + 'parameterLimit', + 'Parameter limit must be a positive number.', + ); } _validated[this] = true; From cf2a7ff41397101235d950bcc2c967364cb7057e Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 10:09:29 +0000 Subject: [PATCH 36/42] chore: replace assertion with ArgumentError in charset validation --- lib/src/utils.dart | 1 - test/unit/utils_test.dart | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 900f139..03e9569 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -585,7 +585,6 @@ final class Utils { Encoding charset = utf8, Format? format = Format.rfc3986, }) { - assert(charset == utf8 || charset == latin1, 'Invalid charset'); if (charset != utf8 && charset != latin1) { throw ArgumentError.value(charset, 'charset', 'Invalid charset'); } diff --git a/test/unit/utils_test.dart b/test/unit/utils_test.dart index f99d8b0..2896013 100644 --- a/test/unit/utils_test.dart +++ b/test/unit/utils_test.dart @@ -55,10 +55,7 @@ void main() { test('encode throws for invalid charset', () { expect( () => Utils.encode('x', charset: const FakeEncoding()), - throwsA(anyOf( - isA(), - isA(), - )), + throwsA(isA()), ); }); From 072c87d60609da1f2c0f62bafb8cd5e2d6d0bdfe Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 10:11:22 +0000 Subject: [PATCH 37/42] chore: update CHANGELOG with new fixes for ByteBuffer decoding and charset validation --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d46d668..8a834b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ * [FEAT] add `DecodeOptions.throwOnLimitExceeded` for strict limit enforcement on parameter, list, and depth overflows * [FIX] tolerate malformed UTF-8 when encoding `ByteBuffer` values to match Node `Buffer.toString('utf8')` +* [FIX] decode `ByteBuffer` values via charset even when `encode=false` (avoid `Instance of 'ByteBuffer'` output) +* [FIX] ensure invalid charset in `Utils.encode` consistently throws `ArgumentError` * [FIX] improve merge semantics for lists/maps (hole replacement, undefined normalization under `parseLists=false`, and non-mutating list/set merges) * [FIX] add runtime validation and DecodeOptions constructor asserts for invalid charsets and inconsistent dot options * [CHORE] refactor encode/merge internals to stack-based frames (`EncodeFrame`, `MergeFrame`, `MergePhase`) for deep-nesting safety From 5192fbabf464d772d92a5afd617a54d1e3025c8d Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 10:45:40 +0000 Subject: [PATCH 38/42] Update lib/src/models/decode_options.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/src/models/decode_options.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 6607224..3183b15 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -317,7 +317,12 @@ final class DecodeOptions with EquatableMixin { } if (decodeDotInKeys && !allowDots) { - throw ArgumentError('decodeDotInKeys requires allowDots to be true'); + throw ArgumentError.value( + decodeDotInKeys, + 'decodeDotInKeys', + 'Invalid combination: decodeDotInKeys=$decodeDotInKeys requires ' + 'allowDots=true (currently allowDots=$allowDots).', + ); } final num limit = parameterLimit; From bac8d337565faef584c78311b4149ec25ed16d88 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 10:46:01 +0000 Subject: [PATCH 39/42] Update lib/src/utils.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/src/utils.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 03e9569..1defe05 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -586,7 +586,11 @@ final class Utils { Format? format = Format.rfc3986, }) { if (charset != utf8 && charset != latin1) { - throw ArgumentError.value(charset, 'charset', 'Invalid charset'); + throw ArgumentError.value( + charset, + 'charset', + 'Invalid charset; only utf8 and latin1 are supported', + ); } // these can not be encoded From bceff9980324a3ddbae7b4c1adb4e27150733e42 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 18:10:22 +0000 Subject: [PATCH 40/42] chore: improve error messages in EncodeOptions validation --- lib/src/models/encode_options.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/src/models/encode_options.dart b/lib/src/models/encode_options.dart index 953c2d1..ae8195f 100644 --- a/lib/src/models/encode_options.dart +++ b/lib/src/models/encode_options.dart @@ -159,12 +159,20 @@ final class EncodeOptions with EquatableMixin { void validate() { final Encoding charset = this.charset; if (charset != utf8 && charset != latin1) { - throw ArgumentError.value(charset, 'charset', 'Invalid charset'); + throw ArgumentError.value( + charset, + 'charset', + 'Invalid charset; only utf8 and latin1 are supported', + ); } final dynamic filter = this.filter; if (filter != null && filter is! Function && filter is! Iterable) { - throw ArgumentError.value(filter, 'filter', 'Invalid filter'); + throw ArgumentError.value( + filter, + 'filter', + 'Invalid filter; expected Function or Iterable', + ); } } From 409599cb2d4a0d2f86745832458e9df7a86b3838 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 18:11:31 +0000 Subject: [PATCH 41/42] fix: correct typo in CHANGELOG for ByteBuffer encoding description --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a834b2..1826694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ * [FEAT] add `DecodeOptions.throwOnLimitExceeded` for strict limit enforcement on parameter, list, and depth overflows * [FIX] tolerate malformed UTF-8 when encoding `ByteBuffer` values to match Node `Buffer.toString('utf8')` -* [FIX] decode `ByteBuffer` values via charset even when `encode=false` (avoid `Instance of 'ByteBuffer'` output) +* [FIX] encode `ByteBuffer` values via charset even when `encode=false` (avoid `Instance of 'ByteBuffer'` output) * [FIX] ensure invalid charset in `Utils.encode` consistently throws `ArgumentError` * [FIX] improve merge semantics for lists/maps (hole replacement, undefined normalization under `parseLists=false`, and non-mutating list/set merges) * [FIX] add runtime validation and DecodeOptions constructor asserts for invalid charsets and inconsistent dot options From da20f31e6c3d93994d68c6e9b7d2ee4a7d7cb8a3 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 9 Feb 2026 18:22:44 +0000 Subject: [PATCH 42/42] docs: add note about ByteBuffer decoding behavior in README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index a6201ef..1824400 100644 --- a/README.md +++ b/README.md @@ -575,6 +575,10 @@ expect( ); ``` +Note: when a value is a `ByteBuffer`, it is still decoded using the selected +charset even when `encode` is `false`, so the emitted value reflects the buffer +contents. + Encoding can be disabled for keys by setting the [EncodeOptions.encodeValuesOnly] option to `true`: ```dart