From d0e4a0c151c25317e069ed48b8108ef9fc7b8adc Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 1 Mar 2026 12:34:53 +0100 Subject: [PATCH 1/6] Add support for pattern matching in various cases --- .../ExpressionSyntaxRewriter.cs | 123 +++++ ...ckBodiedMethod_WithAndPattern.verified.txt | 17 + ...iedMethod_WithConstantPattern.verified.txt | 17 + ...ckBodiedMethod_WithNotPattern.verified.txt | 17 + ...ockBodiedMethod_WithOrPattern.verified.txt | 17 + ...iedMethod_WithPatternMatching.verified.txt | 17 + ...dMethod_WithRelationalPattern.verified.txt | 17 + ...died_IsPattern_WithAndPattern.verified.txt | 16 + ..._IsPattern_WithNotNullPattern.verified.txt | 16 + ...odied_IsPattern_WithOrPattern.verified.txt | 16 + ...IsPattern_WithPropertyPattern.verified.txt | 16 + ...nMemberOnInterface.DotNet10_0.verified.txt | 17 + ...ts.ExtensionMemberOnInterface.verified.txt | 17 + ...emberWithBlockBody.DotNet10_0.verified.txt | 17 + ....ExtensionMemberWithBlockBody.verified.txt | 17 + ...sPatternExpression.DotNet10_0.verified.txt | 17 + ...MemberWithIsPatternExpression.verified.txt | 17 + ...thSwitchExpression.DotNet10_0.verified.txt | 17 + ...ionMemberWithSwitchExpression.verified.txt | 17 + ...ression_WithRelationalPattern.verified.txt | 16 + ...onalPattern_OnExtensionMethod.verified.txt | 16 + .../ProjectionExpressionGeneratorTests.cs | 495 ++++++++++++++++++ 22 files changed, 952 insertions(+) create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithAndPattern.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithConstantPattern.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithNotPattern.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithOrPattern.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithPatternMatching.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithRelationalPattern.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithAndPattern.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithNotNullPattern.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithOrPattern.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithPropertyPattern.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnInterface.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnInterface.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithBlockBody.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithBlockBody.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithIsPatternExpression.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithIsPatternExpression.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithSwitchExpression.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithSwitchExpression.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpression_WithRelationalPattern.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpression_WithRelationalPattern_OnExtensionMethod.verified.txt diff --git a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs index f953701..c22e929 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs @@ -676,5 +676,128 @@ private ExpressionSyntax ReplaceVariableWithCast(ExpressionSyntax expression, De return expression; } + + public override SyntaxNode? VisitIsPatternExpression(IsPatternExpressionSyntax node) + { + // Pattern matching is not supported in expression trees (CS8122) + // We need to convert patterns into equivalent expressions + var expression = (ExpressionSyntax)Visit(node.Expression); + + return ConvertPatternToExpression(node.Pattern, expression); + } + + private ExpressionSyntax ConvertPatternToExpression(PatternSyntax pattern, ExpressionSyntax expression) + { + switch (pattern) + { + case RecursivePatternSyntax recursivePattern: + return ConvertRecursivePattern(recursivePattern, expression); + + case ConstantPatternSyntax constantPattern: + // e is null or e is 5 + return SyntaxFactory.BinaryExpression( + SyntaxKind.EqualsExpression, + expression, + (ExpressionSyntax)Visit(constantPattern.Expression) + ); + + case DeclarationPatternSyntax declarationPattern: + // e is string s -> e is string (type check) + return SyntaxFactory.BinaryExpression( + SyntaxKind.IsExpression, + expression, + declarationPattern.Type + ); + + case RelationalPatternSyntax relationalPattern: + // e is > 100 + var binaryKind = relationalPattern.OperatorToken.Kind() switch + { + SyntaxKind.LessThanToken => SyntaxKind.LessThanExpression, + SyntaxKind.LessThanEqualsToken => SyntaxKind.LessThanOrEqualExpression, + SyntaxKind.GreaterThanToken => SyntaxKind.GreaterThanExpression, + SyntaxKind.GreaterThanEqualsToken => SyntaxKind.GreaterThanOrEqualExpression, + _ => throw new NotSupportedException($"Relational operator {relationalPattern.OperatorToken} not supported") + }; + + return SyntaxFactory.BinaryExpression( + binaryKind, + expression, + (ExpressionSyntax)Visit(relationalPattern.Expression) + ); + + case BinaryPatternSyntax binaryPattern: + // e is > 10 and < 100 + var left = ConvertPatternToExpression(binaryPattern.Left, expression); + var right = ConvertPatternToExpression(binaryPattern.Right, expression); + + var logicalKind = binaryPattern.OperatorToken.Kind() switch + { + SyntaxKind.AndKeyword => SyntaxKind.LogicalAndExpression, + SyntaxKind.OrKeyword => SyntaxKind.LogicalOrExpression, + _ => throw new NotSupportedException($"Binary pattern operator {binaryPattern.OperatorToken} not supported") + }; + + return SyntaxFactory.BinaryExpression(logicalKind, left, right); + + case UnaryPatternSyntax unaryPattern when unaryPattern.OperatorToken.IsKind(SyntaxKind.NotKeyword): + // e is not null + var innerPattern = ConvertPatternToExpression(unaryPattern.Pattern, expression); + return SyntaxFactory.PrefixUnaryExpression( + SyntaxKind.LogicalNotExpression, + SyntaxFactory.ParenthesizedExpression(innerPattern) + ); + + default: + throw new NotSupportedException($"Pattern type {pattern.GetType().Name} is not yet supported in projectable methods"); + } + } + + private ExpressionSyntax ConvertRecursivePattern(RecursivePatternSyntax recursivePattern, ExpressionSyntax expression) + { + // entity is { IsActive: true, Value: > 100 } + // Convert to: entity != null && entity.IsActive == true && entity.Value > 100 + + var conditions = new List(); + + // Add null check first (unless pattern explicitly includes null) + var nullCheck = SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + expression, + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression) + ); + conditions.Add(nullCheck); + + // Handle property patterns + if (recursivePattern.PropertyPatternClause != null) + { + foreach (var subpattern in recursivePattern.PropertyPatternClause.Subpatterns) + { + var memberAccess = subpattern.NameColon != null + ? SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + expression, + SyntaxFactory.IdentifierName(subpattern.NameColon.Name.Identifier) + ) + : expression; + + var condition = ConvertPatternToExpression(subpattern.Pattern, memberAccess); + conditions.Add(condition); + } + } + + // Combine all conditions with && + var result = conditions[0]; + for (var i = 1; i < conditions.Count; i++) + { + result = SyntaxFactory.BinaryExpression( + SyntaxKind.LogicalAndExpression, + result, + conditions[i] + ); + } + + return result; + } } } diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithAndPattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithAndPattern.verified.txt new file mode 100644 index 0000000..da5e306 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithAndPattern.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Extensions_IsInRange_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity entity) => entity.Value >= 1 && entity.Value <= 100 ? true : false; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithConstantPattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithConstantPattern.verified.txt new file mode 100644 index 0000000..6356921 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithConstantPattern.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Extensions_IsNull_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity entity) => entity == null ? true : false; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithNotPattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithNotPattern.verified.txt new file mode 100644 index 0000000..797a367 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithNotPattern.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Extensions_IsNotNull_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity entity) => !(entity == null) ? true : false; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithOrPattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithOrPattern.verified.txt new file mode 100644 index 0000000..b5e5f65 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithOrPattern.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Extensions_IsTerminal_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity entity) => entity.Status == "Cancelled" || entity.Status == "Completed" ? true : false; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithPatternMatching.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithPatternMatching.verified.txt new file mode 100644 index 0000000..a11076d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithPatternMatching.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Extensions_GetComplexCategory_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity entity) => entity != null && entity.IsActive == true && entity.Value > 100 ? "Active High" : "Other"; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithRelationalPattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithRelationalPattern.verified.txt new file mode 100644 index 0000000..55dcb0a --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.BlockBodiedMethod_WithRelationalPattern.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Extensions_GetCategory_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity entity) => entity.Value > 100 ? "High" : "Low"; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithAndPattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithAndPattern.verified.txt new file mode 100644 index 0000000..5ee1641 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithAndPattern.verified.txt @@ -0,0 +1,16 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Entity_IsInRange + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Value >= 1 && @this.Value <= 100; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithNotNullPattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithNotNullPattern.verified.txt new file mode 100644 index 0000000..dba7ad6 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithNotNullPattern.verified.txt @@ -0,0 +1,16 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Entity_HasName + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => !(@this.Name == null); + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithOrPattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithOrPattern.verified.txt new file mode 100644 index 0000000..04942c7 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithOrPattern.verified.txt @@ -0,0 +1,16 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Entity_IsOutOfRange + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Value == 0 || @this.Value > 100; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithPropertyPattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithPropertyPattern.verified.txt new file mode 100644 index 0000000..cc2201f --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExpressionBodied_IsPattern_WithPropertyPattern.verified.txt @@ -0,0 +1,16 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Extensions_IsActiveAndPositive_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity entity) => entity != null && entity.IsActive == true && entity.Value > 0; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnInterface.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnInterface.DotNet10_0.verified.txt new file mode 100644 index 0000000..a16c95c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnInterface.DotNet10_0.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_IEntityExtensions_Label + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.IEntity @this) => @this.Id + ": " + @this.Name; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnInterface.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnInterface.verified.txt new file mode 100644 index 0000000..a16c95c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnInterface.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_IEntityExtensions_Label + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.IEntity @this) => @this.Id + ": " + @this.Name; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithBlockBody.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithBlockBody.DotNet10_0.verified.txt new file mode 100644 index 0000000..70c9c10 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithBlockBody.DotNet10_0.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_GetStatus_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.IsActive && @this.Value > 0 ? "Active" : "Inactive"; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithBlockBody.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithBlockBody.verified.txt new file mode 100644 index 0000000..70c9c10 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithBlockBody.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_GetStatus_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.IsActive && @this.Value > 0 ? "Active" : "Inactive"; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithIsPatternExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithIsPatternExpression.DotNet10_0.verified.txt new file mode 100644 index 0000000..758dab3 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithIsPatternExpression.DotNet10_0.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_IsHighValue + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Value > 100; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithIsPatternExpression.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithIsPatternExpression.verified.txt new file mode 100644 index 0000000..758dab3 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithIsPatternExpression.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_IsHighValue + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Value > 100; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithSwitchExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithSwitchExpression.DotNet10_0.verified.txt new file mode 100644 index 0000000..2bbeb71 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithSwitchExpression.DotNet10_0.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_GetGrade_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Score >= 90 ? "A" : @this.Score >= 80 ? "B" : @this.Score >= 70 ? "C" : "F"; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithSwitchExpression.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithSwitchExpression.verified.txt new file mode 100644 index 0000000..2bbeb71 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithSwitchExpression.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_GetGrade_P0_Foo_Entity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Score >= 90 ? "A" : @this.Score >= 80 ? "B" : @this.Score >= 70 ? "C" : "F"; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpression_WithRelationalPattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpression_WithRelationalPattern.verified.txt new file mode 100644 index 0000000..6b1ef59 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpression_WithRelationalPattern.verified.txt @@ -0,0 +1,16 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Entity_GetGrade + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Score >= 90 ? "A" : @this.Score >= 80 ? "B" : @this.Score >= 70 ? "C" : "F"; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpression_WithRelationalPattern_OnExtensionMethod.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpression_WithRelationalPattern_OnExtensionMethod.verified.txt new file mode 100644 index 0000000..2de6000 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpression_WithRelationalPattern_OnExtensionMethod.verified.txt @@ -0,0 +1,16 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_OrderExtensions_GetTier_P0_Foo_Order + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Order order) => order.Amount >= 1000 ? "Platinum" : order.Amount >= 500 ? "Gold" : order.Amount >= 100 ? "Silver" : "Bronze"; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs index 9ca78b1..57c06cf 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs @@ -2011,6 +2011,236 @@ public static ItemData ToData(this Item item) => return Verifier.Verify(result.GeneratedTrees[0].ToString()); } + [Fact] + public Task SwitchExpression_WithRelationalPattern() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Score { get; set; } + + [Projectable] + public string GetGrade() => Score switch + { + >= 90 => ""A"", + >= 80 => ""B"", + >= 70 => ""C"", + _ => ""F"", + }; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task SwitchExpression_WithRelationalPattern_OnExtensionMethod() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Order { + public decimal Amount { get; set; } + } + + static class OrderExtensions { + [Projectable] + public static string GetTier(this Order order) => order.Amount switch + { + >= 1000 => ""Platinum"", + >= 500 => ""Gold"", + >= 100 => ""Silver"", + _ => ""Bronze"", + }; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExpressionBodied_IsPattern_WithAndPattern() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Value { get; set; } + + [Projectable] + public bool IsInRange => Value is >= 1 and <= 100; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExpressionBodied_IsPattern_WithOrPattern() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Value { get; set; } + + [Projectable] + public bool IsOutOfRange => Value is 0 or > 100; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExpressionBodied_IsPattern_WithPropertyPattern() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public bool IsActive { get; set; } + public int Value { get; set; } + } + + static class Extensions { + [Projectable] + public static bool IsActiveAndPositive(this Entity entity) => + entity is { IsActive: true, Value: > 0 }; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExpressionBodied_IsPattern_WithNotNullPattern() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public string? Name { get; set; } + + [Projectable] + public bool HasName => Name is not null; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task BlockBodiedMethod_WithAndPattern() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Value { get; set; } + } + + static class Extensions { + [Projectable(AllowBlockBody = true)] + public static bool IsInRange(this Entity entity) + { + if (entity.Value is >= 1 and <= 100) + { + return true; + } + return false; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task BlockBodiedMethod_WithOrPattern() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public string Status { get; set; } + } + + static class Extensions { + [Projectable(AllowBlockBody = true)] + public static bool IsTerminal(this Entity entity) + { + if (entity.Status is ""Cancelled"" or ""Completed"") + { + return true; + } + return false; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + [Fact] public Task GenericTypes() { @@ -2967,6 +3197,137 @@ static class EntityExtensions { return Verifier.Verify(result.GeneratedTrees[0].ToString()); } + + [Fact] + public Task ExtensionMemberWithBlockBody() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Value { get; set; } + public bool IsActive { get; set; } + } + + static class EntityExtensions { + extension(Entity e) { + [Projectable(AllowBlockBody = true)] + public string GetStatus() + { + if (e.IsActive && e.Value > 0) + { + return ""Active""; + } + return ""Inactive""; + } + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExtensionMemberWithSwitchExpression() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Score { get; set; } + } + + static class EntityExtensions { + extension(Entity e) { + [Projectable] + public string GetGrade() => e.Score switch + { + >= 90 => ""A"", + >= 80 => ""B"", + >= 70 => ""C"", + _ => ""F"", + }; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExtensionMemberOnInterface() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; + +namespace Foo { + interface IEntity { + int Id { get; } + string Name { get; } + } + + static class IEntityExtensions { + extension(IEntity e) { + [Projectable] + public string Label => e.Id + "": "" + e.Name; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExtensionMemberWithIsPatternExpression() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Value { get; set; } + } + + static class EntityExtensions { + extension(Entity e) { + [Projectable] + public bool IsHighValue => e.Value is > 100; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } #endif [Fact] @@ -3506,6 +3867,140 @@ public int GetDouble() // Should have no warnings Assert.Empty(result.Diagnostics); } + + [Fact] + public Task BlockBodiedMethod_WithPatternMatching() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; +namespace Foo { + class Entity { + public bool IsActive { get; set; } + public int Value { get; set; } + } + + static class Extensions { + [Projectable(AllowBlockBody = true)] + public static string GetComplexCategory(this Entity entity) + { + if (entity is { IsActive: true, Value: > 100 }) + { + return ""Active High""; + } + return ""Other""; + } + } +} +"); + + var result = RunGenerator(compilation); + + // The generator should not crash and should handle pattern matching + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task BlockBodiedMethod_WithRelationalPattern() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; +namespace Foo { + class Entity { + public int Value { get; set; } + } + + static class Extensions { + [Projectable(AllowBlockBody = true)] + public static string GetCategory(this Entity entity) + { + if (entity.Value is > 100) + { + return ""High""; + } + return ""Low""; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task BlockBodiedMethod_WithConstantPattern() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; +namespace Foo { + class Entity { + public string Status { get; set; } + } + + static class Extensions { + [Projectable(AllowBlockBody = true)] + public static bool IsNull(this Entity entity) + { + if (entity is null) + { + return true; + } + return false; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task BlockBodiedMethod_WithNotPattern() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; +namespace Foo { + class Entity { + public string Name { get; set; } + } + + static class Extensions { + [Projectable(AllowBlockBody = true)] + public static bool IsNotNull(this Entity entity) + { + if (entity is not null) + { + return true; + } + return false; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } #region Helpers From b567f1634c7949a5efbf083546a16239ea2f8010 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 1 Mar 2026 14:24:36 +0100 Subject: [PATCH 2/6] Better handle unsupported patterns with a new diagnostic --- .../AnalyzerReleases.Unshipped.md | 7 +- .../Diagnostics.cs | 8 + .../ExpressionSyntaxRewriter.cs | 252 +++++++++++++----- 3 files changed, 206 insertions(+), 61 deletions(-) diff --git a/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Unshipped.md b/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Unshipped.md index 5f28270..5f7773d 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Unshipped.md +++ b/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Unshipped.md @@ -1 +1,6 @@ - \ No newline at end of file +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|----------------------------------------------- +EFP0007 | Design | Error | Unsupported pattern in projectable expression + diff --git a/src/EntityFrameworkCore.Projectables.Generator/Diagnostics.cs b/src/EntityFrameworkCore.Projectables.Generator/Diagnostics.cs index 70e2964..9b968a3 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/Diagnostics.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/Diagnostics.cs @@ -52,5 +52,13 @@ public static class Diagnostics DiagnosticSeverity.Error, isEnabledByDefault: true); + public static readonly DiagnosticDescriptor UnsupportedPatternInExpression = new DiagnosticDescriptor( + id: "EFP0007", + title: "Unsupported pattern in projectable expression", + messageFormat: "The pattern '{0}' cannot be rewritten into an expression tree. Simplify the pattern or restructure the projectable member body.", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + } } diff --git a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs index c22e929..08bec48 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs @@ -392,16 +392,26 @@ private ExpressionSyntax CreateMethodCallOnEnumValue(IMethodSymbol methodSymbol, if (arm.Pattern is RelationalPatternSyntax relational) { // Map the pattern operator token to a binary expression kind - var binaryKind = relational.OperatorToken.Kind() switch + SyntaxKind? binaryKindNullable = relational.OperatorToken.Kind() switch { SyntaxKind.LessThanToken => SyntaxKind.LessThanExpression, SyntaxKind.LessThanEqualsToken => SyntaxKind.LessThanOrEqualExpression, SyntaxKind.GreaterThanToken => SyntaxKind.GreaterThanExpression, SyntaxKind.GreaterThanEqualsToken => SyntaxKind.GreaterThanOrEqualExpression, - _ => throw new InvalidOperationException( - $"Unsupported relational operator in switch expression: {relational.OperatorToken.Kind()}") + _ => null }; + if (binaryKindNullable is null) + { + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedPatternInExpression, + arm.Pattern.GetLocation(), + arm.Pattern.ToString())); + return base.VisitSwitchExpression(node); + } + + var binaryKind = binaryKindNullable.Value; + var condition = SyntaxFactory.BinaryExpression( binaryKind, (ExpressionSyntax)Visit(node.GoverningExpression), @@ -427,10 +437,11 @@ private ExpressionSyntax CreateMethodCallOnEnumValue(IMethodSymbol methodSymbol, continue; } - throw new InvalidOperationException( - $"Switch expressions rewriting supports constant values, relational patterns (<=, <, >=, >), and declaration patterns (Type var). " + - $"Unsupported pattern: {arm.Pattern.GetType().Name}" - ); + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedPatternInExpression, + arm.Pattern.GetLocation(), + arm.Pattern.ToString())); + return base.VisitSwitchExpression(node); } return currentExpression; @@ -679,113 +690,234 @@ private ExpressionSyntax ReplaceVariableWithCast(ExpressionSyntax expression, De public override SyntaxNode? VisitIsPatternExpression(IsPatternExpressionSyntax node) { - // Pattern matching is not supported in expression trees (CS8122) - // We need to convert patterns into equivalent expressions + // Pattern matching is not supported in expression trees (CS8122). + // We need to convert patterns into equivalent expressions. var expression = (ExpressionSyntax)Visit(node.Expression); - - return ConvertPatternToExpression(node.Pattern, expression); + + // ConvertPatternToExpression returns null when a pattern is unsupported (a diagnostic + // has already been emitted). Fall back to the original node so the user still sees the + // compiler error, but the generator itself does not crash. + return ConvertPatternToExpression(node.Pattern, expression) + ?? node.WithExpression(expression); } - private ExpressionSyntax ConvertPatternToExpression(PatternSyntax pattern, ExpressionSyntax expression) + /// + /// Returns true when has a type that can be compared to null + /// (i.e. reference types and nullable value types). Returns false for plain value types + /// (struct / record struct) where x != null would not compile. + /// + private bool TypeRequiresNullCheck(ExpressionSyntax expression) + { + var type = _semanticModel.GetTypeInfo(expression).Type; + if (type is null) + { + return true; // conservative: unknown type → assume nullable + } + + // Nullable is a value type whose OriginalDefinition is System.Nullable + if (type.IsValueType && + type.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T) + { + return false; // plain struct / record struct — null check would not compile + } + + return true; + } + + /// + /// Attempts to convert into an ordinary expression that is valid + /// inside an expression tree. Returns null and reports a diagnostic when the pattern + /// cannot be rewritten. + /// + private ExpressionSyntax? ConvertPatternToExpression(PatternSyntax pattern, ExpressionSyntax expression) { switch (pattern) { case RecursivePatternSyntax recursivePattern: return ConvertRecursivePattern(recursivePattern, expression); - + case ConstantPatternSyntax constantPattern: - // e is null or e is 5 + // e is null / e is 5 return SyntaxFactory.BinaryExpression( SyntaxKind.EqualsExpression, expression, (ExpressionSyntax)Visit(constantPattern.Expression) ); - + case DeclarationPatternSyntax declarationPattern: - // e is string s -> e is string (type check) + // e is string _ → type-check only (discard is fine) + // e is string s → we cannot safely rewrite because references to 's' in + // the surrounding expression are outside this node's scope. + if (declarationPattern.Designation is SingleVariableDesignationSyntax) + { + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedPatternInExpression, + pattern.GetLocation(), + pattern.ToString())); + return null; + } + return SyntaxFactory.BinaryExpression( SyntaxKind.IsExpression, expression, declarationPattern.Type ); - + case RelationalPatternSyntax relationalPattern: + { // e is > 100 - var binaryKind = relationalPattern.OperatorToken.Kind() switch + SyntaxKind? binaryKind = relationalPattern.OperatorToken.Kind() switch { - SyntaxKind.LessThanToken => SyntaxKind.LessThanExpression, - SyntaxKind.LessThanEqualsToken => SyntaxKind.LessThanOrEqualExpression, - SyntaxKind.GreaterThanToken => SyntaxKind.GreaterThanExpression, + SyntaxKind.LessThanToken => SyntaxKind.LessThanExpression, + SyntaxKind.LessThanEqualsToken => SyntaxKind.LessThanOrEqualExpression, + SyntaxKind.GreaterThanToken => SyntaxKind.GreaterThanExpression, SyntaxKind.GreaterThanEqualsToken => SyntaxKind.GreaterThanOrEqualExpression, - _ => throw new NotSupportedException($"Relational operator {relationalPattern.OperatorToken} not supported") + _ => null }; - + + if (binaryKind is null) + { + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedPatternInExpression, + pattern.GetLocation(), + pattern.ToString())); + return null; + } + return SyntaxFactory.BinaryExpression( - binaryKind, + binaryKind.Value, expression, (ExpressionSyntax)Visit(relationalPattern.Expression) ); - + } + case BinaryPatternSyntax binaryPattern: + { // e is > 10 and < 100 - var left = ConvertPatternToExpression(binaryPattern.Left, expression); + var left = ConvertPatternToExpression(binaryPattern.Left, expression); var right = ConvertPatternToExpression(binaryPattern.Right, expression); - - var logicalKind = binaryPattern.OperatorToken.Kind() switch + + // Propagate failures from either side + if (left is null || right is null) + { + return null; + } + + SyntaxKind? logicalKind = binaryPattern.OperatorToken.Kind() switch { SyntaxKind.AndKeyword => SyntaxKind.LogicalAndExpression, - SyntaxKind.OrKeyword => SyntaxKind.LogicalOrExpression, - _ => throw new NotSupportedException($"Binary pattern operator {binaryPattern.OperatorToken} not supported") + SyntaxKind.OrKeyword => SyntaxKind.LogicalOrExpression, + _ => null }; - - return SyntaxFactory.BinaryExpression(logicalKind, left, right); - + + if (logicalKind is null) + { + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedPatternInExpression, + pattern.GetLocation(), + pattern.ToString())); + return null; + } + + return SyntaxFactory.BinaryExpression(logicalKind.Value, left, right); + } + case UnaryPatternSyntax unaryPattern when unaryPattern.OperatorToken.IsKind(SyntaxKind.NotKeyword): + { // e is not null - var innerPattern = ConvertPatternToExpression(unaryPattern.Pattern, expression); + var inner = ConvertPatternToExpression(unaryPattern.Pattern, expression); + if (inner is null) + { + return null; + } + return SyntaxFactory.PrefixUnaryExpression( SyntaxKind.LogicalNotExpression, - SyntaxFactory.ParenthesizedExpression(innerPattern) + SyntaxFactory.ParenthesizedExpression(inner) ); - + } + default: - throw new NotSupportedException($"Pattern type {pattern.GetType().Name} is not yet supported in projectable methods"); + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedPatternInExpression, + pattern.GetLocation(), + pattern.ToString())); + return null; } } - private ExpressionSyntax ConvertRecursivePattern(RecursivePatternSyntax recursivePattern, ExpressionSyntax expression) + private ExpressionSyntax? ConvertRecursivePattern(RecursivePatternSyntax recursivePattern, ExpressionSyntax expression) { - // entity is { IsActive: true, Value: > 100 } - // Convert to: entity != null && entity.IsActive == true && entity.Value > 100 - + // Positional / deconstruct patterns (e.g. obj is Point(1, 2)) cannot be rewritten + // into a plain expression tree. Report a diagnostic and bail out. + if (recursivePattern.PositionalPatternClause != null) + { + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedPatternInExpression, + recursivePattern.GetLocation(), + recursivePattern.ToString())); + return null; + } + var conditions = new List(); - - // Add null check first (unless pattern explicitly includes null) - var nullCheck = SyntaxFactory.BinaryExpression( - SyntaxKind.NotEqualsExpression, - expression, - SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression) - ); - conditions.Add(nullCheck); - - // Handle property patterns + + // Null check: only legal (and only necessary) for reference types and nullable value types. + // Emitting "x != null" for a plain struct / record struct would not compile. + if (TypeRequiresNullCheck(expression)) + { + conditions.Add(SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + expression, + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression) + )); + } + + // Type check: "obj is SomeType { ... }" — add "expression is SomeType" guard. + TypeSyntax? visitedType = null; + if (recursivePattern.Type != null) + { + visitedType = (TypeSyntax)(Visit(recursivePattern.Type) ?? recursivePattern.Type); + conditions.Add(SyntaxFactory.BinaryExpression( + SyntaxKind.IsExpression, + expression, + visitedType + )); + } + + // When a concrete type is known, member accesses on sub-patterns must go through a + // cast so the generated code compiles correctly (e.g. ((SomeType)expression).Prop). + var memberBase = visitedType != null + ? SyntaxFactory.ParenthesizedExpression( + SyntaxFactory.CastExpression(visitedType, expression)) + : expression; + + // Handle property sub-patterns: { Prop: value, ... } if (recursivePattern.PropertyPatternClause != null) { foreach (var subpattern in recursivePattern.PropertyPatternClause.Subpatterns) { - var memberAccess = subpattern.NameColon != null + var propExpression = subpattern.NameColon != null ? SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, - expression, - SyntaxFactory.IdentifierName(subpattern.NameColon.Name.Identifier) - ) - : expression; - - var condition = ConvertPatternToExpression(subpattern.Pattern, memberAccess); + memberBase, + SyntaxFactory.IdentifierName(subpattern.NameColon.Name.Identifier)) + : memberBase; + + var condition = ConvertPatternToExpression(subpattern.Pattern, propExpression); + if (condition is null) + { + return null; // diagnostic already emitted + } + conditions.Add(condition); } } - + + if (conditions.Count == 0) + { + return SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression); + } + // Combine all conditions with && var result = conditions[0]; for (var i = 1; i < conditions.Count; i++) @@ -796,7 +928,7 @@ private ExpressionSyntax ConvertRecursivePattern(RecursivePatternSyntax recursiv conditions[i] ); } - + return result; } } From 32f91c0e1a5f739fb1fe3e38c49102d12ca7e926 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 1 Mar 2026 14:48:13 +0100 Subject: [PATCH 3/6] Simplify rewriter logic and use is XXX pattern instead of GetType(), that is more accurate --- .../ExpressionSyntaxRewriter.cs | 128 +++--------------- ...itchExpressionWithTypePattern.verified.txt | 2 +- 2 files changed, 20 insertions(+), 110 deletions(-) diff --git a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs index 08bec48..be906f2 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs @@ -309,141 +309,51 @@ private ExpressionSyntax CreateMethodCallOnEnumValue(IMethodSymbol methodSymbol, public override SyntaxNode? VisitSwitchExpression(SwitchExpressionSyntax node) { - // Reverse arms order to start from the default value var arms = node.Arms.Reverse(); - + var visitedGoverning = (ExpressionSyntax)Visit(node.GoverningExpression); ExpressionSyntax? currentExpression = null; foreach (var arm in arms) { var armExpression = (ExpressionSyntax)Visit(arm.Expression); - - // Handle fallback value + if (currentExpression == null) { - currentExpression = arm.Pattern is DiscardPatternSyntax + currentExpression = arm.Pattern is DiscardPatternSyntax ? armExpression : SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression); - continue; } - - // Handle each arm, only if it's a constant expression - if (arm.Pattern is ConstantPatternSyntax constant) - { - ExpressionSyntax expression = SyntaxFactory.BinaryExpression(SyntaxKind.EqualsExpression, (ExpressionSyntax)Visit(node.GoverningExpression), constant.Expression); - - // Add the when clause as a AND expression - if (arm.WhenClause != null) - { - expression = SyntaxFactory.BinaryExpression( - SyntaxKind.LogicalAndExpression, - expression, - (ExpressionSyntax)Visit(arm.WhenClause.Condition) - ); - } - - currentExpression = SyntaxFactory.ConditionalExpression( - expression, - armExpression, - currentExpression - ); - continue; - } + ExpressionSyntax? condition; - if (arm.Pattern is DeclarationPatternSyntax declaration) + // DeclarationPattern with a named variable requires replacing the variable with a cast in the arm body + if (arm.Pattern is DeclarationPatternSyntax declaration && declaration.Designation is SingleVariableDesignationSyntax) { - var getTypeExpression = SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - (ExpressionSyntax)Visit(node.GoverningExpression), - SyntaxFactory.IdentifierName("GetType") - ); - - var getTypeCall = SyntaxFactory.InvocationExpression(getTypeExpression); - var typeofExpression = SyntaxFactory.TypeOfExpression(declaration.Type); - var equalsExpression = SyntaxFactory.BinaryExpression( - SyntaxKind.EqualsExpression, - getTypeCall, - typeofExpression - ); - - ExpressionSyntax condition = equalsExpression; - if (arm.WhenClause != null) - { - condition = SyntaxFactory.BinaryExpression( - SyntaxKind.LogicalAndExpression, - equalsExpression, - (ExpressionSyntax)Visit(arm.WhenClause.Condition) - ); - } - - var modifiedArmExpression = ReplaceVariableWithCast(armExpression, declaration, node.GoverningExpression); - currentExpression = SyntaxFactory.ConditionalExpression( - condition, - modifiedArmExpression, - currentExpression - ); - - continue; + condition = SyntaxFactory.BinaryExpression(SyntaxKind.IsExpression, visitedGoverning, declaration.Type); + armExpression = ReplaceVariableWithCast(armExpression, declaration, node.GoverningExpression); } - - // Handle relational patterns (<=, <, >=, >) - if (arm.Pattern is RelationalPatternSyntax relational) + else { - // Map the pattern operator token to a binary expression kind - SyntaxKind? binaryKindNullable = relational.OperatorToken.Kind() switch - { - SyntaxKind.LessThanToken => SyntaxKind.LessThanExpression, - SyntaxKind.LessThanEqualsToken => SyntaxKind.LessThanOrEqualExpression, - SyntaxKind.GreaterThanToken => SyntaxKind.GreaterThanExpression, - SyntaxKind.GreaterThanEqualsToken => SyntaxKind.GreaterThanOrEqualExpression, - _ => null - }; - - if (binaryKindNullable is null) + condition = ConvertPatternToExpression(arm.Pattern, visitedGoverning); + if (condition is null) { - _context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.UnsupportedPatternInExpression, - arm.Pattern.GetLocation(), - arm.Pattern.ToString())); return base.VisitSwitchExpression(node); } + } - var binaryKind = binaryKindNullable.Value; - - var condition = SyntaxFactory.BinaryExpression( - binaryKind, - (ExpressionSyntax)Visit(node.GoverningExpression), - (ExpressionSyntax)Visit(relational.Expression) - ); - - // Add the when clause as a AND expression - if (arm.WhenClause != null) - { - condition = SyntaxFactory.BinaryExpression( - SyntaxKind.LogicalAndExpression, - condition, - (ExpressionSyntax)Visit(arm.WhenClause.Condition) - ); - } - - currentExpression = SyntaxFactory.ConditionalExpression( + if (arm.WhenClause != null) + { + condition = SyntaxFactory.BinaryExpression( + SyntaxKind.LogicalAndExpression, condition, - armExpression, - currentExpression + (ExpressionSyntax)Visit(arm.WhenClause.Condition) ); - - continue; } - _context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.UnsupportedPatternInExpression, - arm.Pattern.GetLocation(), - arm.Pattern.ToString())); - return base.VisitSwitchExpression(node); + currentExpression = SyntaxFactory.ConditionalExpression(condition, armExpression, currentExpression); } - + return currentExpression; } diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpressionWithTypePattern.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpressionWithTypePattern.verified.txt index c9efd3c..e611e59 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpressionWithTypePattern.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.SwitchExpressionWithTypePattern.verified.txt @@ -9,7 +9,7 @@ namespace EntityFrameworkCore.Projectables.Generated { static global::System.Linq.Expressions.Expression> Expression() { - return (global::Item item) => item.GetType() == typeof(GroupItem) ? new global::GroupData(((GroupItem)item).Id, ((GroupItem)item).Name, ((GroupItem)item).Description) : item.GetType() == typeof(DocumentItem) ? new global::DocumentData(((DocumentItem)item).Id, ((DocumentItem)item).Name, ((DocumentItem)item).Priority) : null !; + return (global::Item item) => item is GroupItem ? new global::GroupData(((GroupItem)item).Id, ((GroupItem)item).Name, ((GroupItem)item).Description) : item is DocumentItem ? new global::DocumentData(((DocumentItem)item).Id, ((DocumentItem)item).Name, ((DocumentItem)item).Priority) : null !; } } } \ No newline at end of file From 415fb019c484ff9f2138d2ba503b62bacefcdcee Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 1 Mar 2026 15:20:29 +0100 Subject: [PATCH 4/6] Improve fallback --- .../ExpressionSyntaxRewriter.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs index be906f2..08cd94b 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs @@ -338,7 +338,12 @@ private ExpressionSyntax CreateMethodCallOnEnumValue(IMethodSymbol methodSymbol, condition = ConvertPatternToExpression(arm.Pattern, visitedGoverning); if (condition is null) { - return base.VisitSwitchExpression(node); + // A diagnostic (EFP0007) has already been reported for this arm. + // Skip it instead of falling back to base.VisitSwitchExpression which + // would leave an unsupported switch expression in the generated lambda and + // produce unrelated compiler errors. The best-effort ternary chain built + // so far is still emitted so the output remains valid C#. + continue; } } @@ -604,11 +609,11 @@ private ExpressionSyntax ReplaceVariableWithCast(ExpressionSyntax expression, De // We need to convert patterns into equivalent expressions. var expression = (ExpressionSyntax)Visit(node.Expression); - // ConvertPatternToExpression returns null when a pattern is unsupported (a diagnostic - // has already been emitted). Fall back to the original node so the user still sees the - // compiler error, but the generator itself does not crash. - return ConvertPatternToExpression(node.Pattern, expression) - ?? node.WithExpression(expression); + // ConvertPatternToExpression returns null when the pattern cannot be rewritten and has + // already reported a diagnostic (EFP0007). Fall back to the original is-pattern node + // so the output is semantically identical to the source and the compiler's own CS8122 + // points directly at the offending pattern — no misleading placeholder value is emitted. + return ConvertPatternToExpression(node.Pattern, expression) ?? node; } /// From d1687b7163cf55e455f097bd6b69c98c9a074986 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 1 Mar 2026 18:14:44 +0100 Subject: [PATCH 5/6] Apply suggestions --- .../ExpressionSyntaxRewriter.cs | 70 ++++++++++++++----- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs index 08cd94b..5c6cc80 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs @@ -610,20 +610,21 @@ private ExpressionSyntax ReplaceVariableWithCast(ExpressionSyntax expression, De var expression = (ExpressionSyntax)Visit(node.Expression); // ConvertPatternToExpression returns null when the pattern cannot be rewritten and has - // already reported a diagnostic (EFP0007). Fall back to the original is-pattern node - // so the output is semantically identical to the source and the compiler's own CS8122 - // points directly at the offending pattern — no misleading placeholder value is emitted. - return ConvertPatternToExpression(node.Pattern, expression) ?? node; + // already reported a diagnostic (EFP0007). Return a 'false' literal placeholder so + // the generated lambda stays syntactically valid and no additional CS8122 errors are + // triggered by leaving raw pattern-matching syntax inside an expression tree. + return ConvertPatternToExpression(node.Pattern, expression) + ?? SyntaxFactory.LiteralExpression(SyntaxKind.FalseLiteralExpression); } /// - /// Returns true when has a type that can be compared to null - /// (i.e. reference types and nullable value types). Returns false for plain value types - /// (struct / record struct) where x != null would not compile. + /// Returns true when can be compared to null. + /// Accepts a pre-resolved symbol so synthesized (unbound) expression nodes can bypass + /// semantic-model lookup, which would return null for synthesized nodes and cause + /// the method to conservatively (and incorrectly) emit a null-check for value-type properties. /// - private bool TypeRequiresNullCheck(ExpressionSyntax expression) + private static bool TypeRequiresNullCheck(ITypeSymbol? type) { - var type = _semanticModel.GetTypeInfo(expression).Type; if (type is null) { return true; // conservative: unknown type → assume nullable @@ -644,12 +645,19 @@ private bool TypeRequiresNullCheck(ExpressionSyntax expression) /// inside an expression tree. Returns null and reports a diagnostic when the pattern /// cannot be rewritten. /// - private ExpressionSyntax? ConvertPatternToExpression(PatternSyntax pattern, ExpressionSyntax expression) + /// The pattern syntax to convert. + /// The expression being tested against the pattern. + /// + /// Pre-resolved type of . When the expression is a synthesized + /// node (not present in the original source) Roslyn cannot bind it, so callers that know the + /// type should pass it here to avoid falling back to the conservative "assume nullable" path. + /// + private ExpressionSyntax? ConvertPatternToExpression(PatternSyntax pattern, ExpressionSyntax expression, ITypeSymbol? expressionType = null) { switch (pattern) { case RecursivePatternSyntax recursivePattern: - return ConvertRecursivePattern(recursivePattern, expression); + return ConvertRecursivePattern(recursivePattern, expression, expressionType); case ConstantPatternSyntax constantPattern: // e is null / e is 5 @@ -761,7 +769,7 @@ private bool TypeRequiresNullCheck(ExpressionSyntax expression) } } - private ExpressionSyntax? ConvertRecursivePattern(RecursivePatternSyntax recursivePattern, ExpressionSyntax expression) + private ExpressionSyntax? ConvertRecursivePattern(RecursivePatternSyntax recursivePattern, ExpressionSyntax expression, ITypeSymbol? expressionType = null) { // Positional / deconstruct patterns (e.g. obj is Point(1, 2)) cannot be rewritten // into a plain expression tree. Report a diagnostic and bail out. @@ -778,7 +786,10 @@ private bool TypeRequiresNullCheck(ExpressionSyntax expression) // Null check: only legal (and only necessary) for reference types and nullable value types. // Emitting "x != null" for a plain struct / record struct would not compile. - if (TypeRequiresNullCheck(expression)) + // Use the pre-resolved expressionType when available so synthesized nodes (which Roslyn + // cannot bind) are handled correctly instead of falling back to the conservative path. + var typeForNullCheck = expressionType ?? _semanticModel.GetTypeInfo(expression).Type; + if (TypeRequiresNullCheck(typeForNullCheck)) { conditions.Add(SyntaxFactory.BinaryExpression( SyntaxKind.NotEqualsExpression, @@ -791,7 +802,7 @@ private bool TypeRequiresNullCheck(ExpressionSyntax expression) TypeSyntax? visitedType = null; if (recursivePattern.Type != null) { - visitedType = (TypeSyntax)(Visit(recursivePattern.Type) ?? recursivePattern.Type); + visitedType = (TypeSyntax)Visit(recursivePattern.Type); conditions.Add(SyntaxFactory.BinaryExpression( SyntaxKind.IsExpression, expression, @@ -811,14 +822,35 @@ private bool TypeRequiresNullCheck(ExpressionSyntax expression) { foreach (var subpattern in recursivePattern.PropertyPatternClause.Subpatterns) { - var propExpression = subpattern.NameColon != null - ? SyntaxFactory.MemberAccessExpression( + ExpressionSyntax propExpression; + ITypeSymbol? propType = null; + + if (subpattern.NameColon != null) + { + // Look up the property/field type from the original source binding so that + // when the recursive ConvertPatternToExpression call checks TypeRequiresNullCheck + // on the synthesized propExpression it receives the real symbol instead of null. + var memberSymbol = _semanticModel.GetSymbolInfo(subpattern.NameColon.Name).Symbol; + propType = memberSymbol switch + { + IPropertySymbol prop => prop.Type, + IFieldSymbol field => field.Type, + _ => null + }; + + propExpression = SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, memberBase, - SyntaxFactory.IdentifierName(subpattern.NameColon.Name.Identifier)) - : memberBase; + SyntaxFactory.IdentifierName(subpattern.NameColon.Name.Identifier)); + } + else + { + propExpression = memberBase; + } - var condition = ConvertPatternToExpression(subpattern.Pattern, propExpression); + // Pass propType so nested recursive patterns don't misidentify value-type + // properties as nullable when Roslyn can't bind the synthesized node. + var condition = ConvertPatternToExpression(subpattern.Pattern, propExpression, propType); if (condition is null) { return null; // diagnostic already emitted From c2e5aeae5373cc5e2bc17d0492937ba81157ef9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20M=C3=A9nager?= Date: Sun, 1 Mar 2026 18:26:49 +0100 Subject: [PATCH 6/6] Update src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ExpressionSyntaxRewriter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs index 5c6cc80..ae58790 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs @@ -331,7 +331,7 @@ private ExpressionSyntax CreateMethodCallOnEnumValue(IMethodSymbol methodSymbol, if (arm.Pattern is DeclarationPatternSyntax declaration && declaration.Designation is SingleVariableDesignationSyntax) { condition = SyntaxFactory.BinaryExpression(SyntaxKind.IsExpression, visitedGoverning, declaration.Type); - armExpression = ReplaceVariableWithCast(armExpression, declaration, node.GoverningExpression); + armExpression = ReplaceVariableWithCast(armExpression, declaration, visitedGoverning); } else {