From b20d90aef52f4fd0e45600211c3c6b3f206f76c4 Mon Sep 17 00:00:00 2001 From: Svyatoslav Danyliv Date: Tue, 21 Mar 2023 20:02:38 +0200 Subject: [PATCH] Added Temporal tables support. --- Directory.Packages.props | 4 +- NuGet.config | 19 +-- NuGet/linq2db.EntityFrameworkCore.nuspec | 2 +- .../LinqToDBForEFToolsImplDefault.cs | 136 ++++++++++++++++-- .../Models/Northwind.Mapping/ProductsMap.cs | 2 + .../ToolsTests.cs | 32 +++++ 6 files changed, 168 insertions(+), 27 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e88b091..9d9924f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,8 +5,8 @@ - - + + diff --git a/NuGet.config b/NuGet.config index a66ce80..af7d28a 100644 --- a/NuGet.config +++ b/NuGet.config @@ -1,12 +1,7 @@ - - - - - - - - - - - - + + + + + + + diff --git a/NuGet/linq2db.EntityFrameworkCore.nuspec b/NuGet/linq2db.EntityFrameworkCore.nuspec index c1c0d24..575183e 100644 --- a/NuGet/linq2db.EntityFrameworkCore.nuspec +++ b/NuGet/linq2db.EntityFrameworkCore.nuspec @@ -16,7 +16,7 @@ - + diff --git a/Source/LinqToDB.EntityFrameworkCore/LinqToDBForEFToolsImplDefault.cs b/Source/LinqToDB.EntityFrameworkCore/LinqToDBForEFToolsImplDefault.cs index f5e0252..e330c43 100644 --- a/Source/LinqToDB.EntityFrameworkCore/LinqToDBForEFToolsImplDefault.cs +++ b/Source/LinqToDB.EntityFrameworkCore/LinqToDBForEFToolsImplDefault.cs @@ -560,6 +560,13 @@ public virtual MappingSchema GetMappingSchema( static readonly MethodInfo ToSql = MemberHelper.MethodOfGeneric(() => Sql.ToSql(1)); + private static readonly MethodInfo AsSqlServerTable = MemberHelper.MethodOfGeneric>(q => DataProvider.SqlServer.SqlServerTools.AsSqlServer(q)); + private static readonly MethodInfo TemporalAsOfTable = MemberHelper.MethodOfGeneric>(t => SqlServerHints.TemporalTableAsOf(t, default)); + private static readonly MethodInfo TemporalFromTo = MemberHelper.MethodOfGeneric>(t => SqlServerHints.TemporalTableFromTo(t, default, default)); + private static readonly MethodInfo TemporalBetween = MemberHelper.MethodOfGeneric>(t => SqlServerHints.TemporalTableBetween(t, default, default)); + private static readonly MethodInfo TemporalContainedIn = MemberHelper.MethodOfGeneric>(t => SqlServerHints.TemporalTableContainedIn(t, default, default)); + private static readonly MethodInfo TemporalAll = MemberHelper.MethodOfGeneric>(t => SqlServerHints.TemporalTableAll(t)); + /// /// Removes conversions from expression. /// @@ -726,6 +733,27 @@ static List CompactTree(List items, ExpressionType nodeT return result; } + /// + /// Gets current property value via reflection. + /// + /// Property value type. + /// Object instance + /// Property name + /// Property value. + /// + protected static TValue GetPropValue(object obj, string propName) + { + var prop = obj.GetType().GetProperty(propName); + if (prop == null) + { + throw new InvalidOperationException($"Property {obj.GetType().Name}.{propName} not found."); + } + var propValue = prop.GetValue(obj); + if (propValue == default) + return default!; + return (TValue)propValue; + } + /// /// Transforms EF Core expression tree to LINQ To DB expression. /// Method replaces EF Core instances with LINQ To DB @@ -948,19 +976,12 @@ TransformInfo LocalTransform(Expression e) case ExpressionType.Extension: { - if (dc != null && e is FromSqlQueryRootExpression fromSqlQueryRoot) - { - //convert the arguments from the FromSqlOnQueryable method from EF, to a L2DB FromSql call - return new TransformInfo(Expression.Call(null, - L2DBFromSqlMethodInfo.MakeGenericMethod(fromSqlQueryRoot.EntityType.ClrType), - Expression.Constant(dc), - Expression.New(RawSqlStringConstructor, Expression.Constant(fromSqlQueryRoot.Sql)), - fromSqlQueryRoot.Argument)); - } - else if (dc != null && e is QueryRootExpression queryRoot) + if (dc != null) { - var newExpr = Expression.Call(null, Methods.LinqToDB.GetTable.MakeGenericMethod(queryRoot.EntityType.ClrType), Expression.Constant(dc)); - return new TransformInfo(newExpr); + if (e is QueryRootExpression queryRoot) + { + return new TransformInfo(TransformQueryRootExpression(dc, queryRoot)); + } } break; @@ -982,6 +1003,97 @@ TransformInfo LocalTransform(Expression e) return newExpression; } + /// + /// Transforms descendants to linq2db analogue. Handles Temporal tables also. + /// + /// Data context. + /// Query root expression + /// Transformed expression. + protected virtual Expression TransformQueryRootExpression(IDataContext dc, QueryRootExpression queryRoot) + { + static Expression GetAsOfSqlServer(Expression getTableExpr, Type entityType) + { + return Expression.Call( + AsSqlServerTable.MakeGenericMethod(entityType), + getTableExpr); + } + + if (queryRoot is FromSqlQueryRootExpression fromSqlQueryRoot) + { + //convert the arguments from the FromSqlOnQueryable method from EF, to a L2DB FromSql call + return Expression.Call(null, + L2DBFromSqlMethodInfo.MakeGenericMethod(fromSqlQueryRoot.EntityType.ClrType), + Expression.Constant(dc), + Expression.New(RawSqlStringConstructor, Expression.Constant(fromSqlQueryRoot.Sql)), + fromSqlQueryRoot.Argument); + } + + var entityType = queryRoot.EntityType.ClrType; + var getTableExpr = Expression.Call(null, Methods.LinqToDB.GetTable.MakeGenericMethod(entityType), + Expression.Constant(dc)); + + var expressionTypeName = queryRoot.GetType().Name; + if (expressionTypeName == "TemporalAsOfQueryRootExpression") + { + var pointInTime = GetPropValue(queryRoot, "PointInTime"); + + var asOf = Expression.Call(TemporalAsOfTable.MakeGenericMethod(entityType), + GetAsOfSqlServer(getTableExpr, entityType), + Expression.Constant(pointInTime)); + + return asOf; + } + + if (expressionTypeName == "TemporalFromToQueryRootExpression") + { + var from = GetPropValue(queryRoot, "From"); + var to = GetPropValue(queryRoot, "To"); + + var fromTo = Expression.Call(TemporalFromTo.MakeGenericMethod(entityType), + GetAsOfSqlServer(getTableExpr, entityType), + Expression.Constant(from), + Expression.Constant(to)); + + return fromTo; + } + + if (expressionTypeName == "TemporalBetweenQueryRootExpression") + { + var from = GetPropValue(queryRoot, "From"); + var to = GetPropValue(queryRoot, "To"); + + var fromTo = Expression.Call(TemporalBetween.MakeGenericMethod(entityType), + GetAsOfSqlServer(getTableExpr, entityType), + Expression.Constant(from), + Expression.Constant(to)); + + return fromTo; + } + + if (expressionTypeName == "TemporalContainedInQueryRootExpression") + { + var from = GetPropValue(queryRoot, "From"); + var to = GetPropValue(queryRoot, "To"); + + var fromTo = Expression.Call(TemporalContainedIn.MakeGenericMethod(entityType), + GetAsOfSqlServer(getTableExpr, entityType), + Expression.Constant(from), + Expression.Constant(to)); + + return fromTo; + } + + if (expressionTypeName == "TemporalAllQueryRootExpression") + { + var all = Expression.Call(TemporalAll.MakeGenericMethod(entityType), + GetAsOfSqlServer(getTableExpr, entityType)); + + return all; + } + + return getTableExpr; + } + static Expression EnsureEnumerable(Expression expression, MappingSchema mappingSchema) { var enumerable = typeof(IEnumerable<>).MakeGenericType(GetEnumerableElementType(expression.Type, mappingSchema)); diff --git a/Tests/LinqToDB.EntityFrameworkCore.SqlServer.Tests/Models/Northwind.Mapping/ProductsMap.cs b/Tests/LinqToDB.EntityFrameworkCore.SqlServer.Tests/Models/Northwind.Mapping/ProductsMap.cs index 14c3801..4e26b68 100644 --- a/Tests/LinqToDB.EntityFrameworkCore.SqlServer.Tests/Models/Northwind.Mapping/ProductsMap.cs +++ b/Tests/LinqToDB.EntityFrameworkCore.SqlServer.Tests/Models/Northwind.Mapping/ProductsMap.cs @@ -8,6 +8,8 @@ public class ProductsMap : BaseEntityMap { public override void Configure(EntityTypeBuilder builder) { + builder.ToTable(t => t.IsTemporal()); + builder.HasKey(e => e.ProductId); builder.HasIndex(e => e.CategoryId) diff --git a/Tests/LinqToDB.EntityFrameworkCore.SqlServer.Tests/ToolsTests.cs b/Tests/LinqToDB.EntityFrameworkCore.SqlServer.Tests/ToolsTests.cs index d1ba85e..1a517ea 100644 --- a/Tests/LinqToDB.EntityFrameworkCore.SqlServer.Tests/ToolsTests.cs +++ b/Tests/LinqToDB.EntityFrameworkCore.SqlServer.Tests/ToolsTests.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using FluentAssertions; +using FluentAssertions.Specialized; using LinqToDB.Data; using LinqToDB.EntityFrameworkCore.BaseTests; using LinqToDB.EntityFrameworkCore.BaseTests.Models.Northwind; @@ -835,5 +836,36 @@ public void TestTagWith([Values(true, false)] bool enableFilter) } } + + [Test] + public void TestTemporalTables([Values(true, false)] bool enableFilter) + { + using (var ctx = CreateContext(enableFilter)) + { + var query1 = ctx.Products.TemporalAsOf(DateTime.UtcNow); + var query2 = ctx.Products.TemporalFromTo(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow); + var query3 = ctx.Products.TemporalAll(); + var query4 = ctx.Products.TemporalBetween(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow); + var query5 = ctx.Products.TemporalContainedIn(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow); + + var result1 = query1.ToLinqToDB().ToArray(); + var result2 = query2.ToLinqToDB().ToArray(); + var result3 = query3.ToLinqToDB().ToArray(); + var result4 = query4.ToLinqToDB().ToArray(); + var result5 = query5.ToLinqToDB().ToArray(); + + var allQuery = + from p in ctx.Products.ToLinqToDB() + from q1 in ctx.Products.TemporalAsOf(DateTime.UtcNow).Where(q => q.ProductId == p.ProductId).DefaultIfEmpty() + from q2 in ctx.Products.TemporalFromTo(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow).Where(q => q.ProductId == p.ProductId).DefaultIfEmpty() + from q3 in ctx.Products.TemporalAll().Where(q => q.ProductId == p.ProductId).DefaultIfEmpty() + from q4 in ctx.Products.TemporalBetween(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow).Where(q => q.ProductId == p.ProductId).DefaultIfEmpty() + from q5 in ctx.Products.TemporalContainedIn(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow).Where(q => q.ProductId == p.ProductId).DefaultIfEmpty() + select p; + + var result = allQuery.ToArray(); + } + } + } }