Skip to content

Commit b440718

Browse files
authored
✨ add key-aware decoding to the query string parser (#35)
1 parent 79f0659 commit b440718

File tree

6 files changed

+332
-31
lines changed

6 files changed

+332
-31
lines changed

lib/qs_dart.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
/// See the repository README for complete examples and edge‑case notes.
2020
library;
2121

22+
export 'src/enums/decode_kind.dart';
2223
export 'src/enums/duplicates.dart';
2324
export 'src/enums/format.dart';
2425
export 'src/enums/list_format.dart';

lib/src/enums/decode_kind.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/// Decoding context used by the query string parser and utilities.
2+
///
3+
/// This enum indicates whether a piece of text is being decoded as a **key**
4+
/// (or key segment) or as a **value**. The distinction matters for
5+
/// percent‑encoded dots (`%2E` / `%2e`) that appear **in keys**:
6+
///
7+
/// * When decoding **keys**, implementations often *preserve* encoded dots so
8+
/// higher‑level options like `allowDots` and `decodeDotInKeys` can be applied
9+
/// consistently during key‑splitting.
10+
/// * When decoding **values**, implementations typically perform full percent
11+
/// decoding.
12+
///
13+
/// ### Usage
14+
///
15+
/// ```dart
16+
/// import 'package:qs_dart/qs.dart';
17+
///
18+
/// DecodeKind k = DecodeKind.key; // decode a key/segment
19+
/// DecodeKind v = DecodeKind.value; // decode a value
20+
/// ```
21+
///
22+
/// ### Notes
23+
///
24+
/// Prefer identity comparisons with enum members (e.g. `kind == DecodeKind.key`).
25+
/// The underlying `name`/`index` are implementation details and should not be
26+
/// relied upon for logic.
27+
enum DecodeKind {
28+
/// Decode a **key** (or key segment). Implementations may preserve
29+
/// percent‑encoded dots (`%2E` / `%2e`) so that dot‑splitting semantics can be
30+
/// applied later according to parser options.
31+
key,
32+
33+
/// Decode a **value**. Implementations typically perform full percent
34+
/// decoding.
35+
value,
36+
}

lib/src/extensions/decode.dart

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,15 @@ extension _$Decode on QS {
145145

146146
late final String key;
147147
dynamic val;
148-
// Bare key without '=', interpret as null vs empty-string per strictNullHandling.
148+
// Decode key/value using key-aware decoder, no %2E protection shim.
149149
if (pos == -1) {
150-
key = options.decoder(part, charset: charset);
150+
// Decode bare key (no '=') using key-aware decoder
151+
key = options.decoder(part, charset: charset, kind: DecodeKind.key);
151152
val = options.strictNullHandling ? null : '';
152153
} else {
153-
key = options.decoder(part.slice(0, pos), charset: charset);
154+
// Decode key slice using key-aware decoder; values decode as value kind
155+
key = options.decoder(part.slice(0, pos),
156+
charset: charset, kind: DecodeKind.key);
154157
// Decode the substring *after* '=', applying list parsing and the configured decoder.
155158
val = Utils.apply<dynamic>(
156159
_parseListValue(
@@ -160,7 +163,8 @@ extension _$Decode on QS {
160163
? (obj[key] as List).length
161164
: 0,
162165
),
163-
(dynamic val) => options.decoder(val, charset: charset),
166+
(dynamic v) =>
167+
options.decoder(v, charset: charset, kind: DecodeKind.value),
164168
);
165169
}
166170

@@ -257,7 +261,7 @@ extension _$Decode on QS {
257261
? root.slice(1, root.length - 1)
258262
: root;
259263
final String decodedRoot = options.decodeDotInKeys
260-
? cleanRoot.replaceAll('%2E', '.')
264+
? cleanRoot.replaceAll('%2E', '.').replaceAll('%2e', '.')
261265
: cleanRoot;
262266
final int? index = int.tryParse(decodedRoot);
263267
if (!options.parseLists && decodedRoot == '') {

lib/src/models/decode_options.dart

Lines changed: 79 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:convert' show Encoding, latin1, utf8;
22

33
import 'package:equatable/equatable.dart';
4+
import 'package:qs_dart/src/enums/decode_kind.dart';
45
import 'package:qs_dart/src/enums/duplicates.dart';
56
import 'package:qs_dart/src/utils.dart';
67

@@ -27,28 +28,30 @@ import 'package:qs_dart/src/utils.dart';
2728
/// See also: the options types in other ports for parity, and the individual
2829
/// doc comments below for precise semantics.
2930
30-
/// Signature for a custom scalar decoder used by [DecodeOptions].
31+
/// Preferred signature for a custom scalar decoder used by [DecodeOptions].
3132
///
32-
/// The function receives a single raw token (already split from the query)
33-
/// and an optional [charset] hint (either [utf8] or [latin1]). The [charset]
34-
/// reflects the *effective* charset after any sentinel (`utf8=✓`) handling.
35-
///
36-
/// Return the decoded value for the token — typically a `String`, `num`,
37-
/// `bool`, or `null`. If no decoder is provided, the library falls back to
38-
/// [Utils.decode].
39-
///
40-
/// Notes
41-
/// - This hook runs on **individual tokens only**; do not parse brackets,
42-
/// delimiters, or build containers here.
43-
/// - If you throw from this function, the error will surface out of
44-
/// [QS.decode].
45-
typedef Decoder = dynamic Function(String? value, {Encoding? charset});
33+
/// Implementations may choose to ignore [charset] or [kind], but both are
34+
/// provided to enable key-aware decoding when desired.
35+
typedef Decoder = dynamic Function(
36+
String? value, {
37+
Encoding? charset,
38+
DecodeKind? kind,
39+
});
40+
41+
/// Back-compat: decoder with optional [charset] only.
42+
typedef Decoder1 = dynamic Function(String? value, {Encoding? charset});
43+
44+
/// Decoder that accepts only [kind] (no [charset]).
45+
typedef Decoder2 = dynamic Function(String? value, {DecodeKind? kind});
46+
47+
/// Back-compat: single-argument decoder (value only).
48+
typedef Decoder3 = dynamic Function(String? value);
4649

4750
/// Options that configure the output of [QS.decode].
4851
final class DecodeOptions with EquatableMixin {
4952
const DecodeOptions({
5053
bool? allowDots,
51-
Decoder? decoder,
54+
Function? decoder,
5255
bool? decodeDotInKeys,
5356
this.allowEmptyLists = false,
5457
this.listLimit = 20,
@@ -65,7 +68,7 @@ final class DecodeOptions with EquatableMixin {
6568
this.strictDepth = false,
6669
this.strictNullHandling = false,
6770
this.throwOnLimitExceeded = false,
68-
}) : allowDots = allowDots ?? decodeDotInKeys == true || false,
71+
}) : allowDots = allowDots ?? (decodeDotInKeys ?? false),
6972
decodeDotInKeys = decodeDotInKeys ?? false,
7073
_decoder = decoder,
7174
assert(
@@ -156,13 +159,65 @@ final class DecodeOptions with EquatableMixin {
156159

157160
/// Optional custom scalar decoder for a single token.
158161
/// If not provided, falls back to [Utils.decode].
159-
final Decoder? _decoder;
162+
final Function? _decoder;
163+
164+
/// Decode a single scalar using either the custom decoder or the default
165+
/// implementation in [Utils.decode]. The [kind] indicates whether the token
166+
/// is a key (or key segment) or a value.
167+
dynamic decoder(
168+
String? value, {
169+
Encoding? charset,
170+
DecodeKind kind = DecodeKind.value,
171+
}) {
172+
final Function? fn = _decoder;
173+
174+
// If no custom decoder is provided, use the default decoding logic.
175+
if (fn == null) {
176+
return Utils.decode(value, charset: charset ?? this.charset);
177+
}
178+
179+
// Prefer strongly-typed variants first
180+
if (fn is Decoder) return fn(value, charset: charset, kind: kind);
181+
if (fn is Decoder1) return fn(value, charset: charset);
182+
if (fn is Decoder2) return fn(value, kind: kind);
183+
if (fn is Decoder3) return fn(value);
160184

161-
/// Decode a single scalar using either the custom [Decoder] or the default
162-
/// implementation in [Utils.decode].
163-
dynamic decoder(String? value, {Encoding? charset}) => _decoder is Function
164-
? _decoder?.call(value, charset: charset)
165-
: Utils.decode(value, charset: charset);
185+
// Dynamic callable or class with `call` method
186+
try {
187+
// Try full shape (value, {charset, kind})
188+
return (fn as dynamic)(value, charset: charset, kind: kind);
189+
} on NoSuchMethodError catch (_) {
190+
// fall through
191+
} on TypeError catch (_) {
192+
// fall through
193+
}
194+
try {
195+
// Try (value, {charset})
196+
return (fn as dynamic)(value, charset: charset);
197+
} on NoSuchMethodError catch (_) {
198+
// fall through
199+
} on TypeError catch (_) {
200+
// fall through
201+
}
202+
try {
203+
// Try (value, {kind})
204+
return (fn as dynamic)(value, kind: kind);
205+
} on NoSuchMethodError catch (_) {
206+
// fall through
207+
} on TypeError catch (_) {
208+
// fall through
209+
}
210+
try {
211+
// Try (value)
212+
return (fn as dynamic)(value);
213+
} on NoSuchMethodError catch (_) {
214+
// Fallback to default
215+
return Utils.decode(value, charset: charset ?? this.charset);
216+
} on TypeError catch (_) {
217+
// Fallback to default
218+
return Utils.decode(value, charset: charset ?? this.charset);
219+
}
220+
}
166221

167222
/// Return a new [DecodeOptions] with the provided overrides.
168223
DecodeOptions copyWith({
@@ -182,7 +237,7 @@ final class DecodeOptions with EquatableMixin {
182237
bool? parseLists,
183238
bool? strictNullHandling,
184239
bool? strictDepth,
185-
Decoder? decoder,
240+
dynamic Function(String?)? decoder,
186241
}) =>
187242
DecodeOptions(
188243
allowDots: allowDots ?? this.allowDots,

lib/src/qs.dart

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:convert' show latin1, utf8, Encoding;
22
import 'dart:typed_data' show ByteBuffer;
33

4+
import 'package:qs_dart/src/enums/decode_kind.dart';
45
import 'package:qs_dart/src/enums/duplicates.dart';
56
import 'package:qs_dart/src/enums/format.dart';
67
import 'package:qs_dart/src/enums/list_format.dart';
@@ -12,6 +13,9 @@ import 'package:qs_dart/src/models/undefined.dart';
1213
import 'package:qs_dart/src/utils.dart';
1314
import 'package:weak_map/weak_map.dart';
1415

16+
// Re-export for public API: consumers can `import 'package:qs_dart/qs.dart'` and access DecodeKind
17+
export 'package:qs_dart/src/enums/decode_kind.dart';
18+
1519
part 'extensions/decode.dart';
1620
part 'extensions/encode.dart';
1721

@@ -65,8 +69,10 @@ final class QS {
6569
: input;
6670

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

0 commit comments

Comments
 (0)