From d1386f4d281c6306fa929be5f2399be932982a9f Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 3 Nov 2025 20:53:01 +0000 Subject: [PATCH 1/2] :sparkles: add `commaCompactNulls` option to omit nulls from comma lists --- lib/src/extensions/encode.dart | 41 +++++++++++++++++++++++------- lib/src/models/encode_options.dart | 8 ++++++ lib/src/qs.dart | 3 +++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/lib/src/extensions/encode.dart b/lib/src/extensions/encode.dart index 4f108ddb..2b4d3bfc 100644 --- a/lib/src/extensions/encode.dart +++ b/lib/src/extensions/encode.dart @@ -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`. @@ -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, @@ -145,6 +147,7 @@ extension _$Encode on QS { // Cache list form once for non-Map, non-String iterables to avoid repeated enumeration List? seqList_; + int? commaEffectiveLength; final bool isSeq_ = obj is Iterable && obj is! String && obj is! Map; if (isSeq_) { if (obj is List) { @@ -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(obj, encoder); - } + final Iterable iterableObj = obj; + final List commaItems = iterableObj is List + ? List.from(iterableObj) + : iterableObj.toList(growable: false); + + final List filteredItems = commaCompactNulls + ? commaItems.where((dynamic item) => item != null).toList() + : commaItems; + + commaEffectiveLength = filteredItems.length; - if ((obj as Iterable).isNotEmpty) { + final Iterable joinIterable = encodeValuesOnly && encoder != null + ? (Utils.apply(filteredItems, encoder) as Iterable) + : filteredItems; + + final List joinList = joinIterable is List + ? List.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 = [ { @@ -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) { @@ -276,6 +298,7 @@ extension _$Encode on QS { prefix: keyPrefix, generateArrayPrefix: generateArrayPrefix, commaRoundTrip: commaRoundTrip, + commaCompactNulls: commaCompactNulls, allowEmptyLists: allowEmptyLists, strictNullHandling: strictNullHandling, skipNulls: skipNulls, diff --git a/lib/src/models/encode_options.dart b/lib/src/models/encode_options.dart index 8a827bf7..b4fab9e7 100644 --- a/lib/src/models/encode_options.dart +++ b/lib/src/models/encode_options.dart @@ -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 ?? @@ -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; @@ -175,6 +179,7 @@ final class EncodeOptions with EquatableMixin { bool? skipNulls, bool? strictNullHandling, bool? commaRoundTrip, + bool? commaCompactNulls, Sorter? sort, dynamic filter, DateSerializer? serializeDate, @@ -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, @@ -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' @@ -239,6 +246,7 @@ final class EncodeOptions with EquatableMixin { skipNulls, strictNullHandling, commaRoundTrip, + commaCompactNulls, sort, filter, _serializeDate, diff --git a/lib/src/qs.dart b/lib/src/qs.dart index 61b46ef3..9bbf35b2 100644 --- a/lib/src/qs.dart +++ b/lib/src/qs.dart @@ -158,6 +158,8 @@ 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], @@ -165,6 +167,7 @@ final class QS { prefix: key, generateArrayPrefix: gen, commaRoundTrip: crt, + commaCompactNulls: ccn, allowEmptyLists: options.allowEmptyLists, strictNullHandling: options.strictNullHandling, skipNulls: options.skipNulls, From 63088b607a50c0c44e60452a1166755b53280af5 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 3 Nov 2025 20:53:18 +0000 Subject: [PATCH 2/2] :white_check_mark: add tests for `commaCompactNulls` option to validate null handling in encoded lists --- test/unit/encode_test.dart | 51 +++++++++++++++++++++++ test/unit/models/encode_options_test.dart | 10 +++++ 2 files changed, 61 insertions(+) diff --git a/test/unit/encode_test.dart b/test/unit/encode_test.dart index dfa3d78f..3b331780 100644 --- a/test/unit/encode_test.dart +++ b/test/unit/encode_test.dart @@ -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 = {}; map['self'] = map; // self reference diff --git a/test/unit/models/encode_options_test.dart b/test/unit/models/encode_options_test.dart index e253dc62..86efefd8 100644 --- a/test/unit/models/encode_options_test.dart +++ b/test/unit/models/encode_options_test.dart @@ -23,6 +23,7 @@ void main() { skipNulls: true, strictNullHandling: true, commaRoundTrip: true, + commaCompactNulls: true, ); final EncodeOptions newOptions = options.copyWith(); @@ -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)); }); @@ -60,6 +62,7 @@ void main() { skipNulls: true, strictNullHandling: true, commaRoundTrip: true, + commaCompactNulls: true, ); final EncodeOptions newOptions = options.copyWith( @@ -77,6 +80,7 @@ void main() { skipNulls: false, strictNullHandling: false, commaRoundTrip: false, + commaCompactNulls: false, filter: (String key, dynamic value) => false, ); @@ -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', () { @@ -108,6 +116,7 @@ void main() { skipNulls: true, strictNullHandling: true, commaRoundTrip: true, + commaCompactNulls: true, ); expect( @@ -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'