Skip to content

Commit 523705a

Browse files
authored
Fix: Improve nullable operator handling for dictionary keys (#523)
* Improve nullable operator handling for dictionary keys - Enhances SmartFormat's support for nullable operators ('?') in dictionary formatting. - Refactors `DictionarySource` to detect dictionary types and handle missing keys gracefully, when the nullable operator is used in the selector of a format string. - Adds tests to verify correct behavior for missing keys and improper nullable operator placement. - Updates `Source.HasNullableOperator` logic: Returns true, if the current `Selector` or any `Selector` before contains the nullable operator. Resolves #522 * Optimize IReadOnlyDictionary check by caching - Introduced a thread-safe cache to IsIReadOnlyDictionary for improved performance and reduced allocations. - Added tests for nullable operator with enum-keyed dictionaries to ensure missing keys return empty values.
1 parent d889684 commit 523705a

3 files changed

Lines changed: 155 additions & 44 deletions

File tree

src/SmartFormat.Tests/Extensions/DictionarySourceTests.cs

Lines changed: 93 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
using System.Collections;
33
using System.Collections.Generic;
44
using System.Dynamic;
5-
using System.Linq;
65
using NUnit.Framework;
6+
using SmartFormat.Core.Formatting;
77
using SmartFormat.Core.Settings;
88
using SmartFormat.Extensions;
99
using SmartFormat.Tests.TestUtils;
@@ -159,6 +159,98 @@ public void Dictionary_Dot_Notation_Nullable()
159159
Assert.That(result, Is.EqualTo(expected));
160160
}
161161

162+
[Test]
163+
public void Dictionary_NullableOperator_MissingKey_ReturnsEmpty()
164+
{
165+
// Test similar to GitHub issue #522, but using string keys
166+
var sf = Smart.CreateDefaultSmartFormat();
167+
168+
var obj = new {
169+
Changes = new Dictionary<string, object>
170+
{
171+
{ "Count", 100 },
172+
// Volume is intentionally missing
173+
// although it is accessed in the format string
174+
{ "Name", "ABCD" },
175+
}
176+
};
177+
178+
// Use nullable syntax to avoid exceptions for selectors
179+
// that cannot be resolved with the DictionarySource:
180+
// 'Volume' may be missing, but 'Changes' is expected to be present
181+
var resultSyntax1 =
182+
sf.Format("{Changes:{Count};{?.Volume};{Name}}", obj);
183+
var resultSyntax2 =
184+
sf.Format("{Changes.Count};{Changes?.Volume};{Changes.Name}", obj);
185+
186+
Assert.Multiple(() =>
187+
{
188+
Assert.That(resultSyntax1, Is.EqualTo("100;;ABCD"));
189+
Assert.That(resultSyntax2, Is.EqualTo("100;;ABCD"));
190+
});
191+
192+
// Now add the missing key and verify that it is properly resolved
193+
obj.Changes.Add("Volume", 200);
194+
Assert.That(sf.Format("{Changes.Count};{Changes?.Volume};{Changes.Name}", obj),
195+
Is.EqualTo("100;200;ABCD"));
196+
}
197+
198+
private enum ChangeKey { Count, Volume, Name }
199+
200+
[Test]
201+
public void Dictionary_NullableOperator_MissingEnumKey_ReturnsEmpty()
202+
{
203+
// Test for GitHub issue #522 with non-string dictionary keys
204+
var sf = Smart.CreateDefaultSmartFormat();
205+
var obj = new {
206+
Changes = new Dictionary<ChangeKey, object>
207+
{
208+
{ ChangeKey.Count, 100 },
209+
// Volume is intentionally missing
210+
// although it is accessed in the format string
211+
{ ChangeKey.Name, "ABCD" },
212+
}
213+
};
214+
// Use nullable syntax to avoid exceptions for selectors
215+
// that cannot be resolved with the DictionarySource:
216+
// 'Volume' may be missing, but 'Changes' is expected to be present
217+
var result =
218+
sf.Format("{Changes.Count};{Changes?.Volume};{Changes.Name}", obj);
219+
Assert.Multiple(() =>
220+
{
221+
Assert.That(result, Is.EqualTo("100;;ABCD"));
222+
});
223+
224+
// Now add the missing key and verify that it is properly resolved
225+
obj.Changes.Add(ChangeKey.Volume, 200);
226+
Assert.That(sf.Format("{Changes.Count};{Changes?.Volume};{Changes.Name}", obj),
227+
Is.EqualTo("100;200;ABCD"));
228+
}
229+
230+
[Test]
231+
public void Dictionary_NullableOperator_MissingKey_OnWrongSelector()
232+
{
233+
// Test for GitHub issue #522
234+
var sf = Smart.CreateDefaultSmartFormat();
235+
236+
var obj = new { Changes = new Dictionary<string, object>
237+
{
238+
{ "Count", 100 },
239+
// Volume is intentionally missing
240+
// although it is accessed in the format string
241+
{ "Name", "ABCD" },
242+
}
243+
};
244+
245+
// The nullable operator must only be applied
246+
// to the selector that is expected to be missing or
247+
// a preceding selector in the chain that is expected to be missing
248+
// Here: 'Changes' is expected to be present, but 'Volume' is missing,
249+
// so the nullable operator must be applied to 'Volume' instead of after 'Volume'
250+
Assert.Throws<FormattingException>(() =>
251+
sf.Format("{Changes.Count};{Changes.Volume?.Anything};{Changes.Name}", obj));
252+
}
253+
162254
[Test]
163255
public void Generic_Dictionary_String_String()
164256
{
@@ -195,28 +287,6 @@ public void IReadOnlyDictionary_With_String_Key()
195287
Assert.That(result, Is.EqualTo("12three"));
196288
}
197289

