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
74 changes: 70 additions & 4 deletions src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,14 @@ public override Expression VisitArrayCreationExpression(ArrayCreationExpressionS
/// </summary>
public override Expression VisitBinaryExpression(BinaryExpressionSyntax binary)
{
var left = Visit(binary.Left);
var right = Visit(binary.Right);
// The compiler omits implicit reference conversions in binary expression trees.
var typeOperator = binary.Kind() is SyntaxKind.IsExpression or SyntaxKind.AsExpression;
var left = typeOperator
? Visit(binary.Left)
: VisitWithConversion(binary.Left, convertReferenceTypes: false);
var right = typeOperator
? Visit(binary.Right)
: VisitWithConversion(binary.Right, convertReferenceTypes: false);

if (Nullable.GetUnderlyingType(left.Type) == right.Type)
{
Expand Down Expand Up @@ -343,8 +349,68 @@ public override Expression VisitCastExpression(CastExpressionSyntax cast)
public override Expression VisitConditionalExpression(ConditionalExpressionSyntax conditional)
=> Condition(
Visit(conditional.Condition),
Visit(conditional.WhenTrue),
Visit(conditional.WhenFalse));
VisitWithConversion(conditional.WhenTrue),
VisitWithConversion(conditional.WhenFalse));

private Expression VisitWithConversion(ExpressionSyntax node, bool convertReferenceTypes = true)
{
var expression = Visit(node);
var conversion = _semanticModel.GetConversion(node);

if (_semanticModel.GetTypeInfo(node).ConvertedType is { } typeSymbol)
{
var convertedType = ResolveType(typeSymbol);
if (expression.Type != convertedType
&& (convertReferenceTypes || !conversion.IsReference))
{
return ApplyConversion(expression, convertedType, conversion);
}
}

return expression;
}

private Expression ApplyConversion(Expression expression, Type convertedType, Conversion conversion)
{
if (!conversion.IsUserDefined)
{
return Convert(expression, convertedType);
}

var methodSymbol = conversion.MethodSymbol
?? throw new InvalidOperationException($"Could not find method for user-defined conversion: {expression}");
var declaringType = ResolveType(methodSymbol.ContainingType);
var methodInfo = declaringType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
.SingleOrDefault(
method => method.Name == methodSymbol.Name
&& method.ReturnType == ResolveType(methodSymbol.ReturnType)
&& method.GetParameters() is [var parameter]
&& parameter.ParameterType == ResolveType(methodSymbol.Parameters[0].Type))
?? throw new InvalidOperationException(
$"Could not find method '{methodSymbol.Name}' on type '{declaringType.Name}' for user-defined conversion: {expression}");

var parameterType = methodInfo.GetParameters()[0].ParameterType;
var returnType = methodInfo.ReturnType;
if (Nullable.GetUnderlyingType(expression.Type) is not null
&& parameterType.IsValueType
&& returnType.IsValueType
&& Nullable.GetUnderlyingType(parameterType) is null
&& Nullable.GetUnderlyingType(returnType) is null)
Comment thread
Copilot marked this conversation as resolved.
{
parameterType = typeof(Nullable<>).MakeGenericType(parameterType);
returnType = typeof(Nullable<>).MakeGenericType(returnType);
}

if (expression.Type != parameterType)
{
expression = Convert(expression, parameterType);
}

expression = Convert(expression, returnType, methodInfo);
return expression.Type == convertedType
? expression
: Convert(expression, convertedType);
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
Expand Down
107 changes: 107 additions & 0 deletions test/EFCore.Design.Tests/Query/CSharpToLinqTranslatorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,52 @@ public void Binary_add_string()
() => new[] { "foo", "bar" }.Select(s => s + "foo"),
"""new[] { "foo", "bar" }.Select(s => s + "foo")""");

[Fact]
public void Binary_equality_with_implicit_conversion()
=> AssertExpression(
() => new[] { new ConvertibleName("foo") }.Where(name => (string)name == "foo"),
"""new[] { new CSharpToLinqTranslatorTest.ConvertibleName("foo") }.Where(name => name == "foo")""");

[Fact]
public void Binary_equality_with_implicit_conversion_on_right()
=> AssertExpression(
() => new[] { new ConvertibleName("foo") }.Where(name => "foo" == (string)name),
"""new[] { new CSharpToLinqTranslatorTest.ConvertibleName("foo") }.Where(name => "foo" == name)""");

[Fact]
public void Binary_equality_with_implicit_conversion_and_explicit_conversion()
=> AssertExpression(
() => new[] { new ConvertibleWithIntAndLong(1) }.Where(value => 1L == value),
"new[] { new CSharpToLinqTranslatorTest.ConvertibleWithIntAndLong(1) }.Where(value => 1L == value)");

[Fact]
public void Binary_equality_with_implicit_conversion_to_int_and_numeric_widening()
=> AssertExpression(
() => new[] { new ConvertibleInt(1) }.Where(value => 1L == value),
"new[] { new CSharpToLinqTranslatorTest.ConvertibleInt(1) }.Where(value => 1L == value)");

[Fact]
public void Binary_equality_with_nullable_lifted_implicit_conversion()
=> AssertExpression(
() => new[] { new NullableConversionPair(1L, new ConvertibleInt(1)) }
.Where(pair => pair.LongValue == pair.Value),
"new[] { new CSharpToLinqTranslatorTest.NullableConversionPair(1L, new CSharpToLinqTranslatorTest.ConvertibleInt(1)) }.Where(pair => pair.LongValue == pair.Value)");

[Fact]
public void Binary_inequality_with_implicit_conversion()
=> AssertExpression(
() => new[] { new ConvertibleName("foo") }.Where(name => (string)name != "bar"),
"""new[] { new CSharpToLinqTranslatorTest.ConvertibleName("foo") }.Where(name => name != "bar")""");

[Fact]
public void Binary_equality_without_implicit_conversion()
=> AssertExpression(
() => new[] { new ConvertibleName("foo") }.Where(name => name == new ConvertibleName("foo")),
"""
new[] { new CSharpToLinqTranslatorTest.ConvertibleName("foo") }
.Where(name => name == new CSharpToLinqTranslatorTest.ConvertibleName("foo"))
""");

[Fact]
public void Cast()
=> AssertExpression(
Expand All @@ -80,6 +126,32 @@ public void Coalesce()
() => (object?)"foo" ?? (object)"bar",
"""(object?)"foo" ?? (object)"bar" """);

[Theory]
[InlineData("condition ? Math.Sqrt(4.0) : null")]
[InlineData("condition ? (double?)Math.Sqrt(4.0) : null")]
public void Conditional_with_nullable_true_branch(string conditional)
=> AssertExpression(
() => new[] { true, false }.Select(condition => new NullableMetric(condition ? (double?)Math.Sqrt(4.0) : null)),
$$"""
new[] { true, false }.Select(condition => new CSharpToLinqTranslatorTest.NullableMetric({{conditional}}))
""");

[Theory]
[InlineData("condition ? null : Math.Sqrt(4.0)")]
[InlineData("condition ? null : (double?)Math.Sqrt(4.0)")]
public void Conditional_with_nullable_false_branch(string conditional)
=> AssertExpression(
() => new[] { true, false }.Select(condition => new NullableMetric(condition ? null : (double?)Math.Sqrt(4.0))),
$$"""
new[] { true, false }.Select(condition => new CSharpToLinqTranslatorTest.NullableMetric({{conditional}}))
""");

[Fact]
public void Conditional_with_numeric_conversion()
=> AssertExpression(
() => new[] { true, false }.Select(condition => condition ? Math.Abs(8) : Math.Sqrt(4.0)),
"new[] { true, false }.Select(condition => condition ? Math.Abs(8) : Math.Sqrt(4.0))");

[Fact]
public void ElementAccess_over_array()
=> AssertExpression(
Expand Down Expand Up @@ -489,6 +561,41 @@ public enum SomeEnum

private class BlogContext : DbContext;

public sealed record NullableMetric(double? Value);

public class ConvertibleName(string value)
{
private readonly string _value = value;

public static implicit operator string(ConvertibleName name)
=> name._value;
}

public class ConvertibleWithIntAndLong(int value)
{
public int Value { get; } = value;

public static implicit operator int(ConvertibleWithIntAndLong value)
=> value.Value;

public static explicit operator long(ConvertibleWithIntAndLong value)
=> value.Value + 100;
}

public readonly struct ConvertibleInt(int value)
{
public int Value { get; } = value;

public static implicit operator int(ConvertibleInt value)
=> value.Value;
}

public class NullableConversionPair(long longValue, ConvertibleInt value)
{
public long? LongValue { get; } = longValue;
public ConvertibleInt? Value { get; } = value;
}

public class Blog
{
public Blog()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,85 @@ public AdHocPrecompiledQueryRelationalTestBase(NonSharedFixture fixture, ITestOu
: base(fixture)
=> TestOutputHelper = testOutputHelper;

[Fact]
public virtual async Task Implicit_conversions_in_binary_and_conditional_expressions()
{
var contextFactory = await InitializeNonSharedTest<ImplicitConversionContext>(seed: context => context.SeedAsync());

await Test(
"""
await using var context = new AdHocPrecompiledQueryRelationalTestBase.ImplicitConversionContext(dbContextOptions);
var taskType = "generation";
var since = new DateTime(2026, 1, 1);
var metrics = await context.Tasks
.AsNoTracking()
.Where(task => task.Type == taskType && task.CreatedAt >= since)
.GroupBy(task => task.State)
.OrderBy(group => group.Key)
.Select(group => new AdHocPrecompiledQueryRelationalTestBase.ImplicitConversionContext.Metric(
group.Key,
group.Count(),
group.Where(task => task.CompletedAt.HasValue).Any()
? group.Where(task => task.CompletedAt.HasValue).Average(task => task.Id)
: null))
.ToListAsync();

Assert.Equal(
new[]
{
new AdHocPrecompiledQueryRelationalTestBase.ImplicitConversionContext.Metric(0, 2, 2.0),
new AdHocPrecompiledQueryRelationalTestBase.ImplicitConversionContext.Metric(1, 1, null)
},
metrics);
""",
typeof(ImplicitConversionContext),
contextFactory.GetOptions());
}

public class ImplicitConversionContext(DbContextOptions options) : DbContext(options)
{
public DbSet<GenerationTask> Tasks { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<GenerationTask>().Property(task => task.Id).ValueGeneratedNever();
modelBuilder.Entity<GenerationTask>().Property(task => task.Type)
.HasConversion(type => (string)type, value => new TaskType(value));
}

public Task SeedAsync()
{
var createdAt = new DateTime(2026, 1, 1);
AddRange(
new GenerationTask { Id = 1, Type = new TaskType("generation"), CreatedAt = createdAt, CompletedAt = createdAt },
new GenerationTask { Id = 3, Type = new TaskType("generation"), CreatedAt = createdAt, CompletedAt = createdAt },
new GenerationTask { Id = 2, Type = new TaskType("generation"), State = 1, CreatedAt = createdAt },
new GenerationTask { Id = 4, Type = new TaskType("other"), CreatedAt = createdAt },
new GenerationTask { Id = 5, Type = new TaskType("generation"), CreatedAt = createdAt.AddDays(-1) });

return SaveChangesAsync();
}

public class GenerationTask
{
public int Id { get; set; }
public TaskType Type { get; set; } = null!;
public int State { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}

public class TaskType(string value)
{
private readonly string _value = value;

public static implicit operator string(TaskType type)
=> type._value;
}

public sealed record Metric(int State, int Count, double? Average);
}

[Fact]
public virtual async Task Index_no_evaluatability()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ public class AdHocPrecompiledQuerySqlServerTest(NonSharedFixture fixture, ITestO
protected override bool AlwaysPrintGeneratedSources
=> false;

public override async Task Implicit_conversions_in_binary_and_conditional_expressions()
{
await base.Implicit_conversions_in_binary_and_conditional_expressions();

AssertSql(
"""
@taskType='generation' (Size = 4000)
@since='2026-01-01T00:00:00.0000000'

SELECT [t].[State], COUNT(*), CASE
WHEN COUNT_BIG(CASE
WHEN [t].[CompletedAt] IS NOT NULL THEN 1
END) > CAST(0 AS bigint) THEN AVG(CASE
WHEN [t].[CompletedAt] IS NOT NULL THEN CAST([t].[Id] AS float)
END)
END
FROM [Tasks] AS [t]
WHERE [t].[Type] = @taskType AND [t].[CreatedAt] >= @since
GROUP BY [t].[State]
ORDER BY [t].[State]
""");
}

public override async Task Index_no_evaluatability()
{
if (!SqlServerTestEnvironment.SupportsJsonPathExpressions)
Expand Down
Loading