Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
9dc6ca2
feat: add validation for DecodeOptions and EncodeOptions to ensure pr…
techouse Feb 1, 2026
06fa2a0
fix: enhance decoding logic for ByteBuffer to handle malformed UTF-8 …
techouse Feb 1, 2026
e93edd5
feat: add throwOnLimitExceeded option to DecodeOptions for stricter l…
techouse Feb 1, 2026
121c8c7
test: add tests for throwOnLimitExceeded in DecodeOptions copyWith me…
techouse Feb 1, 2026
8840069
feat: implement _EncodeFrame and refactor encoding logic for improved…
techouse Feb 1, 2026
a446970
test: enhance tests for DecodeOptions and EncodeOptions with runtime …
techouse Feb 1, 2026
040ea89
refactor: move _EncodeFrame class to a separate file for better organ…
techouse Feb 1, 2026
5561626
docs: expand _EncodeFrame with additional properties for enhanced enc…
techouse Feb 1, 2026
fd5a91b
refactor: replace _validateDecodeOptions function with validate metho…
techouse Feb 1, 2026
f157c7f
refactor: add validation in DecodeOptions constructor to ensure prope…
techouse Feb 1, 2026
9b75955
refactor: enhance UTF-8 decoding in Utils to allow for malformed sequ…
techouse Feb 1, 2026
124961e
refactor: simplify test description for allowDots and decodeDotInKeys…
techouse Feb 1, 2026
14ff67b
refactor: introduce _MergePhase and _MergeFrame for improved merge ha…
techouse Feb 1, 2026
5bb177c
refactor: enhance DecodeOptions with runtime validation and assertion…
techouse Feb 8, 2026
be760ac
refactor: enhance merge handling in Utils by normalizing undefined va…
techouse Feb 8, 2026
db24759
refactor: rename _MergeFrame and _MergePhase to MergeFrame and MergeP…
techouse Feb 8, 2026
5a400fc
refactor: rename _EncodeFrame to EncodeFrame for improved clarity and…
techouse Feb 8, 2026
65f7c15
refactor: add comment to clarify Expando usage in DecodeOptions valid…
techouse Feb 8, 2026
e0a0518
refactor: add runtime validation for charset in encoding function
techouse Feb 8, 2026
d6750dd
refactor: update encoder documentation to clarify iterative stack-bas…
techouse Feb 8, 2026
7cd71fc
refactor: update import statement for DecodeOptions to use the correc…
techouse Feb 8, 2026
263f5ff
refactor: enhance comment for UTF-8 decoding to clarify Node.js compa…
techouse Feb 8, 2026
93a82ba
refactor: remove StateError from expected exceptions in decode tests
techouse Feb 8, 2026
cb1519e
refactor: create new merged lists and sets instead of mutating existi…
techouse Feb 9, 2026
7a42791
refactor: streamline merging logic for lists and sets in Utils class
techouse Feb 9, 2026
4c1e8c4
refactor: add validation for merge phase in Utils class
techouse Feb 9, 2026
2e6d9be
refactor: optimize iterable handling in encoding extension
techouse Feb 9, 2026
e9d8982
refactor: enhance encoding and decoding tests with additional cases
techouse Feb 9, 2026
d9cbe63
refactor: enhance merging logic to handle Undefined values in Utils c…
techouse Feb 9, 2026
adb8e92
refactor: change result variable declaration to late initialization i…
techouse Feb 9, 2026
15cb9c5
refactor: replace _FakeEncoding with FakeEncoding in tests and add fa…
techouse Feb 9, 2026
1dd3ac9
chore: update CHANGELOG for 1.7.0-wip with new features, fixes, and e…
techouse Feb 9, 2026
8fbc9d0
docs: enhance AI assistant guide with additional details on encoding …
techouse Feb 9, 2026
fcdc598
fix: decode ByteBuffer via charset when no custom encoder is provided
techouse Feb 9, 2026
67c0c84
chore: updated the runtime validation to use ArgumentError.value(...)…
techouse Feb 9, 2026
cf2a7ff
chore: replace assertion with ArgumentError in charset validation
techouse Feb 9, 2026
072c87d
chore: update CHANGELOG with new fixes for ByteBuffer decoding and ch…
techouse Feb 9, 2026
5192fba
Update lib/src/models/decode_options.dart
techouse Feb 9, 2026
bac8d33
Update lib/src/utils.dart
techouse Feb 9, 2026
bceff99
chore: improve error messages in EncodeOptions validation
techouse Feb 9, 2026
409599c
fix: correct typo in CHANGELOG for ByteBuffer encoding description
techouse Feb 9, 2026
da20f31
docs: add note about ByteBuffer decoding behavior in README
techouse Feb 9, 2026
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
462 changes: 260 additions & 202 deletions lib/src/extensions/encode.dart

Large diffs are not rendered by default.

50 changes: 34 additions & 16 deletions lib/src/models/decode_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -73,25 +74,13 @@ 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"}}`.
///
/// 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
Expand Down Expand Up @@ -140,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
Expand Down Expand Up @@ -215,6 +204,8 @@ 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);
}
Expand Down Expand Up @@ -273,6 +264,7 @@ final class DecodeOptions with EquatableMixin {
bool? parseLists,
bool? strictNullHandling,
bool? strictDepth,
bool? throwOnLimitExceeded,
Decoder? decoder,
LegacyDecoder? legacyDecoder,
}) =>
Expand All @@ -294,10 +286,32 @@ 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,
);

/// 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'
Expand All @@ -315,6 +329,7 @@ final class DecodeOptions with EquatableMixin {
' parameterLimit: $parameterLimit,\n'
' parseLists: $parseLists,\n'
' strictDepth: $strictDepth,\n'
' throwOnLimitExceeded: $throwOnLimitExceeded,\n'
' strictNullHandling: $strictNullHandling\n'
')';

Expand All @@ -340,4 +355,7 @@ final class DecodeOptions with EquatableMixin {
_decoder,
_legacyDecoder,
];

static final Expando<bool> _validated =
Expando<bool>('qsDecodeOptionsValidated');
}
122 changes: 122 additions & 0 deletions lib/src/models/encode_frame.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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,
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,
});

/// 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<String> 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<dynamic> objKeys = const [];

/// Current index into [objKeys].
int index = 0;

/// Cached list form for iterable values (to avoid re-iteration).
List<dynamic>? 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<String> values = [];
}
12 changes: 2 additions & 10 deletions lib/src/models/encode_options.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions lib/src/qs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
///
Expand Down Expand Up @@ -45,6 +46,7 @@ final class QS {
static Map<String, dynamic> decode(dynamic input, [DecodeOptions? options]) {
options ??= const DecodeOptions();
// Default to the library's safe, Node-`qs` compatible settings.
options.validate();

// Fail fast on unsupported input shapes to avoid ambiguous behavior.
if (!(input is String? || input is Map<String, dynamic>?)) {
Expand Down Expand Up @@ -102,6 +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);

// Normalize supported inputs into a mutable map we can traverse.
Map<String, dynamic> obj = switch (object) {
Expand Down Expand Up @@ -210,3 +213,15 @@ 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');
}
}
Loading