diff --git a/EFCore.CommonTools.Benchmarks/Context.cs b/EFCore.CommonTools.Benchmarks/Context.cs new file mode 100644 index 0000000..12a3cda --- /dev/null +++ b/EFCore.CommonTools.Benchmarks/Context.cs @@ -0,0 +1,72 @@ +#if EF_CORE +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntityFrameworkCore.CommonTools.Benchmarks +#else +using System.Data.Common; +using System.Data.Entity; +using System.Data.SQLite; + +namespace EntityFramework.CommonTools.Benchmarks +#endif +{ + public class Context : DbContext + { + public DbSet Users { get; set; } + public DbSet Posts { get; set; } + +#if EF_CORE + public Context(SqliteConnection connection) + : base(new DbContextOptionsBuilder().UseSqlite(connection).Options) + { + } + + public static SqliteConnection CreateConnection() + { + var connection = new SqliteConnection("data source=:memory:"); + + connection.Open(); + + using (var context = new Context(connection)) + { + context.Database.EnsureCreated(); + } + + return connection; + } +#else + public Context(DbConnection connection) + : base(connection, false) + { + Database.SetInitializer(null); + } + + public static DbConnection CreateConnection() + { + var connection = new SQLiteConnection("Data Source=:memory:;Version=3;New=True;"); + + connection.Open(); + + using (var context = new Context(connection)) + { + context.Database.ExecuteSqlCommand(@" + CREATE TABLE Users ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Login TEXT + ); + + CREATE TABLE Posts ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + AuthorId INTEGER, + Date DATETIME, + Title TEXT, + Content TEXT + );"); + } + + return connection; + } +#endif + } +} diff --git a/EFCore.CommonTools.Benchmarks/Entities.cs b/EFCore.CommonTools.Benchmarks/Entities.cs new file mode 100644 index 0000000..dff6f24 --- /dev/null +++ b/EFCore.CommonTools.Benchmarks/Entities.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Benchmarks +#else +namespace EntityFramework.CommonTools.Benchmarks +#endif +{ + public class User + { + public int Id { get; set; } + public string Login { get; set; } + + [InverseProperty(nameof(Post.Author))] + public virtual ICollection Posts { get; set; } = new HashSet(); + } + + public class Post + { + public int Id { get; set; } + public int AuthorId { get; set; } + public DateTime Date { get; set; } + public string Title { get; set; } + public string Content { get; set; } + + [ForeignKey(nameof(AuthorId))] + public virtual User Author { get; set; } + } + + public static class Extenisons + { + [Expandable] + public static IQueryable FilterToday(this IEnumerable posts) + { + DateTime today = DateTime.Now.Date; + + return posts.AsQueryable().Where(p => p.Date > today); + } + } +} diff --git a/EFCore.CommonTools.Benchmarks/EntityFrameworkCore.CommonTools.Benchmarks.csproj b/EFCore.CommonTools.Benchmarks/EntityFrameworkCore.CommonTools.Benchmarks.csproj new file mode 100644 index 0000000..7b1bbe8 --- /dev/null +++ b/EFCore.CommonTools.Benchmarks/EntityFrameworkCore.CommonTools.Benchmarks.csproj @@ -0,0 +1,26 @@ + + + + Exe + netcoreapp2.0 + + + + TRACE;DEBUG;EF_CORE + + + + TRACE;EF_CORE + + + + + + + + + + + + + \ No newline at end of file diff --git a/EFCore.CommonTools.Benchmarks/Program.cs b/EFCore.CommonTools.Benchmarks/Program.cs new file mode 100644 index 0000000..81f361a --- /dev/null +++ b/EFCore.CommonTools.Benchmarks/Program.cs @@ -0,0 +1,20 @@ +using BenchmarkDotNet.Running; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Benchmarks +#else +namespace EntityFramework.CommonTools.Benchmarks +#endif + +{ + class Program + { + static void Main(string[] args) + { + new BenchmarkSwitcher(new[] { + typeof(EnumerableQueryBenchmark), + typeof(DatabaseQueryBenchmark), + }).Run(args); + } + } +} diff --git a/EFCore.CommonTools.Benchmarks/Querying/DatabaseQueryBenchmark.cs b/EFCore.CommonTools.Benchmarks/Querying/DatabaseQueryBenchmark.cs new file mode 100644 index 0000000..3ad4b65 --- /dev/null +++ b/EFCore.CommonTools.Benchmarks/Querying/DatabaseQueryBenchmark.cs @@ -0,0 +1,63 @@ +using System; +using System.Linq; +using BenchmarkDotNet.Attributes; + +#if EF_CORE +using Microsoft.Data.Sqlite; + +namespace EntityFrameworkCore.CommonTools.Benchmarks +#else +using System.Data.Common; + +namespace EntityFramework.CommonTools.Benchmarks +#endif +{ + public class DatabaseQueryBenchmark + { +#if EF_CORE + private readonly SqliteConnection _connection = Context.CreateConnection(); +#else + private readonly DbConnection _connection = Context.CreateConnection(); +#endif + + [Benchmark(Baseline = true)] + public object RawQuery() + { + using (var context = new Context(_connection)) + { + DateTime today = DateTime.Now.Date; + + return context.Users + .Where(u => u.Posts.Any(p => p.Date > today)) + .FirstOrDefault(); + } + } + + [Benchmark] + public object ExpandableQuery() + { + using (var context = new Context(_connection)) + { + return context.Users + .AsExpandable() + .Where(u => u.Posts.FilterToday().Any()) + .ToList(); + } + } + + private readonly Random _random = new Random(); + + [Benchmark] + public object NotCachedQuery() + { + using (var context = new Context(_connection)) + { + int[] postIds = new[] { _random.Next(), _random.Next() }; + + return context.Users + .Where(u => u.Posts.Any(p => postIds.Contains(p.Id))) + .ToList(); + } + } + } +} diff --git a/EFCore.CommonTools.Benchmarks/Querying/EnumerableQueryBenchmark.cs b/EFCore.CommonTools.Benchmarks/Querying/EnumerableQueryBenchmark.cs new file mode 100644 index 0000000..7a8b5b8 --- /dev/null +++ b/EFCore.CommonTools.Benchmarks/Querying/EnumerableQueryBenchmark.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using BenchmarkDotNet.Attributes; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Benchmarks +#else +namespace EntityFramework.CommonTools.Benchmarks +#endif +{ + public class EnumerableQueryBenchmark + { + [Benchmark(Baseline = true)] + public object RawQuery() + { + DateTime today = DateTime.Now.Date; + + return Enumerable.Empty() + .AsQueryable() + .Where(u => u.Posts.Any(p => p.Date > today)) + .ToList(); + } + + [Benchmark] + public object ExpandableQuery() + { + return Enumerable.Empty() + .AsQueryable() + .AsExpandable() + .Where(u => u.Posts.FilterToday().Any()) + .ToList(); + } + } +} diff --git a/EFCore.CommonTools.Tests/AssertExtensions.cs b/EFCore.CommonTools.Tests/AssertExtensions.cs new file mode 100644 index 0000000..0f009f1 --- /dev/null +++ b/EFCore.CommonTools.Tests/AssertExtensions.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Linq.Expressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests +#endif +{ + public static class AssertExtensions + { + private class Visitor : ExpressionVisitor + { + public List Expressions = new List(); + + public override Expression Visit(Expression node) + { + Expressions.Add(node); + return base.Visit(node); + } + } + + public static void MethodCallsAreMatch(this Assert assert, Expression expexted, Expression actual) + { + var expectedVisitor = new Visitor(); + expectedVisitor.Visit(expexted); + var expectedList = expectedVisitor.Expressions; + + var actualVisitor = new Visitor(); + actualVisitor.Visit(actual); + var actualList = actualVisitor.Expressions; + + Assert.AreEqual(expectedList.Count, actualList.Count); + + for (int i = 0; i < expectedList.Count; i++) + { + if (expectedList[i] != null && expectedList[i].NodeType == ExpressionType.Call) + { + Assert.AreEqual(ExpressionType.Call, actualList[i].NodeType); + + var expectedCall = (MethodCallExpression)expectedList[i]; + var actualCall = (MethodCallExpression)actualList[i]; + + Assert.AreEqual(expectedCall.Method, actualCall.Method); + } + } + } + } +} diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/AuditableEntitiesTests.cs b/EFCore.CommonTools.Tests/Auditing/AuditableEntitiesTests.cs similarity index 84% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/AuditableEntitiesTests.cs rename to EFCore.CommonTools.Tests/Auditing/AuditableEntitiesTests.cs index 7706b90..132e461 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/AuditableEntitiesTests.cs +++ b/EFCore.CommonTools.Tests/Auditing/AuditableEntitiesTests.cs @@ -1,15 +1,11 @@ -#if EF_CORE -using System; -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests -#else using System; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFramework.ChangeTrackingExtensions.Tests +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests #endif { [TestClass] @@ -30,7 +26,7 @@ public void TestAuditableEntitiesGeneric() context.SaveChanges(author.Id); context.Entry(post).Reload(); - Assert.AreEqual(DateTime.UtcNow.Date, post.CreatedUtc.Date); + Assert.AreEqual(DateTime.UtcNow.Date, post.CreatedUtc.ToUniversalTime().Date); Assert.AreEqual(author.Id, post.CreatorUserId); // update @@ -40,7 +36,7 @@ public void TestAuditableEntitiesGeneric() context.Entry(post).Reload(); Assert.IsNotNull(post.UpdatedUtc); - Assert.AreEqual(DateTime.UtcNow.Date, post.UpdatedUtc?.Date); + Assert.AreEqual(DateTime.UtcNow.Date, post.UpdatedUtc?.ToUniversalTime().Date); Assert.AreEqual(author.Id, post.UpdaterUserId); // delete @@ -51,7 +47,7 @@ public void TestAuditableEntitiesGeneric() context.Entry(post).Reload(); Assert.AreEqual(true, post.IsDeleted); Assert.IsNotNull(post.DeletedUtc); - Assert.AreEqual(DateTime.UtcNow.Date, post.DeletedUtc?.Date); + Assert.AreEqual(DateTime.UtcNow.Date, post.DeletedUtc?.ToUniversalTime().Date); Assert.AreEqual(author.Id, post.DeleterUserId); } } @@ -68,8 +64,8 @@ public async Task TestAuditableEntities() await context.SaveChangesAsync("admin"); context.Entry(settings).Reload(); - Assert.AreEqual(DateTime.UtcNow.Date, settings.CreatedUtc.Date); - Assert.AreEqual("admin", settings.CreatorUser); + Assert.AreEqual(DateTime.UtcNow.Date, settings.CreatedUtc.ToUniversalTime().Date); + Assert.AreEqual("admin", settings.CreatorUserId); // update settings.Value = "second"; @@ -78,8 +74,8 @@ public async Task TestAuditableEntities() context.Entry(settings).Reload(); Assert.IsNotNull(settings.UpdatedUtc); - Assert.AreEqual(DateTime.UtcNow.Date, settings.UpdatedUtc?.Date); - Assert.AreEqual("admin", settings.UpdaterUser); + Assert.AreEqual(DateTime.UtcNow.Date, settings.UpdatedUtc?.ToUniversalTime().Date); + Assert.AreEqual("admin", settings.UpdaterUserId); // delete context.Settings.Remove(settings); @@ -89,9 +85,9 @@ public async Task TestAuditableEntities() context.Entry(settings).Reload(); Assert.AreEqual(true, settings.IsDeleted); Assert.IsNotNull(settings.DeletedUtc); - Assert.AreEqual(DateTime.UtcNow.Date, settings.DeletedUtc?.Date); - Assert.AreEqual("admin", settings.DeleterUser); + Assert.AreEqual(DateTime.UtcNow.Date, settings.DeletedUtc?.ToUniversalTime().Date); + Assert.AreEqual("admin", settings.DeleterUserId); } } } -} \ No newline at end of file +} diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/TrackableEntitiesTests.cs b/EFCore.CommonTools.Tests/Auditing/TrackableEntitiesTests.cs similarity index 81% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/TrackableEntitiesTests.cs rename to EFCore.CommonTools.Tests/Auditing/TrackableEntitiesTests.cs index 4603607..338a8f0 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/TrackableEntitiesTests.cs +++ b/EFCore.CommonTools.Tests/Auditing/TrackableEntitiesTests.cs @@ -1,13 +1,10 @@ -#if EF_CORE -using System; +using System; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests -#else -using System; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace EntityFramework.ChangeTrackingExtensions.Tests +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests #endif { [TestClass] @@ -25,7 +22,7 @@ public void TestTrackableEntities() context.SaveChanges(); context.Entry(user).Reload(); - Assert.AreEqual(DateTime.UtcNow.Date, user.CreatedUtc.Date); + Assert.AreEqual(DateTime.UtcNow.Date, user.CreatedUtc.ToUniversalTime().Date); // update user.Login = "admin"; @@ -34,7 +31,7 @@ public void TestTrackableEntities() context.Entry(user).Reload(); Assert.IsNotNull(user.UpdatedUtc); - Assert.AreEqual(DateTime.UtcNow.Date, user.UpdatedUtc?.Date); + Assert.AreEqual(DateTime.UtcNow.Date, user.UpdatedUtc?.ToUniversalTime().Date); // delete context.Users.Remove(user); @@ -44,7 +41,7 @@ public void TestTrackableEntities() context.Entry(user).Reload(); Assert.AreEqual(true, user.IsDeleted); Assert.IsNotNull(user.DeletedUtc); - Assert.AreEqual(DateTime.UtcNow.Date, user.DeletedUtc?.Date); + Assert.AreEqual(DateTime.UtcNow.Date, user.DeletedUtc?.ToUniversalTime().Date); } } } diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/ConcurrentEntitiesTests.cs b/EFCore.CommonTools.Tests/Concurrency/ConcurrentEntitiesTests.cs similarity index 95% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/ConcurrentEntitiesTests.cs rename to EFCore.CommonTools.Tests/Concurrency/ConcurrentEntitiesTests.cs index c5cf976..f4134dd 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/ConcurrentEntitiesTests.cs +++ b/EFCore.CommonTools.Tests/Concurrency/ConcurrentEntitiesTests.cs @@ -1,19 +1,16 @@ -#if EF_CORE -using System; +using System; using System.Linq; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests -#else -using System; +#if EF_CORE +using Microsoft.EntityFrameworkCore; + +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 using System.Data.Entity.Infrastructure; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFramework.ChangeTrackingExtensions.Tests +namespace EntityFramework.CommonTools.Tests #endif { [TestClass] diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/EntityFrameworkCore.ChangeTrackingExtensions.Tests.csproj b/EFCore.CommonTools.Tests/EntityFrameworkCore.CommonTools.Tests.csproj similarity index 71% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/EntityFrameworkCore.ChangeTrackingExtensions.Tests.csproj rename to EFCore.CommonTools.Tests/EntityFrameworkCore.CommonTools.Tests.csproj index 108982d..ec56443 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/EntityFrameworkCore.ChangeTrackingExtensions.Tests.csproj +++ b/EFCore.CommonTools.Tests/EntityFrameworkCore.CommonTools.Tests.csproj @@ -1,7 +1,7 @@  - netcoreapp1.1 + netcoreapp2.0 @@ -13,15 +13,15 @@ - - - - - + + + + + - + diff --git a/EFCore.CommonTools.Tests/Expression/ExpressionGetValueTests.cs b/EFCore.CommonTools.Tests/Expression/ExpressionGetValueTests.cs new file mode 100644 index 0000000..a60d965 --- /dev/null +++ b/EFCore.CommonTools.Tests/Expression/ExpressionGetValueTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Linq.Expressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests +#endif +{ + [TestClass] + public class ExpressionGetValueTests + { + [TestMethod] + public void ShouldAcceptConstant() + { + Expression> expression = () => 123; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(123, value); + } + + [TestMethod] + public void ShouldAcceptClosure() + { + int expected = 123; + + Expression> expression = () => expected; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(expected, value); + } + + public int ClassField = 123; + + [TestMethod] + public void ShouldAcceptClassField() + { + Expression> expression = () => ClassField; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(ClassField, value); + } + + public int ClassProperty { get; set; } = 456; + + [TestMethod] + public void ShouldAcceptClassProperty() + { + Expression> expression = () => ClassProperty; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(ClassProperty, value); + } + + class TestObject + { + public int Field; + public int Property { get; set; } + public int? Nullable { get; set; } + public int this[int i, int j] => i * j; + } + + [TestMethod] + public void ShouldAcceptObjectField() + { + var obj = new TestObject { Field = 123 }; + + Expression> expression = () => obj.Field; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(obj.Field, value); + } + + [TestMethod] + public void ShouldAcceptObjectProperty() + { + var obj = new TestObject { Property = 456 }; + + Expression> expression = () => obj.Property; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(obj.Property, value); + } + + [TestMethod] + public void ShouldAcceptNullableConversion() + { + var obj = new TestObject { Nullable = 456 }; + + Expression> expression = () => obj.Nullable; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(obj.Nullable, value); + } + + [TestMethod] + public void ShouldAcceptObjectConversion() + { + var expected = new TestObject(); + + Expression> expression = () => expected; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(expected, value); + } + + [TestMethod] + public void ShouldAcceptObjectIndexer() + { + var obj = new TestObject(); + int i = 123; + int j = 456; + + Expression> expression = () => obj[i, j]; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(i * j, value); + } + + [TestMethod] + public void ShouldAcceptListIndexer() + { + IReadOnlyList list = new List { 1, 2, 3 }; + int i = 1; + + Expression> expression = () => list[i]; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(list[i], value); + } + + [TestMethod] + public void ShouldAcceptArrayIndexer() + { + var arr = new[] { 1, 2, 3 }; + int i = 1; + + Expression> expression = () => arr[i]; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(arr[i], value); + } + + [TestMethod] + public void ShouldAcceptArrayLength() + { + var arr = new[] { 1, 2, 3 }; + + Expression> expression = () => arr.Length; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(arr.Length, value); + } + + [TestMethod] + public void ShouldAcceptComplexExpressions() + { + var obj = new TestObject { Field = 123, Nullable = 123 }; + var list = new List { 1, 2, 3 }; + + int expected = obj[list[2], (int)obj.Nullable]; + + Expression> expression = () => obj[list[2], (int)obj.Nullable]; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(expected, value); + } + + [TestMethod] + public void ShouldFallBackToExpressionCompile() + { + int expected = 123; + + Expression> expression = () => expected * expected; + + object value = expression.Body.GetValue(); + + Assert.AreEqual(expected * expected, value); + } + + [TestMethod, ExpectedException(typeof(InvalidOperationException))] + public void ShouldFailWithOpenParams() + { + int expected = 123; + + Expression> expression = num => num * expected; + + object value = expression.Body.GetValue(); + } + } +} diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Utils/JsonFieldIntegrationTests.cs b/EFCore.CommonTools.Tests/Json/JsonFieldIntegrationTests.cs similarity index 79% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/Utils/JsonFieldIntegrationTests.cs rename to EFCore.CommonTools.Tests/Json/JsonFieldIntegrationTests.cs index 8042407..2efc1ed 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Utils/JsonFieldIntegrationTests.cs +++ b/EFCore.CommonTools.Tests/Json/JsonFieldIntegrationTests.cs @@ -1,11 +1,9 @@ -#if EF_CORE -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests -#else -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace EntityFramework.ChangeTrackingExtensions.Tests +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests #endif { [TestClass] diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Utils/JsonFieldTests.cs b/EFCore.CommonTools.Tests/Json/JsonFieldTests.cs similarity index 93% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/Utils/JsonFieldTests.cs rename to EFCore.CommonTools.Tests/Json/JsonFieldTests.cs index 375902b..9a70f58 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Utils/JsonFieldTests.cs +++ b/EFCore.CommonTools.Tests/Json/JsonFieldTests.cs @@ -1,15 +1,11 @@ -#if EF_CORE -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests -#else -using System.Collections.Generic; -using System.Linq; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace EntityFramework.ChangeTrackingExtensions.Tests +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests #endif { [TestClass] @@ -25,8 +21,8 @@ public string AddressJson } public Address Address { - get { return _address.Value; } - set { _address.Value = value; } + get { return _address.Object; } + set { _address.Object = value; } } private JsonField> _phones = new HashSet(); @@ -37,8 +33,8 @@ public string PhonesJson } public ICollection Phones { - get { return _phones.Value; } - set { _phones.Value = value; } + get { return _phones.Object; } + set { _phones.Object = value; } } } @@ -124,8 +120,8 @@ public string ScoresJson } public IDictionary Scores { - get { return _scores.Value; } - set { _scores.Value = value; } + get { return _scores.Object; } + set { _scores.Object = value; } } } @@ -323,8 +319,8 @@ public string ValueJson } public dynamic Value { - get { return _value.Value; } - set { _value.Value = value; } + get { return _value.Object; } + set { _value.Object = value; } } } diff --git a/EFCore.CommonTools.Tests/Querying/AsQueryableExpanderTests.cs b/EFCore.CommonTools.Tests/Querying/AsQueryableExpanderTests.cs new file mode 100644 index 0000000..4c55c4b --- /dev/null +++ b/EFCore.CommonTools.Tests/Querying/AsQueryableExpanderTests.cs @@ -0,0 +1,58 @@ +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests +#endif +{ + [TestClass] + public class AsQueryableExpanderTests + { + [TestMethod] + public void ShouldExpandAsQueryable() + { + var query = Enumerable.Empty() + .AsQueryable() + .AsVisitable(new AsQueryableExpander()) + .Where(u => u.Posts + .AsQueryable() + .OfType() + .OrderBy(p => p.CreatedUtc) + .ThenBy(p => p.UpdatedUtc) + .ThenByDescending(p => p.Id) + .Select(p => p.Tags + .AsQueryable() + .Count()) + .Average() > 10) + .SelectMany(u => u.Posts + .AsQueryable() + .Where(p => !p.IsDeleted) + .SelectMany(p => p.Author.Posts + .Where(ap => ap.Title == "test") + .AsQueryable() + .Select(ap => ap.Author))); + + var expected = Enumerable.Empty() + .AsQueryable() + .Where(u => u.Posts + .OfType() + .OrderBy(p => p.CreatedUtc) + .ThenBy(p => p.UpdatedUtc) + .ThenByDescending(p => p.Id) + .Select(p => p.Tags + .Count()) + .Average() > 10) + .SelectMany(u => u.Posts + .Where(p => !p.IsDeleted) + .SelectMany(p => p.Author.Posts + .Where(ap => ap.Title == "test") + .Select(ap => ap.Author))); + + Assert.AreNotSame(expected.Expression, query.Expression); + + Assert.That.MethodCallsAreMatch(expected.Expression, query.Expression); + } + } +} diff --git a/EFCore.CommonTools.Tests/Querying/ExtensionExpanderTests.cs b/EFCore.CommonTools.Tests/Querying/ExtensionExpanderTests.cs new file mode 100644 index 0000000..d9b6da4 --- /dev/null +++ b/EFCore.CommonTools.Tests/Querying/ExtensionExpanderTests.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +using System.Data.Entity; + +namespace EntityFramework.CommonTools.Tests +#endif +{ + public static class UserQueryableExtensions + { + [Expandable] + public static IQueryable FilterIsActive(this IEnumerable users) + { + return users.AsQueryable().Where(u => !u.IsDeleted); + } + + [Expandable] + public static IQueryable FilterByLogin(this IEnumerable users, string login) + { + return users.AsQueryable().FilterIsActive().Where(u => u.Login == login); + } + } + + public static class PostQueryableExtensions + { + [Expandable] + public static IQueryable FilterIsActive(this IEnumerable posts) + { + return posts.AsQueryable().Where(p => !p.IsDeleted); + } + + [Expandable] + public static IQueryable FilterToday(this IEnumerable posts, int limit = 10) + { + DateTime today = DateTime.UtcNow.Date; + + return posts.AsQueryable().FilterIsActive().Where(p => p.CreatedUtc > today).Take(limit); + } + + [Expandable] + public static IQueryable FilterByEditor(this IEnumerable posts, int editorId) + { + return posts.AsQueryable().Where(p => p.UpdaterUserId == editorId); + } + } + + public static class GenericExtensions + { + [Expandable] + public static IQueryable Filter( + this IEnumerable enumerable, Expression> predicate) + { + return enumerable.AsQueryable().Where(predicate); + } + + [Expandable] + public static IQueryable Map( + this IEnumerable enumerable, Expression> projection) + { + return enumerable.AsQueryable().Select(projection); + } + + [Expandable] + public static IQueryable FlatMap( + this IEnumerable enumerable, Expression>> projection) + { + return enumerable.AsQueryable().SelectMany(projection); + } + } + + public static class NestedExtensions + { + [Expandable] + public static IQueryable HasPostsWithAuthorByLogin( + this IEnumerable posts, string login) + { + return posts.AsQueryable().Where(p => p.Author.Login == login); + } + + [Expandable] + public static IQueryable SelectPostsThat_HasPostsWithAuthorByLogin( + this IEnumerable posts, string login) + { + return posts.AsQueryable() + .SelectMany(p => p.Author.Posts + .HasPostsWithAuthorByLogin(login)); + } + } + + [TestClass] + public class ExtensionExpanderTests : TestInitializer + { + [TestMethod] + public void ShouldExpandExtensions() + { + using (var context = CreateSqliteDbContext()) + { + context.Users.AddRange(new[] + { + new User { Login = "admin", IsDeleted = false }, + new User { Login = "admin", IsDeleted = true }, + }); + + context.SaveChanges(); + + string login = "admin"; + int updaterId = 1; + DateTime today = DateTime.UtcNow.Date; + + var query = context.Users.AsExpandable() + .FilterByLogin(login) + .Select(u => u.Posts + .FilterByEditor(updaterId) + .FilterByEditor(u.Id) + .FilterByEditor(u.Id + 1) + .FilterToday(5) + .Count()); + + var expected = context.Users + .Where(u => !u.IsDeleted) + .Where(u => u.Login == login) + .Select(u => u.Posts +#if !EF_CORE + .AsQueryable() +#endif + .Where(p => p.UpdaterUserId == updaterId) + .Where(p => p.UpdaterUserId == u.Id) + .Where(p => p.UpdaterUserId == u.Id + 1) + .Where(p => !p.IsDeleted) + .Where(p => p.CreatedUtc > today) + .Take(5) + .Count()); + + Assert.AreEqual(expected.ToString(), query.ToString()); + + Assert.AreNotSame(expected.Expression, query.Expression); + + Assert.That.MethodCallsAreMatch(expected.Expression, query.Expression); + + Assert.IsNotNull(query.FirstOrDefault()); + } + } + + [TestMethod] + public void ShouldExpandGeneric() + { + using (var context = CreateSqliteDbContext()) + { + var query = context.Users.AsExpandable() + .FlatMap(u => u.Posts + .Filter(p => !p.IsDeleted) + .Map(p => p.Author) + .FlatMap(a => a.Posts)); + + var expected = context.Users + .SelectMany(u => u.Posts +#if !EF_CORE + .AsQueryable() +#endif + .Where(p => !p.IsDeleted) + .Select(p => p.Author) + .SelectMany(a => a.Posts)); + + Assert.AreEqual(expected.ToString(), query.ToString()); + + Assert.AreNotSame(expected.Expression, query.Expression); + + Assert.That.MethodCallsAreMatch(expected.Expression, query.Expression); + + Assert.IsNull(query.FirstOrDefault()); + } + } + + [TestMethod] + public void ShouldExpandNestedExtensions() + { + var query = Enumerable.Empty() + .AsQueryable() + .AsExpandable() + .SelectMany(u => u.Posts + .SelectPostsThat_HasPostsWithAuthorByLogin(u.Login)); + + var expected = Enumerable.Empty() + .AsQueryable() + .SelectMany(u => u.Posts +#if !EF_CORE + .AsQueryable() +#endif + .SelectMany(p => p.Author.Posts +#if !EF_CORE + .AsQueryable() +#endif + .Where(ap => ap.Author.Login == u.Login))); + + Assert.AreNotSame(expected.Expression, query.Expression); + + Assert.That.MethodCallsAreMatch(expected.Expression, query.Expression); + + Assert.IsNull(query.FirstOrDefault()); + } + } +} diff --git a/EFCore.CommonTools.Tests/Specification/SpecificationExpanderTests.cs b/EFCore.CommonTools.Tests/Specification/SpecificationExpanderTests.cs new file mode 100644 index 0000000..3ac357b --- /dev/null +++ b/EFCore.CommonTools.Tests/Specification/SpecificationExpanderTests.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests +#endif +{ + [TestClass] + public class SpecificationExpanderTests : TestInitializer + { + public class PostActiveSpec : Specification + { + public PostActiveSpec() + : base(p => !p.IsDeleted) { } + } + + [TestMethod] + public void ShouldBeExpandedInExpressionTree() + { + using (var context = CreateSqliteDbContext()) + { + context.Users.Add(new User + { + Login = "admin", IsDeleted = false, + Posts = new List + { + new Post { Title = "test", IsDeleted = false }, + }, + }); + + context.SaveChanges(); + + var postSpec = new PostActiveSpec(); + + var query = context.Users + .AsVisitable(new SpecificationExpander()) + .Select(u => u.Posts.Where(postSpec).ToList()); + + var expected = context.Users + .Select(u => u.Posts.Where(p => !p.IsDeleted).ToList()); + + Assert.AreEqual(expected.ToString(), query.ToString()); + + Assert.That.MethodCallsAreMatch(expected.Expression, query.Expression); + + // assert that we find one user with one post + query.Single().Single(); + } + } + + public class PostByTitleSpec : ISpecification + { + private readonly string _title; + + public PostByTitleSpec(string title) + { + _title = title; + } + + public bool IsSatisfiedBy(Post post) + { + return post.Title == _title; + } + + public Expression> ToExpression() + { + return p => p.Title == _title; + } + } + + [TestMethod] + public void ShouldCallToExpressionInExpressionTree() + { + using (var context = CreateSqliteDbContext()) + { + context.Users.Add(new User + { + Login = "admin", + IsDeleted = false, + Posts = new List + { + new Post { Title = "test", IsDeleted = false }, + }, + }); + + context.SaveChanges(); + + string title = "test"; + + var postSpec = new PostByTitleSpec(title); + + var query = context.Users +#if EF_CORE + .AsVisitable(new SpecificationExpander(), new AsQueryableExpander()) +#elif EF_6 + .AsVisitable(new SpecificationExpander()) +#endif + .SelectMany(u => u.Posts.AsQueryable().Where(postSpec.ToExpression())); + + var expected = context.Users +#if EF_CORE + .AsVisitable(new AsQueryableExpander()) +#endif + .SelectMany(u => u.Posts.AsQueryable().Where(p => p.Title == title)); + + var e = expected.ToList(); + + Assert.AreEqual(expected.ToString(), query.ToString()); + + Assert.That.MethodCallsAreMatch(expected.Expression, query.Expression); + + Assert.IsNotNull(query.Single()); + } + } + + public class PostByContentSpec : Specification + { + public PostByContentSpec(string content) + { + Predicate = p => p.Content.Contains(content); + } + } + + [TestMethod] + public void ShouldSupportConditionalLogicInExpressionTree() + { + using (var context = CreateSqliteDbContext()) + { + context.Users.Add(new User + { + Login = "admin", + IsDeleted = false, + Posts = new List + { + new Post { Content = "content", IsDeleted = false }, + }, + }); + + context.SaveChanges(); + + string content = "content"; + + var query = context.Users + .AsVisitable(new SpecificationExpander()) + .Select(u => u.Posts + .Where(new PostByContentSpec(content) + || new PostByContentSpec(content)) + .ToList()); + + var expected = context.Users + .Select(u => u.Posts + .Where(p => p.Content.Contains(content) + || p.Content.Contains(content)) + .ToList()); + + Assert.AreEqual(expected.ToString(), query.ToString()); + + Assert.That.MethodCallsAreMatch(expected.Expression, query.Expression); + + // assert that we find one user with one post + query.Single().Single(); + } + } + + public class PostRecursiveSpec : Specification + { + public PostRecursiveSpec(string content) + : base(p => p.Author.Posts.Any(new PostByContentSpec(content))) { } + } + + [TestMethod] + public void ShouldSupportRecursiveSpecsInExpressionTree() + { + var users = new[] + { + new User + { + Login = "admin", + IsDeleted = false, + Posts = new List + { + new Post { Content = "content", IsDeleted = false }, + }, + } + }; + + users.First().Posts.First().Author = users.First(); + + string content = "content"; + + var query = users.AsQueryable() + .AsVisitable(new SpecificationExpander()) + .SelectMany(u => u.Posts.Where(new PostRecursiveSpec(content))); + + var expected = users.AsQueryable() + .SelectMany(u => u.Posts.Where(p => p.Author.Posts.Any(ap => ap.Content.Contains(content)))); + + Assert.That.MethodCallsAreMatch(expected.Expression, query.Expression); + + Assert.AreEqual(expected.Single(), query.Single()); + } + + [TestMethod, ExpectedException(typeof(InvalidOperationException))] + public void ShouldNotSupportParametersInExpressionTree() + { + using (var context = CreateSqliteDbContext()) + { + context.Users.Add(new User + { + Login = "admin", + IsDeleted = false, + Posts = new List + { + new Post { Title = "test", IsDeleted = false }, + }, + }); + + context.SaveChanges(); + + try + { + var query = context.Users + .AsVisitable(new SpecificationExpander()) + .Select(u => u.Posts.Where(new PostByContentSpec(u.Login))); + } + +#pragma warning disable CS0168 // Variable is declared but never used + catch (InvalidOperationException exception) +#pragma warning restore CS0168 // Variable is declared but never used + { + throw; + } + } + } + } +} diff --git a/EFCore.CommonTools.Tests/Specification/SpecificationTests.cs b/EFCore.CommonTools.Tests/Specification/SpecificationTests.cs new file mode 100644 index 0000000..5de4e73 --- /dev/null +++ b/EFCore.CommonTools.Tests/Specification/SpecificationTests.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests +#endif +{ + [TestClass] + public class SpecificationTests : TestInitializer + { + public class UserActiveSpec : Specification + { + public UserActiveSpec() + { + Predicate = u => !u.IsDeleted; + } + } + + public class UserByLoginSpec : Specification + { + public UserByLoginSpec(string login) + { + Predicate = u => u.Login == login; + } + } + + public class UserAndSpec : Specification + { + public UserAndSpec(string login) + : base(new UserActiveSpec() && new UserByLoginSpec(login)) { } + } + + public class UserOrSpec : Specification + { + public UserOrSpec(string login) + { + Predicate = new UserActiveSpec() || new UserByLoginSpec(login); + } + } + + [TestMethod] + public void SouldConsumePlainObjects() + { + var activeUser = new User { IsDeleted = false }; + var deletedUser = new User { IsDeleted = true }; + + var spec = new UserActiveSpec(); + + Assert.IsTrue(spec.IsSatisfiedBy(activeUser)); + Assert.IsFalse(spec.IsSatisfiedBy(deletedUser)); + } + + [TestMethod] + public void SouldAcceptParameters() + { + var admin = new User { Login = "admin" }; + + var spec = new UserByLoginSpec("admin"); + + Assert.IsTrue(spec.IsSatisfiedBy(admin)); + } + + [TestMethod] + public void ShouldSupportComposition() + { + var activeAdmin = new User { Login = "admin", IsDeleted = false }; + var deletedAdmin = new User { Login = "admin", IsDeleted = true }; + + var andSpec = new UserAndSpec("admin"); + + Assert.IsTrue(andSpec.IsSatisfiedBy(activeAdmin)); + Assert.IsFalse(andSpec.IsSatisfiedBy(deletedAdmin)); + + var orSpec = new UserOrSpec("admin"); + + Assert.IsTrue(orSpec.IsSatisfiedBy(activeAdmin)); + Assert.IsTrue(orSpec.IsSatisfiedBy(deletedAdmin)); + } + + [TestMethod] + public void ShouldSupportConditionalLogic() + { + var activeAdmin = new User { Login = "admin", IsDeleted = false }; + var deletedAdmin = new User { Login = "admin", IsDeleted = true }; + + var andSpec = new UserActiveSpec() && new UserByLoginSpec("admin"); + + Assert.IsTrue(andSpec.IsSatisfiedBy(activeAdmin)); + Assert.IsFalse(andSpec.IsSatisfiedBy(deletedAdmin)); + + var orSpec = new UserActiveSpec() || new UserByLoginSpec("admin"); + + Assert.IsTrue(orSpec.IsSatisfiedBy(activeAdmin)); + Assert.IsTrue(orSpec.IsSatisfiedBy(deletedAdmin)); + + var notSpec = !new UserByLoginSpec("admin"); + + Assert.IsFalse(notSpec.IsSatisfiedBy(activeAdmin)); + Assert.IsFalse(notSpec.IsSatisfiedBy(deletedAdmin)); + } + + [TestMethod] + public void ShouldSupportComplexExpressions() + { + var user = new User + { + IsDeleted = false, + Posts = new List + { + new Post { IsDeleted = false }, + }, + }; + + var activeUserSpec = new Specification(u => !u.IsDeleted); + var hasPostsSpec = new Specification(u => u.Posts.Any(p => !p.IsDeleted)); + var validUserSpec = activeUserSpec && hasPostsSpec; + + Assert.IsTrue(validUserSpec.IsSatisfiedBy(user)); + } + + [TestMethod] + public void ShouldWorkWithEnumerable() + { + var users = new[] + { + new User { Login = "admin", IsDeleted = false }, + new User { Login = "admin", IsDeleted = true }, + }; + + var andSpec = new UserAndSpec("admin"); + + users.Where(andSpec.IsSatisfiedBy).Single(); + + users.Where(andSpec).Single(); + } + + [TestMethod] + public void ShouldWorkWithQueryable() + { + using (var context = CreateSqliteDbContext()) + { + context.Users.AddRange(new[] + { + new User { Login = "admin", IsDeleted = false }, + new User { Login = "admin", IsDeleted = true }, + }); + + context.SaveChanges(); + + var andSpec = new UserAndSpec("admin"); + + context.Users.Where(andSpec.ToExpression()).Single(); + + context.Users.Where(andSpec).Single(); + + } + } + } +} diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestDbContext.cs b/EFCore.CommonTools.Tests/TestDbContext.cs similarity index 92% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestDbContext.cs rename to EFCore.CommonTools.Tests/TestDbContext.cs index be555e9..b62f402 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestDbContext.cs +++ b/EFCore.CommonTools.Tests/TestDbContext.cs @@ -2,9 +2,9 @@ using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Diagnostics; -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests +namespace EntityFrameworkCore.CommonTools.Tests { public class TestDbContext : DbContext { @@ -12,9 +12,9 @@ public class TestDbContext : DbContext public DbSet Users { get; set; } public DbSet Posts { get; set; } public DbSet Settings { get; set; } - + public DbSet TransactionLogs { get; set; } - + public TestDbContext(string databaseName) : base(new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName) @@ -84,9 +84,9 @@ public int SaveChanges(int editorUserId) return SaveChanges(); } - public Task SaveChangesAsync(string editorUser) + public Task SaveChangesAsync(string editorUserId) { - this.UpdateAuditableEntities(editorUser); + this.UpdateAuditableEntities(editorUserId); return SaveChangesAsync(); } diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestEntities.cs b/EFCore.CommonTools.Tests/TestEntities.cs similarity index 80% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestEntities.cs rename to EFCore.CommonTools.Tests/TestEntities.cs index 3cffbf3..33bf18b 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestEntities.cs +++ b/EFCore.CommonTools.Tests/TestEntities.cs @@ -4,7 +4,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Runtime.Serialization; -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests +namespace EntityFrameworkCore.CommonTools.Tests { public abstract class Entity { @@ -30,6 +30,9 @@ public class User : Entity, IFullTrackable, ITransactionLoggable public DateTime CreatedUtc { get; set; } public DateTime? UpdatedUtc { get; set; } public DateTime? DeletedUtc { get; set; } + + [InverseProperty(nameof(Post.Author))] + public virtual ICollection Posts { get; set; } = new HashSet(); } public class Post : Entity, IFullAuditable, IConcurrencyCheckable, ITransactionLoggable @@ -37,7 +40,7 @@ public class Post : Entity, IFullAuditable, IConcurrencyCheckable, IT public string Title { get; set; } public string Content { get; set; } - JsonField> _tags = new HashSet(); + private JsonField> _tags = new HashSet(); public bool ShouldSerializeTagsJson() => false; @@ -50,8 +53,8 @@ public string TagsJson [NotMapped] public ICollection Tags { - get { return _tags.Value; } - set { _tags.Value = value; } + get { return _tags.Object; } + set { _tags.Object = value; } } public bool IsDeleted { get; set; } @@ -75,7 +78,7 @@ public class Settings : IFullAuditable, IConcurrencyCheckable [Key] public string Key { get; set; } - JsonField _value; + private JsonField _value; [Column("Value"), IgnoreDataMember] public string ValueJson @@ -87,16 +90,16 @@ public string ValueJson [NotMapped] public dynamic Value { - get { return _value.Value; } - set { _value.Value = value; } + get { return _value.Object; } + set { _value.Object = value; } } public bool IsDeleted { get; set; } - public string CreatorUser { get; set; } + public string CreatorUserId { get; set; } public DateTime CreatedUtc { get; set; } - public string UpdaterUser { get; set; } + public string UpdaterUserId { get; set; } public DateTime? UpdatedUtc { get; set; } - public string DeleterUser { get; set; } + public string DeleterUserId { get; set; } public DateTime? DeletedUtc { get; set; } [ConcurrencyCheck] diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestInitializer.cs b/EFCore.CommonTools.Tests/TestInitializer.cs similarity index 94% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestInitializer.cs rename to EFCore.CommonTools.Tests/TestInitializer.cs index a4b2087..bae492f 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestInitializer.cs +++ b/EFCore.CommonTools.Tests/TestInitializer.cs @@ -1,8 +1,9 @@ using Microsoft.Data.Sqlite; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests +namespace EntityFrameworkCore.CommonTools.Tests { + [TestClass] public abstract class TestInitializer { private SqliteConnection _connection; diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestLoggerProvider.cs b/EFCore.CommonTools.Tests/TestLoggerProvider.cs similarity index 94% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestLoggerProvider.cs rename to EFCore.CommonTools.Tests/TestLoggerProvider.cs index 95532dd..47a975f 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/TestLoggerProvider.cs +++ b/EFCore.CommonTools.Tests/TestLoggerProvider.cs @@ -2,7 +2,7 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests +namespace EntityFrameworkCore.CommonTools.Tests { public class TestLoggerProvider : ILoggerProvider { diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/TransactionExtensionsTests.cs b/EFCore.CommonTools.Tests/TransactionLog/TransactionExtensionsTests.cs similarity index 93% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/TransactionExtensionsTests.cs rename to EFCore.CommonTools.Tests/TransactionLog/TransactionExtensionsTests.cs index b20ccc2..8ec6183 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/TransactionExtensionsTests.cs +++ b/EFCore.CommonTools.Tests/TransactionLog/TransactionExtensionsTests.cs @@ -1,13 +1,10 @@ -#if EF_CORE using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests -#else -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace EntityFramework.ChangeTrackingExtensions.Tests +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests #endif { [TestClass] diff --git a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/TransactionLogTests.cs b/EFCore.CommonTools.Tests/TransactionLog/TransactionLogTests.cs similarity index 97% rename from EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/TransactionLogTests.cs rename to EFCore.CommonTools.Tests/TransactionLog/TransactionLogTests.cs index c21f92f..4c6cc90 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions.Tests/Extensions/TransactionLogTests.cs +++ b/EFCore.CommonTools.Tests/TransactionLog/TransactionLogTests.cs @@ -1,15 +1,11 @@ -#if EF_CORE using System.Linq; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFrameworkCore.ChangeTrackingExtensions.Tests -#else -using System.Linq; -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace EntityFramework.ChangeTrackingExtensions.Tests +#if EF_CORE +namespace EntityFrameworkCore.CommonTools.Tests +#elif EF_6 +namespace EntityFramework.CommonTools.Tests #endif { [TestClass] diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Entities/AuditableEntities.cs b/EFCore.CommonTools/Auditing/AuditableEntities.cs similarity index 77% rename from EntityFrameworkCore.ChangeTrackingExtensions/Entities/AuditableEntities.cs rename to EFCore.CommonTools/Auditing/AuditableEntities.cs index a168549..58f63c2 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Entities/AuditableEntities.cs +++ b/EFCore.CommonTools/Auditing/AuditableEntities.cs @@ -1,7 +1,9 @@ -#if EF_CORE -namespace EntityFrameworkCore.ChangeTrackingExtensions -#else -namespace EntityFramework.ChangeTrackingExtensions +using System; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools #endif { /// @@ -21,10 +23,16 @@ public interface ICreationAuditable : ICreationTrackable /// Creation time and creator user are automatically set when saving Entity to database. /// public interface ICreationAuditable : ICreationTrackable + { + string CreatorUserId { get; set; } + } + + [Obsolete("Use ICreationAuditable instead")] + public interface ICreationAuditableV1 : ICreationTrackable { string CreatorUser { get; set; } } - + /// /// This interface is implemented by entities that is wanted /// to store modification information (who and when modified lastly). @@ -42,10 +50,16 @@ public interface IModificationAuditable : IModificationTrackable /// Properties are automatically set when updating the Entity. /// public interface IModificationAuditable : IModificationTrackable + { + string UpdaterUserId { get; set; } + } + + [Obsolete("Use IModificationAuditable instead")] + public interface IModificationAuditableV1 : IModificationTrackable { string UpdaterUser { get; set; } } - + /// /// This interface is implemented by entities which wanted /// to store deletion information (who and when deleted). @@ -61,10 +75,16 @@ public interface IDeletionAuditable : IDeletionTrackable /// to store deletion information (who and when deleted). /// public interface IDeletionAuditable : IDeletionTrackable + { + string DeleterUserId { get; set; } + } + + [Obsolete("Use IDeletionAuditable instead")] + public interface IDeletionAuditableV1 : IDeletionTrackable { string DeleterUser { get; set; } } - + /// /// This interface is implemented by entities which must be audited. /// Related properties automatically set when saving/updating/deleting Entity objects. @@ -83,4 +103,10 @@ public interface IFullAuditable : IFullTrackable, ICreationAuditable, IModificationAuditable, IDeletionAuditable { } + + [Obsolete("Use IFullAuditable instead")] + public interface IFullAuditableV1 : IFullTrackable, + ICreationAuditableV1, IModificationAuditableV1, IDeletionAuditableV1 + { + } } diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Extensions/AuditableEntitiesExtensions.cs b/EFCore.CommonTools/Auditing/AuditableEntitiesExtensions.cs similarity index 68% rename from EntityFrameworkCore.ChangeTrackingExtensions/Extensions/AuditableEntitiesExtensions.cs rename to EFCore.CommonTools/Auditing/AuditableEntitiesExtensions.cs index 6aec9af..00c096b 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Extensions/AuditableEntitiesExtensions.cs +++ b/EFCore.CommonTools/Auditing/AuditableEntitiesExtensions.cs @@ -1,17 +1,16 @@ -#if EF_CORE -using System; +using System; using System.Linq; + +#if EF_CORE using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -namespace EntityFrameworkCore.ChangeTrackingExtensions -#else -using System; -using System.Linq; +namespace EntityFrameworkCore.CommonTools +#elif EF_6 using System.Data.Entity; using EntityEntry = System.Data.Entity.Infrastructure.DbEntityEntry; -namespace EntityFramework.ChangeTrackingExtensions +namespace EntityFramework.CommonTools #endif { public static partial class DbContextExtensions @@ -38,7 +37,7 @@ public static void UpdateAuditableEntities(this DbContext context, TUse /// /// Populate special properties for all Auditable Entities in context. /// - public static void UpdateAuditableEntities(this DbContext context, string editorUser) + public static void UpdateAuditableEntities(this DbContext context, string editorUserId) { DateTime utcNow = DateTime.UtcNow; @@ -49,10 +48,10 @@ public static void UpdateAuditableEntities(this DbContext context, string editor foreach (var dbEntry in changedEntries) { - UpdateAuditableEntity(dbEntry, utcNow, editorUser); + UpdateAuditableEntity(dbEntry, utcNow, editorUserId); } } - + private static void UpdateAuditableEntity( EntityEntry dbEntry, DateTime utcNow, TUserId editorUserId) where TUserId : struct @@ -62,32 +61,26 @@ private static void UpdateAuditableEntity( switch (dbEntry.State) { case EntityState.Added: - var creationAuditable = entity as ICreationAuditable; - if (creationAuditable != null) + if (entity is ICreationAuditable creationAuditable) { UpdateTrackableEntity(dbEntry, utcNow); - creationAuditable.CreatorUserId = editorUserId; } break; case EntityState.Modified: - var modificationAuditable = entity as IModificationAuditable; - if (modificationAuditable != null) + if (entity is IModificationAuditable modificationAuditable) { UpdateTrackableEntity(dbEntry, utcNow); - modificationAuditable.UpdaterUserId = editorUserId; dbEntry.CurrentValues[nameof(IModificationAuditable.UpdaterUserId)] = editorUserId; } break; case EntityState.Deleted: - var deletionAuditable = entity as IDeletionAuditable; - if (deletionAuditable != null) + if (entity is IDeletionAuditable deletionAuditable) { UpdateTrackableEntity(dbEntry, utcNow); - // change CurrentValues after dbEntry.State becomes EntityState.Unchanged deletionAuditable.DeleterUserId = editorUserId; dbEntry.CurrentValues[nameof(IDeletionAuditable.DeleterUserId)] = editorUserId; @@ -100,42 +93,53 @@ private static void UpdateAuditableEntity( } private static void UpdateAuditableEntity( - EntityEntry dbEntry, DateTime utcNow, string editorUser) + EntityEntry dbEntry, DateTime utcNow, string editorUserId) { object entity = dbEntry.Entity; switch (dbEntry.State) { case EntityState.Added: - var creationAuditable = entity as ICreationAuditable; - if (creationAuditable != null) + if (entity is ICreationAuditable creationAuditable) { UpdateTrackableEntity(dbEntry, utcNow); - - creationAuditable.CreatorUser = editorUser; + creationAuditable.CreatorUserId = editorUserId; + } + else if (entity is ICreationAuditableV1 creationAuditableV1) + { + UpdateTrackableEntity(dbEntry, utcNow); + creationAuditableV1.CreatorUser = editorUserId; } break; case EntityState.Modified: - var modificationAuditable = entity as IModificationAuditable; - if (modificationAuditable != null) + if (entity is IModificationAuditable modificationAuditable) { UpdateTrackableEntity(dbEntry, utcNow); - - modificationAuditable.UpdaterUser = editorUser; - dbEntry.CurrentValues[nameof(IModificationAuditable.UpdaterUser)] = editorUser; + modificationAuditable.UpdaterUserId = editorUserId; + dbEntry.CurrentValues[nameof(IModificationAuditable.UpdaterUserId)] = editorUserId; + } + else if (entity is IModificationAuditableV1 modificationAuditableV1) + { + UpdateTrackableEntity(dbEntry, utcNow); + modificationAuditableV1.UpdaterUser = editorUserId; + dbEntry.CurrentValues[nameof(IModificationAuditableV1.UpdaterUser)] = editorUserId; } break; case EntityState.Deleted: - var deletionAuditable = entity as IDeletionAuditable; - if (deletionAuditable != null) + if (entity is IDeletionAuditable deletionAuditable) { UpdateTrackableEntity(dbEntry, utcNow); - // change CurrentValues after dbEntry.State becomes EntityState.Unchanged - deletionAuditable.DeleterUser = editorUser; - dbEntry.CurrentValues[nameof(IDeletionAuditable.DeleterUser)] = editorUser; + deletionAuditable.DeleterUserId = editorUserId; + dbEntry.CurrentValues[nameof(IDeletionAuditable.DeleterUserId)] = editorUserId; + } + else if (entity is IDeletionAuditableV1 deletionAuditableV1) + { + UpdateTrackableEntity(dbEntry, utcNow); + deletionAuditableV1.DeleterUser = editorUserId; + dbEntry.CurrentValues[nameof(IDeletionAuditableV1.DeleterUser)] = editorUserId; } break; diff --git a/EFCore.CommonTools/Auditing/README.md b/EFCore.CommonTools/Auditing/README.md new file mode 100644 index 0000000..cb2e61e --- /dev/null +++ b/EFCore.CommonTools/Auditing/README.md @@ -0,0 +1,171 @@ +## Auditable Entities +Automatically update info about who and when create / modify / delete the entity during `context.SaveCahnges()` + +```cs +class User +{ + public int Id { get;set; } + public string Login { get; set; } +} + +class Post : IFullAuditable +{ + public int Id { get; set; } + public string Content { get; set; } + + // IFullAuditable members + public bool IsDeleted { get; set; } + public int CreatorUserId { get; set; } + public DateTime CreatedUtc { get; set; } + public int? UpdaterUserId { get; set; } + public DateTime? UpdatedUtc { get; set; } + public int? DeleterUserId { get; set; } + public DateTime? DeletedUtc { get; set; } +} + +class MyContext : DbContext +{ + public DbSet Users { get; set; } + public DbSet Posts { get; set; } + + public void SaveChanges(int editorUserId) + { + this.UpdateAuditableEntities(editorUserId); + base.SaveChanges(); + } +} +``` + +
+ +Also you can track only the creation, deletion and so on by implementing the following interfaces: + +#### `ISoftDeletable` +Used to standardize soft deleting entities. Soft-delete entities are not actually deleted, +marked as `IsDeleted == true` in the database, but can not be retrieved to the application. + +```cs +interface ISoftDeletable +{ + bool IsDeleted { get; set; } +} +``` + +#### `ICreationTrackable` +An entity can implement this interface if `CreatedUtc` of this entity must be stored. +`CreatedUtc` is automatically set when saving Entity to database. + +```cs +interface ICreationTrackable +{ + DateTime CreatedUtc { get; set; } +} +``` + +#### `ICreationAuditable` +This interface is implemented by entities that is wanted to store creation information (who and when created). +Creation time and creator user are automatically set when saving Entity to database. + +```cs +interface ICreationAuditable : ICreationTrackable + where TUserId : struct +{ + TUserId CreatorUserId { get; set; } +} +// or +interface ICreationAuditable : ICreationTrackable +{ + string CreatorUserId { get; set; } +} +``` + +#### `IModificationTrackable` +An entity can implement this interface if `UpdatedUtc` of this entity must be stored. +`UpdatedUtc` automatically set when updating the Entity. + +```cs +interface IModificationTrackable +{ + DateTime? UpdatedUtc { get; set; } +} +``` + +#### `IModificationAuditable` +This interface is implemented by entities that is wanted +to store modification information (who and when modified lastly). +Properties are automatically set when updating the Entity. + +```cs +interface IModificationAuditable : IModificationTrackable + where TUserId : struct +{ + TUserId? UpdaterUserId { get; set; } +} +// or +interface IModificationAuditable : IModificationTrackable +{ + string UpdaterUserId { get; set; } +} +``` + +#### `IDeletionTrackable` +An entity can implement this interface if `DeletedUtc` of this entity must be stored. +`DeletedUtc` is automatically set when deleting Entity. + +```cs +interface IDeletionTrackable : ISoftDeletable +{ + DateTime? DeletedUtc { get; set; } +} +``` + +#### `IDeletionAuditable` +This interface is implemented by entities which wanted to store deletion information (who and when deleted). + +```cs +public interface IDeletionAuditable : IDeletionTrackable + where TUserId : struct +{ + TUserId? DeleterUserId { get; set; } +} +// or +public interface IDeletionAuditable : IDeletionTrackable +{ + string DeleterUserId { get; set; } +} +``` + +#### `IFullTrackable` +This interface is implemented by entities which modification times must be tracked. +Related properties automatically set when saving/updating/deleting Entity objects. + +```cs +interface IFullTrackable : ICreationTrackable, IModificationTrackable, IDeletionTrackable { } +``` + +#### `IFullAuditable` +This interface is implemented by entities which must be audited. +Related properties automatically set when saving/updating/deleting Entity objects. + +```cs +interface IFullAuditable : IFullTrackable, + ICreationAuditable, IModificationAuditable, IDeletionAuditable + where TUserId : struct { } +// or +interface IFullAuditable : IFullTrackable, ICreationAuditable, IModificationAuditable, IDeletionAuditable { } +``` + +
+ +You can choose between saving the user `Id` or the user `Login`. +So there are two overloadings for `DbContext.UpdateAudiatbleEntities()`: +```cs +static void UpdateAuditableEntities(this DbContext context, TUserId editorUserId); +static void UpdateAuditableEntities(this DbContext context, string editorUserId); +``` +and also the separate extension to update only `Trackable` entities: +```cs +static void UpdateTrackableEntities(this DbContext context); +``` + +
diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Entities/TrackableEntities.cs b/EFCore.CommonTools/Auditing/TrackableEntities.cs similarity index 91% rename from EntityFrameworkCore.ChangeTrackingExtensions/Entities/TrackableEntities.cs rename to EFCore.CommonTools/Auditing/TrackableEntities.cs index d10abce..0095ac4 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Entities/TrackableEntities.cs +++ b/EFCore.CommonTools/Auditing/TrackableEntities.cs @@ -1,11 +1,9 @@ -#if EF_CORE -using System; +using System; -namespace EntityFrameworkCore.ChangeTrackingExtensions -#else -using System; - -namespace EntityFramework.ChangeTrackingExtensions +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools #endif { /// diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Extensions/TrackableEntitiesExtensions.cs b/EFCore.CommonTools/Auditing/TrackableEntitiesExtensions.cs similarity index 76% rename from EntityFrameworkCore.ChangeTrackingExtensions/Extensions/TrackableEntitiesExtensions.cs rename to EFCore.CommonTools/Auditing/TrackableEntitiesExtensions.cs index 9b179cd..eedb77c 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Extensions/TrackableEntitiesExtensions.cs +++ b/EFCore.CommonTools/Auditing/TrackableEntitiesExtensions.cs @@ -1,17 +1,16 @@ -#if EF_CORE -using System; +using System; using System.Linq; + +#if EF_CORE using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -namespace EntityFrameworkCore.ChangeTrackingExtensions -#else -using System; -using System.Linq; +namespace EntityFrameworkCore.CommonTools +#elif EF_6 using System.Data.Entity; using EntityEntry = System.Data.Entity.Infrastructure.DbEntityEntry; -namespace EntityFramework.ChangeTrackingExtensions +namespace EntityFramework.CommonTools #endif { public static partial class DbContextExtensions @@ -33,7 +32,7 @@ public static void UpdateTrackableEntities(this DbContext context) UpdateTrackableEntity(dbEntry, utcNow); } } - + private static void UpdateTrackableEntity(EntityEntry dbEntry, DateTime utcNow) { object entity = dbEntry.Entity; @@ -41,16 +40,14 @@ private static void UpdateTrackableEntity(EntityEntry dbEntry, DateTime utcNow) switch (dbEntry.State) { case EntityState.Added: - var creationTrackable = entity as ICreationTrackable; - if (creationTrackable != null) + if (entity is ICreationTrackable creationTrackable) { creationTrackable.CreatedUtc = utcNow; } break; case EntityState.Modified: - var modificatonTrackable = entity as IModificationTrackable; - if (modificatonTrackable != null) + if (entity is IModificationTrackable modificatonTrackable) { modificatonTrackable.UpdatedUtc = utcNow; dbEntry.CurrentValues[nameof(IModificationTrackable.UpdatedUtc)] = utcNow; @@ -58,16 +55,13 @@ private static void UpdateTrackableEntity(EntityEntry dbEntry, DateTime utcNow) break; case EntityState.Deleted: - var softDeletable = entity as ISoftDeletable; - if (softDeletable != null) + if (entity is ISoftDeletable softDeletable) { dbEntry.State = EntityState.Unchanged; - softDeletable.IsDeleted = true; dbEntry.CurrentValues[nameof(ISoftDeletable.IsDeleted)] = true; - var deletionTrackable = entity as IDeletionTrackable; - if (deletionTrackable != null) + if (entity is IDeletionTrackable deletionTrackable) { deletionTrackable.DeletedUtc = utcNow; dbEntry.CurrentValues[nameof(IDeletionTrackable.DeletedUtc)] = utcNow; diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Entities/ConcurrentEntities.cs b/EFCore.CommonTools/Concurrency/ConcurrentEntities.cs similarity index 88% rename from EntityFrameworkCore.ChangeTrackingExtensions/Entities/ConcurrentEntities.cs rename to EFCore.CommonTools/Concurrency/ConcurrentEntities.cs index afa23be..dbd045c 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Entities/ConcurrentEntities.cs +++ b/EFCore.CommonTools/Concurrency/ConcurrentEntities.cs @@ -1,14 +1,14 @@ #if EF_CORE -namespace EntityFrameworkCore.ChangeTrackingExtensions -#else -namespace EntityFramework.ChangeTrackingExtensions +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools #endif { /// /// An entity can implement this interface if it should use Optimistic Concurrency Check /// with populating from client-side. Allowed types: /// - /// is : + /// is : /// RowVersion property should be decorated by [Timestamp] attribute. /// RowVersion column should have ROWVERSION type in SQL Server. /// diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Extensions/ConcurrentEntitiesExtensions.cs b/EFCore.CommonTools/Concurrency/ConcurrentEntitiesExtensions.cs similarity index 78% rename from EntityFrameworkCore.ChangeTrackingExtensions/Extensions/ConcurrentEntitiesExtensions.cs rename to EFCore.CommonTools/Concurrency/ConcurrentEntitiesExtensions.cs index bfb1262..af63022 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Extensions/ConcurrentEntitiesExtensions.cs +++ b/EFCore.CommonTools/Concurrency/ConcurrentEntitiesExtensions.cs @@ -1,20 +1,18 @@ -#if EF_CORE -using System; +using System; using System.Linq; using System.Threading.Tasks; + +#if EF_CORE using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -namespace EntityFrameworkCore.ChangeTrackingExtensions -#else -using System; -using System.Linq; +namespace EntityFrameworkCore.CommonTools +#elif EF_6 using System.Data.Entity; -using System.Threading.Tasks; using System.Data.Entity.Infrastructure; using EntityEntry = System.Data.Entity.Infrastructure.DbEntityEntry; -namespace EntityFramework.ChangeTrackingExtensions +namespace EntityFramework.CommonTools #endif { public static partial class DbContextExtensions @@ -41,28 +39,22 @@ public static void UpdateConcurrentEntities(this DbContext dbContext) { object entity = dbEntry.Entity; - var concurrencyCheckableTimestamp = entity as IConcurrencyCheckable; - if (concurrencyCheckableTimestamp != null) + if (entity is IConcurrencyCheckable concurrencyCheckableTimestamp) { // take row version from entity that modified by client dbEntry.OriginalValues[ROW_VERSION] = concurrencyCheckableTimestamp.RowVersion; - continue; } - var concurrencyCheckableLong = entity as IConcurrencyCheckable; - if (concurrencyCheckableLong != null) + else if (entity is IConcurrencyCheckable concurrencyCheckableLong) { // take row version from entity that modified by client dbEntry.OriginalValues[ROW_VERSION] = concurrencyCheckableLong.RowVersion; - continue; } - var concurrencyCheckableGuid = entity as IConcurrencyCheckable; - if (concurrencyCheckableGuid != null) + else if (entity is IConcurrencyCheckable concurrencyCheckableGuid) { // take row version from entity that modified by client dbEntry.OriginalValues[ROW_VERSION] = concurrencyCheckableGuid.RowVersion; // generate new row version concurrencyCheckableGuid.RowVersion = Guid.NewGuid(); - continue; } } #if !EF_CORE @@ -95,7 +87,7 @@ public static void SaveChangesIgnoreConcurrency( { throw; } - // update original values from the database + // update original values from the database EntityEntry dbEntry = ex.Entries.Single(); dbEntry.OriginalValues.SetValues(dbEntry.GetDatabaseValues()); @@ -126,7 +118,7 @@ public static async Task SaveChangesIgnoreConcurrencyAsync( { throw; } - // update original values from the database + // update original values from the database EntityEntry dbEntry = ex.Entries.Single(); dbEntry.OriginalValues.SetValues(await dbEntry.GetDatabaseValuesAsync()); @@ -139,23 +131,17 @@ private static void UpdateRowVersionFromDb(EntityEntry dbEntry) { object entity = dbEntry.Entity; - var concurrencyCheckableTimestamp = entity as IConcurrencyCheckable; - if (concurrencyCheckableTimestamp != null) + if (entity is IConcurrencyCheckable concurrencyCheckableTimestamp) { concurrencyCheckableTimestamp.RowVersion = (byte[])dbEntry.OriginalValues[ROW_VERSION]; - return; } - var concurrencyCheckableLong = entity as IConcurrencyCheckable; - if (concurrencyCheckableLong != null) + else if (entity is IConcurrencyCheckable concurrencyCheckableLong) { concurrencyCheckableLong.RowVersion = (long)dbEntry.OriginalValues[ROW_VERSION]; - return; } - var concurrencyCheckableGuid = entity as IConcurrencyCheckable; - if (concurrencyCheckableGuid != null) + else if (entity is IConcurrencyCheckable concurrencyCheckableGuid) { concurrencyCheckableGuid.RowVersion = (Guid)dbEntry.OriginalValues[ROW_VERSION]; - return; } } } diff --git a/EFCore.CommonTools/Concurrency/README.md b/EFCore.CommonTools/Concurrency/README.md new file mode 100644 index 0000000..06a4389 --- /dev/null +++ b/EFCore.CommonTools/Concurrency/README.md @@ -0,0 +1,100 @@ +## Concurrency Checks +By default EF and EFCore uses `EntityEntry.OriginalValues["RowVersion"]` for concurrency checks +([see docs](https://docs.microsoft.com/en-us/ef/core/saving/concurrency)). + +With this behaviour the concurrency conflict may occur only between the `SELECT` statement +that loads entities to the `DbContext` and the `UPDATE` statement from `DbContext.SaveChanges()`. + +But sometimes we want check concurrency conflicts between two or more edit operations that comes from client-side. For example: + +* user_1 loads the editor form +* user_2 loads the same editor form +* user_1 saves his changes +* user_2 saves his changes __and gets concurrency conflict__. + +To provide this behaviour, an entity should implement the following interface: +```cs +interface IConcurrencyCheckable +{ + TRowVersion RowVersion { get; set; } +} +``` +And the `DbContext` should overload `SaveChanges()` method with `UpdateConcurrentEntities()` extension: +```cs +class MyDbContext : DbContext +{ + public override int SaveChanges() + { + this.UpdateConcurrentEntities(); + return base.SaveChanges(); + } +} +``` + +
+ +There are also three different behaviours for `IConcurrencyCheckable`: + +#### `IConcurrencyCheckable` +`RowVersion` property should be decorated by `[Timestamp]` attribute. +`RowVersion` column should have `ROWVERSION` type in SQL Server. +The default behaviour. Supported only by Microsoft SQL Server. + +```cs +class MyEntity : IConcurrencyCheckable +{ + [Timestamp] + public byte[] RowVersion { get; set; } +} +``` + +#### `IConcurrencyCheckable` +`RowVersion` property should be decorated by `[ConcurrencyCheck]` attribute. +It's value is populated by `Guid.NewGuid()` during each `DbContext.SaveChanges()` call at client-side. +No specific database support is needed. + +```cs +class MyEntity : IConcurrencyCheckable +{ + [ConcurrencyCheck] + public Guid RowVersion { get; set; } +} +``` + +#### `IConcurrencyCheckable` +`RowVersion` property should be decorated by `[ConcurrencyCheck]` and `[DatabaseGenerated(DatabaseGeneratedOption.Computed)]` attributes. + +```cs +class MyEntity : IConcurrencyCheckable +{ + [ConcurrencyCheck] + [DatabaseGenerated(DatabaseGeneratedOption.Computed)] + public long RowVersion { get; set; } +} +``` + +`RowVersion` column should be updated by trigger in DB. Example for SQLite: +```sql +CREATE TABLE MyEntities ( RowVersion INTEGER DEFAULT 0 ); + +CREATE TRIGGER TRG_MyEntities_UPD +AFTER UPDATE ON MyEntities + WHEN old.RowVersion = new.RowVersion +BEGIN + UPDATE MyEntities + SET RowVersion = RowVersion + 1; +END; +``` + +
+ +But sometimes we want to ignore `DbUpdateConcurrencyException`. +And there are two extension methods for this. + +__`static void SaveChangesIgnoreConcurrency(this DbContext dbContext, int retryCount = 3)`__ +Save changes regardless of `DbUpdateConcurrencyException`. + +__`static async Task SaveChangesIgnoreConcurrencyAsync(this DbContext dbContext, int retryCount = 3)`__ +Save changes regardless of `DbUpdateConcurrencyException`. + +
diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/EntityFrameworkCore.ChangeTrackingExtensions.csproj b/EFCore.CommonTools/EntityFrameworkCore.CommonTools.csproj similarity index 64% rename from EntityFrameworkCore.ChangeTrackingExtensions/EntityFrameworkCore.ChangeTrackingExtensions.csproj rename to EFCore.CommonTools/EntityFrameworkCore.CommonTools.csproj index 2fcaf63..0939a55 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/EntityFrameworkCore.ChangeTrackingExtensions.csproj +++ b/EFCore.CommonTools/EntityFrameworkCore.CommonTools.csproj @@ -1,19 +1,19 @@  - netstandard1.6 - EntityFrameworkCore.ChangeTrackingExtensions - 1.0.0 + netstandard2.0 + EntityFrameworkCore.CommonTools + 2.0.0 An extension for EntityFrameworkCore that provides Auditing, Concurrency Checks, JSON Complex Types and writing history to Transaction Log. Dmitry Panyushkin - https://github.com/gnaeus/EntityFramework.ChangeTrackingExtensions/blob/master/LICENSE - https://github.com/gnaeus/EntityFramework.ChangeTrackingExtensions - https://raw.githubusercontent.com/gnaeus/EntityFramework.ChangeTrackingExtensions/master/icon.png - https://github.com/gnaeus/EntityFramework.ChangeTrackingExtensions.git + https://github.com/gnaeus/EntityFramework.CommonTools/blob/master/LICENSE + https://github.com/gnaeus/EntityFramework.CommonTools + https://raw.githubusercontent.com/gnaeus/EntityFramework.CommonTools/master/icon.png + https://github.com/gnaeus/EntityFramework.CommonTools.git git false - First release + EFCore 2.0; Improve AuditableEntities API Copyright © Dmitry Panyushkin 2017 EF EFCore EntityFrameworkCore EntityFramework Entity Framework ChangeTracking Change Tracking Auditing Audit TransactionLog Transaction Log ComplexType Complex Type JSON True @@ -23,20 +23,20 @@ TRACE;DEBUG;EF_CORE bin\Debug\ $(NoWarn);1591 - bin\Debug\netstandard1.6\EntityFrameworkCore.ChangeTrackingExtensions.xml + bin\Debug\netstandard2.0\EntityFrameworkCore.CommonTools.xml TRACE;EF_CORE bin\Release\ $(NoWarn);1591 - bin\Release\netstandard1.6\EntityFrameworkCore.ChangeTrackingExtensions.xml + bin\Release\netstandard2.0\EntityFrameworkCore.CommonTools.xml - - - + + + - \ No newline at end of file + diff --git a/EFCore.CommonTools/Expression/ExpressionExtensions.cs b/EFCore.CommonTools/Expression/ExpressionExtensions.cs new file mode 100644 index 0000000..4060912 --- /dev/null +++ b/EFCore.CommonTools/Expression/ExpressionExtensions.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq.Expressions; +using System.Reflection; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + public static class ExpressionExtensions + { + /// + /// Get computed value of Expression. + /// + /// + public static object GetValue(this Expression expression) + { + if (expression == null) throw new ArgumentNullException(nameof(expression)); + + switch (expression.NodeType) + { + case ExpressionType.Constant: + return ((ConstantExpression)expression).Value; + + case ExpressionType.MemberAccess: + var memberExpr = (MemberExpression)expression; + { + object instance = memberExpr.Expression.GetValue(); + switch (memberExpr.Member) + { + case FieldInfo field: + return field.GetValue(instance); + + case PropertyInfo property: + return property.GetValue(instance); + } + } + break; + + case ExpressionType.Convert: + var convertExpr = (UnaryExpression)expression; + { + if (convertExpr.Method == null) + { + Type type = Nullable.GetUnderlyingType(convertExpr.Type) ?? convertExpr.Type; + object value = convertExpr.Operand.GetValue(); + return Convert.ChangeType(value, type); + } + } + break; + + case ExpressionType.ArrayIndex: + var indexExpr = (BinaryExpression)expression; + { + var array = (Array)indexExpr.Left.GetValue(); + var index = (int)indexExpr.Right.GetValue(); + return array.GetValue(index); + } + + case ExpressionType.ArrayLength: + var lengthExpr = (UnaryExpression)expression; + { + var array = (Array)lengthExpr.Operand.GetValue(); + return array.Length; + } + + case ExpressionType.Call: + var callExpr = (MethodCallExpression)expression; + { + if (callExpr.Method.Name == "get_Item") + { + object instance = callExpr.Object.GetValue(); + object[] arguments = new object[callExpr.Arguments.Count]; + for (int i = 0; i < arguments.Length; i++) + { + arguments[i] = callExpr.Arguments[i].GetValue(); + } + return callExpr.Method.Invoke(instance, arguments); + } + } + break; + } + + // we can't interpret the expression but we can compile and run it + var objectMember = Expression.Convert(expression, typeof(object)); + var getterLambda = Expression.Lambda>(objectMember); + + return getterLambda.Compile().Invoke(); + } + } +} diff --git a/EFCore.CommonTools/Expression/README.md b/EFCore.CommonTools/Expression/README.md new file mode 100644 index 0000000..1a5c13b --- /dev/null +++ b/EFCore.CommonTools/Expression/README.md @@ -0,0 +1,8 @@ +## ExpressionExtensions + +Get computed value of `Expression` +```cs +public static object GetValue(this Expression expression); +``` + +
diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Utils/JsonField.cs b/EFCore.CommonTools/Json/JsonField.cs similarity index 59% rename from EntityFrameworkCore.ChangeTrackingExtensions/Utils/JsonField.cs rename to EFCore.CommonTools/Json/JsonField.cs index bab48d6..8efa779 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Utils/JsonField.cs +++ b/EFCore.CommonTools/Json/JsonField.cs @@ -1,22 +1,21 @@ -#if EF_CORE -using System; +using System; using Jil; -namespace EntityFrameworkCore.ChangeTrackingExtensions +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools #else -using System; -using Jil; - -namespace EntityFramework.ChangeTrackingExtensions +namespace System.Linq.CommonTools #endif { /// - /// Utility struct for storing complex types as JSON strings in database table. + /// Utility structure for storing complex types as JSON strings in DB table. /// - public struct JsonField - where TValue : class + public struct JsonField + where TObject : class { - private TValue _value; + private TObject _object; private string _json; private bool _isMaterialized; private bool _hasDefault; @@ -27,8 +26,8 @@ public string Json { if (_isMaterialized) { - _json = _value == null - ? null : JSON.Serialize(_value, Options.IncludeInherited); + _json = _object == null + ? null : JSON.Serialize(_object, Options.IncludeInherited); } return _json; } @@ -39,7 +38,7 @@ public string Json } } - public TValue Value + public TObject Object { get { @@ -53,29 +52,29 @@ public TValue Value } else { - _value = null; + _object = null; } } else { - _value = JSON.Deserialize(_json); + _object = JSON.Deserialize(_json); } _isMaterialized = true; } - return _value; + return _object; } set { - _value = value; + _object = value; _isMaterialized = true; } } - public static implicit operator JsonField(TValue value) + public static implicit operator JsonField(TObject defaultValue) { - var field = new JsonField(); + var field = new JsonField(); - field._value = value; + field._object = defaultValue; field._hasDefault = true; return field; diff --git a/EFCore.CommonTools/Json/README.md b/EFCore.CommonTools/Json/README.md new file mode 100644 index 0000000..a0d0a9f --- /dev/null +++ b/EFCore.CommonTools/Json/README.md @@ -0,0 +1,94 @@ +## JSON Complex Types +There is an utility struct named `JsonField`, that helps to persist any Complex Type as JSON string in single table column. + +```cs +struct JsonField + where TObject : class +{ + public string Json { get; set; } + public TObject Object { get; set; } +} +``` + +Usage: +```cs +class User +{ + public int Id { get; set; } + public string Name { get; set; } + public string Login { get; set; } + + private JsonField
_address; + // used by EntityFramework + public string AddressJson + { + get { return _address.Json; } + set { _address.Json = value; } + } + // used by application code + public Address Address + { + get { return _address.Object; } + set { _address.Object = value; } + } + + // collection initialization by default + private JsonField> _phones = new HashSet(); + public string PhonesJson + { + get { return _phones.Json; } + set { _phones.Json = value; } + } + public ICollection Phones + { + get { return _phones.Object; } + set { _phones.Object = value; } + } +} + +[NotMapped] +class Address +{ + public string City { get; set; } + public string Street { get; set; } + public string Building { get; set; } +} +``` + +If we update these Complex Type properties, the following SQL is generated during `SaveChanges`: +```sql +UPDATE Users +SET AddressJson = '{"City":"Moscow","Street":"Arbat","Building":"10"}', + PhonesJson = '["+7 (123) 456-7890","+7 (098) 765-4321"]' +WHERE Id = 1; +``` + +The `AddressJson` property is serialized from `Address` only when it accessed by EntityFramework. +And the `Address` property is materialized from `AddressJson` only when EntityFramework writes to `AddressJson`. + +If we want to initialize some JSON collection in entity consctuctor, for example: +```cs +class MyEntity +{ + public ICollection MyObjects { get; set; } = new HashSet(); +} +``` +We can use the following implicit conversion: +```cs +class MyEntity +{ + private JsonField> _myObjects = new HashSet(); +} +``` +It uses the following implicit operator: +```cs +struct JsonField +{ + public static implicit operator JsonField(TObject defaultValue); +} +``` + +The only caveat is that `TObject` object should not contain reference loops. +Because `JsonField` uses [Jil](https://github.com/kevin-montrose/Jil) (the fastest .NET JSON serializer) behind the scenes. + +
diff --git a/EFCore.CommonTools/Properties/AssemblyInfo.cs b/EFCore.CommonTools/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..6009aad --- /dev/null +++ b/EFCore.CommonTools/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("EntityFrameworkCore.CommonTools.Tests")] diff --git a/EFCore.CommonTools/Querying/AsQueryableExpander.cs b/EFCore.CommonTools/Querying/AsQueryableExpander.cs new file mode 100644 index 0000000..6904db6 --- /dev/null +++ b/EFCore.CommonTools/Querying/AsQueryableExpander.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + /// + /// that expands + /// inside Expression. + /// + public class AsQueryableExpander : ExpressionVisitor + { + private readonly ExpressionVisitor _expressionExpander = new AsQueryableExpressionExpander(); + + protected override Expression VisitUnary(UnaryExpression node) + { + if (node.NodeType == ExpressionType.Quote) + { + return _expressionExpander.Visit(node); + } + return base.VisitUnary(node); + } + } + + internal class AsQueryableExpressionExpander : ExpressionVisitor + { + protected override Expression VisitMethodCall(MethodCallExpression node) + { + MethodInfo originalMethod = node.Method; + + if (originalMethod.DeclaringType == typeof(Queryable) + && originalMethod.IsDefined(typeof(ExtensionAttribute), true)) + { + if (originalMethod.Name == nameof(Queryable.AsQueryable)) + { + return Visit(node.Arguments[0]); + } + + ParameterInfo[] originalParams = originalMethod.GetParameters(); + + Type[] genericArguments = null; + + if (originalMethod.IsGenericMethod) + { + genericArguments = originalMethod.GetGenericArguments(); + originalMethod = originalMethod.GetGenericMethodDefinition(); + } + + MethodInfo replacementMethod; + + if (MethodReplacements.TryGetValue(originalMethod, out replacementMethod)) + { + if (genericArguments != null) + { + replacementMethod = replacementMethod.MakeGenericMethod(genericArguments); + } + + Expression[] expandedArguments = new Expression[node.Arguments.Count]; + + for (int i = 0; i < node.Arguments.Count; i++) + { + Expression argument = node.Arguments[i]; + + if (argument.NodeType == ExpressionType.Quote) + { + expandedArguments[i] = ((UnaryExpression)argument).Operand; + } + else + { + expandedArguments[i] = argument; + } + } + + if (typeof(IOrderedQueryable).IsAssignableFrom(expandedArguments[0].Type)) + { + expandedArguments[0] = Visit(expandedArguments[0]); + } + + return Visit(Expression.Call(replacementMethod, expandedArguments)); + } + } + return base.VisitMethodCall(node); + } + + /// + /// Key is method from , Value is method from + /// + static readonly Dictionary MethodReplacements; + + static AsQueryableExpressionExpander() + { + MethodReplacements = new Dictionary(); + + var queryableMethods = typeof(Queryable) + .GetMethods(BindingFlags.Static | BindingFlags.Public) + .Where(method => method.IsDefined(typeof(ExtensionAttribute), true)) + .Select(method => new + { + Name = method.Name, + Method = method, + Signature = GetMethodSignature(method), + }); + + var enumerableLookup = typeof(Enumerable) + .GetMethods(BindingFlags.Static | BindingFlags.Public) + .Where(method => method.IsDefined(typeof(ExtensionAttribute), true)) + .ToLookup(method => method.Name, method => new + { + Method = method, + Signature = GetMethodSignature(method), + }); + + foreach (var queryable in queryableMethods) + { + var enumerableMethods = enumerableLookup[queryable.Name]; + + if (enumerableMethods != null) + { + var enumerable = enumerableMethods + .FirstOrDefault(method => method.Signature == queryable.Signature); + + if (enumerable != null) + { + MethodReplacements[queryable.Method] = enumerable.Method; + } + } + } + } + + private static string GetMethodSignature(MethodInfo method) + { + var sb = new StringBuilder(); + + foreach (ParameterInfo param in method.GetParameters()) + { + AddTypeSignature(sb, param.ParameterType); + } + + return sb.ToString(); + } + + private static void AddTypeSignature(StringBuilder sb, Type type) + { + if (type == typeof(IQueryable)) + { + type = typeof(IEnumerable); + } + + if (type.GetTypeInfo().IsGenericType) + { + Type generic = type.GetGenericTypeDefinition(); + + if (generic == typeof(Expression<>)) + { + type = type.GetGenericArguments().First(); + } + else if (generic == typeof(IQueryable<>)) + { + type = typeof(IEnumerable<>).MakeGenericType(type.GetGenericArguments()); + } + else if (generic == typeof(IOrderedQueryable<>)) + { + type = typeof(IOrderedEnumerable<>).MakeGenericType(type.GetGenericArguments()); + } + } + + sb.Append(type.Name); + + if (type.GetTypeInfo().IsGenericType) + { + sb.Append("[ "); + + foreach (Type argument in type.GetGenericArguments()) + { + AddTypeSignature(sb, argument); + } + + sb.Append("]"); + } + + sb.Append(" "); + } + } +} \ No newline at end of file diff --git a/EFCore.CommonTools/Querying/DbAsyncEnumerator.cs b/EFCore.CommonTools/Querying/DbAsyncEnumerator.cs new file mode 100644 index 0000000..0ec0bfd --- /dev/null +++ b/EFCore.CommonTools/Querying/DbAsyncEnumerator.cs @@ -0,0 +1,51 @@ +// The MIT License +// Based on https://github.com/scottksmith95/LINQKit +// Original work: Copyright (c) 2007-2009 Joseph Albahari, Tomas Petricek +// Copyright (c) 2013-2017 Scott Smith, Stef Heyenrath, Tuomas Hietanen +// Modified work: Copyright (c) 2017 Dmitry Panyushkin + +#if EF_6 +using System; +using System.Collections.Generic; +using System.Linq; +using System.Data.Entity.Infrastructure; +using System.Threading.Tasks; +using System.Threading; + +namespace EntityFramework.CommonTools +{ + /// + /// Class for async-await style list enumeration support + /// (e.g. ) + /// + internal class DbAsyncEnumerator : IDisposable, IDbAsyncEnumerator + { + private readonly IEnumerator _inner; + + public DbAsyncEnumerator(IEnumerator inner) + { + _inner = inner; + } + + public void Dispose() + { + _inner.Dispose(); + } + + public Task MoveNextAsync(CancellationToken cancellationToken) + { + return Task.FromResult(_inner.MoveNext()); + } + + public T Current + { + get { return _inner.Current; } + } + + object IDbAsyncEnumerator.Current + { + get { return Current; } + } + } +} +#endif diff --git a/EFCore.CommonTools/Querying/ExpandableAttribute.cs b/EFCore.CommonTools/Querying/ExpandableAttribute.cs new file mode 100644 index 0000000..c83222c --- /dev/null +++ b/EFCore.CommonTools/Querying/ExpandableAttribute.cs @@ -0,0 +1,15 @@ +using System; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + public class ExpandableAttribute : Attribute + { + } +} diff --git a/EFCore.CommonTools/Querying/ExtensionExpander.cs b/EFCore.CommonTools/Querying/ExtensionExpander.cs new file mode 100644 index 0000000..ce36073 --- /dev/null +++ b/EFCore.CommonTools/Querying/ExtensionExpander.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + /// + /// that expands extension methods inside Expression. + /// + public class ExtensionExpander : ExpressionVisitor + { + protected override Expression VisitMethodCall(MethodCallExpression node) + { + MethodInfo method = node.Method; + + if (method.IsDefined(typeof(ExtensionAttribute), true) + && method.IsDefined(typeof(ExpandableAttribute), true)) + { + ParameterInfo[] methodParams = method.GetParameters(); + Type queryableType = methodParams.First().ParameterType; + Type entityType = queryableType.GetGenericArguments().Single(); + + object inputQueryable = MakeEnumerableQuery(entityType); + + object[] arguments = new object[methodParams.Length]; + + arguments[0] = inputQueryable; + + var argumentReplacements = new List>(); + + for (int i = 1; i < methodParams.Length; i++) + { + try + { + arguments[i] = node.Arguments[i].GetValue(); + } + catch (InvalidOperationException) + { + ParameterInfo paramInfo = methodParams[i]; + Type paramType = paramInfo.GetType(); + + arguments[i] = paramType.GetTypeInfo().IsValueType + ? Activator.CreateInstance(paramType) : null; + + argumentReplacements.Add( + new KeyValuePair(paramInfo.Name, node.Arguments[i])); + } + } + + object outputQueryable = method.Invoke(null, arguments); + + Expression expression = ((IQueryable)outputQueryable).Expression; + + Expression realQueryable = node.Arguments[0]; + + if (!typeof(IQueryable).IsAssignableFrom(realQueryable.Type)) + { + MethodInfo asQueryable = _asQueryable.MakeGenericMethod(entityType); + realQueryable = Expression.Call(asQueryable, realQueryable); + } + + expression = new ExtensionRebinder( + inputQueryable, realQueryable, argumentReplacements).Visit(expression); + + return Visit(expression); + } + return base.VisitMethodCall(node); + } + + private static object MakeEnumerableQuery(Type entityType) + { + return _queryableEmpty.MakeGenericMethod(entityType).Invoke(null, null); + } + + private static readonly MethodInfo _asQueryable = typeof(Queryable) + .GetMethods(BindingFlags.Static | BindingFlags.Public) + .First(m => m.Name == nameof(Queryable.AsQueryable) && m.IsGenericMethod); + + private static readonly MethodInfo _queryableEmpty = (typeof(ExtensionExpander)) + .GetMethod(nameof(QueryableEmpty), BindingFlags.Static | BindingFlags.NonPublic); + + private static IQueryable QueryableEmpty() + { + return Enumerable.Empty().AsQueryable(); + } + } +} diff --git a/EFCore.CommonTools/Querying/ExtensionRebinder.cs b/EFCore.CommonTools/Querying/ExtensionRebinder.cs new file mode 100644 index 0000000..7944443 --- /dev/null +++ b/EFCore.CommonTools/Querying/ExtensionRebinder.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + internal class ExtensionRebinder : ExpressionVisitor + { + private readonly object _originalQueryable; + private readonly Expression _replacementQueryable; + private readonly List> _argumentReplacements; + + public ExtensionRebinder( + object originalQueryable, Expression replacementQueryable, + List> argumentReplacements) + { + _originalQueryable = originalQueryable; + _replacementQueryable = replacementQueryable; + _argumentReplacements = argumentReplacements; + } + + protected override Expression VisitConstant(ConstantExpression node) + { + return node.Value == _originalQueryable ? _replacementQueryable : node; + } + + protected override Expression VisitMember(MemberExpression node) + { + if (node.NodeType == ExpressionType.MemberAccess + && node.Expression.NodeType == ExpressionType.Constant + && node.Expression.Type.GetTypeInfo().IsDefined(typeof(CompilerGeneratedAttribute))) + { + string argumentName = node.Member.Name; + + Expression replacement = _argumentReplacements + .Where(p => p.Key == argumentName) + .Select(p => p.Value) + .FirstOrDefault(); + + if (replacement != null) + { + return replacement; + } + } + return base.VisitMember(node); + } + } +} diff --git a/EFCore.CommonTools/Querying/QueryableExtensions.cs b/EFCore.CommonTools/Querying/QueryableExtensions.cs new file mode 100644 index 0000000..040fc3b --- /dev/null +++ b/EFCore.CommonTools/Querying/QueryableExtensions.cs @@ -0,0 +1,43 @@ +using System; +using System.Linq; +using System.Linq.Expressions; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + public static partial class QueryableExtensions + { + /// + /// Expand all extension methods that marked by . + /// + public static IQueryable AsExpandable(this IQueryable queryable) + { + if (queryable == null) throw new ArgumentNullException(nameof(queryable)); + +#if EF_CORE + return queryable.AsVisitable(new ExtensionExpander(), new AsQueryableExpander()); +#else + return queryable.AsVisitable(new ExtensionExpander()); +#endif + } + + /// + /// Wrap to decorator that intercepts + /// IQueryable.Expression with provided . + /// + public static IQueryable AsVisitable( + this IQueryable queryable, params ExpressionVisitor[] visitors) + { + if (queryable == null) throw new ArgumentNullException(nameof(queryable)); + if (visitors == null) throw new ArgumentNullException(nameof(visitors)); + + return queryable as VisitableQuery + ?? VisitableQueryFactory.Create(queryable, visitors); + } + } +} diff --git a/EFCore.CommonTools/Querying/README.md b/EFCore.CommonTools/Querying/README.md new file mode 100644 index 0000000..6828af2 --- /dev/null +++ b/EFCore.CommonTools/Querying/README.md @@ -0,0 +1,65 @@ +## Attaching ExpressionVisitor to IQueryable + +With `.AsVisitable()` extension we can attach any `ExpressionVisitor` to `IQueryable`. + +```cs +public static IQueryable AsVisitable( + this IQueryable queryable, params ExpressionVisitor[] visitors); +``` + +## Expandable extension methods for IQueryable + +We can use extension methods for `IQueryable` to incapsulate custom buisiness logic. +But if we call these methods from `Expression`, we get runtime error. + +```cs +public static IQueryable FilterByAuthor(this IQueryable posts, int authorId) +{ + return posts.Where(p => p.AuthorId = authorId); +} + +public static IQueryable FilterTodayComments(this IQueryable comments) +{ + DateTime today = DateTime.Now.Date; + + return comments.Where(c => c.CreationTime > today) +} + +Comment[] comments = context.Posts + .FilterByAuthor(authorId) // it's OK + .SelectMany(p => p.Comments + .AsQueryable() + .FilterTodayComments()) // will throw Error + .ToArray(); +``` + +With `.AsExpandable()` extension we can use extension methods everywhere. + +```cs +Comment[] comments = context.Posts + .AsExpandable() + .FilterByAuthor(authorId) // it's OK + .SelectMany(p => p.Comments + .FilterTodayComments()) // it's OK too + .ToArray(); +``` + +Expandable extension methods should return `IQueryable` and should have `[Expandable]` attribute. + +```cs +[Expandable] +public static IQueryable FilterByAuthor(this IEnumerable posts, int authorId) +{ + return posts.AsQueryable().Where(p => p.AuthorId = authorId); +} + +[Expandable] +public static IQueryable FilterTodayComments(this IEnumerable comments) +{ + DateTime today = DateTime.Now.Date; + + return comments.AsQueryable().Where(c => c.CreationTime > today) +} +``` + +
diff --git a/EFCore.CommonTools/Querying/VisitableQuery.cs b/EFCore.CommonTools/Querying/VisitableQuery.cs new file mode 100644 index 0000000..30cee30 --- /dev/null +++ b/EFCore.CommonTools/Querying/VisitableQuery.cs @@ -0,0 +1,144 @@ +// The MIT License +// Based on https://github.com/scottksmith95/LINQKit +// Original work: Copyright (c) 2007-2009 Joseph Albahari, Tomas Petricek +// Copyright (c) 2013-2017 Scott Smith, Stef Heyenrath, Tuomas Hietanen +// Modified work: Copyright (c) 2017 Dmitry Panyushkin + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Collections; +using System.Reflection; + +#if EF_CORE +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query.Internal; + +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +using System.Data.Entity; +using System.Data.Entity.Infrastructure; + +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + /// + /// An wrapper that allows us to visit + /// the query's expression tree just before LINQ to SQL gets to it. + /// + internal class VisitableQuery : IQueryable, IOrderedQueryable, IOrderedQueryable +#if EF_CORE + , IAsyncEnumerable +#elif EF_6 + , IDbAsyncEnumerable +#endif + { + private readonly ExpressionVisitor[] _visitors; + private readonly IQueryable _queryable; + private readonly VisitableQueryProvider _provider; + + internal ExpressionVisitor[] Visitors => _visitors; + internal IQueryable InnerQuery => _queryable; + + public VisitableQuery(IQueryable queryable, ExpressionVisitor[] visitors) + { + _queryable = queryable; + _visitors = visitors; + _provider = new VisitableQueryProvider(this); + } + + Expression IQueryable.Expression => _queryable.Expression; + + Type IQueryable.ElementType => typeof(T); + + IQueryProvider IQueryable.Provider => _provider; + + public IEnumerator GetEnumerator() + { + return _queryable.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _queryable.GetEnumerator(); + } + + public override string ToString() + { + return _queryable.ToString(); + } + +#if EF_CORE + IAsyncEnumerator IAsyncEnumerable.GetEnumerator() + { + return (_queryable as IAsyncEnumerable)?.GetEnumerator() + ?? (_queryable as IAsyncEnumerableAccessor)?.AsyncEnumerable.GetEnumerator(); + } +#elif EF_6 + public IDbAsyncEnumerator GetAsyncEnumerator() + { + return (_queryable as IDbAsyncEnumerable)?.GetAsyncEnumerator() + ?? new DbAsyncEnumerator(_queryable.GetEnumerator()); + } + + IDbAsyncEnumerator IDbAsyncEnumerable.GetAsyncEnumerator() + { + return GetAsyncEnumerator(); + } +#endif + } + +#if EF_CORE || EF_6 + internal class VisitableQueryOfClass : VisitableQuery + where T : class + { + public VisitableQueryOfClass(IQueryable queryable, ExpressionVisitor[] visitors) + : base(queryable, visitors) + { + } + +#if EF_CORE + public IQueryable Include(Expression> navigationPropertyPath) + { + return InnerQuery.Include(navigationPropertyPath).AsVisitable(Visitors); + } +#elif EF_6 + public IQueryable Include(string path) + { + return InnerQuery.Include(path).AsVisitable(Visitors); + } +#endif + } + + internal static class VisitableQueryFactory + { + public static readonly Func, ExpressionVisitor[], VisitableQuery> Create; + + static VisitableQueryFactory() + { + if (!typeof(T).GetTypeInfo().IsClass) + { + Create = (query, visitors) => new VisitableQuery(query, visitors); + return; + } + + var queryType = typeof(IQueryable); + var visitorsType = typeof(ExpressionVisitor[]); + var ctorInfo = typeof(VisitableQueryOfClass<>) + .MakeGenericType(typeof(T)) + .GetConstructor(new[] { queryType, visitorsType }); + + var queryParam = Expression.Parameter(queryType); + var visitorsParam = Expression.Parameter(visitorsType); + var newExpr = Expression.New(ctorInfo, queryParam, visitorsParam); + var createExpr = Expression.Lambda, ExpressionVisitor[], VisitableQuery>>( + newExpr, queryParam, visitorsParam); + + Create = createExpr.Compile(); + } + } +#endif +} diff --git a/EFCore.CommonTools/Querying/VisitableQueryProvider.cs b/EFCore.CommonTools/Querying/VisitableQueryProvider.cs new file mode 100644 index 0000000..345d81a --- /dev/null +++ b/EFCore.CommonTools/Querying/VisitableQueryProvider.cs @@ -0,0 +1,101 @@ +// The MIT License +// Based on https://github.com/scottksmith95/LINQKit +// Original work: Copyright (c) 2007-2009 Joseph Albahari, Tomas Petricek +// Copyright (c) 2013-2017 Scott Smith, Stef Heyenrath, Tuomas Hietanen +// Modified work: Copyright (c) 2017 Dmitry Panyushkin + +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; + +#if EF_CORE +using Microsoft.EntityFrameworkCore.Query.Internal; + +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +using System.Data.Entity; +using System.Data.Entity.Infrastructure; + +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + internal class VisitableQueryProvider : IQueryProvider +#if EF_CORE + , IAsyncQueryProvider +#elif EF_6 + , IDbAsyncQueryProvider +#endif + { + private readonly VisitableQuery _query; + + public VisitableQueryProvider(VisitableQuery query) + { + _query = query; + } + + /// + /// The following four methods first call ExpressionExpander to visit the expression tree, + /// then call upon the inner query to do the remaining work. + /// + IQueryable IQueryProvider.CreateQuery(Expression expression) + { + expression = _query.Visitors.Visit(expression); + return _query.InnerQuery.Provider.CreateQuery(expression).AsVisitable(_query.Visitors); + } + + IQueryable IQueryProvider.CreateQuery(Expression expression) + { + expression = _query.Visitors.Visit(expression); + return _query.InnerQuery.Provider.CreateQuery(expression); + } + + TResult IQueryProvider.Execute(Expression expression) + { + expression = _query.Visitors.Visit(expression); + return _query.InnerQuery.Provider.Execute(expression); + } + + object IQueryProvider.Execute(Expression expression) + { + expression = _query.Visitors.Visit(expression); + return _query.InnerQuery.Provider.Execute(expression); + } + +#if EF_CORE + public IAsyncEnumerable ExecuteAsync(Expression expression) + { + expression = _query.Visitors.Visit(expression); + var asyncProvider = (IAsyncQueryProvider)_query.InnerQuery.Provider; + return asyncProvider.ExecuteAsync(expression); + } + + public Task ExecuteAsync(Expression expression, CancellationToken cancellationToken) + { + expression = _query.Visitors.Visit(expression); + var asyncProvider = _query.InnerQuery.Provider as IAsyncQueryProvider; + return asyncProvider?.ExecuteAsync(expression, cancellationToken) + ?? Task.FromResult(_query.InnerQuery.Provider.Execute(expression)); + } +#elif EF_6 + public Task ExecuteAsync(Expression expression, CancellationToken cancellationToken) + { + expression = _query.Visitors.Visit(expression); + var asyncProvider = _query.InnerQuery.Provider as IDbAsyncQueryProvider; + return asyncProvider?.ExecuteAsync(expression, cancellationToken) + ?? Task.FromResult(_query.InnerQuery.Provider.Execute(expression)); + } + + public Task ExecuteAsync(Expression expression, CancellationToken cancellationToken) + { + expression = _query.Visitors.Visit(expression); + var asyncProvider = _query.InnerQuery.Provider as IDbAsyncQueryProvider; + return asyncProvider?.ExecuteAsync(expression, cancellationToken) + ?? Task.FromResult(_query.InnerQuery.Provider.Execute(expression)); + } +#endif + } +} diff --git a/EFCore.CommonTools/Querying/VisitorExtensions.cs b/EFCore.CommonTools/Querying/VisitorExtensions.cs new file mode 100644 index 0000000..d5834ed --- /dev/null +++ b/EFCore.CommonTools/Querying/VisitorExtensions.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq.Expressions; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + public static class VisitorExtensions + { + /// + /// Apply all to Expression one by one. + /// + public static Expression Visit(this IEnumerable visitors, Expression node) + { + if (visitors != null) + { + foreach (ExpressionVisitor visitor in visitors) + { + node = visitor.Visit(node); + } + } + return node; + } + } +} diff --git a/EFCore.CommonTools/Specification/ParameterReplacer.cs b/EFCore.CommonTools/Specification/ParameterReplacer.cs new file mode 100644 index 0000000..6b666d3 --- /dev/null +++ b/EFCore.CommonTools/Specification/ParameterReplacer.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + internal class ParameterReplacer : ExpressionVisitor + { + private readonly ParameterExpression _parameter; + private readonly ParameterExpression _replacement; + + public ParameterReplacer(ParameterExpression parameter, ParameterExpression replacement) + { + _parameter = parameter; + _replacement = replacement; + } + + protected override Expression VisitParameter(ParameterExpression node) + { + return base.VisitParameter(_parameter == node ? _replacement : node); + } + } +} diff --git a/EFCore.CommonTools/Specification/README.md b/EFCore.CommonTools/Specification/README.md new file mode 100644 index 0000000..22319f7 --- /dev/null +++ b/EFCore.CommonTools/Specification/README.md @@ -0,0 +1,85 @@ +## Specification Pattern + +Generic implementation of [Specification Pattern](https://en.wikipedia.org/wiki/Specification_pattern). + +```cs +public interface ISpecification +{ + bool IsSatisfiedBy(T entity); + + Expression> ToExpression(); +} + +public class Specification : ISpecification +{ + public Specification(Expression> predicate); +} +``` + +We can define named specifications: +```cs +class UserIsActiveSpec : Specification +{ + public UserIsActiveSpec() + : base(u => !u.IsDeleted) { } +} + +class UserByLoginSpec : Specification +{ + public UserByLoginSpec(string login) + : base(u => u.Login == login) { } +} +``` + +Then we can combine specifications with conditional logic operators `&&`, `||` and `!`: +```cs +class CombinedSpec +{ + public CombinedSpec(string login) + : base(new UserIsActiveSpec() && new UserByLoginSpec(login)) { } +} +``` + +Also we can test it: +```cs +var user = new User { Login = "admin", IsDeleted = false }; +var spec = new CombinedSpec("admin"); + +Assert.IsTrue(spec.IsSatisfiedBy(user)); +``` + +And use with `IEnumerable`: + +```cs +var users = Enumerable.Empty(); +var spec = new UserByLoginSpec("admin"); + +var admin = users.FirstOrDefault(spec.IsSatisfiedBy); + +// or even +var admin = users.FirstOrDefault(spec); +``` + +Or even with `IQueryable`: +```cs +var spec = new UserByLoginSpec("admin"); + +var admin = context.Users.FirstOrDefault(spec.ToExpression()); + +// or even +var admin = context.Users.FirstOrDefault(spec); + +// and also inside Expression +var adminFiends = context.Users + .AsVisitable(new SpecificationExpander()) + .Where(u => u.Firends.Any(spec.ToExpression())) + .ToList(); + +// or even +var adminFiends = context.Users + .AsVisitable(new SpecificationExpander()) + .Where(u => u.Firends.Any(spec)) + .ToList(); +``` + +
diff --git a/EFCore.CommonTools/Specification/Specification.cs b/EFCore.CommonTools/Specification/Specification.cs new file mode 100644 index 0000000..4b15912 --- /dev/null +++ b/EFCore.CommonTools/Specification/Specification.cs @@ -0,0 +1,132 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Linq.Expressions; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + /// + /// Specification pattren https://en.wikipedia.org/wiki/Specification_pattern. + /// + /// + public interface ISpecification + { + bool IsSatisfiedBy(T entity); + + Expression> ToExpression(); + } + + /// + /// Implementation of Specification pattren, that can be used with expressions. + /// + [DebuggerDisplay("{Predicate}")] + public class Specification : ISpecification + { + private Func _function; + + private Func Function => _function ?? (_function = Predicate.Compile()); + + protected Expression> Predicate; + + protected Specification() { } + + public Specification(Expression> predicate) + { + Predicate = predicate; + } + + public bool IsSatisfiedBy(T entity) + { + return Function.Invoke(entity); + } + + public Expression> ToExpression() + { + return Predicate; + } + + public static implicit operator Func(Specification spec) + { + if (spec == null) throw new ArgumentNullException(nameof(spec)); + + return spec.Function; + } + + public static implicit operator Expression>(Specification spec) + { + if (spec == null) throw new ArgumentNullException(nameof(spec)); + + return spec.Predicate; + } + + /// + /// For user-defined conditional logical operators. + /// https://msdn.microsoft.com/en-us/library/aa691312(v=vs.71).aspx + /// + public static bool operator true(Specification spec) + { + return false; + } + + /// + /// For user-defined conditional logical operators. + /// https://msdn.microsoft.com/en-us/library/aa691312(v=vs.71).aspx + /// + public static bool operator false(Specification spec) + { + return false; + } + + public static Specification operator !(Specification spec) + { + if (spec == null) throw new ArgumentNullException(nameof(spec)); + + return new Specification( + Expression.Lambda>( + Expression.Not(spec.Predicate.Body), + spec.Predicate.Parameters)); + } + + public static Specification operator &(Specification left, Specification right) + { + if (left == null) throw new ArgumentNullException(nameof(left)); + if (right == null) throw new ArgumentNullException(nameof(right)); + + var leftExpr = left.Predicate; + var rightExpr = right.Predicate; + var leftParam = leftExpr.Parameters[0]; + var rightParam = rightExpr.Parameters[0]; + + return new Specification( + Expression.Lambda>( + Expression.AndAlso( + leftExpr.Body, + new ParameterReplacer(rightParam, leftParam).Visit(rightExpr.Body)), + leftParam)); + } + + public static Specification operator |(Specification left, Specification right) + { + if (left == null) throw new ArgumentNullException(nameof(left)); + if (right == null) throw new ArgumentNullException(nameof(right)); + + var leftExpr = left.Predicate; + var rightExpr = right.Predicate; + var leftParam = leftExpr.Parameters[0]; + var rightParam = rightExpr.Parameters[0]; + + return new Specification( + Expression.Lambda>( + Expression.OrElse( + leftExpr.Body, + new ParameterReplacer(rightParam, leftParam).Visit(rightExpr.Body)), + leftParam)); + } + } +} diff --git a/EFCore.CommonTools/Specification/SpecificationExpander.cs b/EFCore.CommonTools/Specification/SpecificationExpander.cs new file mode 100644 index 0000000..349d5e9 --- /dev/null +++ b/EFCore.CommonTools/Specification/SpecificationExpander.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +#if EF_CORE +namespace EntityFrameworkCore.CommonTools +#elif EF_6 +namespace EntityFramework.CommonTools +#else +namespace System.Linq.CommonTools +#endif +{ + /// + /// that expands inside Expression. + /// + public class SpecificationExpander : ExpressionVisitor + { + protected override Expression VisitUnary(UnaryExpression node) + { + if (node.NodeType == ExpressionType.Convert) + { + MethodInfo method = node.Method; + + if (method != null && method.Name == "op_Implicit") + { + Type declaringType = method.DeclaringType; + + if (declaringType.GetTypeInfo().IsGenericType + && declaringType.GetGenericTypeDefinition() == typeof(Specification<>)) + { + const string name = nameof(Specification.ToExpression); + + MethodInfo toExpression = declaringType.GetMethod(name); + + return ExpandSpecification(node.Operand, toExpression); + } + } + } + + return base.VisitUnary(node); + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + MethodInfo method = node.Method; + + if (method.Name == nameof(ISpecification.ToExpression)) + { + Type declaringType = method.DeclaringType; + Type[] interfaces = declaringType.GetTypeInfo().GetInterfaces(); + + if (interfaces.Any(i => i.GetTypeInfo().IsGenericType + && i.GetGenericTypeDefinition() == typeof(ISpecification<>))) + { + return ExpandSpecification(node.Object, method); + } + } + + return base.VisitMethodCall(node); + } + + private Expression ExpandSpecification(Expression specification, MethodInfo toExpression) + { + object expression = Expression.Call(specification, toExpression).GetValue(); + + return Visit((Expression)expression); + } + } +} diff --git a/EFCore.CommonTools/TransactionLog/README.md b/EFCore.CommonTools/TransactionLog/README.md new file mode 100644 index 0000000..6b03fa5 --- /dev/null +++ b/EFCore.CommonTools/TransactionLog/README.md @@ -0,0 +1,105 @@ +## Transaction Logs +Write all inserted / updated / deleted entities (serialized to JSON) to the separete table named `TransactionLog`. + +To capture transaction logs an entity must inherit from empty `ITransactionLoggable { }` interface. + +And the `DbContext` should overload `SaveChanges()` method with `SaveChangesWithTransactionLog()` wrapper, +and register the `TransactionLog` entity in `ModelBuilder`. + +```cs +class Post : ITransactionLoggable +{ + public string Content { get; set; } +} + +// for EntityFramework 6 +class MyDbContext : DbContext +{ + public DbSet Posts { get; set; } + + protected override void OnModelCreating(DbModelBuilder modelBuilder) + { + modelBuilder.UseTransactionLog(); + } + + public override int SaveChanges() + { + return this.SaveChangesWithTransactionLog(base.SaveChanges); + } + + // override the most general SaveChangesAsync + public override Task SaveChangesAsync(CancellationToken cancellationToken) + { + return this.SaveChangesWithTransactionLogAsync(base.SaveChangesAsync, cancellationToken); + } +} + +// for EntityFramework Core +class MyCoreDbContext : DbContext +{ + public DbSet Posts { get; set; } + + protected override void OnModelCreating(DbModelBuilder modelBuilder) + { + modelBuilder.UseTransactionLog(); + } + + // override the most general SaveChanges + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + return this.SaveChangesWithTransactionLog(base.SaveChanges, acceptAllChangesOnSuccess); + } + + // override the most general SaveChangesAsync + public override Task SaveChangesAsync( + bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken)) + { + return this.SaveChangesWithTransactionLogAsync( + base.SaveChangesAsync, acceptAllChangesOnSuccess, cancellationToken); + } +} + +``` + +After that the transaction logs can be accessed via `TransactionLog` entity: + +```cs +class TransactionLog +{ + // Auto incremented primary key. + public long Id { get; set; } + + // An ID of all changes that captured during single DbContext.SaveChanges() call. + public Guid TransactionId { get; set; } + + // UTC timestamp of DbContext.SaveChanges() call. + public DateTime CreatedUtc { get; set; } + + // "INS", "UPD" or "DEL". Not null. + public string Operation { get; set; } + + // Schema for captured entity. Can be null for SQLite. + public string Schema { get; set; } + + // Table for captured entity. Not null. + public string TableName { get; set; } + + // Assembly qualified type name of captured entity. Not null. + public string EntityType { get; set; } + + // The captured entity serialized to JSON by Jil serializer. Not null. + public string EntityJson { get; set; } + + // Lazily deserialized entity object. + // Type for deserialization is taken from EntityType property. + // All navigation properties and collections will be empty. + public object Entity { get; } + + // Get strongly typed entity from transaction log. + // Can be null if TEntity and type from EntityType property are incompatible. + // All navigation properties and collections will be empty. + public TEntity GetEntity(); +} +``` + +
diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Extensions/TransactionExtensions.cs b/EFCore.CommonTools/TransactionLog/TransactionExtensions.cs similarity index 92% rename from EntityFrameworkCore.ChangeTrackingExtensions/Extensions/TransactionExtensions.cs rename to EFCore.CommonTools/TransactionLog/TransactionExtensions.cs index 2262436..56eece0 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Extensions/TransactionExtensions.cs +++ b/EFCore.CommonTools/TransactionLog/TransactionExtensions.cs @@ -1,15 +1,14 @@ -#if EF_CORE -using System; +using System; using System.Threading.Tasks; + +#if EF_CORE using Microsoft.EntityFrameworkCore; -namespace EntityFrameworkCore.ChangeTrackingExtensions -#else -using System; -using System.Threading.Tasks; +namespace EntityFrameworkCore.CommonTools +#elif EF_6 using System.Data.Entity; -namespace EntityFramework.ChangeTrackingExtensions +namespace EntityFramework.CommonTools #endif { public static partial class DbContextExtensions diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Entities/TransactionLog.cs b/EFCore.CommonTools/TransactionLog/TransactionLog.cs similarity index 89% rename from EntityFrameworkCore.ChangeTrackingExtensions/Entities/TransactionLog.cs rename to EFCore.CommonTools/TransactionLog/TransactionLog.cs index a259cd1..36b6a15 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Entities/TransactionLog.cs +++ b/EFCore.CommonTools/TransactionLog/TransactionLog.cs @@ -1,17 +1,15 @@ -#if EF_CORE -using System; +using System; using System.Diagnostics; -using Microsoft.EntityFrameworkCore; using Jil; -namespace EntityFrameworkCore.ChangeTrackingExtensions -#else -using System; -using System.Diagnostics; +#if EF_CORE +using Microsoft.EntityFrameworkCore; + +namespace EntityFrameworkCore.CommonTools +#elif EF_6 using System.Data.Entity; -using Jil; -namespace EntityFramework.ChangeTrackingExtensions +namespace EntityFramework.CommonTools #endif { /// @@ -33,12 +31,12 @@ public class TransactionLog public long Id { get; set; } /// - /// An ID of all changes that captured during single call. + /// An ID of all changes that captured during single call. /// public Guid TransactionId { get; set; } /// - /// UTC timestamp of call. + /// UTC timestamp of call. /// public DateTime CreatedUtc { get; set; } diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Utils/TransactionLogContext.cs b/EFCore.CommonTools/TransactionLog/TransactionLogContext.cs similarity index 87% rename from EntityFrameworkCore.ChangeTrackingExtensions/Utils/TransactionLogContext.cs rename to EFCore.CommonTools/TransactionLog/TransactionLogContext.cs index 076cb11..57c6fe4 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Utils/TransactionLogContext.cs +++ b/EFCore.CommonTools/TransactionLog/TransactionLogContext.cs @@ -1,38 +1,35 @@ -#if EF_CORE -using System; +using System; using System.Collections.Generic; using System.Linq; using Jil; + +#if EF_CORE using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -namespace EntityFrameworkCore.ChangeTrackingExtensions -#else -using System; -using System.Collections.Generic; -using System.Linq; +namespace EntityFrameworkCore.CommonTools +#elif EF_6 using System.Data.Entity; using System.Data.Entity.Core.Objects; using System.Data.Entity.Infrastructure; -using Jil; using EntityEntry = System.Data.Entity.Infrastructure.DbEntityEntry; -namespace EntityFramework.ChangeTrackingExtensions +namespace EntityFramework.CommonTools #endif { /// - /// Utility for capturing transaction logs from . + /// Utility for capturing transaction logs from . /// Tracked entities must implement interface. /// internal class TransactionLogContext { - readonly DbContext _context; - readonly Guid _transactionId = Guid.NewGuid(); - readonly DateTime _createdUtc = DateTime.UtcNow; + private readonly DbContext _context; + private readonly Guid _transactionId = Guid.NewGuid(); + private readonly DateTime _createdUtc = DateTime.UtcNow; - readonly List _insertedEntries = new List(); - readonly List _updatedEntries = new List(); - readonly List _deletedLogs = new List(); + private readonly List _insertedEntries = new List(); + private readonly List _updatedEntries = new List(); + private readonly List _deletedLogs = new List(); public TransactionLogContext(DbContext context) { @@ -101,7 +98,7 @@ private TransactionLog CreateTransactionLog(EntityEntry entry, string operation) Type entityType = entity.GetType(); #if EF_CORE var tableAndSchema = entry.Metadata.Relational(); -#else +#elif EF_6 if (_context.Configuration.ProxyCreationEnabled) { entityType = ObjectContext.GetObjectType(entityType); @@ -127,7 +124,7 @@ private TransactionLog CreateTransactionLog(EntityEntry entry, string operation) .Properties .Select(p => entry.Property(p.Name)) .ToDictionary(p => p.Metadata.Name, p => p.CurrentValue); -#else +#elif EF_6 var primaryKey = ((IObjectContextAdapter)_context) .ObjectContext.ObjectStateManager .GetObjectStateEntry(entity) diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Extensions/TransactionLogExtensions.cs b/EFCore.CommonTools/TransactionLog/TransactionLogExtensions.cs similarity index 89% rename from EntityFrameworkCore.ChangeTrackingExtensions/Extensions/TransactionLogExtensions.cs rename to EFCore.CommonTools/TransactionLog/TransactionLogExtensions.cs index 1bd2acc..f0ac376 100644 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Extensions/TransactionLogExtensions.cs +++ b/EFCore.CommonTools/TransactionLog/TransactionLogExtensions.cs @@ -1,29 +1,27 @@ -#if EF_CORE -using System; +using System; using System.Threading; using System.Threading.Tasks; + +#if EF_CORE using Microsoft.EntityFrameworkCore; -namespace EntityFrameworkCore.ChangeTrackingExtensions -#else -using System; -using System.Threading; -using System.Threading.Tasks; +namespace EntityFrameworkCore.CommonTools +#elif EF_6 using System.Data.Entity; using ModelBuilder = System.Data.Entity.DbModelBuilder; -namespace EntityFramework.ChangeTrackingExtensions +namespace EntityFramework.CommonTools #endif { public static partial class DbContextExtensions { /// - /// Wrapper for that saves to DB. + /// Wrapper for that saves to DB. /// public static int SaveChangesWithTransactionLog( #if EF_CORE this DbContext dbContext, Func baseSaveChanges, bool acceptAllChangesOnSuccess = true) -#else +#elif EF_6 this DbContext dbContext, Func baseSaveChanges) #endif { @@ -33,7 +31,7 @@ public static int SaveChangesWithTransactionLog( #if EF_CORE // save main entities int count = baseSaveChanges.Invoke(acceptAllChangesOnSuccess); -#else +#elif EF_6 // save main entities int count = baseSaveChanges.Invoke(); #endif @@ -41,7 +39,7 @@ public static int SaveChangesWithTransactionLog( #if EF_CORE // save TransactionLog entities baseSaveChanges.Invoke(acceptAllChangesOnSuccess); -#else +#elif EF_6 // save TransactionLog entities baseSaveChanges.Invoke(); #endif @@ -50,7 +48,8 @@ public static int SaveChangesWithTransactionLog( } /// - /// Wrapper for that saves to DB. + /// Wrapper for + /// that saves to DB. /// public static Task SaveChangesWithTransactionLogAsync( #if EF_CORE @@ -58,7 +57,7 @@ public static Task SaveChangesWithTransactionLogAsync( Func> baseSaveChangesAsync, bool acceptAllChangesOnSuccess = true, CancellationToken cancellationToken = default(CancellationToken)) -#else +#elif EF_6 this DbContext dbContext, Func> baseSaveChangesAsync, CancellationToken cancellationToken = default(CancellationToken)) @@ -70,7 +69,7 @@ public static Task SaveChangesWithTransactionLogAsync( #if EF_CORE // save main entities int count = await baseSaveChangesAsync.Invoke(acceptAllChangesOnSuccess, cancellationToken); -#else +#elif EF_6 // save main entities int count = await baseSaveChangesAsync.Invoke(cancellationToken); #endif @@ -78,7 +77,7 @@ public static Task SaveChangesWithTransactionLogAsync( #if EF_CORE // save TransactionLog entities await baseSaveChangesAsync.Invoke(acceptAllChangesOnSuccess, cancellationToken); -#else +#elif EF_6 // save TransactionLog entities await baseSaveChangesAsync.Invoke(cancellationToken); #endif diff --git a/EntityFramework.ChangeTrackingExtensions.Tests/packages.config b/EntityFramework.ChangeTrackingExtensions.Tests/packages.config deleted file mode 100644 index 35bca2a..0000000 --- a/EntityFramework.ChangeTrackingExtensions.Tests/packages.config +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/EntityFramework.ChangeTrackingExtensions/Utils/TableAndSchema.cs b/EntityFramework.ChangeTrackingExtensions/Utils/TableAndSchema.cs deleted file mode 100644 index 1590d75..0000000 --- a/EntityFramework.ChangeTrackingExtensions/Utils/TableAndSchema.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace EntityFramework.ChangeTrackingExtensions -{ - public struct TableAndSchema - { - public string TableName; - public string Schema; - - public TableAndSchema(string table, string schema) - { - TableName = table; - Schema = schema; - } - - public void Deconstruct(out string table, out string schema) - { - table = TableName; - schema = Schema; - } - } -} diff --git a/EntityFramework.CommonTools.Benchmarks/EntityFramework.CommonTools.Benchmarks.csproj b/EntityFramework.CommonTools.Benchmarks/EntityFramework.CommonTools.Benchmarks.csproj new file mode 100644 index 0000000..2abcca7 --- /dev/null +++ b/EntityFramework.CommonTools.Benchmarks/EntityFramework.CommonTools.Benchmarks.csproj @@ -0,0 +1,111 @@ + + + + + Debug + AnyCPU + {01646964-497E-43A0-897C-267A3D04F429} + Exe + EntityFramework.CommonTools.Benchmarks + EntityFramework.CommonTools.Benchmarks + v4.5 + 512 + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\BenchmarkDotNet.0.9.9\lib\net45\BenchmarkDotNet.dll + + + ..\packages\BenchmarkDotNet.Core.0.9.9\lib\net45\BenchmarkDotNet.Core.dll + + + ..\packages\BenchmarkDotNet.Toolchains.Roslyn.0.9.9\lib\net45\BenchmarkDotNet.Toolchains.Roslyn.dll + + + ..\packages\EntityFramework.6.2.0\lib\net45\EntityFramework.dll + + + ..\packages\EntityFramework.6.2.0\lib\net45\EntityFramework.SqlServer.dll + + + ..\packages\Microsoft.CodeAnalysis.Common.1.3.2\lib\net45\Microsoft.CodeAnalysis.dll + + + ..\packages\Microsoft.CodeAnalysis.CSharp.1.3.2\lib\net45\Microsoft.CodeAnalysis.CSharp.dll + + + + + ..\packages\System.Collections.Immutable.1.1.37\lib\dotnet\System.Collections.Immutable.dll + + + + + + ..\packages\System.Data.SQLite.Core.1.0.108.0\lib\net45\System.Data.SQLite.dll + + + ..\packages\System.Data.SQLite.EF6.1.0.108.0\lib\net45\System.Data.SQLite.EF6.dll + + + ..\packages\System.Data.SQLite.Linq.1.0.108.0\lib\net45\System.Data.SQLite.Linq.dll + + + + ..\packages\System.Reflection.Metadata.1.2.0\lib\portable-net45+win8\System.Reflection.Metadata.dll + + + + ..\packages\System.Threading.Tasks.Extensions.4.0.0\lib\portable-net45+win8+wp8+wpa81\System.Threading.Tasks.Extensions.dll + + + + + + + %(RecursiveDir)%(Filename)%(Extension) + + + + + + + + + + {1760a172-08a0-4232-b507-55e08971e87d} + EntityFramework.CommonTools + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + \ No newline at end of file diff --git a/EntityFramework.CommonTools.Benchmarks/Properties/AssemblyInfo.cs b/EntityFramework.CommonTools.Benchmarks/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..71fedf0 --- /dev/null +++ b/EntityFramework.CommonTools.Benchmarks/Properties/AssemblyInfo.cs @@ -0,0 +1,20 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("EntityFramework.CommonTools.Benchmarks")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Microsoft")] +[assembly: AssemblyProduct("EntityFramework.CommonTools.Benchmarks")] +[assembly: AssemblyCopyright("Copyright © Dmitry Panyushkin 2017")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +[assembly: ComVisible(false)] + +[assembly: Guid("01646964-497e-43a0-897c-267a3d04f429")] + +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/EntityFramework.ChangeTrackingExtensions.Tests/app.config b/EntityFramework.CommonTools.Benchmarks/app.config similarity index 93% rename from EntityFramework.ChangeTrackingExtensions.Tests/app.config rename to EntityFramework.CommonTools.Benchmarks/app.config index fec8224..a730190 100644 --- a/EntityFramework.ChangeTrackingExtensions.Tests/app.config +++ b/EntityFramework.CommonTools.Benchmarks/app.config @@ -2,6 +2,7 @@
+ @@ -18,9 +19,9 @@ - + \ No newline at end of file diff --git a/EntityFramework.CommonTools.Benchmarks/packages.config b/EntityFramework.CommonTools.Benchmarks/packages.config new file mode 100644 index 0000000..46d6e6d --- /dev/null +++ b/EntityFramework.CommonTools.Benchmarks/packages.config @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/EntityFramework.ChangeTrackingExtensions.Tests/EntityFramework.ChangeTrackingExtensions.Tests.csproj b/EntityFramework.CommonTools.Tests/EntityFramework.CommonTools.Tests.csproj similarity index 58% rename from EntityFramework.ChangeTrackingExtensions.Tests/EntityFramework.ChangeTrackingExtensions.Tests.csproj rename to EntityFramework.CommonTools.Tests/EntityFramework.CommonTools.Tests.csproj index 96dba8a..f64a597 100644 --- a/EntityFramework.ChangeTrackingExtensions.Tests/EntityFramework.ChangeTrackingExtensions.Tests.csproj +++ b/EntityFramework.CommonTools.Tests/EntityFramework.CommonTools.Tests.csproj @@ -1,14 +1,14 @@  - + Debug AnyCPU {B0EB94E6-C524-472F-B276-C8B992BB5092} Library Properties - EntityFramework.ChangeTrackingExtensions.Tests - EntityFramework.ChangeTrackingExtensions.Tests + EntityFramework.CommonTools.Tests + EntityFramework.CommonTools.Tests v4.5 512 {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} @@ -25,7 +25,7 @@ full false bin\Debug\ - DEBUG;TRACE + TRACE;DEBUG;EF_6 prompt 4 @@ -33,50 +33,66 @@ pdbonly true bin\Release\ - TRACE + TRACE;EF_6 prompt 4 - ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.dll + ..\packages\EntityFramework.6.2.0\lib\net45\EntityFramework.dll - ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.SqlServer.dll + ..\packages\EntityFramework.6.2.0\lib\net45\EntityFramework.SqlServer.dll - ..\packages\MSTest.TestFramework.1.1.18\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll + ..\packages\MSTest.TestFramework.1.2.0\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll - ..\packages\MSTest.TestFramework.1.1.18\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll + ..\packages\MSTest.TestFramework.1.2.0\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll - - ..\packages\System.Data.SQLite.Core.1.0.105.2\lib\net45\System.Data.SQLite.dll + + ..\packages\System.Data.SQLite.Core.1.0.108.0\lib\net45\System.Data.SQLite.dll - - ..\packages\System.Data.SQLite.EF6.1.0.105.2\lib\net45\System.Data.SQLite.EF6.dll + + ..\packages\System.Data.SQLite.EF6.1.0.108.0\lib\net45\System.Data.SQLite.EF6.dll - - ..\packages\System.Data.SQLite.Linq.1.0.105.2\lib\net45\System.Data.SQLite.Linq.dll + + ..\packages\System.Data.SQLite.Linq.1.0.108.0\lib\net45\System.Data.SQLite.Linq.dll - - Utils\%(RecursiveDir)%(Filename)%(Extension) + + Auditing\%(RecursiveDir)%(Filename)%(Extension) - - Extensions\%(RecursiveDir)%(Filename)%(Extension) + + Concurrency\%(RecursiveDir)%(Filename)%(Extension) - + + Expression\%(RecursiveDir)%(Filename)%(Extension) + + + Json\%(RecursiveDir)%(Filename)%(Extension) + + + Querying\%(RecursiveDir)%(Filename)%(Extension) + + + Specification\%(RecursiveDir)%(Filename)%(Extension) + + + TransactionLog\%(RecursiveDir)%(Filename)%(Extension) + + + @@ -86,11 +102,10 @@ - - + {1760a172-08a0-4232-b507-55e08971e87d} - EntityFramework.ChangeTrackingExtensions + EntityFramework.CommonTools @@ -99,10 +114,10 @@ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - + + + - - + + \ No newline at end of file diff --git a/EntityFramework.ChangeTrackingExtensions.Tests/Extensions/ChangeTrackingExtensionsTests.cs b/EntityFramework.CommonTools.Tests/Extensions/ChangeTrackingExtensionsTests.cs similarity index 98% rename from EntityFramework.ChangeTrackingExtensions.Tests/Extensions/ChangeTrackingExtensionsTests.cs rename to EntityFramework.CommonTools.Tests/Extensions/ChangeTrackingExtensionsTests.cs index c65269a..26ef906 100644 --- a/EntityFramework.ChangeTrackingExtensions.Tests/Extensions/ChangeTrackingExtensionsTests.cs +++ b/EntityFramework.CommonTools.Tests/Extensions/ChangeTrackingExtensionsTests.cs @@ -2,7 +2,7 @@ using System.Linq; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFramework.ChangeTrackingExtensions.Tests +namespace EntityFramework.CommonTools.Tests { [TestClass] public class ChangeTrackingExtensionsTests : TestInitializer diff --git a/EntityFramework.ChangeTrackingExtensions.Tests/Extensions/TableNameExtensionsTests.cs b/EntityFramework.CommonTools.Tests/Extensions/TableNameExtensionsTests.cs similarity index 94% rename from EntityFramework.ChangeTrackingExtensions.Tests/Extensions/TableNameExtensionsTests.cs rename to EntityFramework.CommonTools.Tests/Extensions/TableNameExtensionsTests.cs index c4691e3..5b8edb9 100644 --- a/EntityFramework.ChangeTrackingExtensions.Tests/Extensions/TableNameExtensionsTests.cs +++ b/EntityFramework.CommonTools.Tests/Extensions/TableNameExtensionsTests.cs @@ -1,6 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFramework.ChangeTrackingExtensions.Tests +namespace EntityFramework.CommonTools.Tests { [TestClass] public class TableNameExtensionsTests : TestInitializer diff --git a/EntityFramework.ChangeTrackingExtensions.Tests/Properties/AssemblyInfo.cs b/EntityFramework.CommonTools.Tests/Properties/AssemblyInfo.cs similarity index 78% rename from EntityFramework.ChangeTrackingExtensions.Tests/Properties/AssemblyInfo.cs rename to EntityFramework.CommonTools.Tests/Properties/AssemblyInfo.cs index a81bb9e..0ac7484 100644 --- a/EntityFramework.ChangeTrackingExtensions.Tests/Properties/AssemblyInfo.cs +++ b/EntityFramework.CommonTools.Tests/Properties/AssemblyInfo.cs @@ -2,11 +2,11 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -[assembly: AssemblyTitle("EntityFramework.ChangeTrackingExtensions.Tests")] +[assembly: AssemblyTitle("EntityFramework.CommonTools.Tests")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("EntityFramework.ChangeTrackingExtensions.Tests")] +[assembly: AssemblyProduct("EntityFramework.CommonTools.Tests")] [assembly: AssemblyCopyright("Copyright © Dmitry Panyushkin 2017")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] diff --git a/EntityFramework.ChangeTrackingExtensions.Tests/TestDbContext.cs b/EntityFramework.CommonTools.Tests/TestDbContext.cs similarity index 92% rename from EntityFramework.ChangeTrackingExtensions.Tests/TestDbContext.cs rename to EntityFramework.CommonTools.Tests/TestDbContext.cs index f95803b..2dd8300 100644 --- a/EntityFramework.ChangeTrackingExtensions.Tests/TestDbContext.cs +++ b/EntityFramework.CommonTools.Tests/TestDbContext.cs @@ -4,7 +4,7 @@ using System.Threading; using System.Threading.Tasks; -namespace EntityFramework.ChangeTrackingExtensions.Tests +namespace EntityFramework.CommonTools.Tests { public class TestDbContext : DbContext { @@ -61,11 +61,11 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken) } } - public Task SaveChangesAsync(string editorUser) + public Task SaveChangesAsync(string editorUserId) { using (this.WithChangeTrackingOnce()) { - this.UpdateAuditableEntities(editorUser); + this.UpdateAuditableEntities(editorUserId); this.UpdateTrackableEntities(); this.UpdateConcurrentEntities(); diff --git a/EntityFramework.ChangeTrackingExtensions.Tests/TestEntities.cs b/EntityFramework.CommonTools.Tests/TestEntities.cs similarity index 84% rename from EntityFramework.ChangeTrackingExtensions.Tests/TestEntities.cs rename to EntityFramework.CommonTools.Tests/TestEntities.cs index 1cb1d39..47606e9 100644 --- a/EntityFramework.ChangeTrackingExtensions.Tests/TestEntities.cs +++ b/EntityFramework.CommonTools.Tests/TestEntities.cs @@ -4,7 +4,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Runtime.Serialization; -namespace EntityFramework.ChangeTrackingExtensions.Tests +namespace EntityFramework.CommonTools.Tests { public abstract class Entity { @@ -30,6 +30,9 @@ public class User : Entity, IFullTrackable, ITransactionLoggable public DateTime CreatedUtc { get; set; } public DateTime? UpdatedUtc { get; set; } public DateTime? DeletedUtc { get; set; } + + [InverseProperty(nameof(Post.Author))] + public virtual ICollection Posts { get; set; } = new HashSet(); } public class Post : Entity, IFullAuditable, IConcurrencyCheckable, ITransactionLoggable @@ -49,8 +52,8 @@ public string TagsJson public ICollection Tags { - get { return _tags.Value; } - set { _tags.Value = value; } + get { return _tags.Object; } + set { _tags.Object = value; } } public bool IsDeleted { get; set; } @@ -85,16 +88,16 @@ public string ValueJson public dynamic Value { - get { return _value.Value; } - set { _value.Value = value; } + get { return _value.Object; } + set { _value.Object = value; } } public bool IsDeleted { get; set; } - public string CreatorUser { get; set; } + public string CreatorUserId { get; set; } public DateTime CreatedUtc { get; set; } - public string UpdaterUser { get; set; } + public string UpdaterUserId { get; set; } public DateTime? UpdatedUtc { get; set; } - public string DeleterUser { get; set; } + public string DeleterUserId { get; set; } public DateTime? DeletedUtc { get; set; } [ConcurrencyCheck] diff --git a/EntityFramework.ChangeTrackingExtensions.Tests/TestInitializer.cs b/EntityFramework.CommonTools.Tests/TestInitializer.cs similarity index 94% rename from EntityFramework.ChangeTrackingExtensions.Tests/TestInitializer.cs rename to EntityFramework.CommonTools.Tests/TestInitializer.cs index 3ad2084..1f57be8 100644 --- a/EntityFramework.ChangeTrackingExtensions.Tests/TestInitializer.cs +++ b/EntityFramework.CommonTools.Tests/TestInitializer.cs @@ -2,8 +2,9 @@ using System.Data.SQLite; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace EntityFramework.ChangeTrackingExtensions.Tests +namespace EntityFramework.CommonTools.Tests { + [TestClass] public abstract class TestInitializer { private DbConnection _connection; @@ -69,11 +70,11 @@ CREATE TABLE Settings ( IsDeleted BOOLEAN, CreatedUtc DATETIME, - CreatorUser TEXT, + CreatorUserId TEXT, UpdatedUtc DATETIME, - UpdaterUser TEXT, + UpdaterUserId TEXT, DeletedUtc DATETIME, - DeleterUser TEXT, + DeleterUserId TEXT, RowVersion INTEGER DEFAULT 0 ); diff --git a/EntityFramework.CommonTools.Tests/app.config b/EntityFramework.CommonTools.Tests/app.config new file mode 100644 index 0000000..a730190 --- /dev/null +++ b/EntityFramework.CommonTools.Tests/app.config @@ -0,0 +1,27 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/EntityFramework.CommonTools.Tests/packages.config b/EntityFramework.CommonTools.Tests/packages.config new file mode 100644 index 0000000..0cb3983 --- /dev/null +++ b/EntityFramework.CommonTools.Tests/packages.config @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/EntityFramework.ChangeTrackingExtensions.sln b/EntityFramework.CommonTools.sln similarity index 55% rename from EntityFramework.ChangeTrackingExtensions.sln rename to EntityFramework.CommonTools.sln index 363654c..dc5859c 100644 --- a/EntityFramework.ChangeTrackingExtensions.sln +++ b/EntityFramework.CommonTools.sln @@ -3,13 +3,17 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.26430.15 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFramework.ChangeTrackingExtensions", "EntityFramework.ChangeTrackingExtensions\EntityFramework.ChangeTrackingExtensions.csproj", "{1760A172-08A0-4232-B507-55E08971E87D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFramework.CommonTools", "EntityFramework.CommonTools\EntityFramework.CommonTools.csproj", "{1760A172-08A0-4232-B507-55E08971E87D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EntityFrameworkCore.ChangeTrackingExtensions", "EntityFrameworkCore.ChangeTrackingExtensions\EntityFrameworkCore.ChangeTrackingExtensions.csproj", "{4962545A-A94C-41A9-8C04-B9432F9A9058}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EntityFrameworkCore.CommonTools", "EFCore.CommonTools\EntityFrameworkCore.CommonTools.csproj", "{4962545A-A94C-41A9-8C04-B9432F9A9058}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFramework.ChangeTrackingExtensions.Tests", "EntityFramework.ChangeTrackingExtensions.Tests\EntityFramework.ChangeTrackingExtensions.Tests.csproj", "{B0EB94E6-C524-472F-B276-C8B992BB5092}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFramework.CommonTools.Tests", "EntityFramework.CommonTools.Tests\EntityFramework.CommonTools.Tests.csproj", "{B0EB94E6-C524-472F-B276-C8B992BB5092}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.ChangeTrackingExtensions.Tests", "EntityFrameworkCore.ChangeTrackingExtensions.Tests\EntityFrameworkCore.ChangeTrackingExtensions.Tests.csproj", "{3496C87F-E595-44F3-AB3C-E678A38DA950}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EntityFrameworkCore.CommonTools.Tests", "EFCore.CommonTools.Tests\EntityFrameworkCore.CommonTools.Tests.csproj", "{3496C87F-E595-44F3-AB3C-E678A38DA950}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFramework.CommonTools.Benchmarks", "EntityFramework.CommonTools.Benchmarks\EntityFramework.CommonTools.Benchmarks.csproj", "{01646964-497E-43A0-897C-267A3D04F429}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkCore.CommonTools.Benchmarks", "EFCore.CommonTools.Benchmarks\EntityFrameworkCore.CommonTools.Benchmarks.csproj", "{F2C1ED95-5CB7-4125-B244-3E1AE983B96E}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -33,6 +37,14 @@ Global {3496C87F-E595-44F3-AB3C-E678A38DA950}.Debug|Any CPU.Build.0 = Debug|Any CPU {3496C87F-E595-44F3-AB3C-E678A38DA950}.Release|Any CPU.ActiveCfg = Release|Any CPU {3496C87F-E595-44F3-AB3C-E678A38DA950}.Release|Any CPU.Build.0 = Release|Any CPU + {01646964-497E-43A0-897C-267A3D04F429}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01646964-497E-43A0-897C-267A3D04F429}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01646964-497E-43A0-897C-267A3D04F429}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01646964-497E-43A0-897C-267A3D04F429}.Release|Any CPU.Build.0 = Release|Any CPU + {F2C1ED95-5CB7-4125-B244-3E1AE983B96E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2C1ED95-5CB7-4125-B244-3E1AE983B96E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2C1ED95-5CB7-4125-B244-3E1AE983B96E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2C1ED95-5CB7-4125-B244-3E1AE983B96E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/EntityFramework.ChangeTrackingExtensions/EntityFramework.ChangeTrackingExtensions.csproj b/EntityFramework.CommonTools/EntityFramework.CommonTools.csproj similarity index 61% rename from EntityFramework.ChangeTrackingExtensions/EntityFramework.ChangeTrackingExtensions.csproj rename to EntityFramework.CommonTools/EntityFramework.CommonTools.csproj index 971425c..764f67b 100644 --- a/EntityFramework.ChangeTrackingExtensions/EntityFramework.ChangeTrackingExtensions.csproj +++ b/EntityFramework.CommonTools/EntityFramework.CommonTools.csproj @@ -7,8 +7,8 @@ {1760A172-08A0-4232-B507-55E08971E87D} Library Properties - EntityFramework.ChangeTrackingExtensions - EntityFramework.ChangeTrackingExtensions + EntityFramework.CommonTools + EntityFramework.CommonTools v4.5 512 @@ -18,31 +18,31 @@ full false bin\Debug\ - DEBUG;TRACE + TRACE;DEBUG;EF_6 prompt 4 1591 - bin\Debug\EntityFramework.ChangeTrackingExtensions.xml + bin\Debug\EntityFramework.CommonTools.xml pdbonly true bin\Release\ - TRACE + TRACE;EF_6 prompt 4 - bin\Release\EntityFramework.ChangeTrackingExtensions.xml + bin\Release\EntityFramework.CommonTools.xml 1591 - ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.dll + ..\packages\EntityFramework.6.2.0\lib\net45\EntityFramework.dll - ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.SqlServer.dll + ..\packages\EntityFramework.6.2.0\lib\net45\EntityFramework.SqlServer.dll - - ..\packages\Jil.2.15.1\lib\net45\Jil.dll + + ..\packages\Jil.2.15.4\lib\net45\Jil.dll ..\packages\Sigil.4.7.0\lib\net45\Sigil.dll @@ -53,22 +53,34 @@ - - - - - Entities\%(RecursiveDir)%(Filename)%(Extension) + + Auditing\%(RecursiveDir)%(Filename)%(Extension) + + + Concurrency\%(RecursiveDir)%(Filename)%(Extension) + + + Expression\%(RecursiveDir)%(Filename)%(Extension) - - Extensions\%(RecursiveDir)%(Filename)%(Extension) + + Json\%(RecursiveDir)%(Filename)%(Extension) - - Utils\%(RecursiveDir)%(Filename)%(Extension) + + Querying\%(RecursiveDir)%(Filename)%(Extension) - + + Specification\%(RecursiveDir)%(Filename)%(Extension) + + + TransactionLog\%(RecursiveDir)%(Filename)%(Extension) + + + + - + + diff --git a/EntityFramework.ChangeTrackingExtensions/EntityFramework.ChangeTrackingExtensions.nuspec b/EntityFramework.CommonTools/EntityFramework.CommonTools.nuspec similarity index 70% rename from EntityFramework.ChangeTrackingExtensions/EntityFramework.ChangeTrackingExtensions.nuspec rename to EntityFramework.CommonTools/EntityFramework.CommonTools.nuspec index 6bb0a82..800cf3d 100644 --- a/EntityFramework.ChangeTrackingExtensions/EntityFramework.ChangeTrackingExtensions.nuspec +++ b/EntityFramework.CommonTools/EntityFramework.CommonTools.nuspec @@ -1,22 +1,22 @@ - EntityFramework.ChangeTrackingExtensions - 1.0.0 + EntityFramework.CommonTools + 2.0.0 Auditing, Concurrency Checks, JSON properties and Transaction Logs for EntityFramework An extension for EntityFramework that provides Auditing, Concurrency Checks, storing complex types as JSON and storing history of all changes from DbContext to Transaction Log. Dmitry Panyushkin gnaeus - https://github.com/gnaeus/EntityFramework.ChangeTrackingExtensions - https://github.com/gnaeus/EntityFramework.ChangeTrackingExtensions/blob/master/LICENSE - https://raw.githubusercontent.com/gnaeus/EntityFramework.ChangeTrackingExtensions/master/icon.png + https://github.com/gnaeus/EntityFramework.CommonTools + https://github.com/gnaeus/EntityFramework.CommonTools/blob/master/LICENSE + https://raw.githubusercontent.com/gnaeus/EntityFramework.CommonTools/master/icon.png false - First release + EntityFramework 6.2; Improve AuditableEntities API Copyright © Dmitry Panyushkin 2017 EF EntityFramework Entity Framework ChangeTracking Change Tracking Auditing Audit TransactionLog Transaction Log ComplexType Complex Type JSON - + diff --git a/EntityFramework.ChangeTrackingExtensions/Extensions/ChangeTrackingExtensions.cs b/EntityFramework.CommonTools/Extensions/ChangeTrackingExtensions.cs similarity index 89% rename from EntityFramework.ChangeTrackingExtensions/Extensions/ChangeTrackingExtensions.cs rename to EntityFramework.CommonTools/Extensions/ChangeTrackingExtensions.cs index 175ec50..e2e5003 100644 --- a/EntityFramework.ChangeTrackingExtensions/Extensions/ChangeTrackingExtensions.cs +++ b/EntityFramework.CommonTools/Extensions/ChangeTrackingExtensions.cs @@ -2,7 +2,7 @@ using System.Data.Entity; using System.Data.Entity.Infrastructure; -namespace EntityFramework.ChangeTrackingExtensions +namespace EntityFramework.CommonTools { public static partial class DbContextExtensions { @@ -29,8 +29,8 @@ public static IDisposable WithChangeTrackingOnce(this DbContext dbContext) private struct AutoDetectChangesContext : IDisposable { - readonly DbContextConfiguration _configuration; - readonly bool _autoDetectChangesEnabled; + private readonly DbContextConfiguration _configuration; + private readonly bool _autoDetectChangesEnabled; public AutoDetectChangesContext(DbContextConfiguration configuration) { diff --git a/EntityFramework.CommonTools/Extensions/README.md b/EntityFramework.CommonTools/Extensions/README.md new file mode 100644 index 0000000..6af2e9d --- /dev/null +++ b/EntityFramework.CommonTools/Extensions/README.md @@ -0,0 +1,47 @@ +## DbContext Extensions (EF 6 only) + +__`static IDisposable WithoutChangeTracking(this DbContext dbContext)`__ +Disposable token for `using(...)` statement where `DbContext.Configuration.AutoDetectChanges` is disabled. + +```cs +// here AutoDetectChanges is enabled +using (dbContext.WithoutChangeTracking()) +{ + // inside this block AutoDetectChanges is disabled +} +// here AutoDetectChanges is enabled again +``` + +
+ +__`static IDisposable WithChangeTrackingOnce(this DbContext dbContext)`__ +Run `DbChangeTracker.DetectChanges()` once and return disposable token for `using(...)` statement +where `DbContext.Configuration.AutoDetectChanges` is disabled. + +```cs +// here AutoDetectChanges is enabled +using (dbContext.WithChangeTrackingOnce()) +{ + // inside this block AutoDetectChanges is disabled +} +// here AutoDetectChanges is enabled again +``` + +
+ +__`static TableAndSchema GetTableAndSchemaName(this DbContext context, Type entityType)`__ +Get corresponding table name and schema by `entityType`. + +__`static TableAndSchema[] GetTableAndSchemaNames(this DbContext context, Type entityType)`__ +Get corresponding table name and schema by `entityType`. +Use it if entity is splitted between multiple tables. + +```cs +struct TableAndSchema +{ + public string TableName; + public string Schema; +} +``` + +
diff --git a/EntityFramework.ChangeTrackingExtensions/Extensions/TableNameExtensions.cs b/EntityFramework.CommonTools/Extensions/TableNameExtensions.cs similarity index 89% rename from EntityFramework.ChangeTrackingExtensions/Extensions/TableNameExtensions.cs rename to EntityFramework.CommonTools/Extensions/TableNameExtensions.cs index 6ef18b2..3d91c73 100644 --- a/EntityFramework.ChangeTrackingExtensions/Extensions/TableNameExtensions.cs +++ b/EntityFramework.CommonTools/Extensions/TableNameExtensions.cs @@ -8,8 +8,26 @@ using System.Data.Entity.Infrastructure; using System.Linq; -namespace EntityFramework.ChangeTrackingExtensions +namespace EntityFramework.CommonTools { + public struct TableAndSchema + { + public string TableName; + public string Schema; + + public TableAndSchema(string table, string schema) + { + TableName = table; + Schema = schema; + } + + public void Deconstruct(out string table, out string schema) + { + table = TableName; + schema = Schema; + } + } + public static partial class DbContextExtensions { /// diff --git a/EntityFramework.ChangeTrackingExtensions/Properties/AssemblyInfo.cs b/EntityFramework.CommonTools/Properties/AssemblyInfo.cs similarity index 85% rename from EntityFramework.ChangeTrackingExtensions/Properties/AssemblyInfo.cs rename to EntityFramework.CommonTools/Properties/AssemblyInfo.cs index a8e95cc..f1de768 100644 --- a/EntityFramework.ChangeTrackingExtensions/Properties/AssemblyInfo.cs +++ b/EntityFramework.CommonTools/Properties/AssemblyInfo.cs @@ -5,11 +5,11 @@ // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. -[assembly: AssemblyTitle("EntityFramework.ChangeTrackingExtensions")] +[assembly: AssemblyTitle("EntityFramework.CommonTools")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("EntityFramework.ChangeTrackingExtensions")] +[assembly: AssemblyProduct("EntityFramework.CommonTools")] [assembly: AssemblyCopyright("Copyright © Dmitry Panyushkin 2017")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] @@ -22,7 +22,7 @@ // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("1760a172-08a0-4232-b507-55e08971e87d")] -[assembly: InternalsVisibleTo("EntityFramework.ChangeTrackingExtensions.Tests")] +[assembly: InternalsVisibleTo("EntityFramework.CommonTools.Tests")] // Version information for an assembly consists of the following four values: // diff --git a/EntityFramework.ChangeTrackingExtensions/packages.config b/EntityFramework.CommonTools/packages.config similarity index 52% rename from EntityFramework.ChangeTrackingExtensions/packages.config rename to EntityFramework.CommonTools/packages.config index 5f51ef8..5f17642 100644 --- a/EntityFramework.ChangeTrackingExtensions/packages.config +++ b/EntityFramework.CommonTools/packages.config @@ -1,6 +1,6 @@  - - + + \ No newline at end of file diff --git a/EntityFrameworkCore.ChangeTrackingExtensions/Properties/AssemblyInfo.cs b/EntityFrameworkCore.ChangeTrackingExtensions/Properties/AssemblyInfo.cs deleted file mode 100644 index 4ae8ac8..0000000 --- a/EntityFrameworkCore.ChangeTrackingExtensions/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("EntityFrameworkCore.ChangeTrackingExtensions.Tests")] diff --git a/README.md b/README.md index 094cc5d..4832dfa 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,104 @@ -# EntityFramework.ChangeTrackingExtensions - An extension for EntityFramework and EntityFramework Core that provides Auditing, Concurrency Checks, storing Complex Types as JSON and storing history of all changes from DbContext to Transaction Log. +# EntityFramework.CommonTools logo +Extension for EntityFramework and EntityFramework Core that provides: Expandable Extension Methods, Complex Types as JSON, Auditing, Concurrency Checks, Specifications and serializable Transacton Logs. -[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/gnaeus/EntityFramework.ChangeTrackingExtensions/master/LICENSE) -[![NuGet version](https://img.shields.io/nuget/v/EntityFramework.ChangeTrackingExtensions.svg)](https://www.nuget.org/packages/EntityFramework.ChangeTrackingExtensions) -[![NuGet version](https://img.shields.io/nuget/v/EntityFrameworkCore.ChangeTrackingExtensions.svg)](https://www.nuget.org/packages/EntityFrameworkCore.ChangeTrackingExtensions) +[![Build status](https://ci.appveyor.com/api/projects/status/85f7aqrh2plkl7yn?svg=true)](https://ci.appveyor.com/project/gnaeus/entityframework-commontools) +[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/gnaeus/EntityFramework.CommonTools/master/LICENSE) +[![NuGet version](https://img.shields.io/nuget/v/EntityFramework.CommonTools.svg)](https://www.nuget.org/packages/EntityFramework.CommonTools) +[![NuGet version](https://img.shields.io/nuget/v/EntityFrameworkCore.CommonTools.svg)](https://www.nuget.org/packages/EntityFrameworkCore.CommonTools) ## Documentation + * [Expandable IQueryable Extensions](#ef-querying) * [JSON Complex Types](#ef-json-field) + * [Specification Pattern](#ef-specification) * [Auditable Entities](#ef-auditable-entities) * [Concurrency Checks](#ef-concurrency-checks) * [Transaction Logs](#ef-transaction-logs) * [DbContext Extensions (EF 6 only)](#ef-6-only) * [Usage with EntityFramework Core](#ef-core-usage) * [Usage with EntityFramework 6](#ef-6-usage) + * [Changelog](#changelog) + +### NuGet +``` +PM> Install-Package EntityFramework.CommonTools + +PM> Install-Package EntityFrameworkCore.CommonTools +``` + +
+ +## Attaching ExpressionVisitor to IQueryable + +With `.AsVisitable()` extension we can attach any `ExpressionVisitor` to `IQueryable`. + +```cs +public static IQueryable AsVisitable( + this IQueryable queryable, params ExpressionVisitor[] visitors); +``` + +## Expandable extension methods for IQueryable + +We can use extension methods for `IQueryable` to incapsulate custom buisiness logic. +But if we call these methods from `Expression`, we get runtime error. + +```cs +public static IQueryable FilterByAuthor(this IQueryable posts, int authorId) +{ + return posts.Where(p => p.AuthorId = authorId); +} + +public static IQueryable FilterTodayComments(this IQueryable comments) +{ + DateTime today = DateTime.Now.Date; + + return comments.Where(c => c.CreationTime > today) +} + +Comment[] comments = context.Posts + .FilterByAuthor(authorId) // it's OK + .SelectMany(p => p.Comments + .AsQueryable() + .FilterTodayComments()) // will throw Error + .ToArray(); +``` + +With `.AsExpandable()` extension we can use extension methods everywhere. + +```cs +Comment[] comments = context.Posts + .AsExpandable() + .FilterByAuthor(authorId) // it's OK + .SelectMany(p => p.Comments + .FilterTodayComments()) // it's OK too + .ToArray(); +``` + +Expandable extension methods should return `IQueryable` and should have `[Expandable]` attribute. + +```cs +[Expandable] +public static IQueryable FilterByAuthor(this IEnumerable posts, int authorId) +{ + return posts.AsQueryable().Where(p => p.AuthorId = authorId); +} + +[Expandable] +public static IQueryable FilterTodayComments(this IEnumerable comments) +{ + DateTime today = DateTime.Now.Date; + + return comments.AsQueryable().Where(c => c.CreationTime > today) +} +``` + +### [Benchmarks](./EFCore.CommonTools.Benchmarks/Querying/DatabaseQueryBenchmark.cs) +``` + Method | Median | StdDev | Scaled | Scaled-SD | +---------------- |-------------- |----------- |------- |---------- | + RawQuery | 555.6202 μs | 15.1837 μs | 1.00 | 0.00 | + ExpandableQuery | 644.6258 μs | 3.7793 μs | 1.15 | 0.03 | <<< + NotCachedQuery | 2,277.7138 μs | 10.9754 μs | 4.06 | 0.10 | +```
@@ -20,11 +106,11 @@ There is an utility struct named `JsonField`, that helps to persist any Complex Type as JSON string in single table column. ```cs -struct JsonField - where TValue : class +struct JsonField + where TObject : class { public string Json { get; set; } - public TValue Value { get; set; } + public TObject Object { get; set; } } ``` @@ -46,8 +132,8 @@ class User // used by application code public Address Address { - get { return _address.Value; } - set { _address.Value = value; } + get { return _address.Object; } + set { _address.Object = value; } } // collection initialization by default @@ -59,8 +145,8 @@ class User } public ICollection Phones { - get { return _phones.Value; } - set { _phones.Value = value; } + get { return _phones.Object; } + set { _phones.Object = value; } } } @@ -100,17 +186,103 @@ class MyEntity ``` It uses the following implicit operator: ```cs -struct JsonField +struct JsonField { - public static implicit operator JsonField(TValue value); + public static implicit operator JsonField(TObject defaultValue); } ``` -The only caveat is that `TValue` object should not contain reference loops. +The only caveat is that `TObject` object should not contain reference loops. Because `JsonField` uses [Jil](https://github.com/kevin-montrose/Jil) (the fastest .NET JSON serializer) behind the scenes.
+## Specification Pattern + +Generic implementation of [Specification Pattern](https://en.wikipedia.org/wiki/Specification_pattern). + +```cs +public interface ISpecification +{ + bool IsSatisfiedBy(T entity); + + Expression> ToExpression(); +} + +public class Specification : ISpecification +{ + public Specification(Expression> predicate); +} +``` + +We can define named specifications: +```cs +class UserIsActiveSpec : Specification +{ + public UserIsActiveSpec() + : base(u => !u.IsDeleted) { } +} + +class UserByLoginSpec : Specification +{ + public UserByLoginSpec(string login) + : base(u => u.Login == login) { } +} +``` + +Then we can combine specifications with conditional logic operators `&&`, `||` and `!`: +```cs +class CombinedSpec +{ + public CombinedSpec(string login) + : base(new UserIsActiveSpec() && new UserByLoginSpec(login)) { } +} +``` + +Also we can test it: +```cs +var user = new User { Login = "admin", IsDeleted = false }; +var spec = new CombinedSpec("admin"); + +Assert.IsTrue(spec.IsSatisfiedBy(user)); +``` + +And use with `IEnumerable`: + +```cs +var users = Enumerable.Empty(); +var spec = new UserByLoginSpec("admin"); + +var admin = users.FirstOrDefault(spec.IsSatisfiedBy); + +// or even +var admin = users.FirstOrDefault(spec); +``` + +Or even with `IQueryable`: +```cs +var spec = new UserByLoginSpec("admin"); + +var admin = context.Users.FirstOrDefault(spec.ToExpression()); + +// or even +var admin = context.Users.FirstOrDefault(spec); + +// and also inside Expression +var adminFiends = context.Users + .AsVisitable(new SpecificationExpander()) + .Where(u => u.Firends.Any(spec.ToExpression())) + .ToList(); + +// or even +var adminFiends = context.Users + .AsVisitable(new SpecificationExpander()) + .Where(u => u.Firends.Any(spec)) + .ToList(); +``` + +
+ ## Auditable Entities Automatically update info about who and when create / modify / delete the entity during `context.SaveCahnges()` @@ -188,7 +360,7 @@ interface ICreationAuditable : ICreationTrackable // or interface ICreationAuditable : ICreationTrackable { - string CreatorUser { get; set; } + string CreatorUserId { get; set; } } ``` @@ -217,7 +389,7 @@ interface IModificationAuditable : IModificationTrackable // or interface IModificationAuditable : IModificationTrackable { - string UpdaterUser { get; set; } + string UpdaterUserId { get; set; } } ``` @@ -244,7 +416,7 @@ public interface IDeletionAuditable : IDeletionTrackable // or public interface IDeletionAuditable : IDeletionTrackable { - string DeleterUser { get; set; } + string DeleterUserId { get; set; } } ``` @@ -274,7 +446,7 @@ You can choose between saving the user `Id` or the user `Login`. So there are two overloadings for `DbContext.UpdateAudiatbleEntities()`: ```cs static void UpdateAuditableEntities(this DbContext context, TUserId editorUserId); -static void UpdateAuditableEntities(this DbContext context, string editorUser); +static void UpdateAuditableEntities(this DbContext context, string editorUserId); ``` and also the separate extension to update only `Trackable` entities: ```cs @@ -633,3 +805,53 @@ class MyDbContext : DbContext } } ``` + +
+ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [2.0.0] - 2018-03-23 +### Added +- EFCore 2.0 support +- EntityFramework 6.2 support + +### Changed +- `ICreationAuditable.CreatorUser` renamed to `CreatorUserId` +- `IModificationAuditable.UpdaterUser` renamed to `UpdaterUserId` +- `IDeletionAuditable.DeleterUser` renamed to `DeleterUserId` + +See [#1](https://github.com/gnaeus/EntityFramework.CommonTools/issues/1). + +For compatibility issues you still can use these interfaces: +```cs +public interface ICreationAuditableV1 +{ + string CreatorUser { get; set; } +} + +public interface IModificationAuditableV1 +{ + string UpdaterUser { get; set; } +} + +public interface IDeletionAuditableV1 +{ + string DeleterUser { get; set; } +} + +public interface IFullAuditableV1 : IFullTrackable, + ICreationAuditableV1, IModificationAuditableV1, IDeletionAuditableV1 +{ +} +``` + +## [1.0.0] - 2017-07-20 +### Added +Initial project version. + +[2.0.0]: https://github.com/gnaeus/EntityFramework.CommonTools/compare/1.0.0...2.0.0 +[1.0.0]: https://github.com/gnaeus/EntityFramework.CommonTools/tree/1.0.0 diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..aff4c99 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,13 @@ +version: 1.0.0.{build} + +image: Visual Studio 2017 + +before_build: + - nuget restore + +test_script: + - dotnet test --no-build .\EFCore.CommonTools.Tests\EntityFrameworkCore.CommonTools.Tests.csproj + - dotnet test --no-build .\EntityFramework.CommonTools.Tests\EntityFramework.CommonTools.Tests.csproj + +cache: + - '%USERPROFILE%\.nuget\packages' diff --git a/icon.png b/icon.png index df8e330..dbc8886 100644 Binary files a/icon.png and b/icon.png differ