Skip to content

Instantly share code, notes, and snippets.

@Antaris
Last active October 18, 2016 08:32
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Antaris/ca0f271311fdb84ef3a8db95b149a307 to your computer and use it in GitHub Desktop.
Save Antaris/ca0f271311fdb84ef3a8db95b149a307 to your computer and use it in GitHub Desktop.
Early work on custom type conversions for Entity Framework
public class BlogDbContext : DbContext
{
public DbSet<BlogPost> Posts { get; set; }
}
public class BlogPost
{
public int Id { get; set; }
public TagSet Tags { get; set; }
}
public class CustomEntityMaterializerSource : EntityMaterializerSource
{
public CustomEntityMaterializerSource(IMemberMapper memberMapper) : base(memberMapper)
{
}
public override Expression CreateReadValueExpression(Expression valueBuffer, Type type, int index)
{
if (type == typeof(TagSet))
{
return Expression.Convert(base.CreateReadValueExpression(valueBuffer, typeof(string), index), typeof(TagSet));
}
return base.CreateReadValueExpression(valueBuffer, type, index);
}
}
public class CustomSqlServerCompositeMethodCallTranslator : SqlServerCompositeMethodCallTranslator
{
private static readonly IMethodCallTranslator[] _methodCallTranslators =
{
new TagSetContainsTranslator()
};
public CustomSqlServerCompositeMethodCallTranslator(ILogger<SqlServerCompositeMethodCallTranslator> logger) : base(logger)
{
AddTranslators(_methodCallTranslators);
}
}
public class CustomSqlServerTypeMapper : SqlServerTypeMapper
{
private readonly RelationalTypeMapping _tags
= new SqlServerMaxLengthMapping("nvarchar(1000)", typeof(TagSet), dbType: System.Data.DbType.String, unicode: true, size: 1000);
protected override RelationalTypeMapping FindCustomMapping(IProperty property)
{
if (property.ClrType == typeof(TagSet))
{
return _tags;
}
return base.FindCustomMapping(property);
}
public override RelationalTypeMapping FindMapping(Type clrType)
{
if (clrType == typeof(TagSet))
{
return _tags;
}
return base.FindMapping(clrType);
}
}
services.AddSingleton<IRelationalTypeMapper, CustomSqlServerTypeMapper>();
services.AddSingleton<SqlServerTypeMapper, CustomSqlServerTypeMapper>();
services.AddSingleton<IEntityMaterializerSource, CustomEntityMaterializerSource>();
services.AddScoped<SqlServerCompositeMethodCallTranslator, CustomSqlServerCompositeMethodCallTranslator>();
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
/// <summary>
/// Represents a collection of tags.
/// </summary>
/// <remarks>
/// MA - We store tags in databases as flat strings - this is more efficient than having a complex many-to-many relationship for our target entities.
/// On our domain models, we need to work with collections (general usage, but also model binding), which means we need to translate from the flat
/// tag string to a collection. Instead of a CSV string, we use double-pipe delimiting strings as it becomes possible to use a [Tags] field in a SQL
/// predicate, e.g. WHERE [Tags] LIKE '%|Hello|%'. It also means it is easy to remove and add tags because of the pipe boundary in a tag value.
/// Given the following tag string: |Matt||Martin||Andrew||David||Stuart| would yield 5 tags. Calling <see cref="ToString"/> will yield a flat list of tags
/// for the current tag set.
/// </remarks>
public struct TagSet : ICollection<string>, IEquatable<TagSet>, IEquatable<string>
{
private string _tags;
private Lazy<HashSet<string>> _setThunk;
private bool _modified;
private bool _hasValue;
/// <summary>
/// Initialises a new instance of <see cref="TagSet"/>
/// </summary>
/// <param name="tags">The string representation of tags.</param>
public TagSet(string tags)
{
if (tags != null && tags.Length == 0)
{
tags = null;
}
_tags = tags;
_setThunk = new Lazy<HashSet<string>>(() => ResolveSet(tags));
_modified = true;
_hasValue = true;
}
/// <summary>
/// Initialises a new instance of <see cref="TagSet"/>
/// </summary>
/// <param name="other">The other tag set.</param>
public TagSet(TagSet other)
{
_tags = other._tags;
_setThunk = new Lazy<HashSet<string>>(() => ResolveSet(null));
_modified = other.Modified;
_hasValue = other.HasValue;
if (other.HasValue && other.Modified && other._setThunk.IsValueCreated)
{
foreach (string tag in other)
{
_setThunk.Value.Add(tag);
}
}
}
/// <inheritdoc />
public int Count => _setThunk.Value.Count;
/// <summary>
/// Gets whether the current tag set has a value.
/// </summary>
public bool HasValue => _hasValue;
/// <summary>
/// Gets whether the tag set has been modified.
/// </summary>
public bool Modified => _modified;
/// <inheritdoc />
public bool IsReadOnly => false;
/// <inheritdoc />
public void Add(string item)
{
if (!string.IsNullOrEmpty(item))
{
_setThunk.Value.Add(item);
_modified = true;
}
}
/// <inheritdoc />
public void Clear()
{
_tags = null;
_setThunk = new Lazy<HashSet<string>>(() => ResolveSet(null));
_modified = true;
}
bool ICollection<string>.Contains(string item) => Exists(item);
/// <summary>
/// Determines if a tag exists in the current tag set.
/// </summary>
/// <param name="item">The tag item</para>
/// <returns>True if the tag exists in the current set, otherwise false.</returns>
public bool Exists(string item)
{
if (string.IsNullOrEmpty(item))
{
return false;
}
if (_setThunk.IsValueCreated)
{
return _setThunk.Value.Contains(item);
}
var culture = CultureInfo.CurrentCulture;
return culture.CompareInfo.IndexOf(_tags, item, CompareOptions.IgnoreCase) >= 0;
}
/// <inheritdoc />
public void CopyTo(string[] array, int arrayIndex) => _setThunk.Value.CopyTo(array, arrayIndex);
/// <inheritdoc />
public bool Remove(string item)
{
if (string.IsNullOrEmpty(item) || string.IsNullOrEmpty(_tags))
{
return false;
}
return _setThunk.Value.Remove(item);
}
/// <inheritdoc />
public IEnumerator<string> GetEnumerator() => _setThunk.Value.GetEnumerator();
public override int GetHashCode() => (ToString()?.GetHashCode()).GetValueOrDefault(0);
public override bool Equals(object obj)
{
if (obj is string)
{
return Equals((string)obj);
}
if (obj is TagSet)
{
return Equals((TagSet)obj);
}
return false;
}
public bool Equals(TagSet tags)
{
if (!tags.HasValue && !HasValue)
{
return true;
}
if (HasValue)
{
return Equals(tags.ToString());
}
return tags.Equals(ToString());
}
public bool Equals(string tags)
{
if (_modified)
{
if (_setThunk.IsValueCreated)
{
return ToString().Equals(tags);
}
}
return string.Equals(_tags, tags, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Resolves a hash set of tags from the given tag string.
/// </summary>
/// <param name="tags">The tag string.</param>
/// <returns>The hash set of tags.</returns>
private static HashSet<string> ResolveSet(string tags)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrEmpty(tags))
{
tags = tags.Trim('|');
foreach (string tag in tags.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries))
{
set.Add(tag);
}
}
return set;
}
/// <inheritdoc />
public override string ToString()
{
if (!_hasValue)
{
return null;
}
if (_modified)
{
if (_setThunk.IsValueCreated)
{
if (_setThunk.Value.Count == 0)
{
return null;
}
return $"|{(string.Join("||", _setThunk.Value))}|";
}
}
return _tags;
}
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
/// <summary>
/// Converts from a <see cref="TagSet"/> to a string.
/// </summary>
/// <param name="tags">The tag string.</param>
public static explicit operator string(TagSet tags) => tags.ToString();
/// <summary>
/// Converts from a <see cref="string"/> to a tag set.
/// </summary>
/// <param name="tags">The tag set.</param>
public static explicit operator TagSet(string tags) => new TagSet(tags);
}
public class TagSetContainsTranslator : IMethodCallTranslator
{
private static readonly MethodInfo _methodInfo
= typeof(TagSet).GetRuntimeMethod(nameof(TagSet.Exists), new[] { typeof(string) });
private static readonly MethodInfo _concat
= typeof(string).GetRuntimeMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) });
public Expression Translate(MethodCallExpression methodCallExpression)
{
return ReferenceEquals(methodCallExpression.Method, _methodInfo)
? new LikeExpression(
methodCallExpression.Object,
Expression.Add(
Expression.Add(
Expression.Constant("%|", typeof(string)), methodCallExpression.Arguments[0], _concat),
Expression.Constant("|%", typeof(string)),
_concat))
: null;
}
}
@Antaris
Copy link
Author

Antaris commented Oct 11, 2016

Example:

using (var context = new BlogContext())
{
  var posts = context.Posts.Where(bp => bp.Tags.Exists("Hello"))
  // Translated into WHERE [Tags] LIKE '%|Hello%'

  var post = posts.FirstOrDefault();
  if (post != null)
  {
    // TagSet isn't immutable so state manager doesnt pick up changes because we're mutating the same instance.
    // Create a new tag set.
    post.Tags = new TagSet(post.Tags);
    post.Tags.Add("World");

    context.SaveChanges();
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment