Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DictionarySource can evaluate IReadOnlyDictionary<TKey,TValue> #353

Merged
merged 5 commits into from
Sep 25, 2023
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
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
}