Skip to content

Commit d4ec9f5

Browse files
authored
Merge pull request #165 from koenbeuk/feature/update-readme
Update readme and add new test for constructors
2 parents dfbece7 + b179a5f commit d4ec9f5

3 files changed

Lines changed: 290 additions & 7 deletions

File tree

README.md

Lines changed: 204 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ Flexible projection magic for EF Core
2121
Assuming this sample:
2222

2323
```csharp
24-
class Order {
24+
class Order
25+
{
2526
public int Id { get; set; }
2627
public int UserId { get; set; }
2728
public DateTime CreatedDate { get; set; }
@@ -36,7 +37,8 @@ class Order {
3637
[Projectable] public decimal GrandTotal => Subtotal + Tax;
3738
}
3839

39-
public static class UserExtensions {
40+
public static class UserExtensions
41+
{
4042
[Projectable]
4143
public static Order GetMostRecentOrderForUser(this User user, DateTime? cutoffDate) =>
4244
user.Orders
@@ -92,7 +94,7 @@ Currently, there is no support for overloaded methods. Each method name needs to
9294
No, the runtime component injects itself into the EFCore query compilation pipeline, thus having no impact on the database provider used. Of course, you're still limited to whatever your database provider can do.
9395

9496
#### Are there performance implications that I should be aware of?
95-
There are two compatibility modes: Limited and Full (Default). Most of the time, limited compatibility mode is sufficient. However, if you are running into issues with failed query compilation, then you may want to stick with Full compatibility mode. With Full compatibility mode, each query will first be expanded (any calls to Projectable properties and methods will be replaced by their respective expression) before being handed off to EFCore. (This is similar to how LinqKit/LinqExpander/Expressionify works.) Because of this additional step, there is a small performance impact. Limited compatibility mode is smart about things and only expands the query after it has been accepted by EF. The expanded query will then be stored in the Query Cache. With Limited compatibility, you will likely see increased performance over EFCore without projectables.
97+
There are two compatibility modes: Limited and Full (Default). Most of the time, limited compatibility mode is sufficient. However, if you are running into issues with failed query compilation, then you may want to stick with Full compatibility mode. With Full compatibility mode, each query will first be expanded (any calls to Projectable properties and methods will be replaced by their respective expression) before being handed off to EFCore. (This is similar to how LinqKit/LinqExpander/Expressionify works.) Because of this additional step, there is a small performance impact. Limited compatibility mode is smart about things and only expands the query after it has been accepted by EF. The expanded query will then be stored in the Query Cache. With Limited compatibility, you will likely see increased performance over EFCore without Projectables.
9698

9799
#### Can I call additional properties and methods from my Projectable properties and methods?
98100
Yes, you can! Any projectable property/method can call into other properties and methods as long as those properties/methods are native to EFCore or marked with a Projectable attribute.
@@ -106,12 +108,12 @@ public static int Squared(this int i) => i * i;
106108
Any call to squared given any int will perfectly translate to SQL.
107109

108110
#### How do I deal with nullable properties
109-
Expressions and Lamdas are different and not equal. Expressions can only express a subset of valid CSharp statements that are allowed in lambda's and arrow functions. One obvious limitation is the null-conditional operator. Consider the following example:
111+
Expressions and Lambdas are different and not equal. Expressions can only express a subset of valid C# statements that are allowed in lambda's and arrow functions. One obvious limitation is the null-conditional operator. Consider the following example:
110112
```csharp
111113
[Projectable]
112114
public static string? GetFullAddress(this User? user) => user?.Location?.AddressLine1 + " " + user?.Location.AddressLine2;
113115
```
114-
This is a perfectly valid arrow function but it can't be translated directly to an expression tree. This Project will generate an error by default and suggest 2 solutions: Either you rewrite the function to explicitly check for nullables or you let the generator do that for you!
116+
This is a perfectly valid arrow function, but it can't be translated directly to an expression tree. This Project will generate an error by default and suggest 2 solutions: Either you rewrite the function to explicitly check for nullables or you let the generator do that for you!
115117

116118
Starting from the official release of V2, we can now hint the generator in how to translate this arrow function to an expression tree. We can say:
117119
```csharp
@@ -131,7 +133,7 @@ This will rewrite your expression to explicitly check for nullables. In the form
131133
```
132134
Note that using rewrite (not ignore) may increase the actual SQL query complexity being generated with some database providers such as SQL Server
133135

134-
#### Can I use projectables in any part of my query?
136+
#### Can I use Projectables in any part of my query?
135137
Certainly, consider the following example:
136138
```csharp
137139
public class User
@@ -191,9 +193,203 @@ Both generate identical SQL. Block-bodied members support:
191193

