Skip to content

Commit

Permalink
DictionarySource can evaluate IReadOnlyDictionary<TKey,TValue> (#353)
Browse files Browse the repository at this point in the history
Closes #349
* DictionarySource can evaluate IReadOnlyDictionary<TKey,TValue>
* DictionarySource.IsIReadOnlyDictionarySupported must be set to true (default is false)
  • Loading branch information
axunonb authored Sep 25, 2023
1 parent 6a395ec commit 746ef9d
Show file tree
Hide file tree
Showing 2 changed files with 210 additions and 17 deletions.
98 changes: 96 additions & 2 deletions src/SmartFormat.Tests/Extensions/DictionarySourceTests.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
using System.Collections.Generic;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using NUnit.Framework;
using SmartFormat.Core.Settings;
using SmartFormat.Extensions;
using SmartFormat.Tests.TestUtils;
using SmartFormat.Utilities;

namespace SmartFormat.Tests.Extensions;

Expand Down Expand Up @@ -156,6 +160,96 @@ public void Dictionary_Dot_Notation_Nullable()
Assert.That(result, Is.EqualTo(expected));
}

[Test]
public void Generic_Dictionary_String_String()
{
var dict = new Dictionary<string, string> { { "Name", "Joe" } };
var smart = new SmartFormatter()
.AddExtensions(new DefaultSource(), new DictionarySource())
.AddExtensions(new DefaultFormatter());
var result = smart.Format("{Name}", dict);

Assert.That(result, Is.EqualTo("Joe"));
}

[Test]
public void IReadOnlyDictionary_With_IConvertible_Key()
{
var roDict = new CustomReadOnlyDictionary<IConvertible, object?>(new Dictionary<IConvertible, object?> { { 1, 1 }, { "Two", 2 }, { "Three", "three" }, });
var smart = new SmartFormatter()
.AddExtensions(new DefaultSource(), new DictionarySource { IsIReadOnlyDictionarySupported = true })
.AddExtensions(new DefaultFormatter());
var result = smart.Format("{1}{Two}{Three}", roDict);

Assert.That(result, Is.EqualTo("12three"));
}

[Test]
public void IReadOnlyDictionary_With_String_Key()
{
var roDict = new CustomReadOnlyDictionary<string, object?>(new Dictionary<string, object?> { { "One", 1 }, { "Two", 2 }, { "Three", "three" }, });
var smart = new SmartFormatter()
.AddExtensions(new DefaultSource(), new DictionarySource { IsIReadOnlyDictionarySupported = true })
.AddExtensions(new DefaultFormatter());
var result = smart.Format("{One}{Two}{Three}", roDict);

Assert.That(result, Is.EqualTo("12three"));
}

[Test]
public void IReadOnlyDictionary_Cache_Should_Store_Types_It_Cannot_Handle()
{
var dictSource = new DictionarySource { IsIReadOnlyDictionarySupported = true };
var kvp = new KeyValuePair<string, object?>("One", 1);
var smart = new SmartFormatter()
.AddExtensions(new DefaultSource(), dictSource, new KeyValuePairSource())
.AddExtensions(new DefaultFormatter());
var result = smart.Format("{One}", kvp);

Assert.That(result, Is.EqualTo("1"));
Assert.That(dictSource.RoDictionaryTypeCache.Keys.Count, Is.EqualTo(1));
Assert.That(dictSource.RoDictionaryTypeCache.Keys.First(), Is.EqualTo(typeof(KeyValuePair<string, object?>)));
Assert.That(dictSource.RoDictionaryTypeCache.Values.First(), Is.Null);
}

public class CustomReadOnlyDictionary<TKey, TValue> : IReadOnlyDictionary<TKey, TValue?>
{
private readonly IDictionary<TKey, TValue?> _dictionary;

public CustomReadOnlyDictionary(IDictionary<TKey, TValue?> dictionary)
{
_dictionary = dictionary;
}

public IEnumerator<KeyValuePair<TKey, TValue?>> GetEnumerator()
{
return _dictionary.GetEnumerator();
}

IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}

public int Count => _dictionary.Count;

public bool ContainsKey(TKey key)
{
return _dictionary.ContainsKey(key);
}

public bool TryGetValue(TKey key, out TValue? value)
{
return _dictionary.TryGetValue(key, out value);
}

public TValue? this[TKey key] => _dictionary[key];

public IEnumerable<TKey> Keys => _dictionary.Keys;

public IEnumerable<TValue?> Values => _dictionary.Values;
}

public class Address
{
public CityDetails? City { get; set; } = new();
Expand Down Expand Up @@ -202,4 +296,4 @@ public Dictionary<string, string> ToDictionary()
}
}
}
}
}
129 changes: 114 additions & 15 deletions src/SmartFormat/Extensions/DictionarySource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@
// Copyright SmartFormat Project maintainers and contributors.
// Licensed under the MIT license.

using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Reflection;
using SmartFormat.Core.Extensions;
using SmartFormat.Core.Settings;

namespace SmartFormat.Extensions;