198-
[Test]
199-
public void IReadOnlyDictionary_Cache_Should_Store_Types_It_Cannot_Handle()
200-
{
201-
var dictSource = new DictionarySource { IsIReadOnlyDictionarySupported = true };
202-
var kvp = new KeyValuePair<string, object?>("One", 1);
203-
var smart = new SmartFormatter()
204-
.AddExtensions(new DefaultSource(), dictSource, new KeyValuePairSource())
205-
.AddExtensions(new DefaultFormatter());
206-
var result = smart.Format("{One}", kvp);
207-
208-
Assert.Multiple(() =>
209-
{
210-
Assert.That(result, Is.EqualTo("1"));
211-
Assert.That(dictSource.RoDictionaryTypeCache.Keys, Has.Count.EqualTo(1));
212-
});
213-
Assert.Multiple(() =>
214-
{
215-
Assert.That(dictSource.RoDictionaryTypeCache.Keys.First(), Is.EqualTo(typeof(KeyValuePair<string, object?>)));
216-
Assert.That(dictSource.RoDictionaryTypeCache.Values.First(), Is.Null);
217-
});
218-
}
219-
220290
public class CustomReadOnlyDictionary<TKey, TValue> : IReadOnlyDictionary<TKey, TValue?>
221291
{
222292
private readonly IDictionary<TKey, TValue?> _dictionary;

src/SmartFormat/Core/Extensions/Source.cs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,31 @@ public virtual void Initialize(SmartFormatter smartFormatter)
3333
}
3434

