Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 57 additions & 37 deletions lib/src/extensions/decode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ extension _$Decode on QS {
// Fast-path: split comma-separated scalars into a list when requested.
if (val is String && val.isNotEmpty && options.comma && val.contains(',')) {
final List<String> splitVal = val.split(',');
if (options.throwOnLimitExceeded && splitVal.length > options.listLimit) {
if (options.throwOnLimitExceeded &&
currentListLength + splitVal.length > options.listLimit) {
throw RangeError(
'List limit exceeded. '
'Only ${options.listLimit} element${options.listLimit == 1 ? '' : 's'} allowed in a list.',
Expand All @@ -60,7 +61,7 @@ extension _$Decode on QS {

// Guard incremental growth of an existing list as we parse additional items.
if (options.throwOnLimitExceeded &&
currentListLength >= options.listLimit) {
currentListLength + 1 > options.listLimit) {
throw RangeError(
'List limit exceeded. '
'Only ${options.listLimit} element${options.listLimit == 1 ? '' : 's'} allowed in a list.',
Expand Down Expand Up @@ -94,19 +95,21 @@ extension _$Decode on QS {
throw ArgumentError('Parameter limit must be a positive integer.');
}

// 3) Split by delimiter, respecting `parameterLimit` and whether we throw
// when the limit is exceeded.
final Iterable<String> parts = limit != null && limit > 0
? cleanStr
.split(options.delimiter)
.take(options.throwOnLimitExceeded ? limit + 1 : limit)
: cleanStr.split(options.delimiter);

// If we were asked to throw on overflow, detect it after the split/take.
if (options.throwOnLimitExceeded && limit != null && parts.length > limit) {
throw RangeError(
'Parameter limit exceeded. Only $limit parameter${limit == 1 ? '' : 's'} allowed.',
);
// 3) Split by delimiter once; optionally truncate, optionally throw on overflow.
final List<String> allParts = cleanStr.split(options.delimiter);
late final List<String> parts;
if (limit != null && limit > 0) {
final int takeCount = options.throwOnLimitExceeded ? limit + 1 : limit;
final int count =
allParts.length < takeCount ? allParts.length : takeCount;
parts = allParts.sublist(0, count);
if (options.throwOnLimitExceeded && allParts.length > limit) {
throw RangeError(
'Parameter limit exceeded. Only $limit parameter${limit == 1 ? '' : 's'} allowed.',
);
}
} else {
parts = allParts;
}

// Charset probing (utf8=✓ / utf8=X). Skip the sentinel pair later.
Expand All @@ -118,10 +121,11 @@ extension _$Decode on QS {
// 4) Scan once for a charset sentinel and adjust decoder charset accordingly.
if (options.charsetSentinel) {
for (i = 0; i < parts.length; ++i) {
if (parts.elementAt(i).startsWith('utf8=')) {
if (parts.elementAt(i) == Sentinel.charset.toString()) {
final String p = parts[i];
if (p.startsWith('utf8=')) {
if (p == Sentinel.charset.toString()) {
charset = utf8;
} else if (parts.elementAt(i) == Sentinel.iso.toString()) {
} else if (p == Sentinel.iso.toString()) {
charset = latin1;
}
skipIndex = i;
Expand All @@ -133,10 +137,8 @@ extension _$Decode on QS {
// 5) Parse each `key=value` pair, honoring bracket-`]=` short-circuit for speed.
final Map<String, dynamic> obj = {};
for (i = 0; i < parts.length; ++i) {
if (i == skipIndex) {
continue;
}
final String part = parts.elementAt(i);
if (i == skipIndex) continue;
final String part = parts[i];
final int bracketEqualsPos = part.indexOf(']=');
final int pos =
bracketEqualsPos == -1 ? part.indexOf('=') : bracketEqualsPos + 1;
Expand Down Expand Up @@ -167,16 +169,16 @@ extension _$Decode on QS {
!Utils.isEmpty(val) &&
options.interpretNumericEntities &&
charset == latin1) {
val = Utils.interpretNumericEntities(
val is Iterable
? val.map((e) => e.toString()).join(',')
: val.toString(),
);
if (val is Iterable) {
val = Utils.interpretNumericEntities(_joinIterableToCommaString(val));
} else {
val = Utils.interpretNumericEntities(val.toString());
}
}

// Quirk: a literal `[]=` suffix forces an array container (qs behavior).
if (part.contains('[]=')) {
val = val is Iterable ? [val] : val;
if (options.parseLists && part.contains('[]=')) {
val = [val];
}

// Duplicate key policy: combine/first/last (default: combine).
Expand Down Expand Up @@ -209,14 +211,20 @@ extension _$Decode on QS {
// Determine the current list length if we are appending into `[]`.
late final int currentListLength;

if (chain.isNotEmpty && chain.last == '[]') {
final int? parentKey = int.tryParse(chain.slice(0, -1).join(''));

currentListLength = parentKey != null &&
val is List &&
val.firstWhereIndexedOrNull((int i, _) => i == parentKey) != null
? val.elementAt(parentKey).length
: 0;
if (chain.length >= 2 && chain.last == '[]') {
final String prev = chain[chain.length - 2];
final bool bracketed = prev.startsWith('[') && prev.endsWith(']');
final int? parentIndex =
bracketed ? int.tryParse(prev.substring(1, prev.length - 1)) : null;
if (parentIndex != null &&
parentIndex >= 0 &&
val is List &&
parentIndex < val.length) {
final dynamic parent = val[parentIndex];
currentListLength = parent is List ? parent.length : 0;
} else {
currentListLength = 0;
}
} else {
currentListLength = 0;
}
Expand Down Expand Up @@ -431,4 +439,16 @@ extension _$Decode on QS {

return sb.toString();
}

/// Joins an iterable of objects into a comma-separated string.
static String _joinIterableToCommaString(Iterable it) {
final StringBuffer sb = StringBuffer();
bool first = true;
for (final e in it) {
if (!first) sb.write(',');
sb.write(e == null ? '' : e.toString());
first = false;
}
return sb.toString();
}
}
56 changes: 40 additions & 16 deletions lib/src/extensions/encode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ extension _$Encode on QS {
}) {
prefix ??= addQueryPrefix ? '?' : '';
generateArrayPrefix ??= ListFormat.indices.generator;
commaRoundTrip ??= generateArrayPrefix == ListFormat.comma.generator;
commaRoundTrip ??=
identical(generateArrayPrefix, ListFormat.comma.generator);
formatter ??= format.formatter;

dynamic obj = object;
Expand Down Expand Up @@ -105,7 +106,7 @@ extension _$Encode on QS {
null => obj.toIso8601String(),
_ => serializeDate(obj),
};
} else if (generateArrayPrefix == ListFormat.comma.generator &&
} else if (identical(generateArrayPrefix, ListFormat.comma.generator) &&
obj is Iterable) {
obj = Utils.apply(
obj,
Expand Down Expand Up @@ -142,12 +143,24 @@ extension _$Encode on QS {
return values;
}

// Cache list form once for non-Map, non-String iterables to avoid repeated enumeration
List<dynamic>? seqList_;
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);
}
}

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 (generateArrayPrefix == ListFormat.comma.generator && obj is Iterable) {
if (identical(generateArrayPrefix, ListFormat.comma.generator) &&
obj is Iterable) {
// we need to join elements in
if (encodeValuesOnly && encoder != null) {
obj = Utils.apply<String>(obj, encoder);
Expand All @@ -173,10 +186,10 @@ extension _$Encode on QS {
late final Iterable keys;
if (obj is Map) {
keys = obj.keys;
} else if (obj is Iterable) {
keys = [for (int index = 0; index < obj.length; index++) index];
} else if (seqList_ != null) {
keys = List<int>.generate(seqList_.length, (i) => i, growable: false);
} else {
keys = [];
keys = const <int>[];
}
objKeys = sort != null ? (keys.toList()..sort(sort)) : keys.toList();
}
Expand All @@ -188,12 +201,12 @@ extension _$Encode on QS {
encodeDotInKeys ? prefix.replaceAll('.', '%2E') : prefix;

final String adjustedPrefix =
commaRoundTrip && obj is Iterable && obj.length == 1
(commaRoundTrip == true) && seqList_ != null && seqList_.length == 1
? '$encodedPrefix[]'
: encodedPrefix;

// Emit `key[]` when an empty list is allowed, to preserve shape on round-trip.
if (allowEmptyLists && obj is Iterable && obj.isEmpty) {
if (allowEmptyLists && seqList_ != null && seqList_.isEmpty) {
return '$adjustedPrefix[]';
}

Expand All @@ -213,9 +226,15 @@ extension _$Encode on QS {
if (obj is Map) {
value = obj[key];
valueUndefined = !obj.containsKey(key);
} else if (obj is Iterable) {
value = obj.elementAt(key);
valueUndefined = false;
} else if (seqList_ != null) {
final int? idx = key is int ? key : int.tryParse(key.toString());
if (idx != null && idx >= 0 && idx < seqList_.length) {
value = seqList_[idx];
valueUndefined = false;
} else {
value = null;
valueUndefined = true;
}
} 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.
Expand All @@ -237,9 +256,14 @@ extension _$Encode on QS {
? key.toString().replaceAll('.', '%2E')
: key.toString();

final String keyPrefix = obj is Iterable
? generateArrayPrefix(adjustedPrefix, encodedKey)
: '$adjustedPrefix${allowDots ? '.$encodedKey' : '[$encodedKey]'}';
final bool isCommaSentinel =
key is Map<String, dynamic> && 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;
Expand All @@ -256,9 +280,9 @@ extension _$Encode on QS {
strictNullHandling: strictNullHandling,
skipNulls: skipNulls,
encodeDotInKeys: encodeDotInKeys,
encoder: generateArrayPrefix == ListFormat.comma.generator &&
encoder: identical(generateArrayPrefix, ListFormat.comma.generator) &&
encodeValuesOnly &&
obj is Iterable
seqList_ != null
? null
: encoder,
serializeDate: serializeDate,
Expand Down
77 changes: 49 additions & 28 deletions lib/src/extensions/extensions.dart
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import 'dart:math' show min;

/// Utilities mirroring small JavaScript conveniences used across the qs Dart port.
///
/// - `IterableExtension.whereNotType<Q>()`: filters out elements of a given type
/// while preserving order (useful when handling heterogeneous collections during
/// parsing).
/// - `ListExtension.slice(start, [end])`: JS-style `Array.prototype.slice` for
/// lists. Supports negative indices, clamps to bounds, and never throws for
/// out-of-range values. Returns a new list that references the same element
/// objects (non-deep copy).
/// - `StringExtension.slice(start, [end])`: JS-style `String.prototype.slice`
/// for strings with the same semantics (negative indices and clamping).
///
/// These helpers are intentionally tiny and non-mutating so the compiler can
/// inline them; they keep call sites close to the semantics of the original
/// Node `qs` implementation.
// Utilities mirroring small JavaScript conveniences used across the qs Dart port.
//
// - `IterableExtension.whereNotType<Q>()`: filters out elements of a given type
// while preserving order (useful when handling heterogeneous collections during
// parsing).
// - `ListExtension.slice(start, [end])`: JS-style `Array.prototype.slice` for
// lists. Supports negative indices, clamps to bounds, and never throws for
// out-of-range values. Returns a new list that references the same element
// objects (non-deep copy).
// - `StringExtension.slice(start, [end])`: JS-style `String.prototype.slice`
// for strings with the same semantics (negative indices and clamping).
//
// These helpers are intentionally tiny and non-mutating so the compiler can
// inline them; they keep call sites close to the semantics of the original
// Node `qs` implementation.

extension IterableExtension<T> on Iterable<T> {
/// Returns a **lazy** [Iterable] view that filters out all elements of type [Q].
Expand Down Expand Up @@ -47,11 +45,25 @@ extension ListExtension<T> on List<T> {
/// ['a','b','c'].slice(-2, -1); // ['b']
/// ['a','b','c'].slice(0, 99); // ['a','b','c']
/// ```
List<T> slice([int start = 0, int? end]) => sublist(
(start < 0 ? length + start : start).clamp(0, length),
(end == null ? length : (end < 0 ? length + end : end))
.clamp(0, length),
);
List<T> slice([int start = 0, int? end]) {
final int l = length;
int s = start < 0 ? l + start : start;
int e = end == null ? l : (end < 0 ? l + end : end);

if (s < 0) {
s = 0;
} else if (s > l) {
s = l;
}
if (e < 0) {
e = 0;
} else if (e > l) {
e = l;
}

if (e <= s) return <T>[];
return sublist(s, e);
}
}

extension StringExtension on String {
Expand All @@ -70,13 +82,22 @@ extension StringExtension on String {
/// 'hello'.slice(0, 99); // 'hello'
/// ```
String slice(int start, [int? end]) {
end ??= length;
if (end < 0) {
end = length + end;
final int l = length;
int s = start < 0 ? l + start : start;
int e = end == null ? l : (end < 0 ? l + end : end);

if (s < 0) {
s = 0;
} else if (s > l) {
s = l;
}
if (start < 0) {
start = length + start;
if (e < 0) {
e = 0;
} else if (e > l) {
e = l;
}
return substring(start, min(end, length));

if (e <= s) return '';
return substring(s, e);
}
}
5 changes: 2 additions & 3 deletions lib/src/models/undefined.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ final class Undefined with EquatableMixin {
/// No-op copy that returns another equal sentinel. Kept for API symmetry.
Undefined copyWith() => const Undefined();

@override

/// No distinguishing fields — all [Undefined] instances are equal.
List<Object> get props => [];
@override
List<Object> get props => const [];
}
Loading