Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/EFCore.SqlServer/Properties/SqlServerStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,9 @@
<data name="TemporalNotSupportedForTableSplittingWithInconsistentPeriodMapping" xml:space="preserve">
<value>When multiple temporal entities are mapped to the same table, their period {periodType} properties must map to the same column. Issue happens for entity type '{entityType}' with period property '{periodProperty}' which is mapped to column '{periodColumn}'. Expected period column name is '{expectedColumnName}'.</value>
</data>
<data name="TemporalOperatorRequiresConstantArgumentInCompiledQuery" xml:space="preserve">
<value>The temporal operator '{operatorName}' cannot be used in a compiled query with an argument that varies per invocation. The point in time is written into the SQL as a literal, and a compiled query reuses a single SQL string, so the argument must be a constant. Either use a constant point in time, or execute the query without EF.CompileQuery.</value>
</data>
Comment on lines +385 to +387
<data name="TemporalOnlyOnRoot" xml:space="preserve">
<value>Only root entity type should be marked as temporal. Entity type: '{entityType}'.</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
{
var method = methodCallExpression.Method;

// A temporal operator normally executes while the query is being built, returning a
// temporal query root. Inside EF.CompileQuery/CompileAsyncQuery it is never executed -- the
// lambda is only an expression tree -- so the call reaches translation intact. It cannot be
// honoured here: the point-in-time is emitted into the SQL as a literal
// (SqlServerQuerySqlGenerator, GenerateSqlLiteral), and a compiled query caches one SQL
// string across invocations, so a value that varies per invocation has nowhere to go.
// Report that rather than letting it fall through to a generic "could not be translated".
if (method.DeclaringType == typeof(SqlServerDbSetExtensions))
{
throw new InvalidOperationException(
SqlServerStrings.TemporalOperatorRequiresConstantArgumentInCompiledQuery(method.Name));
}

if (method.DeclaringType == typeof(SqlServerQueryableExtensions)
&& Visit(methodCallExpression.Arguments[0]) is ShapedQueryExpression source)
{
Expand Down
35 changes: 34 additions & 1 deletion src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,40 @@ static bool TryUnwrapSpanImplicitCast(Expression expression, [NotNullWhen(true)]
throw new UnreachableException();
}

return methodCall.Update(@object, ((IReadOnlyList<Expression>?)arguments) ?? methodCall.Arguments);
var finalArguments = ((IReadOnlyList<Expression>?)arguments) ?? methodCall.Arguments;

// Evaluating a DbSet-typed argument inlines its query root, which is typed IQueryable<T>
// rather than DbSet<T> (see the IQueryable case in ProcessEvaluatableRoot). That is what we
// want for e.g. Queryable.Count(IQueryable<T>), but it cannot be rebuilt into a method whose
// parameter is declared DbSet<T> -- FromSql and the SQL Server temporal operators are the
// notable cases -- and Update would throw ArgumentException. VisitMember already declines to
// inline DbSet-typed members for this reason, but that guard is bypassed when the parent
// processes the member as an evaluatable root instead. Keep the original argument in that
// case: a query root needs no inlining, it already is the root.
ParameterInfo[]? finalParameters = null;
for (var i = 0; i < finalArguments.Count; i++)
{
if (!ReferenceEquals(finalArguments[i], methodCall.Arguments[i]))
{
finalParameters ??= methodCall.Method.GetParameters();
var parameterType = finalParameters[i].ParameterType;
if (parameterType.IsConstructedGenericType
&& parameterType.GetGenericTypeDefinition() == typeof(DbSet<>)
&& !parameterType.IsAssignableFrom(finalArguments[i].Type)
&& parameterType.IsAssignableFrom(methodCall.Arguments[i].Type))
{
if (finalArguments is not List<Expression> mutableArguments)
{
mutableArguments = finalArguments.ToList();
finalArguments = mutableArguments;
}

mutableArguments[i] = methodCall.Arguments[i];
}
}
}

return methodCall.Update(@object, finalArguments);

Expression HandleParameter(MethodCallExpression methodCall, string methodName)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.EntityFrameworkCore.Query;

public class TemporalCompiledQuerySqlServerFixture : SharedStoreFixtureBase<TemporalCompiledQueryContext>
{
// A dedicated store: the GearsOfWar temporal fixture mutates its data to build history and is
// not idempotent, so sharing its StoreName across test classes corrupts it.
protected override string StoreName
=> "TemporalCompiledQueryTest";

protected override ITestStoreFactory TestStoreFactory
=> SqlServerTestStoreFactory.Instance;

protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
=> modelBuilder.Entity<TemporalCompiledQueryCustomer>().ToTable("Customers", t => t.IsTemporal());
}

public class TemporalCompiledQueryContext(DbContextOptions options) : DbContext(options)
{
public DbSet<TemporalCompiledQueryCustomer> Customers
=> Set<TemporalCompiledQueryCustomer>();
}

public class TemporalCompiledQueryCustomer
{
public int Id { get; set; }
public string? Name { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.EntityFrameworkCore.SqlServer.Internal;

namespace Microsoft.EntityFrameworkCore.Query;

public class TemporalCompiledQuerySqlServerTest(TemporalCompiledQuerySqlServerFixture fixture)
: IClassFixture<TemporalCompiledQuerySqlServerFixture>
Comment on lines +8 to +9

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add this coverage to TemporalGearsOfWarQuerySqlServerTest instead of creating a new fixture

{
private static readonly DateTime PointInTime = new(2020, 1, 1);
private static readonly DateTime LaterPointInTime = new(2021, 1, 1);

// A per-invocation argument used to throw ArgumentException out of ExpressionTreeFuncletizer,
// complaining that IQueryable<City> did not fit a DbSet<City> parameter. It now reports why.

[Fact]
public async Task TemporalAsOf_with_query_parameter_reports_a_guided_error()
{
using var context = fixture.CreateContext();

var compiled = EF.CompileAsyncQuery(
(TemporalCompiledQueryContext c, DateTime asOf) => c.Customers.TemporalAsOf(asOf).Select(x => x.Name));

var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await compiled(context, PointInTime).ToListAsync());

Assert.Equal(
SqlServerStrings.TemporalOperatorRequiresConstantArgumentInCompiledQuery(
nameof(SqlServerDbSetExtensions.TemporalAsOf)),
exception.Message);
}

[Theory]
[InlineData("FromTo")]
[InlineData("Between")]
[InlineData("ContainedIn")]
public async Task Temporal_range_operators_with_query_parameters_report_a_guided_error(string op)
{
using var context = fixture.CreateContext();

Func<TemporalCompiledQueryContext, DateTime, DateTime, IAsyncEnumerable<string?>> compiled = op switch
{
"FromTo" => EF.CompileAsyncQuery(
(TemporalCompiledQueryContext c, DateTime a, DateTime b) => c.Customers.TemporalFromTo(a, b).Select(x => x.Name)),
"Between" => EF.CompileAsyncQuery(
(TemporalCompiledQueryContext c, DateTime a, DateTime b) => c.Customers.TemporalBetween(a, b).Select(x => x.Name)),
"ContainedIn" => EF.CompileAsyncQuery(
(TemporalCompiledQueryContext c, DateTime a, DateTime b) => c.Customers.TemporalContainedIn(a, b).Select(x => x.Name)),
_ => throw new ArgumentOutOfRangeException(nameof(op)),
};

var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await compiled(context, PointInTime, LaterPointInTime).ToListAsync());

Assert.Contains("compiled query", exception.Message);
}
Comment on lines +34 to +57

[Fact]
public void TemporalAsOf_with_query_parameter_reports_a_guided_error_for_sync_CompileQuery()
{
using var context = fixture.CreateContext();

var compiled = EF.CompileQuery(
(TemporalCompiledQueryContext c, DateTime asOf) => c.Customers.TemporalAsOf(asOf).Select(x => x.Name));

var exception = Assert.Throws<InvalidOperationException>(() => compiled(context, PointInTime).ToList());

Assert.Contains("compiled query", exception.Message);
}

// The supported shapes must keep working: these evaluate to a temporal query root before
// translation, so they never reach the guard above.

[Fact]
public async Task TemporalAsOf_with_a_constant_still_works()
{
using var context = fixture.CreateContext();

var compiled = EF.CompileAsyncQuery(
(TemporalCompiledQueryContext c) => c.Customers.TemporalAsOf(new DateTime(2020, 1, 1)).Select(x => x.Name));

_ = await compiled(context).ToListAsync();
}

[Fact]
public async Task TemporalAsOf_with_a_captured_variable_still_works()
{
using var context = fixture.CreateContext();

var captured = PointInTime;
var compiled = EF.CompileAsyncQuery(
(TemporalCompiledQueryContext c) => c.Customers.TemporalAsOf(captured).Select(x => x.Name));

_ = await compiled(context).ToListAsync();
}

[Fact]
public async Task TemporalAll_still_works()
{
using var context = fixture.CreateContext();

var compiled = EF.CompileAsyncQuery(
(TemporalCompiledQueryContext c) => c.Customers.TemporalAll().Select(x => x.Name));

_ = await compiled(context).ToListAsync();
}

[Fact]
public async Task Non_temporal_compiled_query_still_works()
{
using var context = fixture.CreateContext();

var compiled = EF.CompileAsyncQuery((TemporalCompiledQueryContext c) => c.Customers.Select(x => x.Name));

_ = await compiled(context).ToListAsync();
}
}
Loading