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
41 changes: 32 additions & 9 deletions lib/src/extensions/encode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ extension _$Encode on QS {
/// - [prefix]: Current key path (e.g., `user[address]`). If `addQueryPrefix` is true at the root, we start with `?`.
/// - [generateArrayPrefix]: Strategy for array key generation (brackets/indices/repeat/comma).
/// - [commaRoundTrip]: When true and a single-element list is encountered under `.comma`, emit `[]` to ensure the value round-trips back to an array.
/// - [commaCompactNulls]: When true, nulls are omitted from `.comma` lists.
/// - [allowEmptyLists]: If a list is empty, emit `key[]` instead of skipping.
/// - [strictNullHandling]: If a present value is `null`, emit only the key (no `=`) instead of `key=`.
/// - [skipNulls]: Skip keys whose value is `null`.
Expand All @@ -53,6 +54,7 @@ extension _$Encode on QS {
String? prefix,
ListFormatGenerator? generateArrayPrefix,
bool? commaRoundTrip,
bool commaCompactNulls = false,
bool allowEmptyLists = false,
bool strictNullHandling = false,
bool skipNulls = false,
Expand Down Expand Up @@ -145,6 +147,7 @@ extension _$Encode on QS {

// Cache list form once for non-Map, non-String iterables to avoid repeated enumeration
List<dynamic>? seqList_;
int? commaEffectiveLength;
final bool isSeq_ = obj is Iterable && obj is! String && obj is! Map;
if (isSeq_) {
if (obj is List) {
Expand All @@ -161,14 +164,28 @@ extension _$Encode on QS {
// - Otherwise derive keys from Map/Iterable, and optionally sort them.
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);
}
final Iterable<dynamic> iterableObj = obj;
final List<dynamic> commaItems = iterableObj is List
? List<dynamic>.from(iterableObj)
: iterableObj.toList(growable: false);

final List<dynamic> filteredItems = commaCompactNulls
? commaItems.where((dynamic item) => item != null).toList()
: commaItems;

commaEffectiveLength = filteredItems.length;

if ((obj as Iterable).isNotEmpty) {
final Iterable<dynamic> joinIterable = encodeValuesOnly && encoder != null
? (Utils.apply<String>(filteredItems, encoder) as Iterable)
: filteredItems;

final List<dynamic> joinList = joinIterable is List
? List<dynamic>.from(joinIterable)
: joinIterable.toList(growable: false);

if (joinList.isNotEmpty) {
final String objKeysValue =
obj.map((e) => e != null ? e.toString() : '').join(',');
joinList.map((e) => e != null ? e.toString() : '').join(',');

objKeys = [
{
Expand Down Expand Up @@ -200,10 +217,15 @@ extension _$Encode on QS {
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 =
(commaRoundTrip == true) && seqList_ != null && seqList_.length == 1
? '$encodedPrefix[]'
: encodedPrefix;
shouldAppendRoundTripMarker ? '$encodedPrefix[]' : encodedPrefix;

// Emit `key[]` when an empty list is allowed, to preserve shape on round-trip.
if (allowEmptyLists && seqList_ != null && seqList_.isEmpty) {
Expand Down Expand Up @@ -276,6 +298,7 @@ extension _$Encode on QS {
prefix: keyPrefix,
generateArrayPrefix: generateArrayPrefix,
commaRoundTrip: commaRoundTrip,
commaCompactNulls: commaCompactNulls,
allowEmptyLists: allowEmptyLists,
strictNullHandling: strictNullHandling,
skipNulls: skipNulls,
Expand Down
8 changes: 8 additions & 0 deletions lib/src/models/encode_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ final class EncodeOptions with EquatableMixin {
this.skipNulls = false,
this.strictNullHandling = false,
this.commaRoundTrip,
this.commaCompactNulls = false,
this.sort,
}) : allowDots = allowDots ?? encodeDotInKeys || false,
listFormat = listFormat ??
Expand Down Expand Up @@ -117,6 +118,9 @@ final class EncodeOptions with EquatableMixin {
/// single-item [List]s, so that they can round trip through a parse.
final bool? commaRoundTrip;

/// When [listFormat] is [ListFormat.comma], drop `null` items before joining.
final bool commaCompactNulls;

/// Set a [Sorter] to affect the order of parameter keys.
final Sorter? sort;

Expand Down Expand Up @@ -175,6 +179,7 @@ final class EncodeOptions with EquatableMixin {
bool? skipNulls,
bool? strictNullHandling,
bool? commaRoundTrip,
bool? commaCompactNulls,
Sorter? sort,
dynamic filter,
DateSerializer? serializeDate,
Expand All @@ -195,6 +200,7 @@ final class EncodeOptions with EquatableMixin {
skipNulls: skipNulls ?? this.skipNulls,
strictNullHandling: strictNullHandling ?? this.strictNullHandling,
commaRoundTrip: commaRoundTrip ?? this.commaRoundTrip,
commaCompactNulls: commaCompactNulls ?? this.commaCompactNulls,
sort: sort ?? this.sort,
filter: filter ?? this.filter,
serializeDate: serializeDate ?? _serializeDate,
Expand All @@ -217,6 +223,7 @@ final class EncodeOptions with EquatableMixin {
' skipNulls: $skipNulls,\n'
' strictNullHandling: $strictNullHandling,\n'
' commaRoundTrip: $commaRoundTrip,\n'
' commaCompactNulls: $commaCompactNulls,\n'
' sort: $sort,\n'
' filter: $filter,\n'
' serializeDate: $_serializeDate,\n'
Expand All @@ -239,6 +246,7 @@ final class EncodeOptions with EquatableMixin {
skipNulls,
strictNullHandling,
commaRoundTrip,
commaCompactNulls,
sort,
filter,
_serializeDate,
Expand Down
3 changes: 3 additions & 0 deletions lib/src/qs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,16 @@ final class QS {
final ListFormatGenerator gen = options.listFormat.generator;
final bool crt = identical(gen, ListFormat.comma.generator) &&
options.commaRoundTrip == true;
final bool ccn = identical(gen, ListFormat.comma.generator) &&
options.commaCompactNulls == true;

final encoded = _$Encode._encode(
obj[key],
undefined: !obj.containsKey(key),
prefix: key,
generateArrayPrefix: gen,
commaRoundTrip: crt,
commaCompactNulls: ccn,
allowEmptyLists: options.allowEmptyLists,
strictNullHandling: options.strictNullHandling,
skipNulls: options.skipNulls,
Expand Down
51 changes: 51 additions & 0 deletions test/unit/encode_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5260,6 +5260,57 @@ void main() {
);
});

test('commaCompactNulls drops null entries before joining', () {
expect(
QS.encode(
{
'a': {
'b': [true, false, null, true]
}
},
const EncodeOptions(
listFormat: ListFormat.comma,
commaCompactNulls: true,
encode: false,
),
),
'a[b]=true,false,true',
);
});

test('commaCompactNulls omits key when all entries are null', () {
expect(
QS.encode(
{
'a': [null, null]
},
const EncodeOptions(
listFormat: ListFormat.comma,
commaCompactNulls: true,
encode: false,
),
),
isEmpty,
);
});

test('commaCompactNulls keeps round-trip marker after filtering', () {
expect(
QS.encode(
{
'a': [null, 'foo']
},
const EncodeOptions(
listFormat: ListFormat.comma,
commaRoundTrip: true,
commaCompactNulls: true,
encode: false,
),
),
'a[]=foo',
);
});

test('cycle detection throws RangeError', () {
final map = <String, dynamic>{};
map['self'] = map; // self reference
Expand Down
10 changes: 10 additions & 0 deletions test/unit/models/encode_options_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ void main() {
skipNulls: true,
strictNullHandling: true,
commaRoundTrip: true,
commaCompactNulls: true,
);

final EncodeOptions newOptions = options.copyWith();
Expand All @@ -41,6 +42,7 @@ void main() {
expect(newOptions.skipNulls, isTrue);
expect(newOptions.strictNullHandling, isTrue);
expect(newOptions.commaRoundTrip, isTrue);
expect(newOptions.commaCompactNulls, isTrue);
expect(newOptions, equals(options));
});

Expand All @@ -60,6 +62,7 @@ void main() {
skipNulls: true,
strictNullHandling: true,
commaRoundTrip: true,
commaCompactNulls: true,
);

final EncodeOptions newOptions = options.copyWith(
Expand All @@ -77,6 +80,7 @@ void main() {
skipNulls: false,
strictNullHandling: false,
commaRoundTrip: false,
commaCompactNulls: false,
filter: (String key, dynamic value) => false,
);

Expand All @@ -90,6 +94,10 @@ void main() {
expect(newOptions.encode, isFalse);
expect(newOptions.encodeDotInKeys, isFalse);
expect(newOptions.encodeValuesOnly, isFalse);
expect(newOptions.skipNulls, isFalse);
expect(newOptions.strictNullHandling, isFalse);
expect(newOptions.commaRoundTrip, isFalse);
expect(newOptions.commaCompactNulls, isFalse);
});

test('toString', () {
Expand All @@ -108,6 +116,7 @@ void main() {
skipNulls: true,
strictNullHandling: true,
commaRoundTrip: true,
commaCompactNulls: true,
);

expect(
Expand All @@ -127,6 +136,7 @@ void main() {
' skipNulls: true,\n'
' strictNullHandling: true,\n'
' commaRoundTrip: true,\n'
' commaCompactNulls: true,\n'
' sort: null,\n'
' filter: null,\n'
' serializeDate: null,\n'
Expand Down