192194
The generator will also detect and report side effects (assignments, method calls to non-projectable members, etc.) with precise error messages. See [Block-Bodied Members Documentation](docs/BlockBodiedMembers.md) for complete details.
193195

196+
#### Can I use `[Projectable]` on a constructor?
197+
198+
Yes! As of version 6.x, constructors can now be marked with `[Projectable]`. The generator will produce a member-init expression (`new T() { Prop = value, … }`) that EF Core can translate to a SQL projection.
199+
200+
**Requirements:**
201+
- The class must expose an accessible **parameterless constructor** (public, internal, or protected-internal), because the generated code relies on `new T() { … }` syntax.
202+
- If a parameterless constructor is missing, the generator reports **EFP0008**.
203+
204+
```csharp
205+
public class Customer
206+
{
207+
public int Id { get; set; }
208+
public string FirstName { get; set; }
209+
public string LastName { get; set; }
210+
public bool IsActive { get; set; }
211+
public ICollection<Order> Orders { get; set; }
212+
}
213+
214+
public class Order
215+
{
216+
public int Id { get; set; }
217+
public decimal Amount { get; set; }
218+
}
219+
220+
public class CustomerDto
221+
{
222+
public int Id { get; set; }
223+
public string FullName { get; set; }
224+
public bool IsActive { get; set; }
225+
public int OrderCount { get; set; }
226+
227+
public CustomerDto() { } // required parameterless ctor
228+
229+
[Projectable]
230+
public CustomerDto(Customer customer)
231+
{
232+
Id = customer.Id;
233+
FullName = customer.FirstName + " " + customer.LastName;
234+
IsActive = customer.IsActive;
235+
OrderCount = customer.Orders.Count();
236+
}
237+
}
238+
239+
// Usage — the constructor call is translated directly to SQL
240+
var customers = dbContext.Customers
241+
.Select(c => new CustomerDto(c))
242+
.ToList();
243+
```
244+
245+
The generator produces an expression equivalent to:
246+
```csharp
247+
(Customer customer) => new CustomerDto()
248+
{
249+
Id = customer.Id,
250+
FullName = customer.FirstName + " " + customer.LastName,
251+
IsActive = customer.IsActive,
252+
OrderCount = customer.Orders.Count()
253+
}
254+
```
255+
256+
**Supported in constructor bodies:**
257+
- Simple property assignments (`FullName = customer.FirstName + " " + customer.LastName;`)
258+
- Local variable declarations (inlined at usage points)
259+
- If/else and chained if/else-if statements (converted to ternary expressions)
260+
- Switch expressions
261+
- Base/this initializer chainsthe generator recursively inlines the delegated constructor's assignments
262+
263+
The base/this initializer chain is particularly useful when you have a DTO inheritance hierarchy:
264+
265+
```csharp
266+
public class PersonDto
267+
{
268+
public string FullName { get; set; }
269+
public string Email { get; set; }
270+
271+
public PersonDto() { }
272+
273+
[Projectable]
274+
public PersonDto(Person person)
275+
{
276+
FullName = person.FirstName + " " + person.LastName;
277+
Email = person.Email;
278+
}
279+
}
280+
281+
public class EmployeeDto : PersonDto
282+
{
283+
public string Department { get; set; }
284+
public string Grade { get; set; }
285+
286+
public EmployeeDto() { }
287+
288+
[Projectable]
289+
public EmployeeDto(Employee employee) : base(employee) // PersonDto assignments are inlined automatically
290+
{
291+
Department = employee.Department.Name;
292+
Grade = employee.YearsOfService >= 10 ? "Senior" : "Junior";
293+
}
294+
}
295+
296+
// Usage
297+
var employees = dbContext.Employees
298+
.Select(e => new EmployeeDto(e))
299+
.ToList();
300+
```
301+
302+
The generated expression inlines both the base constructor and the derived constructor body:
303+
```csharp
304+
(Employee employee) => new EmployeeDto()
305+
{
306+
FullName = employee.FirstName + " " + employee.LastName,
307+
Email = employee.Email,
308+
Department = employee.Department.Name,
309+
Grade = employee.YearsOfService >= 10 ? "Senior" : "Junior"
310+
}
311+
```
312+
313+
Multiple `[Projectable]` constructors (overloads) per class are fully supported.
314+
315+
> **Note:** If the delegated constructor's source is not available in the current compilation, the generator reports **EFP0009** and skips the projection.
316+
317+
#### Can I use pattern matching in projectable members?
318+
319+
Yes! As of version 6.x, the generator supports a rich set of C# pattern-matching constructs and rewrites them into expression-tree-compatible ternary/binary expressions that EF Core can translate to SQL CASE expressions.
320+
321+
**Switch expressions** with the following arm patterns are supported:
322+
323+
| Pattern | Example |
324+
|-----------------------|--------------------------|
325+
| Constant | `1 => "one"` |
326+
| Discard / default | `_ => "other"` |
327+
| Type | `GroupItem g => …` |
328+
| Relational | `>= 90 => "A"` |
329+
| `and` / `or` combined | `>= 80 and < 90 => "B"` |
330+
| `when` guard | `4 when Prop == 12 => …` |
331+
332+
```csharp
333+
[Projectable]
334+
public string GetGrade() => Score switch
335+
{
336+
>= 90 => "A",
337+
>= 80 => "B",
338+
>= 70 => "C",
339+
_ => "F",
340+
};
341+
```
342+
343+
Generated expression (which EF Core translates to a SQL CASE):
344+
```csharp
345+
(@this) => @this.Score >= 90 ? "A" : @this.Score >= 80 ? "B" : @this.Score >= 70 ? "C" : "F"
346+
```
347+
348+
**`is` patterns** in expression-bodied members are also supported:
349+
350+
```csharp
351+
// Range check using 'and'
352+
[Projectable]
353+
public bool IsInRange => Value is >= 1 and <= 100;
354+
355+
// Alternative-value check using 'or'
356+
[Projectable]
357+
public bool IsOutOfRange => Value is 0 or > 100;
358+
359+
// Null check using 'not'
360+
[Projectable]
361+
public bool HasName => Name is not null;
362+
363+
// Property pattern
364+
[Projectable]
365+
public static bool IsActiveAndPositive(this Entity entity) =>
366+
entity is { IsActive: true, Value: > 0 };
367+
```
368+
369+
These are all rewritten into plain binary/unary expressions that expression trees support:
370+
```csharp
371+
// Value is >= 1 and <= 100 → Value >= 1 && Value <= 100
372+
// Name is not null → !(Name == null)
373+
// entity is { IsActive: true, Value: > 0 }
374+
// → entity != null && entity.IsActive == true && entity.Value > 0
375+
```
376+
377+
**Type patterns in switch arms** produce a cast + type-check:
378+
```csharp
379+
[Projectable]
380+
public static ItemData ToData(this Item item) =>
381+
item switch
382+
{
383+
GroupItem g => new GroupData(g.Id, g.Name, g.Description),
384+
DocumentItem d => new DocumentData(d.Id, d.Name, d.Priority),
385+
_ => null!
386+
};
387+
```
388+
389+
Unsupported patterns (e.g. positional/deconstruct patterns, variable designations outside switch arms) are reported as **EFP0007**.
194390

