Skip to content

Commit 38bfb34

Browse files
authored
✨ add EncodeOptions.CommaCompactNulls (#33)
1 parent 9808e4b commit 38bfb34

File tree

7 files changed

+139
-15
lines changed

7 files changed

+139
-15
lines changed

QsNet.Tests/EncodeOptionsTests.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ public void CopyWith_NoModifications_ShouldReturnIdenticalOptions()
2929
Format = Format.Rfc1738,
3030
SkipNulls = true,
3131
StrictNullHandling = true,
32-
CommaRoundTrip = true
32+
CommaRoundTrip = true,
33+
CommaCompactNulls = true
3334
};
3435

3536
// Act
@@ -50,6 +51,7 @@ public void CopyWith_NoModifications_ShouldReturnIdenticalOptions()
5051
newOptions.SkipNulls.Should().BeTrue();
5152
newOptions.StrictNullHandling.Should().BeTrue();
5253
newOptions.CommaRoundTrip.Should().BeTrue();
54+
newOptions.CommaCompactNulls.Should().BeTrue();
5355

5456
newOptions.Should().BeEquivalentTo(options);
5557
}
@@ -73,7 +75,8 @@ public void CopyWith_WithModifications_ShouldReturnModifiedOptions()
7375
Format = Format.Rfc1738,
7476
SkipNulls = true,
7577
StrictNullHandling = true,
76-
CommaRoundTrip = true
78+
CommaRoundTrip = true,
79+
CommaCompactNulls = true
7780
};
7881

7982
// Act
@@ -92,6 +95,7 @@ public void CopyWith_WithModifications_ShouldReturnModifiedOptions()
9295
skipNulls: false,
9396
strictNullHandling: false,
9497
commaRoundTrip: false,
98+
commaCompactNulls: false,
9599
filter: new FunctionFilter((_, _) => new Dictionary<string, object?>())
96100
);
97101

@@ -110,6 +114,7 @@ public void CopyWith_WithModifications_ShouldReturnModifiedOptions()
110114
newOptions.SkipNulls.Should().BeFalse();
111115
newOptions.StrictNullHandling.Should().BeFalse();
112116
newOptions.CommaRoundTrip.Should().BeFalse();
117+
newOptions.CommaCompactNulls.Should().BeFalse();
113118
newOptions.Filter.Should().NotBeNull();
114119
newOptions.Filter.Should().BeOfType<FunctionFilter>();
115120
}
@@ -248,4 +253,4 @@ public void CopyWith_Indices_Sort_Encoder_DateSerializer_Mapping()
248253
copy.Sort.Should().NotBeNull();
249254
}
250255
}
251-
#pragma warning restore CS0618
256+
#pragma warning restore CS0618

QsNet.Tests/EncodeTests.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,66 @@ public void Encode_WithDefaultParameterValues()
186186
.Be("a[]=b");
187187
}
188188