/// <summary>
/// Class to evaluate sources of types <see cref="IDictionary"/>,
/// generic <see cref="IDictionary{TKey,TValue}"/> and dynamic <see cref="System.Dynamic.ExpandoObject"/>.
/// generic <see cref="IDictionary{TKey,TValue}"/>, dynamic <see cref="System.Dynamic.ExpandoObject"/>,
/// and <see cref="IReadOnlyDictionary{TKey,TValue}"/>.
/// Include this source, if any of these types shall be used.
/// <para/>
/// For support of <see cref="IReadOnlyDictionary{TKey,TValue}"/> <see cref="IsIReadOnlyDictionarySupported"/> must be set to <see langword="true"/>.
/// This uses Reflection and is slower than the other types despite caching.
/// </summary>
public class DictionarySource : Source
{
Expand All @@ -25,28 +33,119 @@ public override bool TryEvaluateSelector(ISelectorInfo selectorInfo)

var selector = selectorInfo.SelectorText;

// See if current is an IDictionary and contains the selector:
// See if current is an IDictionary (including generic dictionaries) and contains the selector:
if (current is IDictionary rawDict)
foreach (DictionaryEntry entry in rawDict)
{
var key = entry.Key as string ?? entry.Key.ToString()!;

if (key.Equals(selector, selectorInfo.FormatDetails.Settings.GetCaseSensitivityComparison()))
{
selectorInfo.Result = entry.Value;
return true;
}
if (!key.Equals(selector, selectorInfo.FormatDetails.Settings.GetCaseSensitivityComparison()))
continue;

selectorInfo.Result = entry.Value;
return true;
}

// this check is for dynamics and generic dictionaries
if (current is not IDictionary<string, object?> dict) return false;
// This check is for dynamics (ExpandoObject):
if (current is IDictionary<string, object?> dict)
{
// We're using the CaseSensitivityType of the dictionary,
// not the one from Settings.GetCaseSensitivityComparison().
// This is faster and has less GC than Key.Equals(...)
if (!dict.TryGetValue(selector, out var val)) return false;

selectorInfo.Result = val;
return true;
}

// This is for IReadOnlyDictionary<,> using Reflection
if (IsIReadOnlyDictionarySupported && TryGetDictionaryValue(current, selector,
selectorInfo.FormatDetails.Settings.GetCaseSensitivityComparison(), out var value))
{
selectorInfo.Result = value;
return true;
}

return false;
}

#region *** IReadOnlyDictionary<,> ***

/// <summary>
/// Gets the type cache <see cref="IDictionary{TKey,TValue}"/> for <see cref="IReadOnlyDictionary{TKey,TValue}"/>.
/// It could e.g. be pre-filled or cleared in a derived class.
/// </summary>
/// <remarks>
/// Note: For reading, <see cref="Dictionary{TKey, TValue}"/> and <see cref="ConcurrentDictionary{TKey,TValue}"/> perform equally.
/// For writing, <see cref="ConcurrentDictionary{TKey, TValue}"/> is slower with more garbage (tested under net5.0).
/// </remarks>
protected internal readonly IDictionary<Type, (PropertyInfo, PropertyInfo)?> RoDictionaryTypeCache =
SmartSettings.IsThreadSafeMode
? new ConcurrentDictionary<Type, (PropertyInfo, PropertyInfo)?>()
: new Dictionary<Type, (PropertyInfo, PropertyInfo)?>();

/// <summary>
/// Gets or sets, whether the <see cref="IReadOnlyDictionary{TKey,TValue}"/> interface should be supported.
/// Although caching is used, this is still slower than the other types.
/// Default is <see langword="false"/>.
/// </summary>
public bool IsIReadOnlyDictionarySupported { get; set; } = false;

private bool TryGetDictionaryValue(object obj, string key, StringComparison comparison, out object? value)
{
value = null;

if (!TryGetDictionaryProperties(obj.GetType(), out var propertyTuple)) return false;

var keys = (IEnumerable) propertyTuple!.Value.KeyProperty.GetValue(obj);

foreach (var k in keys)
{
if (!k.ToString().Equals(key, comparison))
continue;

value = propertyTuple.Value.ItemProperty.GetValue(obj, new [] { k });
return true;
}

// We're using the CaseSensitivityType of the dictionary,
// not the one from Settings.GetCaseSensitivityComparison().
// This is faster and has less GC than Key.Equals(...)
if (!dict.TryGetValue(selector, out var val)) return false;
return false;
}

private bool TryGetDictionaryProperties(Type type, out (PropertyInfo KeyProperty, PropertyInfo ItemProperty)? propertyTuple)
{
// try to get the properties from the cache
if (RoDictionaryTypeCache.TryGetValue(type, out propertyTuple))
return propertyTuple != null;

if (!IsIReadOnlyDictionary(type))
{
// don't check the type again, although it's not a IReadOnlyDictionary
RoDictionaryTypeCache[type] = null;
return false;
}

// get Key and Item properties of the dictionary
propertyTuple = (type.GetProperty(nameof(IDictionary.Keys)), type.GetProperty("Item"));

System.Diagnostics.Debug.Assert(propertyTuple.Value.KeyProperty != null && propertyTuple.Value.ItemProperty != null, "Key and Item properties must not be null");

selectorInfo.Result = val;
RoDictionaryTypeCache[type] = propertyTuple;
return true;
}
}

private static bool IsIReadOnlyDictionary(Type type)
{
// No Linq for less garbage
foreach (var typeInterface in type.GetInterfaces())
{
if (typeInterface == typeof(IReadOnlyDictionary<,>) ||
(typeInterface.IsGenericType
&& typeInterface.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)))
return true;
}

return false;
}

#endregion
}

0 comments on commit 746ef9d

Please sign in to comment.