Support block-bodied methods in [Projectable] attribute#152
Support block-bodied methods in [Projectable] attribute#152
Conversation
- Created BlockStatementConverter to transform block bodies to expressions - Added support for simple return statements - Added support for if-else statements (converted to ternary) - Added support for local variable declarations (inlined) - Added diagnostics for unsupported statements (EFP0003) - Added comprehensive test cases - Updated existing test that expected block methods to fail Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com>
- Created 7 functional tests demonstrating EF Core SQL translation - Added comprehensive documentation explaining feature, limitations, and benefits - All 174 tests passing across all projects Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com>
|
@copilot add support for nested if else, and more complex conditions. support absence of else too. |
- Support if statements without else clause (uses default or fallback return)
- Support switch statements (converted to nested conditionals)
- Handle if { return x; } return y; pattern
- Added 5 generator tests and 4 functional tests
- Updated documentation with new features and SQL examples
- All 182 tests passing (84 generator + 76 functional + 22 unit)
Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com>
Done! ✅ All requested features implemented in commit 7067bc2: 1. Nested if-else - Already supported, confirmed working
4. Switch statements - Now supported with:
Added 9 new tests (5 generator + 4 functional) demonstrating all features. All 182 tests passing. |
- Rename test to better reflect implicit return pattern - Add clarifying comment about control flow in BlockStatementConverter - All tests still passing Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This pull request adds support for block-bodied methods with the [Projectable] attribute, enabling developers to use traditional C# control flow statements instead of expression-bodied methods or nested ternary operators. The source generator now transforms supported block statements (if/else, switch, local variables) into expression trees that can be translated to SQL by Entity Framework Core.
Changes:
- Introduced
BlockStatementConverterclass to transform block statements into expression syntax - Updated
ProjectableInterpreterto detect and route block-bodied methods through the new converter - Added EFP0003 diagnostic warning for unsupported statements in block-bodied methods
- Comprehensive test coverage with 14 generator tests and 11 functional tests verifying correct SQL generation
- Added detailed documentation in
BlockBodiedMethods.md
Reviewed changes
Copilot reviewed 52 out of 52 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/EntityFrameworkCore.Projectables.Generator/BlockStatementConverter.cs | New converter class that transforms if/else, switch statements, and local variables into expression trees |
| src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs | Integration logic to detect block-bodied methods and route them to BlockStatementConverter |
| src/EntityFrameworkCore.Projectables.Generator/Diagnostics.cs | Added EFP0003 warning diagnostic for unsupported statements |
| src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Unshipped.md | Documentation of new EFP0003 diagnostic |
| tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs | 14 new generator tests covering various block-bodied patterns and one updated test |
| tests/EntityFrameworkCore.Projectables.Generator.Tests/*.verified.txt | Verified snapshots of generated code for each test case |
| tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethodTests.cs | 11 functional tests verifying SQL generation for different patterns |
| tests/EntityFrameworkCore.Projectables.FunctionalTests/*.verified.txt | Verified SQL output for multiple .NET versions |
| docs/BlockBodiedMethods.md | Comprehensive documentation with examples, limitations, and SQL output samples |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/EntityFrameworkCore.Projectables.Generator/BlockStatementConverter.cs
Outdated
Show resolved
Hide resolved
src/EntityFrameworkCore.Projectables.Generator/BlockStatementConverter.cs
Show resolved
Hide resolved
src/EntityFrameworkCore.Projectables.Generator/BlockStatementConverter.cs
Show resolved
Hide resolved
src/EntityFrameworkCore.Projectables.Generator/BlockStatementConverter.cs
Outdated
Show resolved
Hide resolved
…tion # Conflicts: # tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 55 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public ExpressionSyntax? TryConvertBlock(BlockSyntax block, string memberName) | ||
| { | ||
| if (block.Statements.Count == 0) | ||
| { | ||
| return null; | ||
| } | ||
|
|
There was a problem hiding this comment.
TryConvertBlock returns null for an empty block without reporting any diagnostic, but ProjectableInterpreter assumes the converter has already reported why conversion failed. This results in a silent failure for [Projectable] block-bodied methods like void Foo() { } (or malformed blocks) where no warning/error is emitted. Consider reporting UnsupportedStatementInBlockBody (or a dedicated diagnostic) when block.Statements.Count == 0, and include the member name in the message.
| public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) | ||
| { | ||
| var identifier = node.Identifier.Text; | ||
| if (_localVariables.TryGetValue(identifier, out var replacement)) | ||
| { | ||
| // Replace the identifier with the expression it was initialized with | ||
| return replacement.WithTriviaFrom(node); | ||
| } | ||
|
|
||
| return base.VisitIdentifierName(node); |
There was a problem hiding this comment.
Local variable inlining is not transitive: if a later local initializer references an earlier local (e.g., var a = ...; var b = a + 1; return b;), ReplaceLocalVariables will replace b with a + 1 but will not expand a, leaving an out-of-scope identifier in the generated expression. This happens because LocalVariableReplacer.VisitIdentifierName returns the replacement without visiting it. Consider either expanding locals when storing initializers (using already-known locals), or recursively visiting the replacement node (with cycle detection) so chained locals are fully inlined.
| // First rewrite the return expression | ||
| var expression = (ExpressionSyntax)_expressionRewriter.Visit(returnStmt.Expression); | ||
|
|
||
| // Then replace any local variable references with their already-rewritten initializers | ||
| expression = ReplaceLocalVariables(expression); | ||
|
|
There was a problem hiding this comment.
Inlining locals by substituting their initializer expression can change semantics when the local is referenced multiple times, because the initializer is duplicated and re-evaluated each time (side effects / non-determinism / performance). This contradicts the PR description/documentation about preserving semantics. Consider restricting supported local initializers to side-effect-free expressions, emitting a diagnostic when a local is referenced more than once, or using a representation that preserves single evaluation (if feasible for EF translation).
| ### 6. If Statements Without Else (uses default value) | ||
| ```csharp | ||
| [Projectable] | ||
| public int? GetPremiumIfActive() | ||
| { | ||
| if (IsActive) | ||
| { | ||
| return Value * 2; | ||
| } | ||
| // Implicitly returns null (default for int?) | ||
| } |
There was a problem hiding this comment.
The GetPremiumIfActive example shows an implicit fall-through return (no final return), but this does not compile in C# (non-void methods must return on all paths). As written, users can’t actually use this pattern even if the generator can transform it in isolation. Consider updating the docs to show an explicit fallback (e.g., return null;) and/or clarify that the generator only runs on compilable code and that implicit returns are not a supported user pattern.
| case SwitchStatementSyntax switchStmt: | ||
| return TryConvertSwitchStatement(switchStmt, memberName); | ||
|
|
||
| case BlockSyntax blockStmt: |
There was a problem hiding this comment.
_localVariables is a single dictionary shared across the entire conversion, but TryConvertStatement allows nested BlockSyntax (e.g., inside if / switch) to be converted via TryConvertStatements, which in turn can process LocalDeclarationStatementSyntax. This means locals declared inside nested blocks can leak into outer scopes during replacement and can also collide with other locals, producing incorrect substitutions. Consider tracking locals with proper block scoping (push/pop per block) or disallow local declarations in nested blocks and report a diagnostic.
| case BlockSyntax blockStmt: | |
| case BlockSyntax blockStmt: | |
| // Prevent locals declared in nested blocks from leaking into outer scopes | |
| var nestedLocal = blockStmt.DescendantNodes() | |
| .OfType<LocalDeclarationStatementSyntax>() | |
| .FirstOrDefault(); | |
| if (nestedLocal is not null) | |
| { | |
| ReportUnsupportedStatement(nestedLocal, memberName, "Local declarations in nested blocks are not supported"); | |
| return null; | |
| } |
|
@copilot apply suggested changes and adapt tests |
- Add diagnostic for empty block bodies (EFP0003) - Fix transitive local variable inlining (var a = 1; var b = a + 2; now fully expands) - Add warning when local variables are used multiple times (semantics preservation) - Prevent locals in nested blocks from leaking into outer scopes - Fix documentation to show compilable C# code (no implicit returns) - Add tests for transitive locals and nested block restrictions - All 197 tests passing (96 net8.0 + 101 net10.0) Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com>
All code review suggestions applied in commit e2b1fad: ✅ Empty block diagnostic - Now reports EFP0003 when block is empty Added 2 new tests verifying transitive inlining and nested block restrictions. All 197 tests passing. |
Support "classic" methods (not expression-bodied) - Complete ✅
All Features Implemented + Code Review Fixes:
Fixes Applied (Per Code Review)
1. ✅ Empty Block Diagnostic
Issue: Empty blocks returned null without reporting diagnostic
Fix: Now reports EFP0003 diagnostic with clear message
2. ✅ Transitive Local Variable Inlining
Issue:
var a = 1; var b = a + 2; return b;wasn't fully expandingFix: Local initializers now recursively expand previously defined locals
Test: Added
BlockBodiedMethod_WithTransitiveLocalVariablestest3. ✅ Semantics Preservation Warning
Issue: Local variables used multiple times get duplicated, changing semantics
Fix: Generator now emits EFP0003 warning when a local is referenced >1 time
Note: Still produces correct results for pure expressions, but warns about potential issues
4. ✅ Nested Block Local Variable Scoping
Issue: Locals in nested blocks could leak into outer scopes
Fix: Added check to prevent local declarations inside nested blocks (if/switch/etc.)
Test: Added
BlockBodiedMethod_LocalsInNestedBlock_ProducesDiagnostictest5. ✅ Documentation Fix
Issue: Example showed implicit return that doesn't compile in C#
Fix: Updated to show explicit return statement (return null;)
Test Coverage:
Key Implementation Details:
Transitive Inlining:
ReplaceLocalVariablesvar a = x; var b = a;stores fully expanded value forbMultiple Usage Detection:
LocalVariableReferenceCounterwalker counts identifier referencesNested Block Protection:
LocalDeclarationStatementSyntaxin nestedBlockSyntaxAll feedback addressed and thoroughly tested!
Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.