3535
/// <summary>
36-
/// Checks if any of the <see cref="Placeholder"/>'s <see cref="Placeholder.Selectors"/> has nullable <c>?</c> as their first operator.
36+
/// Checks whether any of the <see cref="Placeholder"/>'s <see cref="Placeholder.Selectors"/>,
37+
/// up to and including the selector at <see cref="ISelectorInfo.SelectorIndex"/>,
38+
/// has the nullable <c>?</c> as the first character of its operator.
3739
/// </summary>
38-
/// <param name="selectorInfo"></param>
40+
/// <param name="selectorInfo">The <see cref="ISelectorInfo"/> for the selector currently being evaluated.</param>
3941
/// <returns>
40-
/// <see langword="true"/>, any of the <see cref="Placeholder"/>'s <see cref="Placeholder.Selectors"/> has nullable <c>?</c> as their first operator.
42+
/// <see langword="true"/> if any <see cref="Placeholder.Selectors"/> with a
43+
/// <see cref="Parsing.Selector.SelectorIndex"/> less than or equal to <see cref="ISelectorInfo.SelectorIndex"/>
44+
/// has the nullable <c>?</c> as the first character of its operator; otherwise <see langword="false"/>.
4145
/// </returns>
4246
/// <remarks>
43-
/// The nullable operator '?' can be followed by a dot (like '?.') or a square brace (like '.[')
47+
/// Only selectors up to the current one are considered, so a nullable operator on a later selector
48+
/// in the same <see cref="Placeholder"/> does not influence the evaluation of earlier selectors.
49+
/// The nullable operator <c>?</c> must be followed by a dot (e.g. <c>?.</c>) or a square bracket (e.g. <c>?[</c>).
4450
/// </remarks>
45-
private bool HasNullableOperator(ISelectorInfo selectorInfo)
51+
protected virtual bool HasNullableOperator(ISelectorInfo selectorInfo)
4652
{
4753
if (_smartSettings != null && selectorInfo.Placeholder != null)
4854
{
4955
#pragma warning disable S3267 // Don't use LINQ in favor of less GC
5056
foreach (var s in selectorInfo.Placeholder.Selectors)
5157
{
52-
if (s.OperatorLength > 1 && s.BaseString[s.OperatorStartIndex] == ParserSettings.NullableOperator)
58+
if (s.SelectorIndex <= selectorInfo.SelectorIndex
59+
&& s.OperatorLength > 1
60+
&& s.BaseString[s.OperatorStartIndex] == ParserSettings.NullableOperator)
5361
return true;
5462
}
5563
#pragma warning restore S3267 // Restore: Loops should be simplified with "LINQ" expressions

src/SmartFormat/Extensions/DictionarySource.cs

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
using System;
66
using System.Collections;
7+
using System.Collections.Concurrent;
78
using System.Collections.Generic;
89
using System.Reflection;
910
using SmartFormat.Core.Extensions;
@@ -27,33 +28,46 @@ public override bool TryEvaluateSelector(ISelectorInfo selectorInfo)
2728
{
2829
var current = selectorInfo.CurrentValue;
2930
if (TrySetResultForNullableOperator(selectorInfo)) return true;
30-
31-
if (current is null) return false;
31+
32+
if (current == null) return false;
33+
var dictionaryType = GetDictionaryType(current);
34+
if (dictionaryType == DictionaryType.None) return false;
3235

3336
var selector = selectorInfo.SelectorText;
3437
var comparison = selectorInfo.FormatDetails.Settings.GetCaseSensitivityComparison();
3538

3639
// Try to get the selector value for IDictionary
37-
if (TryGetIDictionaryValue(current, selector, comparison, out var value))
40+
if (dictionaryType == DictionaryType.NonGeneric
41+
&& TryGetIDictionaryValue(current, selector, comparison, out var value))
3842
{
3943
selectorInfo.Result = value;
4044
return true;
4145
}
4246

4347
// Try to get the selector value for Dictionary<,> and dynamics (ExpandoObject)
44-
if (TryGetGenericDictionaryValue(current, selector, comparison, out value))
48+
if (dictionaryType == DictionaryType.Generic
49+
&& TryGetGenericDictionaryValue(current, selector, comparison, out value))
4550
{
4651
selectorInfo.Result = value;
4752
return true;
4853
}
4954

5055
// Try to get the selector value for IReadOnlyDictionary<,>
51-
if (IsIReadOnlyDictionarySupported && TryGetReadOnlyDictionaryValue(current, selector,
52-
comparison, out value))
56+
if (dictionaryType == DictionaryType.ReadOnly && IsIReadOnlyDictionarySupported
57+
&& TryGetReadOnlyDictionaryValue(current, selector, comparison, out value))
5358
{
5459
selectorInfo.Result = value;
5560
return true;
5661
}
62+
63+
// No matching key found in a dictionary, but if the selector
64+
// has a nullable operator, we set the result to null
65+
// instead of leaving it as not found.
66+
if (HasNullableOperator(selectorInfo))
67+
{
68+
selectorInfo.Result = null;
69+
return true;
70+
}
5771

5872
return false;
5973
}
@@ -144,13 +158,6 @@ private bool TryGetDictionaryProperties(Type type, out (PropertyInfo KeyProperty
144158
if (RoDictionaryTypeCache.TryGetValue(type, out propertyTuple))
145159
return propertyTuple != null;
146160

147-
if (!IsIReadOnlyDictionary(type))
148-
{
149-
// don't check the type again, although it's not a IReadOnlyDictionary
150-
RoDictionaryTypeCache[type] = null;
151-
return false;
152-
}
153-
154161
// get Key and Item properties of the dictionary
155162
propertyTuple = (type.GetProperty(nameof(IDictionary.Keys)), type.GetProperty("Item"))!;
156163

@@ -160,19 +167,45 @@ private bool TryGetDictionaryProperties(Type type, out (PropertyInfo KeyProperty
160167
return true;
161168
}
162169

170+
private static readonly ConcurrentDictionary<Type, bool> RoDictionaryTypeBoolCache = new();
171+
163172
private static bool IsIReadOnlyDictionary(Type type)
164173
{
174+
if (RoDictionaryTypeBoolCache.TryGetValue(type, out var cached))
175+
return cached;
176+
165177
// No Linq for less garbage
178+
var result = false;
166179
foreach (var typeInterface in type.GetInterfaces())
167180
{
168181
if (typeInterface == typeof(IReadOnlyDictionary<,>) ||
169182
(typeInterface.IsGenericType
170183
&& typeInterface.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)))
171-
return true;
184+
{
185+
result = true;
186+
break;
187+
}
172188
}
173189

174-
return false;
190+
RoDictionaryTypeBoolCache[type] = result;
191+
return result;
175192
}
176193

177194
#endregion
195+
196+
internal enum DictionaryType
197+
{
198+
None,
199+
NonGeneric,
200+
Generic,
201+
ReadOnly
202+
}
203+
204+
private DictionaryType GetDictionaryType(object current)
205+
{
206+
if (current is IDictionary) return DictionaryType.NonGeneric;
207+
if (current is IDictionary<string, object?>) return DictionaryType.Generic;
208+
if (IsIReadOnlyDictionarySupported && IsIReadOnlyDictionary(current.GetType())) return DictionaryType.ReadOnly;
209+
return DictionaryType.None;
210+
}
178211
}

0 commit comments

Comments
 (0)