195391
#### How do I expand enum extension methods?
196-
When you have an enum property and want to call an extension method on it (like getting a display name from a `[Display]` attribute), you can use the `ExpandEnumMethods` property on the `[Projectable]` attribute. This will expand the enum method call into a chain of ternary expressions for each enum value, allowing EF Core to translate it to SQL CASE expressions.
392+
As of version 6.x, when you have an enum property and want to call an extension method on it (like getting a display name from a `[Display]` attribute), you can use the `ExpandEnumMethods` property on the `[Projectable]` attribute. This will expand the enum method call into a chain of ternary expressions for each enum value, allowing EF Core to translate it to SQL CASE expressions.
197393

198394
```csharp
199395
public enum OrderStatus
@@ -285,6 +481,7 @@ The `ExpandEnumMethods` feature supports:
285481
- **Methods with parameters** - parameters are passed through to each enum value call
286482
- **Enum properties on navigation properties** - works with nested navigation
287483

484+
288485
#### How does this relate to [Expressionify](https://github.com/ClaveConsulting/Expressionify)?
289486
Expressionify is a project that was launched before this project. It has some overlapping features and uses similar approaches. When I first published this project, I was not aware of its existence, so shame on me. Currently, Expressionify targets a more focused scope of what this project is doing, and thereby it seems to be more limiting in its capabilities. Check them out though!
290487

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// <auto-generated/>
2+
#nullable disable
3+
using EntityFrameworkCore.Projectables;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using Foo;
7+
8+
namespace EntityFrameworkCore.Projectables.Generated
9+
{
10+
/// <summary>
11+
/// <para>Generated from:</para>
12+
/// <code>
13+
/// [Projectable]
14+
/// public CustomerDto(Customer customer)
15+
/// {
16+
/// Id = customer.Id;
17+
/// FullName = customer.FirstName + " " + customer.LastName;
18+
/// IsActive = customer.IsActive;
19+
/// OrderCount = customer.Orders.Count();
20+
/// }
21+
/// </code>
22+
/// </summary>
23+
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
24+
static class Foo_CustomerDto__ctor_P0_Foo_Customer
25+
{
26+
static global::System.Linq.Expressions.Expression<global::System.Func<global::Foo.Customer, global::Foo.CustomerDto>> Expression()
27+
{
28+
return (global::Foo.Customer customer) => new global::Foo.CustomerDto()
29+
{
30+
Id = customer.Id,
31+
FullName = customer.FirstName + " " + customer.LastName,
32+
IsActive = customer.IsActive,
33+
OrderCount = global::System.Linq.Enumerable.Count(customer.Orders)
34+
};
35+
}
36+
}
37+
}

tests/EntityFrameworkCore.Projectables.Generator.Tests/ConstructorTests.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,4 +1292,53 @@ public ShapeDto(int sides) {
12921292

12931293
return Verifier.Verify(result.GeneratedTrees[0].ToString());
12941294
}
1295+
1296+
[Fact]
1297+
public Task ProjectableConstructor_WithFullObject()
1298+
{
1299+
var compilation = CreateCompilation(@"
1300+
using EntityFrameworkCore.Projectables;
1301+
using System.Collections.Generic;
1302+
using System.Linq;
1303+
1304+
namespace Foo {
1305+
public class Customer {
1306+
public int Id { get; set; }
1307+
public string FirstName { get; set; }
1308+
public string LastName { get; set; }
1309+
public bool IsActive { get; set; }
1310+
public ICollection<Order> Orders { get; set; }
1311+
}
1312+
1313+
public class Order {
1314+
public int Id { get; set; }
1315+
public decimal Amount { get; set; }
1316+
}
1317+
1318+
public class CustomerDto {
1319+
public int Id { get; set; }
1320+
public string FullName { get; set; }
1321+
public bool IsActive { get; set; }
1322+
public int OrderCount { get; set; }
1323+
1324+
public CustomerDto() { } // required parameterless ctor
1325+
1326+
[Projectable]
1327+
public CustomerDto(Customer customer)
1328+
{
1329+
Id = customer.Id;
1330+
FullName = customer.FirstName + "" "" + customer.LastName;
1331+
IsActive = customer.IsActive;
1332+
OrderCount = customer.Orders.Count();
1333+
}
1334+
}
1335+
}
1336+
");
1337+
var result = RunGenerator(compilation);
1338+
1339+
Assert.Empty(result.Diagnostics);
1340+
Assert.Single(result.GeneratedTrees);
1341+
1342+
return Verifier.Verify(result.GeneratedTrees[0].ToString());
1343+
}
12951344
}

0 commit comments

Comments
 (0)