From b4aee313029cbb50300125570c042334587b7254 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:46:22 +0000 Subject: [PATCH 1/6] Initial plan From 191091a0ff0274f0cff241511a4ebf026198ce16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:09:46 +0000 Subject: [PATCH 2/6] Preserve implicit conversions in precompiled binary and conditional expressions Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .agents/skills/query-pipeline/SKILL.md | 1 + .../Query/Internal/CSharpToLinqTranslator.cs | 31 +++++++- .../Query/CSharpToLinqTranslatorTest.cs | 63 +++++++++++++++ ...AdHocPrecompiledQueryRelationalTestBase.cs | 79 +++++++++++++++++++ .../AdHocPrecompiledQuerySqlServerTest.cs | 23 ++++++ 5 files changed, 193 insertions(+), 4 deletions(-) diff --git a/.agents/skills/query-pipeline/SKILL.md b/.agents/skills/query-pipeline/SKILL.md index 73f01b0eef5..407b194eced 100644 --- a/.agents/skills/query-pipeline/SKILL.md +++ b/.agents/skills/query-pipeline/SKILL.md @@ -15,6 +15,7 @@ user-invocable: false ## Translation +- C#-to-LINQ reconstruction must preserve Roslyn's semantic operand and branch conversions, including nullable lifting and user-defined conversions, before constructing binary or conditional expression nodes. Type syntax in `is`/`as` is not a value operand. - Translation fallback must distinguish established unsupported shapes from unexpected defects. Do not turn arbitrary exceptions into a successful fallback. - Check whether a failed rewrite mutated state that fallback will reuse. For test coordination, establish the actual collection, assembly, and async-flow boundaries before claiming shared static state can race; theory rows are not inherently parallel, and reset in `finally` is not mutual exclusion. diff --git a/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs b/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs index c01789ab548..23c3f41cd36 100644 --- a/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs +++ b/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs @@ -252,8 +252,14 @@ public override Expression VisitArrayCreationExpression(ArrayCreationExpressionS /// 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) { @@ -343,8 +349,25 @@ 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); + + if (_semanticModel.GetTypeInfo(node).ConvertedType is { } typeSymbol) + { + var convertedType = ResolveType(typeSymbol); + if (expression.Type != convertedType + && (convertReferenceTypes || !_semanticModel.GetConversion(node).IsReference)) + { + return Convert(expression, convertedType); + } + } + + return expression; + } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/test/EFCore.Design.Tests/Query/CSharpToLinqTranslatorTest.cs b/test/EFCore.Design.Tests/Query/CSharpToLinqTranslatorTest.cs index 1b765ea5bc2..a5b626ea111 100644 --- a/test/EFCore.Design.Tests/Query/CSharpToLinqTranslatorTest.cs +++ b/test/EFCore.Design.Tests/Query/CSharpToLinqTranslatorTest.cs @@ -68,6 +68,33 @@ 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_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( @@ -80,6 +107,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( @@ -489,6 +542,16 @@ 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 Blog { public Blog() diff --git a/test/EFCore.Relational.Specification.Tests/Query/AdHocPrecompiledQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/AdHocPrecompiledQueryRelationalTestBase.cs index 367700e3d4d..4590bcb1668 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/AdHocPrecompiledQueryRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/AdHocPrecompiledQueryRelationalTestBase.cs @@ -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(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 Tasks { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(task => task.Id).ValueGeneratedNever(); + modelBuilder.Entity().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() { diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs index 85ceb6eaf31..54dd57a3496 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs @@ -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) From 598cb3b075fe3b885218fe066803c5cdc35310a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:21:44 +0000 Subject: [PATCH 3/6] Revert query-pipeline skill changes as requested Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .agents/skills/query-pipeline/SKILL.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.agents/skills/query-pipeline/SKILL.md b/.agents/skills/query-pipeline/SKILL.md index 407b194eced..73f01b0eef5 100644 --- a/.agents/skills/query-pipeline/SKILL.md +++ b/.agents/skills/query-pipeline/SKILL.md @@ -15,7 +15,6 @@ user-invocable: false ## Translation -- C#-to-LINQ reconstruction must preserve Roslyn's semantic operand and branch conversions, including nullable lifting and user-defined conversions, before constructing binary or conditional expression nodes. Type syntax in `is`/`as` is not a value operand. - Translation fallback must distinguish established unsupported shapes from unexpected defects. Do not turn arbitrary exceptions into a successful fallback. - Check whether a failed rewrite mutated state that fallback will reuse. For test coordination, establish the actual collection, assembly, and async-flow boundaries before claiming shared static state can race; theory rows are not inherently parallel, and reset in `finally` is not mutual exclusion. From bac7fa02e374846880b641a78529d4b01080a542 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:12:40 +0000 Subject: [PATCH 4/6] Preserve selected user-defined conversions Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Query/Internal/CSharpToLinqTranslator.cs | 43 +++++++++++++++++- .../Query/CSharpToLinqTranslatorTest.cs | 44 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs b/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs index 23c3f41cd36..24e7551c64b 100644 --- a/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs +++ b/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs @@ -355,20 +355,59 @@ public override Expression VisitConditionalExpression(ConditionalExpressionSynta 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 || !_semanticModel.GetConversion(node).IsReference)) + && (convertReferenceTypes || !conversion.IsReference)) { - return Convert(expression, convertedType); + 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.GetMethod( + methodSymbol.Name, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + [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 + && Nullable.GetUnderlyingType(parameterType) is null + && Nullable.GetUnderlyingType(returnType) is null) + { + 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); + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/test/EFCore.Design.Tests/Query/CSharpToLinqTranslatorTest.cs b/test/EFCore.Design.Tests/Query/CSharpToLinqTranslatorTest.cs index a5b626ea111..ec8687e515e 100644 --- a/test/EFCore.Design.Tests/Query/CSharpToLinqTranslatorTest.cs +++ b/test/EFCore.Design.Tests/Query/CSharpToLinqTranslatorTest.cs @@ -80,6 +80,25 @@ public void Binary_equality_with_implicit_conversion_on_right() () => 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( @@ -552,6 +571,31 @@ 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() From 1e940256f3aa85b1662a5afdc0141f33e9d91427 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Wed, 21 Oct 2026 18:03:55 -0700 Subject: [PATCH 5/6] Fix conditional logic for nullable type checks Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs b/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs index 24e7551c64b..6478ac99711 100644 --- a/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs +++ b/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs @@ -390,6 +390,8 @@ private Expression ApplyConversion(Expression expression, Type convertedType, Co 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) { From 44a37a28d1a763c3838bd87c199e82bc874c71df Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Wed, 21 Oct 2026 18:04:16 -0700 Subject: [PATCH 6/6] Refactor method retrieval logic for user-defined conversion Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Query/Internal/CSharpToLinqTranslator.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs b/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs index 6478ac99711..288fd66f0ea 100644 --- a/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs +++ b/src/EFCore.Design/Query/Internal/CSharpToLinqTranslator.cs @@ -380,10 +380,12 @@ private Expression ApplyConversion(Expression expression, Type convertedType, Co var methodSymbol = conversion.MethodSymbol ?? throw new InvalidOperationException($"Could not find method for user-defined conversion: {expression}"); var declaringType = ResolveType(methodSymbol.ContainingType); - var methodInfo = declaringType.GetMethod( - methodSymbol.Name, - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, - [ResolveType(methodSymbol.Parameters[0].Type)]) + 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}");