Disclaimer: What are you asking is not supported naturally by EF Core 5.0, hence the provided workaround most likely will break in future EF Core versions. Use it on your own risk, or use what is supported (mapping to real database view containing the desired SQL, as mentioned by other people).
Now, the problems. First, the entity type you want to map to SQL and also use in relationship cannot be keyless. It's simply because currently keyless entity types
Only support a subset of navigation mapping capabilities, specifically:
- They may never act as the principal end of a relationship.
- They may not have navigations to owned entities
- They can only contain reference navigation properties pointing to regular entities.
- Entities cannot contain navigation properties to keyless entity types.
In your case, Customer is violating the last rule by defining navigation property to keyless entity. But without it you won't be able to use Include, which is the end goal of all that.
There is no workaround for that limitation. Even if with some hackery you map the relationship and get correct SQL translation, still the navigation property won't be loaded because all EF Core related data loading methods rely on change tracking, and it requires entities with keys.
So, the entity must be "normal" (with key). There is no problem with that since the query has unique column which defines one-to-one relationship. However this hits another current EF Core limitation - you get NotImplemented exception for normal entities mapped to SqlQuery during the model finalization. Unfortunately this is inside static function used by many places inside the relational model finalization, which is also a static method, so virtually it's not possible to intercept and fix it from outside.
Once you know the problems (what is supported and what is not), here is the workaround. The supported mapping is normal entity to view. So we'll use that (ToView instead of failing ToSqlQuery), but instead of name will provide the SQL enclosed with () to be able to recognize and extract it from the associated EF Core metadata. Note that EF Core does not validate/care what are you providing them as names in ToTable and ToView methods - just wheter they are null or not.
Then we need to plug into EF Core query processing pipeline and replace the "view name" with the actual SQL.
Following is the implementation of the above idea (put it in some code file inside your EF Core project):
namespace Microsoft.EntityFrameworkCore
{
using Metadata.Builders;
using Query;
public static class InlineSqlViewSupport
{
public static DbContextOptionsBuilder AddInlineSqlViewSupport(this DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.ReplaceService<ISqlExpressionFactory, CustomSqlExpressionFactory>();
public static EntityTypeBuilder<TEntity> ToInlineView<TEntity>(this EntityTypeBuilder<TEntity> entityTypeBuilder, string sql)
where TEntity : class => entityTypeBuilder.ToView($"({sql})");
}
}
namespace Microsoft.EntityFrameworkCore.Query
{
using System.Linq.Expressions;
using Metadata;
using SqlExpressions;
public class CustomSqlExpressionFactory : SqlExpressionFactory
{
public override SelectExpression Select(IEntityType entityType)
{
var viewName = entityType.GetViewName();
if (viewName != null && viewName.StartsWith("(") && viewName.EndsWith(")"))
{
var sql = viewName.Substring(1, viewName.Length - 2);
return Select(entityType, new FromSqlExpression("q", sql, NoArgs));
}
return base.Select(entityType);
}
private static readonly Expression NoArgs = Expression.Constant(new object[0]);
public CustomSqlExpressionFactory(SqlExpressionFactoryDependencies dependencies) : base(dependencies) { }
}
}
First two methods are just for convenience - one for adding the necessary plumbing and one for encoding the sql inside the name.
The actual work is inside the third class which replaces one of the standard EF Core services, intercepts the Select method which is responsinble for table/view/TVF expression mapping, and converts the special view names to SQL queries.
With these helpers in hand, you can use your sample model and DbSets as is. All you need is to add the following to your derived DbContext class:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// ...
optionsBuilder.AddInlineSqlViewSupport(); // <--
}
and use the following fluent configuration:
modelBuilder.Entity<MaxOrder>(builder =>
{
builder.HasKey(e => e.CustomerId);
builder.ToInlineView(
@"SELECT CustomerId, SUM(Amount) AS TotalAmount
FROM Orders O
WHERE Id = (SELECT MAX(Id)
FROM Orders
WHERE CustomerId = O.CustomerId)
GROUP BY CustomerId");
});
Now
var test = dbContext.Customers
.Include(x => x.MaxOrder)
.ToList();
will run w/o errors and generate SQL like
SELECT [c].[Id], [c].[Name], [q].[CustomerId], [q].[TotalAmount]
FROM [Customers] AS [c]
LEFT JOIN (
SELECT CustomerId, SUM(Amount) AS TotalAmount
FROM Orders O
WHERE Id = (SELECT MAX(Id)
FROM Orders
WHERE CustomerId = O.CustomerId)
GROUP BY CustomerId
) AS [q] ON [c].[Id] = [q].[CustomerId]
and more importantly, will correctly populate the Customer.MaxOrder property. Mission done :)