From f56d7f6ea2ae987d7b47369e7f1e4e02de9a2dad Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 Aug 2025 18:51:34 +0100 Subject: [PATCH 1/7] :zap: add key-aware decoder support with DecodeKind enum for improved query parsing v1 --- lib/qs_dart.dart | 1 + lib/src/enums/decode_kind.dart | 36 ++++++ lib/src/extensions/decode.dart | 14 +- lib/src/models/decode_options.dart | 93 ++++++++++---- lib/src/qs.dart | 10 +- test/unit/decode_test.dart | 199 +++++++++++++++++++++++++++++ 6 files changed, 323 insertions(+), 30 deletions(-) create mode 100644 lib/src/enums/decode_kind.dart diff --git a/lib/qs_dart.dart b/lib/qs_dart.dart index 55c30f3..7015613 100644 --- a/lib/qs_dart.dart +++ b/lib/qs_dart.dart @@ -19,6 +19,7 @@ /// See the repository README for complete examples and edge‑case notes. library; +export 'src/enums/decode_kind.dart'; export 'src/enums/duplicates.dart'; export 'src/enums/format.dart'; export 'src/enums/list_format.dart'; diff --git a/lib/src/enums/decode_kind.dart b/lib/src/enums/decode_kind.dart new file mode 100644 index 0000000..48c1e58 --- /dev/null +++ b/lib/src/enums/decode_kind.dart @@ -0,0 +1,36 @@ +/// Decoding context used by the query string parser and utilities. +/// +/// This enum indicates whether a piece of text is being decoded as a **key** +/// (or key segment) or as a **value**. The distinction matters for +/// percent‑encoded dots (`%2E` / `%2e`) that appear **in keys**: +/// +/// * When decoding **keys**, implementations often *preserve* encoded dots so +/// higher‑level options like `allowDots` and `decodeDotInKeys` can be applied +/// consistently during key‑splitting. +/// * When decoding **values**, implementations typically perform full percent +/// decoding. +/// +/// ### Usage +/// +/// ```dart +/// import 'decode_kind.dart'; +/// +/// DecodeKind k = DecodeKind.key; // decode a key/segment +/// DecodeKind v = DecodeKind.value; // decode a value +/// ``` +/// +/// ### Notes +/// +/// Prefer identity comparisons with enum members (e.g. `kind == DecodeKind.key`). +/// The underlying `name`/`index` are implementation details and should not be +/// relied upon for logic. +enum DecodeKind { + /// Decode a **key** (or key segment). Implementations may preserve + /// percent‑encoded dots (`%2E` / `%2e`) so that dot‑splitting semantics can be + /// applied later according to parser options. + key, + + /// Decode a **value**. Implementations typically perform full percent + /// decoding. + value, +} diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 764686c..869fe5e 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -145,12 +145,15 @@ extension _$Decode on QS { late final String key; dynamic val; - // Bare key without '=', interpret as null vs empty-string per strictNullHandling. + // Decode key/value using key-aware decoder, no %2E protection shim. if (pos == -1) { - key = options.decoder(part, charset: charset); + // Decode bare key (no '=') using key-aware decoder + key = options.decoder(part, charset: charset, kind: DecodeKind.key); val = options.strictNullHandling ? null : ''; } else { - key = options.decoder(part.slice(0, pos), charset: charset); + // Decode key slice using key-aware decoder; values decode as value kind + key = options.decoder(part.slice(0, pos), + charset: charset, kind: DecodeKind.key); // Decode the substring *after* '=', applying list parsing and the configured decoder. val = Utils.apply( _parseListValue( @@ -160,7 +163,8 @@ extension _$Decode on QS { ? (obj[key] as List).length : 0, ), - (dynamic val) => options.decoder(val, charset: charset), + (dynamic v) => + options.decoder(v, charset: charset, kind: DecodeKind.value), ); } @@ -257,7 +261,7 @@ extension _$Decode on QS { ? root.slice(1, root.length - 1) : root; final String decodedRoot = options.decodeDotInKeys - ? cleanRoot.replaceAll('%2E', '.') + ? cleanRoot.replaceAll('%2E', '.').replaceAll('%2e', '.') : cleanRoot; final int? index = int.tryParse(decodedRoot); if (!options.parseLists && decodedRoot == '') { diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index dad59bb..1d49671 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -1,6 +1,7 @@ import 'dart:convert' show Encoding, latin1, utf8; import 'package:equatable/equatable.dart'; +import 'package:qs_dart/src/enums/decode_kind.dart'; import 'package:qs_dart/src/enums/duplicates.dart'; import 'package:qs_dart/src/utils.dart'; @@ -27,28 +28,31 @@ import 'package:qs_dart/src/utils.dart'; /// See also: the options types in other ports for parity, and the individual /// doc comments below for precise semantics. -/// Signature for a custom scalar decoder used by [DecodeOptions]. +/// Preferred signature for a custom scalar decoder used by [DecodeOptions]. /// -/// The function receives a single raw token (already split from the query) -/// and an optional [charset] hint (either [utf8] or [latin1]). The [charset] -/// reflects the *effective* charset after any sentinel (`utf8=✓`) handling. -/// -/// Return the decoded value for the token — typically a `String`, `num`, -/// `bool`, or `null`. If no decoder is provided, the library falls back to -/// [Utils.decode]. -/// -/// Notes -/// - This hook runs on **individual tokens only**; do not parse brackets, -/// delimiters, or build containers here. -/// - If you throw from this function, the error will surface out of -/// [QS.decode]. -typedef Decoder = dynamic Function(String? value, {Encoding? charset}); +/// Implementations may choose to ignore [charset] or [kind], but both are +/// provided to enable key-aware decoding when desired. +typedef Decoder = dynamic Function(String? value, + {Encoding? charset, DecodeKind? kind}); + +/// Back-compat: single-argument decoder (value only). +typedef DecoderV1 = dynamic Function(String? value); + +/// Back-compat: decoder with optional [charset] only. +typedef DecoderV2 = dynamic Function(String? value, {Encoding? charset}); + +/// Full-featured: decoder with [charset] and key/value [kind]. +typedef DecoderV3 = dynamic Function(String? value, + {Encoding? charset, DecodeKind? kind}); + +/// Decoder that accepts only [kind] (no [charset]). +typedef DecoderV4 = dynamic Function(String? value, {DecodeKind? kind}); /// Options that configure the output of [QS.decode]. final class DecodeOptions with EquatableMixin { const DecodeOptions({ bool? allowDots, - Decoder? decoder, + Object? decoder, bool? decodeDotInKeys, this.allowEmptyLists = false, this.listLimit = 20, @@ -156,13 +160,56 @@ final class DecodeOptions with EquatableMixin { /// Optional custom scalar decoder for a single token. /// If not provided, falls back to [Utils.decode]. - final Decoder? _decoder; + final Object? _decoder; + + /// Decode a single scalar using either the custom decoder or the default + /// implementation in [Utils.decode]. The [kind] indicates whether the token + /// is a key (or key segment) or a value. + dynamic decoder(String? value, + {Encoding? charset, DecodeKind kind = DecodeKind.value}) { + final d = _decoder; + if (d == null) { + return Utils.decode(value, charset: charset); + } + + // Prefer strongly-typed variants first + if (d is DecoderV3) { + return d(value, charset: charset, kind: kind); + } + if (d is DecoderV2) { + return d(value, charset: charset); + } + if (d is DecoderV4) { + return d(value, kind: kind); + } + if (d is DecoderV1) { + return d(value); + } - /// Decode a single scalar using either the custom [Decoder] or the default - /// implementation in [Utils.decode]. - dynamic decoder(String? value, {Encoding? charset}) => _decoder is Function - ? _decoder?.call(value, charset: charset) - : Utils.decode(value, charset: charset); + // Dynamic callable or class with `call` method + try { + // Try full shape (value, {charset, kind}) + return (d as dynamic)(value, charset: charset, kind: kind); + } catch (_) { + try { + // Try (value, {charset}) + return (d as dynamic)(value, charset: charset); + } catch (_) { + try { + // Try (value, {kind}) + return (d as dynamic)(value, kind: kind); + } catch (_) { + try { + // Try (value) + return (d as dynamic)(value); + } catch (_) { + // Fallback to default + return Utils.decode(value, charset: charset); + } + } + } + } + } /// Return a new [DecodeOptions] with the provided overrides. DecodeOptions copyWith({ @@ -182,7 +229,7 @@ final class DecodeOptions with EquatableMixin { bool? parseLists, bool? strictNullHandling, bool? strictDepth, - Decoder? decoder, + Object? decoder, }) => DecodeOptions( allowDots: allowDots ?? this.allowDots, diff --git a/lib/src/qs.dart b/lib/src/qs.dart index 3e83390..742ea0f 100644 --- a/lib/src/qs.dart +++ b/lib/src/qs.dart @@ -1,6 +1,7 @@ import 'dart:convert' show latin1, utf8, Encoding; import 'dart:typed_data' show ByteBuffer; +import 'package:qs_dart/src/enums/decode_kind.dart'; import 'package:qs_dart/src/enums/duplicates.dart'; import 'package:qs_dart/src/enums/format.dart'; import 'package:qs_dart/src/enums/list_format.dart'; @@ -12,6 +13,9 @@ import 'package:qs_dart/src/models/undefined.dart'; import 'package:qs_dart/src/utils.dart'; import 'package:weak_map/weak_map.dart'; +// Re-export for public API: consumers can `import 'package:qs_dart/qs.dart'` and access DecodeKind +export 'package:qs_dart/src/enums/decode_kind.dart'; + part 'extensions/decode.dart'; part 'extensions/encode.dart'; @@ -65,8 +69,10 @@ final class QS { : input; // Guardrail: if the top-level parameter count is large, temporarily disable - // list parsing to keep memory bounded (matches Node `qs`). - if (options.parseLists && + // list parsing to keep memory bounded (matches Node `qs`). Only apply for + // raw string inputs, not for pre-tokenized maps. + if (input is String && + options.parseLists && options.listLimit > 0 && (tempObj?.length ?? 0) > options.listLimit) { options = options.copyWith(parseLists: false); diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index b129025..1914bcf 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -2014,4 +2014,203 @@ void main() { ); }); }); + + group('key-aware decoder + options isolation', () { + test('custom decoder receives kind for keys and values', () { + final kinds = []; + dynamic dec(String? v, {Encoding? charset, DecodeKind? kind}) { + kinds.add(kind ?? DecodeKind.value); + return Utils.decode(v, charset: charset); + } + + expect(QS.decode('a=b&c=d', DecodeOptions(decoder: dec)), { + 'a': 'b', + 'c': 'd', + }); + + expect(kinds, [ + DecodeKind.key, DecodeKind.value, // a=b + DecodeKind.key, DecodeKind.value, // c=d + ]); + }); + + test('legacy single-arg decoder still works', () { + String? dec(String? v) => v?.toUpperCase(); + expect(QS.decode('a=b', DecodeOptions(decoder: dec)), {'A': 'B'}); + }); + + test('decoder that only accepts kind also works', () { + dynamic dec(String? v, {DecodeKind? kind}) => + kind == DecodeKind.key ? v?.toUpperCase() : v; + + expect(QS.decode('aa=bb', DecodeOptions(decoder: dec)), {'AA': 'bb'}); + }); + + test('parseLists toggle does not leak across calls (string input)', () { + // Build a query with many top-level params to trigger the internal guardrail + final bigQuery = List.generate(25, (i) => 'k$i=v$i').join('&'); + final opts = const DecodeOptions(listLimit: 20); + + final res1 = QS.decode(bigQuery, opts); + expect(res1.length, 25); + + // The same options instance should still parse lists on the next call + final res2 = QS.decode('a[]=1&a[]=2', opts); + expect(res2, { + 'a': ['1', '2'] + }); + }); + }); + + group('DecodeKind scenarios', () { + test('uses KEY for bare key without = (strictNullHandling true)', () { + final kinds = []; + dynamic dec(String? v, {Encoding? charset, DecodeKind? kind}) { + kinds.add(kind ?? DecodeKind.value); + return Utils.decode(v, charset: charset); + } + + final res = QS.decode( + 'foo', DecodeOptions(strictNullHandling: true, decoder: dec)); + expect(res, {'foo': null}); + expect(kinds, [DecodeKind.key]); + }); + + test('comma-split invokes VALUE for each segment', () { + final kinds = []; + dynamic dec(String? v, {Encoding? charset, DecodeKind? kind}) { + kinds.add(kind ?? DecodeKind.value); + return Utils.decode(v, charset: charset); + } + + final res = QS.decode('a=b,c', DecodeOptions(comma: true, decoder: dec)); + expect(res, { + 'a': ['b', 'c'] + }); + // Order: key, value(b), value(c) + expect(kinds, [DecodeKind.key, DecodeKind.value, DecodeKind.value]); + }); + + test('custom decoder can mutate keys only (KEY) without touching values', + () { + dynamic dec(String? v, {Encoding? charset, DecodeKind? kind}) { + if (kind == DecodeKind.key) return v?.toUpperCase(); + return v; + } + + expect(QS.decode('a=b&c=d', DecodeOptions(decoder: dec)), { + 'A': 'b', + 'C': 'd', + }); + }); + + test('custom decoder returning null for VALUE preserves null in result', + () { + dynamic dec(String? v, {Encoding? charset, DecodeKind? kind}) { + if (kind == DecodeKind.value) return null; + return v; + } + + expect(QS.decode('a=b', DecodeOptions(decoder: dec)), {'a': null}); + }); + + test('decoder is not invoked for Map input', () { + dynamic dec(String? v, {Encoding? charset, DecodeKind? kind}) { + throw StateError('decoder should not be called for Map input'); + } + + final input = {'a': 'b'}; + expect(QS.decode(input, DecodeOptions(decoder: dec)), equals(input)); + }); + + test('duplicates=combine yields KEY,VALUE per pair', () { + final kinds = []; + dynamic dec(String? v, {Encoding? charset, DecodeKind? kind}) { + kinds.add(kind ?? DecodeKind.value); + return v; + } + + final res = QS.decode('foo=bar&foo=baz', DecodeOptions(decoder: dec)); + expect(res, { + 'foo': ['bar', 'baz'] + }); + expect(kinds, [ + DecodeKind.key, DecodeKind.value, // first + DecodeKind.key, DecodeKind.value, // second + ]); + }); + + test('charset sentinel switches charset observed by decoder', () { + final seen = []; + dynamic dec(String? v, {Encoding? charset, DecodeKind? kind}) { + seen.add(charset); + return v; // pass through + } + + // Numeric entity sentinel implies latin1 + final res = QS.decode( + 'utf8=%26%2310003%3B&x=%C3%B8', + DecodeOptions(charsetSentinel: true, charset: utf8, decoder: dec), + ); + expect(res, contains('x')); + // We expect at least one latin1 observation (for the x pair after sentinel) + expect(seen.any((e) => e == latin1), isTrue); + }); + + test('parseLists=false still passes KEY for keys and VALUE for values', () { + final kinds = []; + dynamic dec(String? v, {Encoding? charset, DecodeKind? kind}) { + kinds.add(kind ?? DecodeKind.value); + return v; + } + + final res = + QS.decode('a[0]=b', DecodeOptions(parseLists: false, decoder: dec)); + expect(res, { + 'a': {'0': 'b'} + }); + expect(kinds, [DecodeKind.key, DecodeKind.value]); + }); + }); + + group('decoder dynamic fallback', () { + test( + 'callable object with mismatching named params falls back to (value) only', + () { + final calls = []; + // A callable object whose named parameters do not match the library typedefs. + final res = QS.decode('a=b', DecodeOptions(decoder: _Loose1(calls))); + // Since the dynamic path ends up invoking `(value)` with no named args, + // both key and value get prefixed with 'X'. + expect(res, {'Xa': 'Xb'}); + expect(calls, ['a', 'b']); + }); + + test( + 'callable object with a required named param triggers Utils.decode fallback', + () { + final res = QS.decode('a=b', DecodeOptions(decoder: _Loose2())); + expect(res, {'a': 'b'}); + }); + }); +} + +// Helper callable used to exercise the dynamic function fallback in DecodeOptions.decoder. +// Named parameters intentionally do not match `charset`/`kind` so the typed branches +// are skipped and the dynamic ladder is exercised. +class _Loose1 { + final List sink; + + _Loose1(this.sink); + + dynamic call(String? v, {Encoding? cs, DecodeKind? kd}) { + sink.add(v); + return v == null ? null : 'X$v'; + } +} + +// Helper callable that requires an unsupported named parameter; all dynamic attempts +// should throw, causing the code to fall back to Utils.decode. +class _Loose2 { + dynamic call(String? v, {required int must}) => 'Y$v'; } From 49373c760b07551e4ccc2d1ca5e150c085a4083a Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 Aug 2025 19:54:50 +0100 Subject: [PATCH 2/7] :hammer: fix Makefile target to use 'test' instead of 'tests' in 'sure' recipe --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 250a643..613a8b3 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ install: sure: @# Help: Analyze the project's Dart code, check the formatting one or more Dart files and run unit tests for the current project. - make check_style && make tests + make check_style && make test show_test_coverage: @# Help: Run Dart unit tests for the current project and show the coverage. From 0cfa374c91f359c2307e417fe74e7b21bf471d6e Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 Aug 2025 20:08:46 +0100 Subject: [PATCH 3/7] :bulb: update usage example to use package import in decode_kind.dart --- lib/src/enums/decode_kind.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/enums/decode_kind.dart b/lib/src/enums/decode_kind.dart index 48c1e58..85ea1eb 100644 --- a/lib/src/enums/decode_kind.dart +++ b/lib/src/enums/decode_kind.dart @@ -13,7 +13,7 @@ /// ### Usage /// /// ```dart -/// import 'decode_kind.dart'; +/// import 'package:qs_dart/qs.dart'; /// /// DecodeKind k = DecodeKind.key; // decode a key/segment /// DecodeKind v = DecodeKind.value; // decode a value From 0acf28d3917f0638e9ee0c94f65c25fc3b02e656 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 Aug 2025 20:08:52 +0100 Subject: [PATCH 4/7] :bug: fix allowDots initialization and improve decoder error handling for dot-in-keys support --- lib/src/models/decode_options.dart | 50 ++++++++++++++++++------------ 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 1d49671..78f6575 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -69,7 +69,7 @@ final class DecodeOptions with EquatableMixin { this.strictDepth = false, this.strictNullHandling = false, this.throwOnLimitExceeded = false, - }) : allowDots = allowDots ?? decodeDotInKeys == true || false, + }) : allowDots = allowDots ?? (decodeDotInKeys ?? false), decodeDotInKeys = decodeDotInKeys ?? false, _decoder = decoder, assert( @@ -190,24 +190,36 @@ final class DecodeOptions with EquatableMixin { try { // Try full shape (value, {charset, kind}) return (d as dynamic)(value, charset: charset, kind: kind); - } catch (_) { - try { - // Try (value, {charset}) - return (d as dynamic)(value, charset: charset); - } catch (_) { - try { - // Try (value, {kind}) - return (d as dynamic)(value, kind: kind); - } catch (_) { - try { - // Try (value) - return (d as dynamic)(value); - } catch (_) { - // Fallback to default - return Utils.decode(value, charset: charset); - } - } - } + } on NoSuchMethodError catch (_) { + // fall through + } on TypeError catch (_) { + // fall through + } + try { + // Try (value, {charset}) + return (d as dynamic)(value, charset: charset); + } on NoSuchMethodError catch (_) { + // fall through + } on TypeError catch (_) { + // fall through + } + try { + // Try (value, {kind}) + return (d as dynamic)(value, kind: kind); + } on NoSuchMethodError catch (_) { + // fall through + } on TypeError catch (_) { + // fall through + } + try { + // Try (value) + return (d as dynamic)(value); + } on NoSuchMethodError catch (_) { + // Fallback to default + return Utils.decode(value, charset: charset); + } on TypeError catch (_) { + // Fallback to default + return Utils.decode(value, charset: charset); } } From 0ebee886a432a99fc34cb4cddab7dc5ee8fc6f1d Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 Aug 2025 21:52:14 +0100 Subject: [PATCH 5/7] :label: refactor decoder typedefs for clarity and simplify decoder dispatch logic in DecodeOptions --- lib/src/models/decode_options.dart | 60 ++++++++++++++---------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 78f6575..92e9803 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -32,21 +32,20 @@ import 'package:qs_dart/src/utils.dart'; /// /// Implementations may choose to ignore [charset] or [kind], but both are /// provided to enable key-aware decoding when desired. -typedef Decoder = dynamic Function(String? value, - {Encoding? charset, DecodeKind? kind}); - -/// Back-compat: single-argument decoder (value only). -typedef DecoderV1 = dynamic Function(String? value); +typedef Decoder = dynamic Function( + String? value, { + Encoding? charset, + DecodeKind? kind, +}); /// Back-compat: decoder with optional [charset] only. -typedef DecoderV2 = dynamic Function(String? value, {Encoding? charset}); - -/// Full-featured: decoder with [charset] and key/value [kind]. -typedef DecoderV3 = dynamic Function(String? value, - {Encoding? charset, DecodeKind? kind}); +typedef Decoder1 = dynamic Function(String? value, {Encoding? charset}); /// Decoder that accepts only [kind] (no [charset]). -typedef DecoderV4 = dynamic Function(String? value, {DecodeKind? kind}); +typedef Decoder2 = dynamic Function(String? value, {DecodeKind? kind}); + +/// Back-compat: single-argument decoder (value only). +typedef Decoder3 = dynamic Function(String? value); /// Options that configure the output of [QS.decode]. final class DecodeOptions with EquatableMixin { @@ -165,31 +164,26 @@ final class DecodeOptions with EquatableMixin { /// Decode a single scalar using either the custom decoder or the default /// implementation in [Utils.decode]. The [kind] indicates whether the token /// is a key (or key segment) or a value. - dynamic decoder(String? value, - {Encoding? charset, DecodeKind kind = DecodeKind.value}) { - final d = _decoder; - if (d == null) { - return Utils.decode(value, charset: charset); - } + dynamic decoder( + String? value, { + Encoding? charset, + DecodeKind kind = DecodeKind.value, + }) { + final Object? decoder = _decoder; + + // If no custom decoder is provided, use the default decoding logic. + if (decoder == null) return Utils.decode(value, charset: charset); // Prefer strongly-typed variants first - if (d is DecoderV3) { - return d(value, charset: charset, kind: kind); - } - if (d is DecoderV2) { - return d(value, charset: charset); - } - if (d is DecoderV4) { - return d(value, kind: kind); - } - if (d is DecoderV1) { - return d(value); - } + if (decoder is Decoder) return decoder(value, charset: charset, kind: kind); + if (decoder is Decoder1) return decoder(value, charset: charset); + if (decoder is Decoder2) return decoder(value, kind: kind); + if (decoder is Decoder3) return decoder(value); // Dynamic callable or class with `call` method try { // Try full shape (value, {charset, kind}) - return (d as dynamic)(value, charset: charset, kind: kind); + return (decoder as dynamic)(value, charset: charset, kind: kind); } on NoSuchMethodError catch (_) { // fall through } on TypeError catch (_) { @@ -197,7 +191,7 @@ final class DecodeOptions with EquatableMixin { } try { // Try (value, {charset}) - return (d as dynamic)(value, charset: charset); + return (decoder as dynamic)(value, charset: charset); } on NoSuchMethodError catch (_) { // fall through } on TypeError catch (_) { @@ -205,7 +199,7 @@ final class DecodeOptions with EquatableMixin { } try { // Try (value, {kind}) - return (d as dynamic)(value, kind: kind); + return (decoder as dynamic)(value, kind: kind); } on NoSuchMethodError catch (_) { // fall through } on TypeError catch (_) { @@ -213,7 +207,7 @@ final class DecodeOptions with EquatableMixin { } try { // Try (value) - return (d as dynamic)(value); + return (decoder as dynamic)(value); } on NoSuchMethodError catch (_) { // Fallback to default return Utils.decode(value, charset: charset); From 9acc6b7af708f44632db4d8d8bdb9f50afba3b49 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 19 Aug 2025 21:53:39 +0100 Subject: [PATCH 6/7] :bug: ensure fallback decoder uses correct charset in DecodeOptions --- lib/src/models/decode_options.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 92e9803..56a8cc4 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -172,7 +172,9 @@ final class DecodeOptions with EquatableMixin { final Object? decoder = _decoder; // If no custom decoder is provided, use the default decoding logic. - if (decoder == null) return Utils.decode(value, charset: charset); + if (decoder == null) { + return Utils.decode(value, charset: charset ?? this.charset); + } // Prefer strongly-typed variants first if (decoder is Decoder) return decoder(value, charset: charset, kind: kind); @@ -210,10 +212,10 @@ final class DecodeOptions with EquatableMixin { return (decoder as dynamic)(value); } on NoSuchMethodError catch (_) { // Fallback to default - return Utils.decode(value, charset: charset); + return Utils.decode(value, charset: charset ?? this.charset); } on TypeError catch (_) { // Fallback to default - return Utils.decode(value, charset: charset); + return Utils.decode(value, charset: charset ?? this.charset); } } From 157d7be9256e3b3ce7feb7b167bd4c6a63110004 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Wed, 20 Aug 2025 06:59:33 +0100 Subject: [PATCH 7/7] :bug: fix decoder type handling and invocation in DecodeOptions; update tests for callable decoder objects --- lib/src/models/decode_options.dart | 26 +++++++++++++------------- test/unit/decode_test.dart | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 56a8cc4..98b664a 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -51,7 +51,7 @@ typedef Decoder3 = dynamic Function(String? value); final class DecodeOptions with EquatableMixin { const DecodeOptions({ bool? allowDots, - Object? decoder, + Function? decoder, bool? decodeDotInKeys, this.allowEmptyLists = false, this.listLimit = 20, @@ -159,7 +159,7 @@ final class DecodeOptions with EquatableMixin { /// Optional custom scalar decoder for a single token. /// If not provided, falls back to [Utils.decode]. - final Object? _decoder; + final Function? _decoder; /// Decode a single scalar using either the custom decoder or the default /// implementation in [Utils.decode]. The [kind] indicates whether the token @@ -169,23 +169,23 @@ final class DecodeOptions with EquatableMixin { Encoding? charset, DecodeKind kind = DecodeKind.value, }) { - final Object? decoder = _decoder; + final Function? fn = _decoder; // If no custom decoder is provided, use the default decoding logic. - if (decoder == null) { + if (fn == null) { return Utils.decode(value, charset: charset ?? this.charset); } // Prefer strongly-typed variants first - if (decoder is Decoder) return decoder(value, charset: charset, kind: kind); - if (decoder is Decoder1) return decoder(value, charset: charset); - if (decoder is Decoder2) return decoder(value, kind: kind); - if (decoder is Decoder3) return decoder(value); + if (fn is Decoder) return fn(value, charset: charset, kind: kind); + if (fn is Decoder1) return fn(value, charset: charset); + if (fn is Decoder2) return fn(value, kind: kind); + if (fn is Decoder3) return fn(value); // Dynamic callable or class with `call` method try { // Try full shape (value, {charset, kind}) - return (decoder as dynamic)(value, charset: charset, kind: kind); + return (fn as dynamic)(value, charset: charset, kind: kind); } on NoSuchMethodError catch (_) { // fall through } on TypeError catch (_) { @@ -193,7 +193,7 @@ final class DecodeOptions with EquatableMixin { } try { // Try (value, {charset}) - return (decoder as dynamic)(value, charset: charset); + return (fn as dynamic)(value, charset: charset); } on NoSuchMethodError catch (_) { // fall through } on TypeError catch (_) { @@ -201,7 +201,7 @@ final class DecodeOptions with EquatableMixin { } try { // Try (value, {kind}) - return (decoder as dynamic)(value, kind: kind); + return (fn as dynamic)(value, kind: kind); } on NoSuchMethodError catch (_) { // fall through } on TypeError catch (_) { @@ -209,7 +209,7 @@ final class DecodeOptions with EquatableMixin { } try { // Try (value) - return (decoder as dynamic)(value); + return (fn as dynamic)(value); } on NoSuchMethodError catch (_) { // Fallback to default return Utils.decode(value, charset: charset ?? this.charset); @@ -237,7 +237,7 @@ final class DecodeOptions with EquatableMixin { bool? parseLists, bool? strictNullHandling, bool? strictDepth, - Object? decoder, + dynamic Function(String?)? decoder, }) => DecodeOptions( allowDots: allowDots ?? this.allowDots, diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 1914bcf..fb62f02 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -2179,7 +2179,7 @@ void main() { () { final calls = []; // A callable object whose named parameters do not match the library typedefs. - final res = QS.decode('a=b', DecodeOptions(decoder: _Loose1(calls))); + final res = QS.decode('a=b', DecodeOptions(decoder: _Loose1(calls).call)); // Since the dynamic path ends up invoking `(value)` with no named args, // both key and value get prefixed with 'X'. expect(res, {'Xa': 'Xb'}); @@ -2189,7 +2189,7 @@ void main() { test( 'callable object with a required named param triggers Utils.decode fallback', () { - final res = QS.decode('a=b', DecodeOptions(decoder: _Loose2())); + final res = QS.decode('a=b', DecodeOptions(decoder: _Loose2().call)); expect(res, {'a': 'b'}); }); });