189+
[Fact]
190+
public void Encode_CommaCompactNulls_DropsNullEntries()
191+
{
192+
var options = new EncodeOptions
193+
{
194+
ListFormat = ListFormat.Comma,
195+
Encode = false,
196+
CommaCompactNulls = true
197+
};
198+
199+
var data = new Dictionary<string, object?>
200+
{
201+
[
202+
"a"
203+
] = new Dictionary<string, object?>
204+
{
205+
["b"] = new object?[] { "one", "two", null, "three" }
206+
}
207+
};
208+
209+
Qs.Encode(data, options).Should().Be("a[b]=one,two,three");
210+
}
211+
212+
[Fact]
213+
public void Encode_CommaCompactNulls_OmitsKeyWhenAllNull()
214+
{
215+
var options = new EncodeOptions
216+
{
217+
ListFormat = ListFormat.Comma,
218+
Encode = false,
219+
CommaCompactNulls = true
220+
};
221+
222+
var data = new Dictionary<string, object?>
223+
{
224+
["a"] = new object?[] { null, null }
225+
};
226+
227+
Qs.Encode(data, options).Should().BeEmpty();
228+
}
229+
230+
[Fact]
231+
public void Encode_CommaCompactNulls_PreservesRoundTripMarker()
232+
{
233+
var options = new EncodeOptions
234+
{
235+
ListFormat = ListFormat.Comma,
236+
Encode = false,
237+
CommaRoundTrip = true,
238+
CommaCompactNulls = true
239+
};
240+
241+
var data = new Dictionary<string, object?>
242+
{
243+
["a"] = new object?[] { null, "foo" }
244+
};
245+
246+
Qs.Encode(data, options).Should().Be("a[]=foo");
247+
}
248+
189249
[Fact]
190250
public void Encode_EncodesList()
191251
{
@@ -4513,6 +4573,7 @@ public void IDictionary_Object_Generic_FastPath_With_IterableFilter()
45134573
false,
45144574
false,
45154575
false,
4576+
false,
45164577
null,
45174578
null,
45184579
null,
@@ -4542,6 +4603,7 @@ public void IDictionary_String_Generic_FastPath_Missing_Key_Omitted()
45424603
false,
45434604
false,
45444605
false,
4606+
false,
45454607
null,
45464608
null,
45474609
null,
@@ -4571,6 +4633,7 @@ public void IDictionary_NonGeneric_DefaultContainsPath_With_Missing()
45714633
false,
45724634
false,
45734635
false,
4636+
false,
45744637
null,
45754638
null,
45764639
null,
@@ -4600,6 +4663,7 @@ public void Array_IndexOutOfRange_Omitted()
46004663
false,
46014664
false,
46024665
false,
4666+
false,
46034667
null,
46044668
null,
46054669
null,
@@ -4629,6 +4693,7 @@ public void IList_StringIndexParsing_And_OutOfRange()
46294693
false,
46304694
false,
46314695
false,
4696+
false,
46324697
null,
46334698
null,
46344699
null,
@@ -4658,6 +4723,7 @@ public void IEnumerable_NonList_Indexing_With_OutOfRange()
46584723
false,
46594724
false,
46604725
false,
4726+
false,
46614727
null,
46624728
null,
46634729
null,
@@ -4683,6 +4749,7 @@ public void AddQueryPrefix_IsUsed_When_No_Prefix_And_StrictNullHandling()
46834749
ListFormat.Indices.GetGenerator(),
46844750
false,
46854751
false,
4752+
false,
46864753
true,
46874754
false,
46884755
false,
@@ -4715,6 +4782,7 @@ public void Primitive_With_EncodeValuesOnly_Uses_RawKey_And_EncodedValue()
47154782
false,
47164783
false,
47174784
false,
4785+
false,
47184786
(v, _, _) => v?.ToString()?.ToUpperInvariant() ?? string.Empty,
47194787
null,
47204788
null,

QsNet/Internal/Encoder.cs

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ internal static class Encoder
2424
/// <param name="prefix">An optional prefix for the encoded string.</param>
2525
/// <param name="generateArrayPrefix">A generator for array prefixes.</param>
2626
/// <param name="commaRoundTrip">If true, uses comma for array encoding.</param>
27+
/// <param name="commaCompactNulls">When true (and using comma arrays), drops null entries before joining.</param>
2728
/// <param name="allowEmptyLists">If true, allows empty lists in the output.</param>
2829
/// <param name="strictNullHandling">If true, handles nulls strictly.</param>
2930
/// <param name="skipNulls">If true, skips null values in the output.</param>
@@ -46,6 +47,7 @@ public static object Encode(
4647
string? prefix = null,
4748
ListFormatGenerator? generateArrayPrefix = null,
4849
bool? commaRoundTrip = null,
50+
bool commaCompactNulls = false,
4951
bool allowEmptyLists = false,
5052
bool strictNullHandling = false,
5153
bool skipNulls = false,
@@ -68,6 +70,7 @@ public static object Encode(
6870

6971
var isCommaGen = ReferenceEquals(gen, ListFormat.Comma.GetGenerator());
7072
var crt = commaRoundTrip ?? isCommaGen;
73+
var compactNulls = commaCompactNulls && isCommaGen;
7174

7275
var keyPrefixStr = prefix ?? (addQueryPrefix ? "?" : "");
7376
var obj = data;
@@ -152,17 +155,46 @@ public static object Encode(
152155
isSeq = true;
153156
seqList = seq0.Cast<object?>().ToList();
154157
}
158+
int? commaEffectiveLength = null;
155159

156160
List<object?> objKeys;
157161
if (isCommaGen && obj is IEnumerable enumerable and not string and not IDictionary)
158162
{
159-
List<string> strings = [];
163+
var commaItems = seqList ?? enumerable.Cast<object?>().ToList();
164+
List<object?> itemsForJoin;
165+
if (compactNulls)
166+
{
167+
itemsForJoin = new List<object?>(commaItems.Count);
168+
foreach (var item in commaItems)
169+
if (item is not null)
170+
itemsForJoin.Add(item);
171+
}
172+
else
173+
{
174+
itemsForJoin = commaItems;
175+
}
176+
177+
commaEffectiveLength = itemsForJoin.Count;
178+
179+
var strings = new List<string>(itemsForJoin.Count);
160180
if (encodeValuesOnly && encoder != null)
161-
foreach (var el in enumerable)
162-
strings.Add(el is null ? "" : encoder(el.ToString(), null, null));
181+
{
182+
foreach (var el in itemsForJoin)
183+
{
184+
if (el is null)
185+
{
186+
strings.Add("");
187+
continue;
188+
}
189+
190+
strings.Add(encoder(el.ToString(), null, null));
191+
}
192+
}
163193
else
164-
foreach (var el in enumerable)
194+
{
195+
foreach (var el in itemsForJoin)
165196
strings.Add(el?.ToString() ?? "");
197+
}
166198

167199
if (strings.Count != 0)
168200
{
@@ -232,10 +264,15 @@ public static object Encode(
232264
values.Capacity = Math.Max(values.Capacity, objKeys.Count);
233265

234266
var encodedPrefix = encodeDotInKeys ? keyPrefixStr.Replace(".", "%2E") : keyPrefixStr;
235-
var adjustedPrefix =
236-
crt && isSeq && seqList is { Count: 1 }
237-
? $"{encodedPrefix}[]"
238-
: encodedPrefix;
267+
var shouldAppendRoundTrip = crt
268+
&& isSeq
269+
&& (
270+
isCommaGen && commaEffectiveLength.HasValue
271+
? commaEffectiveLength.Value == 1
272+
: seqList is { Count: 1 }
273+
);
274+
275+
var adjustedPrefix = shouldAppendRoundTrip ? $"{encodedPrefix}[]" : encodedPrefix;
239276

240277
if (allowEmptyLists && isSeq && seqList is { Count: 0 })
241278
return $"{adjustedPrefix}[]";
@@ -402,6 +439,7 @@ obj is IEnumerable and not string and not IDictionary
402439
keyPrefix,
403440
gen,
404441
crt,
442+
compactNulls,
405443
allowEmptyLists,
406444
strictNullHandling,
407445
skipNulls,
@@ -427,4 +465,4 @@ obj is IEnumerable and not string and not IDictionary
427465

428466
return values;
429467
}
430-
}
468+
}

QsNet/Models/EncodeOptions.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@ public bool AllowDots
173173
/// </summary>
174174
public bool? CommaRoundTrip { get; init; }
175175

176+
/// <summary>
177+
/// When ListFormat is ListFormat.Comma, drop null items before joining instead of preserving empty slots.
178+
/// </summary>
179+
public bool CommaCompactNulls { get; init; }
180+
176181
/// <summary>
177182
/// Set a Sorter to affect the order of parameter keys.
178183
/// </summary>
@@ -227,6 +232,7 @@ public string GetDateSerializer(DateTime date)
227232
/// <param name="skipNulls">Set to override SkipNulls</param>
228233
/// <param name="strictNullHandling">Set to override StrictNullHandling</param>
229234
/// <param name="commaRoundTrip">Set to override CommaRoundTrip</param>
235+
/// <param name="commaCompactNulls">Set to override CommaCompactNulls</param>
230236
/// <param name="sort">Set to override Sort</param>
231237
/// <param name="indices">Set to override Indices (deprecated)</param>
232238
/// <param name="encoder">Set to override the encoder function</param>
@@ -248,6 +254,7 @@ public EncodeOptions CopyWith(
248254
bool? skipNulls = null,
249255
bool? strictNullHandling = null,
250256
bool? commaRoundTrip = null,
257+
bool? commaCompactNulls = null,
251258
Comparison<object?>? sort = null,
252259
bool? indices = null,
253260
ValueEncoder? encoder = null,
@@ -271,6 +278,7 @@ public EncodeOptions CopyWith(
271278
SkipNulls = skipNulls ?? SkipNulls,
272279
StrictNullHandling = strictNullHandling ?? StrictNullHandling,
273280
CommaRoundTrip = commaRoundTrip ?? CommaRoundTrip,
281+
CommaCompactNulls = commaCompactNulls ?? CommaCompactNulls,
274282
Sort = sort ?? Sort,
275283
#pragma warning disable CS0618 // Type or member is obsolete
276284
Indices = indices ?? Indices,
@@ -279,4 +287,4 @@ public EncodeOptions CopyWith(
279287
DateSerializer = dateSerializer ?? DateSerializer
280288
};
281289
}
282-
}
290+
}

QsNet/Qs.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ public static string Encode(object? data, EncodeOptions? options = null)
214214
key,
215215
opts.ListFormat?.GetGenerator(),
216216
opts is { ListFormat: ListFormat.Comma, CommaRoundTrip: true },
217+
opts is { ListFormat: ListFormat.Comma, CommaCompactNulls: true },
217218
opts.AllowEmptyLists,
218219
opts.StrictNullHandling,
219220
opts.SkipNulls,
@@ -277,4 +278,4 @@ public static string Encode(object? data, EncodeOptions? options = null)
277278
return dict;
278279
}
279280
}
280-
}
281+
}

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,8 @@ Qs.Encode(data, options.CopyWith(listFormat: ListFormat.Comma));
365365
// => "a=b,c"
366366
```
367367

368+
**Note:** When `ListFormat.Comma` is selected, you can set `EncodeOptions.CommaRoundTrip` to `true` or `false` to append `[]` on single-item lists so they round-trip through decoding. Set `EncodeOptions.CommaCompactNulls` to `true` alongside the comma format when you'd like to drop `null` entries instead of keeping empty slots (for example, `["one", null, "two"]` becomes `one,two`).
369+
368370
### Nested dictionaries
369371

370372
```csharp

docs/docs/encoding.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ Qs.Encode(data, options.CopyWith(listFormat: ListFormat.Comma));
8484
// => "a=b,c"
8585
```
8686

87+
**Note:** When `ListFormat.Comma` is used, you can set `EncodeOptions.CommaRoundTrip` to `true` or `false` so single-item lists append `[]` and round-trip through decoding. Set `EncodeOptions.CommaCompactNulls` to `true` with the comma format to drop `null` entries instead of keeping empty slots (for example, `["one", null, "two"]` becomes `one,two`).
88+
8789
### Nested dictionaries
8890

8991
```csharp
@@ -368,4 +370,4 @@ Qs.Encode(new Dictionary<string, object?> { ["a"] = "b c" }, new EncodeOptions {
368370
// => "a=b+c"
369371
```
370372

371-
---
373+
---

0 commit comments

Comments
 (0)