diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 3712b0f..6d3f92b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -57,6 +57,7 @@ const sidebar: DefaultTheme.Sidebar = { text: 'Reference', items: [ { text: '[Expressive] Attribute', link: '/reference/expressive-attribute' }, + { text: 'Projectable Properties', link: '/reference/projectable-properties' }, { text: '[ExpressiveFor] Mapping', link: '/reference/expressive-for' }, { text: 'Null-Conditional Rewrite', link: '/reference/null-conditional-rewrite' }, { text: 'Pattern Matching', link: '/reference/pattern-matching' }, @@ -85,6 +86,7 @@ const sidebar: DefaultTheme.Sidebar = { text: 'Recipes', items: [ { text: 'Computed Entity Properties', link: '/recipes/computed-properties' }, + { text: 'Projection Middleware (HotChocolate, AutoMapper)', link: '/recipes/projection-middleware' }, { text: 'DTO Projections with Constructors', link: '/recipes/dto-projections' }, { text: 'Scoring & Classification', link: '/recipes/scoring-classification' }, { text: 'Nullable Navigation Properties', link: '/recipes/nullable-navigation' }, diff --git a/docs/guide/integrations/mongodb.md b/docs/guide/integrations/mongodb.md index d938262..5eed76c 100644 --- a/docs/guide/integrations/mongodb.md +++ b/docs/guide/integrations/mongodb.md @@ -43,6 +43,27 @@ db.Customers No custom MQL is emitted — MongoDB's own translator does all the heavy lifting after ExpressiveSharp has normalized the tree. +## `[Expressive]` Properties Are Unmapped from BSON + +ExpressiveSharp provides a MongoDB `IClassMapConvention` that unmaps every `[Expressive]`-decorated property from the BSON class map, so the property's backing field is not persisted to documents. This matters most for [Projectable properties](../../reference/projectable-properties), which have a writable `init` accessor and would otherwise be serialized as a real BSON field. + +::: warning Ordering constraint +MongoDB builds and caches a class map the first time you call `IMongoDatabase.GetCollection()` for a given `T`. A convention registered *after* that call does not apply to the cached map. If any of your document types use `[Expressive]`, register the convention before the first `GetCollection` call: + +```csharp +using ExpressiveSharp.MongoDB.Infrastructure; + +// At application startup, before any GetCollection: +ExpressiveMongoIgnoreConvention.EnsureRegistered(); + +var client = new MongoClient(connectionString); +var db = client.GetDatabase("shop"); +var customers = db.GetCollection("customers"); // class map built now +``` + +The convention is also registered automatically when you construct `ExpressiveMongoCollection` or call `collection.AsExpressive()` — but only if that happens before any `GetCollection` call for a type with `[Expressive]` properties. The explicit `EnsureRegistered()` call is the most reliable pattern. +::: + ## Async Methods All MongoDB async LINQ methods (from `MongoQueryable`) work with modern syntax via interceptors. They are stubs on `IExpressiveMongoQueryable` that forward to their `MongoQueryable` counterparts: diff --git a/docs/guide/migration-from-projectables.md b/docs/guide/migration-from-projectables.md index d8145ae..ea27737 100644 --- a/docs/guide/migration-from-projectables.md +++ b/docs/guide/migration-from-projectables.md @@ -122,16 +122,46 @@ Both the old `Ignore` and `Rewrite` behaviors converge to the same result in Exp | Old Property | Migration | |---|---| -| `UseMemberBody = "SomeMethod"` | Replace with `[ExpressiveFor]`. See [Migrating UseMemberBody](#migrating-usememberbody) below. | +| `UseMemberBody = "SomeMethod"` | Replace with `[Expressive(Projectable = true)]` or `[ExpressiveFor]`. See [Migrating UseMemberBody](#migrating-usememberbody) below. | | `AllowBlockBody = true` | Keep -- block bodies remain opt-in. Set per-member or globally via `Expressive_AllowBlockBody` MSBuild property. | | `ExpandEnumMethods = true` | Remove -- enum method expansion is enabled by default. | | `CompatibilityMode.Full / .Limited` | Remove -- only the full approach exists. | ### Migrating `UseMemberBody` -In Projectables, `UseMemberBody` let you point one member's expression body at another member -- typically to work around syntax limitations or to provide an expression-tree-friendly alternative. +In Projectables, `UseMemberBody` let you point one member's expression body at another member -- typically to work around syntax limitations or to provide an expression-tree-friendly alternative for projection middleware (HotChocolate, AutoMapper) that required a writable target. -ExpressiveSharp replaces this with `[ExpressiveFor]` (in the `ExpressiveSharp.Mapping` namespace), which is more explicit and works for external types too. +ExpressiveSharp offers **two replacement options**, depending on your scenario: + +- **`[Expressive(Projectable = true)]`** -- the ergonomic fit when your goal was specifically to participate in projection middleware. Keeps the formula on the property itself via the `field ?? ()` pattern. Closest to Projectables' intent. +- **`[ExpressiveFor]`** -- the verbose but explicit alternative. Works for external types too (scenarios `UseMemberBody` never supported). + +Either is correct; pick based on ergonomic preference and whether you need the cross-type capability. + +::: info About the `Projectable` name overlap +ExpressiveSharp's `Projectable` attribute property and the EFCore.Projectables library's `[Projectable]` attribute share a name because both describe the same capability -- a computed property that participates in LINQ projections. They are different mechanisms; the shared word is intentional to reduce migration friction. +::: + +**Option A -- `[Expressive(Projectable = true)]`** (single declaration): + +```csharp +// Before (Projectables) +[Projectable(UseMemberBody = nameof(FullNameProjection))] +public string FullName { get; init; } +private string FullNameProjection => LastName + ", " + FirstName; + +// After (ExpressiveSharp) -- formula lives on the property +[Expressive(Projectable = true)] +public string FullName +{ + get => field ?? (LastName + ", " + FirstName); + init => field = value; +} +``` + +See the [Projectable Properties reference](../reference/projectable-properties) and the [Projection Middleware recipe](../recipes/projection-middleware) for the complete feature. + +**Option B -- `[ExpressiveFor]`** (separate stub, supports cross-type mapping): **Scenario 1: Same-type member with an alternative body** diff --git a/docs/recipes/projection-middleware.md b/docs/recipes/projection-middleware.md new file mode 100644 index 0000000..e41dc12 --- /dev/null +++ b/docs/recipes/projection-middleware.md @@ -0,0 +1,169 @@ +# Computed Properties in Projection Middleware + +If your computed property returns an empty value -- or is silently dropped from the response -- when consumed by **HotChocolate**, **AutoMapper's `ProjectTo`**, **Mapperly's projection mode**, or any other framework that emits `Select(src => new Entity { Member = src.Member, ... })` over your entity type, this recipe is the fix. + +## Why plain `[Expressive]` isn't enough + +HotChocolate's `[UseProjection]` middleware (and similar mechanisms in other libraries) generates a projection expression from the GraphQL selection set. Empirically, when the GraphQL query asks for only `fullName`: + +```graphql +query { users { fullName } } +``` + +HotChocolate inspects the `User.FullName` property, finds it is **read-only** (no setter), and silently drops it from the projection. The generated SQL is `SELECT 1 FROM Users` -- nothing is fetched. At materialization time, HC constructs fresh `User` instances with all fields at their defaults (`FirstName = ""`, `LastName = ""`), calls the `FullName` getter, and the formula returns `", "`. The response looks successful but the data is wrong. + +The same mechanism affects AutoMapper's `ProjectTo`, Mapperly's generated projections, and any hand-rolled `Select(u => new User { ... })` that projects into the source type itself. + +## The fix: `[Expressive(Projectable = true)]` + +Turning on `Projectable = true` makes the property writable (so the projection middleware emits a binding) while still registering the formula for SQL translation. The dual-direction runtime behavior is: + +- **In memory**, reading the property evaluates the formula from dependencies (same as plain `[Expressive]`). +- **After materialization from SQL**, reading the property returns the stored value (which the middleware's binding wrote via the `init` accessor). + +## Before and after + +**Before** -- plain `[Expressive]` on a read-only property. Broken for projection middleware. + +```csharp +public class User +{ + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive] + public string FullName => LastName + ", " + FirstName; +} +``` + +GraphQL response: `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }` -- wrong. +SQL emitted: `SELECT 1 FROM Users` -- nothing fetched. + +**After** -- `Projectable = true`. + +```csharp +public class User +{ + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } +} +``` + +GraphQL response: `{ "users": [{ "fullName": "Lovelace, Ada" }, { "fullName": "Turing, Alan" }] }` -- correct. +SQL emitted: `SELECT u.LastName || ', ' || u.FirstName AS "FullName" FROM Users u` -- formula pushed into SQL. + +No HC glue code is required beyond the normal `.UseExpressives()` on the DbContext options. The convention auto-ignores the property in EF's model (so no `FullName` column is created), and the projection rewrite happens automatically when the query compiler intercepts. + +## Full HotChocolate example + +```csharp +// Entity +public class User +{ + public int Id { get; set; } + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + public string Email { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } + + [Expressive(Projectable = true)] + public string DisplayLabel + { + get => field ?? (FullName + " <" + Email + ">"); + init => field = value; + } +} + +// DbContext +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); +} + +// Program.cs +builder.Services.AddDbContext(o => o + .UseSqlServer(connectionString) + .UseExpressives()); + +builder.Services + .AddGraphQLServer() + .AddQueryType() + .AddProjections(); + +// Query type +public class Query +{ + [UseProjection] + public IQueryable GetUsers([Service] AppDbContext db) => db.Users; +} +``` + +A GraphQL query for `{ users { displayLabel } }` now produces: + +```sql +SELECT (u.LastName || ', ' || u.FirstName) || ' <' || u.Email || '>' AS "DisplayLabel" +FROM Users u +``` + +Notice how `DisplayLabel` composes with `FullName` (which is itself Projectable) -- the transitive rewrite is handled automatically by the expression resolver. + +## Full AutoMapper example + +AutoMapper's `ProjectTo()` emits the same `new T { ... }` pattern as HotChocolate, so Projectable members work the same way: + +```csharp +var config = new MapperConfiguration(cfg => +{ + cfg.CreateMap(); // same-type projection +}); + +var users = await db.Users + .ProjectTo(config) + .ToListAsync(); + +// SQL emitted: SELECT u.Id, u.FirstName, u.LastName, u.Email, +// (u.LastName || ', ' || u.FirstName) AS "FullName", +// (... || u.Email || '>') AS "DisplayLabel" +// FROM Users u +``` + +## When to use `[ExpressiveFor]` instead + +If your class can't use the C# 14 `field` keyword, or you want to keep the formula in a separate mapping class, the [`[ExpressiveFor]`](../reference/expressive-for) pattern is a verbose alternative: + +```csharp +public class User +{ + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + public string FullName { get; set; } = ""; // plain auto-property +} + +internal static class UserMappings +{ + [ExpressiveFor(typeof(User), nameof(User.FullName))] + private static string FullName(User u) => u.LastName + ", " + u.FirstName; +} +``` + +Both approaches produce the same SQL and work identically with HotChocolate / AutoMapper. The `Projectable` form is more concise and keeps the formula on the property itself; the `ExpressiveFor` form is explicit about the separation. See the [migration guide](../guide/migration-from-projectables) for a side-by-side comparison. + +## See Also + +- [Projectable Properties](../reference/projectable-properties) -- full reference including restrictions and runtime semantics +- [`[ExpressiveFor]` Mapping](../reference/expressive-for) -- alternative pattern for scenarios where you can't modify the entity type +- [Migrating from Projectables](../guide/migration-from-projectables) -- side-by-side migration paths for `UseMemberBody` +- [Computed Entity Properties](./computed-properties) -- plain `[Expressive]` computed values for DTO projections diff --git a/docs/reference/diagnostics.md b/docs/reference/diagnostics.md index 9e1451e..74b1915 100644 --- a/docs/reference/diagnostics.md +++ b/docs/reference/diagnostics.md @@ -29,6 +29,14 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t | [EXP0017](#exp0017) | Error | `[ExpressiveFor]` return type mismatch | -- | | [EXP0019](#exp0019) | Error | `[ExpressiveFor]` conflicts with `[Expressive]` | -- | | [EXP0020](#exp0020) | Error | Duplicate `[ExpressiveFor]` mapping | -- | +| [EXP0021](#exp0021) | Error | Projectable requires writable accessor | -- | +| [EXP0022](#exp0022) | Error | Projectable get accessor pattern | -- | +| [EXP0023](#exp0023) | Error | Projectable setter must store to backing field | -- | +| [EXP0024](#exp0024) | Error | Projectable requires non-nullable property type | -- | +| [EXP0025](#exp0025) | Error | Projectable backing field type mismatch | -- | +| [EXP0026](#exp0026) | Error | Projectable incompatible with `required` | -- | +| [EXP0028](#exp0028) | Error | Projectable not allowed on interface property | -- | +| [EXP0029](#exp0029) | Error | Projectable not allowed on override | -- | | [EXP1001](#exp1001) | Warning | Replace `[Projectable]` with `[Expressive]` | [Replace attribute](#exp1001-fix) | | [EXP1002](#exp1002) | Warning | Replace `UseProjectables()` with `UseExpressives()` | [Replace method call](#exp1002-fix) | | [EXP1003](#exp1003) | Warning | Replace Projectables namespace | [Replace namespace](#exp1003-fix) | @@ -493,6 +501,249 @@ Duplicate [ExpressiveFor] mapping for member '{0}' on type '{1}'; only one stub --- +## Projectable Diagnostics (EXP0021--EXP0029) + +These diagnostics apply only to properties decorated with `[Expressive(Projectable = true)]`. See [Projectable Properties](./projectable-properties) for the full feature reference. + +### EXP0021 -- Projectable requires writable accessor {#exp0021} + +**Severity:** Error +**Category:** Design + +**Message:** +``` +[Expressive(Projectable = true)] requires '{0}' to declare a 'set' or 'init' accessor +``` + +**Cause:** A Projectable property has only a getter. Projection middlewares (HotChocolate, AutoMapper) require a writable member to emit a binding, so Projectable properties must declare `init` or `set`. + +**Fix:** Add an `init` or `set` accessor: + +```csharp +// Error: get-only +[Expressive(Projectable = true)] +public string FullName => field ?? (LastName + ", " + FirstName); + +// Fixed +[Expressive(Projectable = true)] +public string FullName +{ + get => field ?? (LastName + ", " + FirstName); + init => field = value; +} +``` + +--- + +### EXP0022 -- Projectable get accessor pattern {#exp0022} + +**Severity:** Error +**Category:** Design + +**Message:** +``` +The get accessor of a Projectable property must be of the form '=> field ?? ()' +or '=> _backingField ?? ()' where _backingField is a private nullable field on +the same type. Found: {0}. +``` + +**Cause:** The get accessor doesn't match the expected ` ?? ()` shape. ExpressiveSharp extracts the formula by locating the right operand of a top-level `??` coalesce, so alternative shapes (ternary `a != null ? a : b`, multi-statement block bodies, expression bodies without coalesce) are rejected. + +**Fix:** Rewrite the get accessor to use `??`: + +```csharp +// Error: ternary +[Expressive(Projectable = true)] +public string FullName +{ + get => _full != null ? _full : (LastName + ", " + FirstName); + init => _full = value; +} + +// Fixed: use ?? instead +[Expressive(Projectable = true)] +public string FullName +{ + get => _full ?? (LastName + ", " + FirstName); + init => _full = value; +} +``` + +If you need flexibility the `??` pattern can't express, use [`[ExpressiveFor]`](./expressive-for) instead. + +--- + +### EXP0023 -- Projectable setter must store to backing field {#exp0023} + +**Severity:** Error +**Category:** Design + +**Message:** +``` +The init/set accessor of a Projectable property must store the incoming value into the same +backing field referenced by the get accessor. Found: {0}. +``` + +**Cause:** The init/set accessor does something other than a plain `field = value` assignment — for example `field = value?.Trim()`, a different field, or a multi-statement block. + +**Fix:** Use a plain assignment: + +```csharp +// Error: transforms the value +[Expressive(Projectable = true)] +public string FullName +{ + get => field ?? (LastName + ", " + FirstName); + init => field = value?.Trim() ?? ""; +} + +// Fixed +[Expressive(Projectable = true)] +public string FullName +{ + get => field ?? (LastName + ", " + FirstName); + init => field = value; +} +``` + +This restriction may be relaxed in a future version. If you need to transform the materialized value, consider applying the transformation in the get accessor's formula instead. + +--- + +### EXP0024 -- Projectable requires non-nullable property type {#exp0024} + +**Severity:** Error +**Category:** Design + +**Message:** +``` +[Expressive(Projectable = true)] cannot be applied to a property with a nullable type ('{0}'). +Nullable types prevent distinguishing 'not materialized' from 'materialized to null'. +``` + +**Cause:** The property type is nullable (`string?`, `int?`). The Projectable pattern uses `field ?? formula` to distinguish "not yet materialized" from "materialized to a value" — but if the property itself is nullable, "materialized to null" and "not materialized" are indistinguishable. + +**Fix:** Make the property non-nullable: + +```csharp +// Error +[Expressive(Projectable = true)] +public string? FullName { get => field ?? ...; init => field = value; } + +// Fixed +[Expressive(Projectable = true)] +public string FullName { get => field ?? ...; init => field = value; } +``` + +If you need nullable-result semantics, use plain `[Expressive]` on a read-only property and `[ExpressiveFor]` for the projection-middleware case. + +--- + +### EXP0025 -- Projectable backing field type mismatch {#exp0025} + +**Severity:** Error +**Category:** Design + +**Message:** +``` +The backing field referenced in the get accessor of '{0}' must be of type '{1}?' +(Nullable<{1}>) to support the '??' coalesce. Found: {2}. +``` + +**Cause:** The manually declared backing field's type doesn't match the property's type wrapped in `Nullable`. For a `string` property, the backing field must be `string?`. For an `int` property, it must be `int?` / `Nullable`. + +**Fix:** Correct the backing field's type: + +```csharp +// Error: backing field is 'int?' but property is 'string' +private int? _wrong; + +[Expressive(Projectable = true)] +public string FullName +{ + get => _wrong.ToString() ?? ...; + init => field = value; +} + +// Fixed: use 'field' keyword (preferred) or a matching nullable backing field +[Expressive(Projectable = true)] +public string FullName +{ + get => field ?? (LastName + ", " + FirstName); + init => field = value; +} +``` + +--- + +### EXP0026 -- Projectable incompatible with `required` {#exp0026} + +**Severity:** Error +**Category:** Design + +**Message:** +``` +[Expressive(Projectable = true)] cannot be combined with the 'required' modifier on '{0}'; +remove 'required' since EF will materialize the value from query results +``` + +**Cause:** The property has both `required` and `Projectable = true`. With `required`, every caller constructing the entity must set the property — but Projectable properties are designed to be left unset so the formula fires, with EF populating the value via `init` during materialization. + +**Fix:** Remove the `required` modifier: + +```csharp +// Error +[Expressive(Projectable = true)] +public required string FullName +{ + get => field ?? (LastName + ", " + FirstName); + init => field = value; +} + +// Fixed +[Expressive(Projectable = true)] +public string FullName +{ + get => field ?? (LastName + ", " + FirstName); + init => field = value; +} +``` + +--- + +### EXP0028 -- Projectable not allowed on interface property {#exp0028} + +**Severity:** Error +**Category:** Design + +**Message:** +``` +[Expressive(Projectable = true)] is not supported on interface members +``` + +**Cause:** The Projectable pattern relies on a backing field on the instance. Interfaces cannot declare instance fields, so the pattern is not representable on an interface property. + +**Fix:** Move the Projectable property to the concrete type that implements the interface. If the interface needs to expose the member, declare an abstract `{ get; }` on the interface and override it on the concrete type with the Projectable pattern. + +--- + +### EXP0029 -- Projectable not allowed on override {#exp0029} + +**Severity:** Error +**Category:** Design + +**Message:** +``` +[Expressive(Projectable = true)] is not supported on override properties; declare it on the +base property instead +``` + +**Cause:** The property is `override`. Projectable semantics interact ambiguously with inheritance — the base class might already have its own `[Expressive]` registration and the registry key resolution would need to flow through virtual dispatch. This scenario is not supported in v1. + +**Fix:** Move `[Expressive(Projectable = true)]` to the base class, or flatten the hierarchy. If you need inheritance-like behavior, use composition instead. + +--- + ## Migration Diagnostics (EXP1001--EXP1003) These diagnostics are emitted by the `MigrationAnalyzer` in the `ExpressiveSharp.EntityFrameworkCore.CodeFixers` package. They detect usage of the legacy `EntityFrameworkCore.Projectables` library and offer automated code fixes to migrate to ExpressiveSharp. diff --git a/docs/reference/expressive-attribute.md b/docs/reference/expressive-attribute.md index 19fdd6c..6a7fb7f 100644 --- a/docs/reference/expressive-attribute.md +++ b/docs/reference/expressive-attribute.md @@ -56,6 +56,28 @@ Or enable globally for the entire project: --- +### `Projectable` + +**Type:** `bool` +**Default:** `false` + +Opts the property into **dual-direction semantics**: in-memory reads evaluate the formula, while values materialized from query results (e.g. by EF Core or HotChocolate's projection middleware) are stored and returned verbatim. Enables the property to participate as a binding target in projections of the form `Select(src => new T { Member = src.Member, ... })`. + +Requires a specific accessor shape: + +```csharp +[Expressive(Projectable = true)] +public string FullName +{ + get => field ?? (LastName + ", " + FirstName); // `field ?? (formula)` is required + init => field = value; // or `set => field = value` +} +``` + +See [Projectable Properties](./projectable-properties) for the full reference, including the list of restrictions, runtime semantics, and HotChocolate / AutoMapper integration details. + +--- + ### `Transformers` **Type:** `Type[]?` diff --git a/docs/reference/projectable-properties.md b/docs/reference/projectable-properties.md new file mode 100644 index 0000000..b6629ac --- /dev/null +++ b/docs/reference/projectable-properties.md @@ -0,0 +1,227 @@ +# Projectable Properties + +## The problem + +You have a computed property on your entity: + +```csharp +public class User +{ + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive] + public string FullName => LastName + ", " + FirstName; +} +``` + +This works fine in direct LINQ (`db.Users.Select(u => u.FullName)`) and in DTO projections (`Select(u => new UserDto { Name = u.FullName })`). It **breaks** when something in your stack generates a projection that materializes back into the **same entity type**: + +```csharp +// What HotChocolate's [UseProjection] middleware generates for `query { users { fullName } }`: +db.Users.Select(src => new User { FullName = src.FullName }); +``` + +What happens next, silently: + +- HotChocolate checks whether `FullName` is writable (it looks at `PropertyInfo.CanWrite`). It isn't -- getter only. +- HC **drops** the `FullName = src.FullName` binding from the projection. No warning, no error. +- EF emits `SELECT 1 FROM Users` -- nothing is fetched because the projection is empty. +- HC constructs `User` instances with all fields at their defaults (`FirstName = ""`, `LastName = ""`), then reads `user.FullName`. +- The getter evaluates `"" + ", " + ""` and returns `", "`. +- Your GraphQL response is `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }`. + +The response looks valid but the data is garbage. This is the single most common trap when migrating from EFCore.Projectables or wiring up GraphQL against EF Core. The same mechanism affects **AutoMapper's `ProjectTo`**, **Mapperly's projection mode**, and any hand-rolled `Select(u => new User { ... })` that projects into the source type. + +## The fix + +Turn the property into a Projectable: + +```csharp +[Expressive(Projectable = true)] +public string FullName +{ + get => field ?? (LastName + ", " + FirstName); + init => field = value; +} +``` + +Now the property is **writable** (via `init`), so the projection middleware emits the binding. The formula still gets pushed down into SQL because ExpressiveSharp extracts it from the right operand of `??`. After the round trip, the response is correct: + +``` +{ "users": [{ "fullName": "Lovelace, Ada" }, { "fullName": "Turing, Alan" }] } +``` + +SQL emitted: + +```sql +SELECT u.LastName || ', ' || u.FirstName AS "FullName" FROM Users u +``` + +No glue code beyond `UseExpressives()` on the DbContext. The property is auto-ignored by the EF model convention, so no column is created. + +## How it works + +A Projectable property has two states, distinguished by the backing field's value: + +| State | `field` value | What the getter returns | How it gets here | +|---|---|---|---| +| **Not materialized** | `null` | Evaluates the formula from dependencies | In-memory construction (`new User { FirstName = "Ada" }`) | +| **Materialized** | non-null | The stored value | EF / HC wrote to `init` after computing the formula in SQL | + +The `??` operator picks between the two. In both cases, reading `user.FullName` returns the correct value -- the difference is only *where* the value came from. + +```csharp +// State 1 -- in memory, field is null, formula fires +var u1 = new User { FirstName = "Ada", LastName = "Lovelace" }; +u1.FullName; // "Lovelace, Ada" (computed) + +// State 2 -- after SQL materialization, stored value wins +var u2 = await db.Users.FirstAsync(); +u2.FullName; // "Lovelace, Ada" (stored, originally computed server-side) + +// Mutation behavior differs between states +u1.FirstName = "Augusta"; u1.FullName; // "Lovelace, Augusta" -- formula reruns +u2.FirstName = "Augusta"; u2.FullName; // "Lovelace, Ada" -- stored value wins +``` + +The mutation-after-materialization behavior mirrors how EF's change tracking works: once a value is loaded, it stays put until something deliberately writes to it. + +### Gotcha: stale values after dependency mutation + +Once the backing field has been written -- which happens every time the property is materialized from a SQL projection -- mutating the formula's dependencies does **not** update the stored value. This can surprise users coming from [EFCore.Projectables](https://github.com/koenbeuk/EntityFrameworkCore.Projectables), where the formula always reruns on every read. + +```csharp +// Loaded via EF/HC projection that included FullName -- `field` is now populated. +var user = await db.Users.FirstAsync(u => u.Id == 1); +user.FullName; // "Lovelace, Ada" + +user.FirstName = "Augusta"; +user.FullName; // Still "Lovelace, Ada" -- the stored value wins, formula is not rerun. +``` + +**Why this behaves this way**: the stored value is authoritative. ExpressiveSharp treats a materialized property the same way EF treats any loaded property -- if you want a change reflected, you write it explicitly. This keeps the two states (in-memory-computed vs. SQL-materialized) from silently disagreeing with each other. + +**The staleness applies only to materialized instances.** Two cases where the formula still fires on every read: + +- **Constructed in memory** (`new User { FirstName = "Ada", LastName = "Lovelace" }`) -- `field` is null, mutations to `FirstName`/`LastName` propagate as you'd expect. +- **Loaded without projecting the property** (e.g. `db.Users.FirstAsync()` without a `Select` that includes `FullName`) -- the `init` accessor was never called, so `field` is still null. + +**If you need the formula to rerun after dependency mutation on a materialized instance**, you have a few options: + +1. **Re-fetch the entity** -- let EF re-run the query with the new values. +2. **Use plain `[Expressive]` with a DTO projection** -- if you're not going through projection middleware, a read-only `[Expressive]` on a DTO type is simpler and has no staleness. +3. **Expose a reset method** -- for example `public void ResetFullName() { typeof(User).GetField(...).SetValue(this, null); }` to null out the backing field and let the formula fire again on the next read. This is rarely worth the complexity; options 1 and 2 cover the common cases. + +## When to use it vs. plain `[Expressive]` + +Only turn `Projectable = true` on when you actually have the problem above. The quick test: + +> *Does anything in my stack generate `Select(src => new Entity { ... })` over this entity type?* + +If you can answer yes (HotChocolate with `[UseProjection]`, AutoMapper `ProjectTo`, Mapperly projections, hand-rolled patterns), the property needs to be Projectable. If not -- if you only ever project into DTOs or read the property directly -- plain `[Expressive]` is simpler and has no restrictions. + +## Syntax + +### Required shape + +```csharp +[Expressive(Projectable = true)] +public string FullName +{ + get => field ?? (LastName + ", " + FirstName); + init => field = value; +} +``` + +- **Get accessor**: must be `=> ?? ()`. The generator matches this exact shape -- ternaries (`a != null ? a : b`), block bodies with `if`/`return`, and other forms are rejected with [EXP0022](./diagnostics#exp0022). +- **Init/set accessor**: must be `=> = value`. Transformations like `value?.Trim()` are rejected with [EXP0023](./diagnostics#exp0023). +- **Class does not need to be `partial`.** **Property does not need to be `partial`.** + +### Backing field options + +Either the C# 14 `field` keyword (preferred) or a manually declared private nullable field works: + +```csharp +// Option A -- `field` keyword (C# 14+) +[Expressive(Projectable = true)] +public string FullName +{ + get => field ?? (LastName + ", " + FirstName); + init => field = value; +} + +// Option B -- manual backing field +private string? _fullName; + +[Expressive(Projectable = true)] +public string FullName +{ + get => _fullName ?? (LastName + ", " + FirstName); + init => _fullName = value; +} +``` + +The manual field must be **private**, **on the same type as the property**, and **nullable** (`string?` for reference types, `Nullable` for value types). The `??` needs a distinguishable "not materialized" state. + +### `init` vs. `set` + +Both are accepted. Pick based on whether callers should be able to override the stored value after construction: + +- `init` -- value can only be set through object initializers (EF, HC) or the constructor. Recommended default. +- `set` -- callers can also assign `user.FullName = "..."` directly. Useful if you want to support manual overrides. + +## Restrictions + +The generator enforces these at compile time. Each maps to a specific diagnostic for the exact error message: + +| Restriction | Diagnostic | +|---|---| +| Property must declare `set` or `init`. | [EXP0021](./diagnostics#exp0021) | +| Get accessor must match ` ?? ()`. | [EXP0022](./diagnostics#exp0022) | +| Init/set must be ` = value` (no transformations in v1). | [EXP0023](./diagnostics#exp0023) | +| Property type must be non-nullable. | [EXP0024](./diagnostics#exp0024) | +| Manual backing field must match `Nullable`. | [EXP0025](./diagnostics#exp0025) | +| Cannot combine with `required`. | [EXP0026](./diagnostics#exp0026) | +| Not allowed on interface properties. | [EXP0028](./diagnostics#exp0028) | +| Not allowed on `override` properties. | [EXP0029](./diagnostics#exp0029) | + +## EF Core integration + +With `UseExpressives()` on the DbContext options, the `ExpressivePropertiesNotMappedConvention` auto-ignores the property: + +- **No column is created.** Migrations generated against the DbContext will not include a `FullName` column. +- **Queries work as expected.** `db.Users.Select(u => u.FullName)` emits SQL with the inlined formula. +- **Projections materialize correctly.** `Select(u => new User { FullName = u.FullName })` produces SQL like `SELECT LastName || ', ' || FirstName AS FullName FROM Users` and writes the result through `init`. + +No `[NotMapped]` annotation or manual `modelBuilder.Ignore(...)` call is required. + +## Comparison with `[ExpressiveFor]` + +`[ExpressiveFor]` is the verbose alternative -- the formula lives in a separate static stub instead of on the property: + +```csharp +public class User +{ + public string FullName { get; set; } = ""; +} + +internal static class UserMappings +{ + [ExpressiveFor(typeof(User), nameof(User.FullName))] + private static string FullName(User u) => u.LastName + ", " + u.FirstName; +} +``` + +Both produce identical SQL and both work with HC/AutoMapper. Pick based on preference: + +- **`[Expressive(Projectable = true)]`** -- formula lives on the property. Single declaration site. Recommended default. +- **`[ExpressiveFor]`** -- formula in a separate class. More explicit. Required when you can't modify the entity type (third-party code) or need to map cross-type. + +See the [migration guide](../guide/migration-from-projectables#migrating-usememberbody) for side-by-side examples. + +## Further reading + +- [Projection Middleware recipe](../recipes/projection-middleware) -- end-to-end HotChocolate + AutoMapper examples. +- [`[Expressive]` Attribute reference](./expressive-attribute) -- base attribute. +- [`[ExpressiveFor]` reference](./expressive-for) -- verbose alternative. diff --git a/src/ExpressiveSharp.Abstractions/ExpressiveAttribute.cs b/src/ExpressiveSharp.Abstractions/ExpressiveAttribute.cs index e5ed770..10f5118 100644 --- a/src/ExpressiveSharp.Abstractions/ExpressiveAttribute.cs +++ b/src/ExpressiveSharp.Abstractions/ExpressiveAttribute.cs @@ -27,4 +27,15 @@ public sealed class ExpressiveAttribute : Attribute /// Each type must have a parameterless constructor. /// public Type[]? Transformers { get; set; } + + /// + /// When true, the property's body is treated as a SQL formula and the property gains + /// dual semantics: in-memory reads evaluate the formula, while values materialized from + /// query results (e.g. by EF Core or HotChocolate's projection middleware) are stored and + /// returned verbatim. Requires the property's get accessor to use the pattern + /// => field ?? (<formula>) (or with a manually declared private nullable backing field + /// in place of field), and an init or set accessor that stores into the same backing + /// location. The property must not be nullable. + /// + public bool Projectable { get; set; } } diff --git a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs index 8edd7fd..9ef7983 100644 --- a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs +++ b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs @@ -166,4 +166,72 @@ static internal class Diagnostics category: "Design", DiagnosticSeverity.Error, isEnabledByDefault: true); + + // ── [Expressive(Projectable = true)] Diagnostics ──────────────────────── + + public readonly static DiagnosticDescriptor ProjectableRequiresWritableAccessor = new DiagnosticDescriptor( + id: "EXP0021", + title: "Projectable requires writable accessor", + messageFormat: "[Expressive(Projectable = true)] requires '{0}' to declare a 'set' or 'init' accessor", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public readonly static DiagnosticDescriptor ProjectableGetAccessorPattern = new DiagnosticDescriptor( + id: "EXP0022", + title: "Projectable get accessor pattern", + messageFormat: "The get accessor of a Projectable property must be of the form '=> field ?? ()' or '=> _backingField ?? ()' where _backingField is a private nullable field on the same type. Found: {0}.", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public readonly static DiagnosticDescriptor ProjectableSetterMustStoreToBackingField = new DiagnosticDescriptor( + id: "EXP0023", + title: "Projectable setter must store to backing field", + messageFormat: "The init/set accessor of a Projectable property must store the incoming value into the same backing field referenced by the get accessor. Found: {0}.", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public readonly static DiagnosticDescriptor ProjectableRequiresNonNullablePropertyType = new DiagnosticDescriptor( + id: "EXP0024", + title: "Projectable requires non-nullable property type", + messageFormat: "[Expressive(Projectable = true)] cannot be applied to a property with a nullable type ('{0}'). Nullable types prevent distinguishing 'not materialized' from 'materialized to null'.", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public readonly static DiagnosticDescriptor ProjectableBackingFieldTypeMismatch = new DiagnosticDescriptor( + id: "EXP0025", + title: "Projectable backing field type mismatch", + messageFormat: "The backing field referenced in the get accessor of '{0}' must be of type '{1}?' (Nullable<{1}>) to support the '??' coalesce. Found: {2}.", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public readonly static DiagnosticDescriptor ProjectableIncompatibleWithRequired = new DiagnosticDescriptor( + id: "EXP0026", + title: "Projectable incompatible with required", + messageFormat: "[Expressive(Projectable = true)] cannot be combined with the 'required' modifier on '{0}'; remove 'required' since EF will materialize the value from query results", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + // NOTE: EXP0027 is reserved for future use. + + public readonly static DiagnosticDescriptor ProjectableNotAllowedOnInterface = new DiagnosticDescriptor( + id: "EXP0028", + title: "Projectable not allowed on interface property", + messageFormat: "[Expressive(Projectable = true)] is not supported on interface members", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public readonly static DiagnosticDescriptor ProjectableNotAllowedOnOverride = new DiagnosticDescriptor( + id: "EXP0029", + title: "Projectable not allowed on override", + messageFormat: "[Expressive(Projectable = true)] is not supported on override properties; declare it on the base property instead", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs index 544d2d2..78ea5c4 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs @@ -129,6 +129,192 @@ private static bool TryApplyPropertyBody( return true; } + /// + /// Fills from a [Expressive(Projectable = true)] + /// property. The get accessor must have the pattern field ?? (<formula>), and the + /// init/set accessor must be a plain field = value assignment. The formula (the right + /// operand of the coalesce) is extracted and fed through the regular property-body emission + /// pipeline, so the registry records a lambda keyed on the property's getter handle with the + /// formula as its body. Returns false and reports diagnostics on failure. + /// + private static bool TryApplyProjectablePropertyBody( + PropertyDeclarationSyntax propertyDeclarationSyntax, + ISymbol memberSymbol, + SemanticModel semanticModel, + DeclarationSyntaxRewriter declarationSyntaxRewriter, + SourceProductionContext context, + ExpressiveDescriptor descriptor) + { + var propertyLocation = propertyDeclarationSyntax.Identifier.GetLocation(); + + if (memberSymbol is not IPropertySymbol propertySymbol) + { + // Belt-and-braces — the dispatch is already property-specific. + return false; + } + + // ── Step 1: Property-level validation ─────────────────────────────── + + // EXP0028: not allowed on interface members (interfaces cannot have instance fields). + if (propertySymbol.ContainingType.TypeKind == TypeKind.Interface) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableNotAllowedOnInterface, propertyLocation)); + return false; + } + + // EXP0029: not allowed on override properties. + if (propertySymbol.IsOverride) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableNotAllowedOnOverride, propertyLocation)); + return false; + } + + // EXP0022 (specialized): abstract or extern has no body to analyze. + if (propertySymbol.IsAbstract || propertySymbol.IsExtern) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableGetAccessorPattern, propertyLocation, + "abstract or extern property has no body to analyze")); + return false; + } + + // EXP0021: must have a writable accessor (set or init). + if (propertySymbol.SetMethod is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableRequiresWritableAccessor, propertyLocation, + propertySymbol.Name)); + return false; + } + + // EXP0024: must not be nullable. Rejects both `string?` and `int?`/`Nullable`. + if (IsNullablePropertyType(propertySymbol.Type)) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableRequiresNonNullablePropertyType, propertyLocation, + propertySymbol.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + return false; + } + + // EXP0026: incompatible with the `required` modifier. + if (propertySymbol.IsRequired) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableIncompatibleWithRequired, propertyLocation, + propertySymbol.Name)); + return false; + } + + // ── Step 2: Find the get and set/init accessor declarations ───────── + + if (propertyDeclarationSyntax.AccessorList is null) + { + // Expression-bodied property (=> expr) has no accessor list, so it cannot have an + // `init` or `set` accessor. This shape is rejected by the writable-accessor check + // above, but be defensive. + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableGetAccessorPattern, propertyLocation, + "Projectable properties must use a { get; init/set; } accessor list, not expression-body syntax")); + return false; + } + + var getAccessor = propertyDeclarationSyntax.AccessorList.Accessors + .FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); + var setAccessor = propertyDeclarationSyntax.AccessorList.Accessors + .FirstOrDefault(a => a.IsKind(SyntaxKind.SetAccessorDeclaration) + || a.IsKind(SyntaxKind.InitAccessorDeclaration)); + + if (getAccessor is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableGetAccessorPattern, propertyLocation, + "Projectable property must declare a get accessor")); + return false; + } + + if (setAccessor is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableRequiresWritableAccessor, propertyLocation, + propertySymbol.Name)); + return false; + } + + // ── Step 3: Recognize the get accessor pattern ────────────────────── + + var getOperation = semanticModel.GetOperation(getAccessor); + if (getOperation is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableGetAccessorPattern, getAccessor.GetLocation(), + "get accessor has no analyzable body")); + return false; + } + + if (!ProjectablePatternRecognizer.TryRecognizeGetPattern( + propertySymbol, getOperation, context, getAccessor.GetLocation(), + out var backingField, out var formulaOperation) + || backingField is null + || formulaOperation is null) + { + return false; + } + + // ── Step 4: Validate the set/init accessor pattern ────────────────── + + var setOperation = semanticModel.GetOperation(setAccessor); + if (setOperation is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableSetterMustStoreToBackingField, setAccessor.GetLocation(), + "init/set accessor has no analyzable body")); + return false; + } + + if (!ProjectablePatternRecognizer.ValidateSetterPattern( + setOperation, backingField, context, setAccessor.GetLocation())) + { + return false; + } + + // ── Step 5: Feed the formula through the regular emission pipeline ── + + // The formula's Syntax node is the right operand of the `??` coalesce. Passing it to + // EmitExpressionTreeForProperty produces a LambdaExpression factory whose registry key + // is the property's getter handle — exactly what ExpressiveReplacer.VisitMember looks up + // at runtime. + var formulaSyntax = formulaOperation.Syntax; + if (formulaSyntax is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableGetAccessorPattern, getAccessor.GetLocation(), + "formula expression has no source syntax")); + return false; + } + + var returnTypeSyntax = declarationSyntaxRewriter.Visit(propertyDeclarationSyntax.Type); + descriptor.ReturnTypeName = returnTypeSyntax.ToString(); + + descriptor.ExpressionTreeEmission = EmitExpressionTreeForProperty( + formulaSyntax, semanticModel, context, descriptor, memberSymbol); + + return true; + } + + /// + /// Returns true if the property's declared type is either a nullable reference type + /// (e.g. string?) or a nullable value type (int? / Nullable<int>). + /// + private static bool IsNullablePropertyType(ITypeSymbol type) + { + if (type.NullableAnnotation == NullableAnnotation.Annotated) return true; + if (type is INamedTypeSymbol named && named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + return true; + return false; + } + /// /// Fills from a constructor declaration body. /// Constructors produce Expression.MemberInit (object initializer) for EF Core projections. diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs index 89772cb..4a0497d 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs @@ -51,6 +51,10 @@ static internal partial class ExpressiveInterpreter TryApplyMethodBody(methodDecl, memberSymbol, semanticModel, declarationSyntaxRewriter, context, descriptor, allowBlockBody), + PropertyDeclarationSyntax propDecl when expressiveAttribute.Projectable => + TryApplyProjectablePropertyBody(propDecl, memberSymbol, semanticModel, + declarationSyntaxRewriter, context, descriptor), + PropertyDeclarationSyntax propDecl => TryApplyPropertyBody(propDecl, memberSymbol, semanticModel, declarationSyntaxRewriter, context, descriptor, allowBlockBody), diff --git a/src/ExpressiveSharp.Generator/Interpretation/ProjectablePatternRecognizer.cs b/src/ExpressiveSharp.Generator/Interpretation/ProjectablePatternRecognizer.cs new file mode 100644 index 0000000..d7d2c2b --- /dev/null +++ b/src/ExpressiveSharp.Generator/Interpretation/ProjectablePatternRecognizer.cs @@ -0,0 +1,273 @@ +using ExpressiveSharp.Generator.Infrastructure; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace ExpressiveSharp.Generator.Interpretation; + +/// +/// Recognizes the IOperation shape produced by a [Expressive(Projectable = true)] property. +/// +/// The get accessor must be of the form field ?? (<formula>) or +/// _backingField ?? (<formula>) where the backing field is either the C# 14 +/// synthesized property backing field or a manually declared private nullable field of the +/// corresponding type. +/// +/// +/// The init/set accessor must be a single statement that assigns the implicit value +/// parameter into the same backing field. +/// +/// +static internal class ProjectablePatternRecognizer +{ + /// + /// Inspects the get accessor's IOperation and, if it matches the Projectable pattern, + /// returns the backing field symbol and the formula operation (the right operand of the coalesce). + /// Reports EXP0022 or EXP0025 on mismatch. + /// + public static bool TryRecognizeGetPattern( + IPropertySymbol property, + IOperation getAccessorOperation, + SourceProductionContext context, + Location diagnosticLocation, + out IFieldSymbol? backingField, + out IOperation? formulaOperation) + { + backingField = null; + formulaOperation = null; + + // Step 1: unwrap MethodBodyOperation → BlockOperation → ReturnOperation → . + // Expression-bodied accessors produce the same shape through Roslyn. + var coalesce = UnwrapToReturnExpression(getAccessorOperation); + if (coalesce is null) + { + ReportGetAccessorPattern(context, diagnosticLocation, "get accessor body is empty or not a single return expression"); + return false; + } + + // Step 2: the return expression must be a top-level ?? coalesce. + if (coalesce is not ICoalesceOperation coalesceOp) + { + ReportGetAccessorPattern(context, diagnosticLocation, DescribeOperation(coalesce)); + return false; + } + + // Step 3: left operand must be a backing field reference. + if (coalesceOp.Value is not IFieldReferenceOperation fieldRef) + { + ReportGetAccessorPattern(context, diagnosticLocation, + $"the left operand of '??' must be a backing field reference, found {DescribeOperation(coalesceOp.Value)}"); + return false; + } + + // Receiver must be null (field keyword, no explicit receiver) or an implicit `this`. + if (fieldRef.Instance is not null and not IInstanceReferenceOperation) + { + ReportGetAccessorPattern(context, diagnosticLocation, + "the backing field reference must have an implicit 'this' receiver (or none, for the 'field' keyword)"); + return false; + } + + // Step 4: the field must satisfy Pattern A (C# 14 field keyword) or Pattern B (manual private nullable field). + if (!MatchesPatternAOrB(property, fieldRef.Field, context, diagnosticLocation)) + { + return false; + } + + backingField = fieldRef.Field; + formulaOperation = coalesceOp.WhenNull; + return true; + } + + /// + /// Validates that the init/set accessor body is a single assignment of the implicit + /// value parameter into the same backing field recognized in the get accessor. + /// Reports EXP0023 on mismatch. + /// + public static bool ValidateSetterPattern( + IOperation setterAccessorOperation, + IFieldSymbol expectedBackingField, + SourceProductionContext context, + Location diagnosticLocation) + { + // Unwrap MethodBodyOperation → BlockOperation → single statement. + var statement = UnwrapToSingleStatement(setterAccessorOperation); + if (statement is null) + { + ReportSetterPattern(context, diagnosticLocation, + "init/set accessor must contain exactly one assignment statement"); + return false; + } + + if (statement is not IExpressionStatementOperation { Operation: ISimpleAssignmentOperation assignment }) + { + ReportSetterPattern(context, diagnosticLocation, + $"init/set accessor must be a simple assignment, found {DescribeOperation(statement)}"); + return false; + } + + if (assignment.Target is not IFieldReferenceOperation targetFieldRef) + { + ReportSetterPattern(context, diagnosticLocation, + $"init/set accessor must assign into a backing field, found {DescribeOperation(assignment.Target)}"); + return false; + } + + if (!SymbolEqualityComparer.Default.Equals(targetFieldRef.Field, expectedBackingField)) + { + ReportSetterPattern(context, diagnosticLocation, + $"init/set accessor assigns into '{targetFieldRef.Field.Name}' but the get accessor reads from '{expectedBackingField.Name}'"); + return false; + } + + if (assignment.Value is not IParameterReferenceOperation paramRef + || paramRef.Parameter.Name != "value" + || !paramRef.Parameter.IsImplicitlyDeclared) + { + ReportSetterPattern(context, diagnosticLocation, + "init/set accessor value must be a plain reference to the implicit 'value' parameter; transformations like 'value?.Trim()' are not supported in v1"); + return false; + } + + return true; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private static bool MatchesPatternAOrB( + IPropertySymbol property, + IFieldSymbol field, + SourceProductionContext context, + Location diagnosticLocation) + { + // Pattern A: C# 14 `field` keyword — the synthesized backing field whose AssociatedSymbol + // is the containing property. Probed against Roslyn 5.0.0 and confirmed to surface both + // IsImplicitlyDeclared == true and AssociatedSymbol == the property symbol. + if (field.IsImplicitlyDeclared + && SymbolEqualityComparer.Default.Equals(field.AssociatedSymbol, property)) + { + return true; + } + + // Pattern B: manually declared private *instance* nullable backing field on the same type. + // A static backing field is explicitly rejected because it would share materialized state + // across all instances of the containing type, breaking per-entity materialization semantics. + if (field.IsStatic + || field.DeclaredAccessibility != Accessibility.Private + || !SymbolEqualityComparer.Default.Equals(field.ContainingType, property.ContainingType)) + { + ReportGetAccessorPattern(context, diagnosticLocation, + $"the backing field '{field.Name}' must be the 'field' keyword or a private instance field on '{property.ContainingType.Name}'"); + return false; + } + + if (!IsNullableOfPropertyType(field.Type, property.Type)) + { + ReportBackingFieldTypeMismatch(context, diagnosticLocation, + property.Name, + property.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), + field.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)); + return false; + } + + return true; + } + + private static bool IsNullableOfPropertyType(ITypeSymbol fieldType, ITypeSymbol propertyType) + { + // For value types: manually declared backing field must be Nullable where T matches + // the property type. + if (propertyType.IsValueType) + { + if (fieldType is not INamedTypeSymbol named || !named.IsGenericType) return false; + if (named.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T) return false; + return SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], propertyType); + } + + // For reference types: manually declared backing field must be the same type with a + // nullable annotation (e.g. string? for a string property). + return SymbolEqualityComparer.Default.Equals(fieldType.OriginalDefinition, propertyType.OriginalDefinition) + && fieldType.NullableAnnotation == NullableAnnotation.Annotated; + } + + private static IOperation? UnwrapToReturnExpression(IOperation operation) + { + // Expression-bodied accessors may give us the expression directly; block-bodied produce + // MethodBodyOperation → BlockOperation → ReturnOperation. + var current = operation; + while (true) + { + switch (current) + { + case IMethodBodyOperation methodBody: + current = methodBody.BlockBody ?? methodBody.ExpressionBody; + if (current is null) return null; + break; + + case IBlockOperation block: + if (block.Operations.Length != 1) return null; + current = block.Operations[0]; + break; + + case IReturnOperation ret: + return ret.ReturnedValue; + + default: + return current; + } + } + } + + private static IOperation? UnwrapToSingleStatement(IOperation operation) + { + var current = operation; + while (true) + { + switch (current) + { + case IMethodBodyOperation methodBody: + current = methodBody.BlockBody ?? methodBody.ExpressionBody; + if (current is null) return null; + break; + + case IBlockOperation block: + if (block.Operations.Length != 1) return null; + return block.Operations[0]; + + default: + return current; + } + } + } + + private static string DescribeOperation(IOperation? operation) => operation switch + { + null => "", + ICoalesceOperation => "coalesce (??)", + IBinaryOperation bin => $"binary operator '{bin.OperatorKind}'", + IConditionalOperation => "ternary '?:' (use '??' instead)", + IInvocationOperation => "method invocation", + IPropertyReferenceOperation => "property access", + IFieldReferenceOperation => "field access", + IParameterReferenceOperation => "parameter reference", + ILiteralOperation => "literal", + IExpressionStatementOperation => "expression statement", + ISimpleAssignmentOperation => "simple assignment", + _ => operation.Kind.ToString() + }; + + private static void ReportGetAccessorPattern( + SourceProductionContext context, Location location, string detail) => + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableGetAccessorPattern, location, detail)); + + private static void ReportSetterPattern( + SourceProductionContext context, Location location, string detail) => + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableSetterMustStoreToBackingField, location, detail)); + + private static void ReportBackingFieldTypeMismatch( + SourceProductionContext context, Location location, + string propertyName, string propertyType, string actualType) => + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ProjectableBackingFieldTypeMismatch, location, propertyName, propertyType, actualType)); +} diff --git a/src/ExpressiveSharp.Generator/Models/ExpressiveAttributeData.cs b/src/ExpressiveSharp.Generator/Models/ExpressiveAttributeData.cs index 5d9e469..9ab7ff6 100644 --- a/src/ExpressiveSharp.Generator/Models/ExpressiveAttributeData.cs +++ b/src/ExpressiveSharp.Generator/Models/ExpressiveAttributeData.cs @@ -9,12 +9,15 @@ readonly internal record struct ExpressiveAttributeData { public bool? AllowBlockBody { get; } + public bool Projectable { get; } + // Custom transformer type names (fully qualified) public IReadOnlyList TransformerTypeNames { get; } public ExpressiveAttributeData(AttributeData attribute) { bool? allowBlockBody = null; + var projectable = false; var transformerTypeNames = new List(); foreach (var namedArgument in attribute.NamedArguments) @@ -26,6 +29,9 @@ public ExpressiveAttributeData(AttributeData attribute) case nameof(AllowBlockBody): allowBlockBody = value.Value is true; break; + case nameof(Projectable): + projectable = value.Value is true; + break; case "Transformers": if (value.Kind == TypedConstantKind.Array) { @@ -43,6 +49,7 @@ public ExpressiveAttributeData(AttributeData attribute) } AllowBlockBody = allowBlockBody; + Projectable = projectable; TransformerTypeNames = transformerTypeNames.ToArray(); } } diff --git a/src/ExpressiveSharp.MongoDB/ExpressiveMongoCollection.cs b/src/ExpressiveSharp.MongoDB/ExpressiveMongoCollection.cs index 0a7b8c0..aab5705 100644 --- a/src/ExpressiveSharp.MongoDB/ExpressiveMongoCollection.cs +++ b/src/ExpressiveSharp.MongoDB/ExpressiveMongoCollection.cs @@ -1,4 +1,5 @@ using ExpressiveSharp.MongoDB.Extensions; +using ExpressiveSharp.MongoDB.Infrastructure; using ExpressiveSharp.Services; using MongoDB.Driver; @@ -38,6 +39,12 @@ public ExpressiveMongoCollection(IMongoCollection inner, ExpressiveOp { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _options = options ?? MongoExpressiveOptions.CreateDefault(); + + // Idempotent registration; belt-and-braces for code paths that access + // `Inner` directly for writes (InsertOne/ReplaceOne/…) without ever + // going through `AsQueryable`. The `AsExpressive` extension registers + // the convention on the query path. + ExpressiveMongoIgnoreConvention.EnsureRegistered(); } /// diff --git a/src/ExpressiveSharp.MongoDB/Extensions/MongoExpressiveExtensions.cs b/src/ExpressiveSharp.MongoDB/Extensions/MongoExpressiveExtensions.cs index 72155fd..2b5a583 100644 --- a/src/ExpressiveSharp.MongoDB/Extensions/MongoExpressiveExtensions.cs +++ b/src/ExpressiveSharp.MongoDB/Extensions/MongoExpressiveExtensions.cs @@ -25,6 +25,12 @@ public static IExpressiveMongoQueryable AsExpressive( this IQueryable source, ExpressiveOptions? options = null) { + // Idempotent; cheap on subsequent calls via an Interlocked.Exchange guard. + // Registering here (rather than in ExpressiveMongoCollection's constructor) + // guarantees the BSON class-map convention fires on the common + // `collection.AsExpressive()` entry point too. + ExpressiveMongoIgnoreConvention.EnsureRegistered(); + var mongoProvider = source.Provider as IMongoQueryProvider ?? throw new ArgumentException( "The source queryable's Provider must implement IMongoQueryProvider. " + @@ -52,6 +58,13 @@ public static IExpressiveMongoQueryable AsExpressive( ExpressiveOptions? options = null, AggregateOptions? aggregateOptions = null) { + // MongoDB's AsQueryable() builds the BSON class map eagerly to prepare the + // aggregation pipeline. We must register our ignore convention *before* that + // call, otherwise the class map for T is constructed with the default auto-map + // behavior that includes writable [Expressive] properties as BSON fields — + // persisting backing-field state to the document. + ExpressiveMongoIgnoreConvention.EnsureRegistered(); + var queryable = aggregateOptions is not null ? collection.AsQueryable(aggregateOptions) : collection.AsQueryable(); diff --git a/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs b/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs new file mode 100644 index 0000000..7a896bd --- /dev/null +++ b/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs @@ -0,0 +1,100 @@ +using System.Reflection; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; + +namespace ExpressiveSharp.MongoDB.Infrastructure; + +/// +/// Mongo that unmaps every property marked with +/// from its containing class map. This is the Mongo +/// counterpart of the EF Core ExpressivePropertiesNotMappedConvention: without it, +/// a [Expressive(Projectable = true)] property would be serialized to its BSON +/// document as a real field (because the property has a writable accessor), and the +/// backing field's default value would leak into storage. +/// +/// +/// +/// The convention fires when a class map is built — typically the first time a given +/// document type participates in a Mongo query or serialization. It runs once per type +/// (Mongo caches the resulting class map). +/// +/// +/// Read-only computed [Expressive] properties are also unmapped defensively. +/// Mongo would skip them anyway in most cases (there's no setter), but matching the EF +/// convention's behavior keeps the two providers consistent. +/// +/// +public sealed class ExpressiveMongoIgnoreConvention : ConventionBase, IClassMapConvention +{ + /// + /// The name under which this convention's pack is registered in the global + /// . Exposed so callers can inspect or unregister it. + /// + public const string ConventionPackName = "ExpressiveSharp.MongoDB"; + + public ExpressiveMongoIgnoreConvention() : base("ExpressiveSharpIgnore") { } + + /// + /// Runs during class-map construction. Inspects the class's CLR properties directly + /// (instead of , which may not be populated + /// yet at this stage) and unmaps any that carry . + /// + public void Apply(BsonClassMap classMap) + { + ArgumentNullException.ThrowIfNull(classMap); + + foreach (var property in classMap.ClassType.GetProperties( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + // Only unmap properties declared on this exact type; inherited ones are handled + // by the base class's own class-map convention pass. + if (property.DeclaringType != classMap.ClassType) + { + continue; + } + if (property.GetCustomAttribute(inherit: false) is null) + { + continue; + } + + classMap.UnmapProperty(property.Name); + } + } + + // ── Registration ──────────────────────────────────────────────────────── + + private static int _registered; + + /// + /// Registers this convention once, idempotently, against the global + /// . Subsequent calls are no-ops. + /// + /// + /// + /// Ordering matters. MongoDB builds and caches a for a + /// document type on the first call to IMongoDatabase.GetCollection<T>(). A + /// convention registered after that call does not apply to the cached map; the + /// [Expressive] properties will still be serialized to BSON. + /// + /// + /// Call this method once at application startup, before any GetCollection<T> + /// call for a type that has [Expressive] properties. Alternatively, wrap the + /// collection in or call + /// collection.AsExpressive() before any other collection handle is obtained; both + /// of those paths call this method. + /// + /// + /// The filter predicate returns true for every type — the convention's + /// is a no-op for classes without [Expressive] + /// properties, so applying it globally is harmless and avoids subtle ordering issues + /// where a type-level predicate is evaluated before attribute metadata is visible. + /// + /// + public static void EnsureRegistered() + { + if (Interlocked.Exchange(ref _registered, 1) == 1) return; + + var pack = new ConventionPack { new ExpressiveMongoIgnoreConvention() }; + ConventionRegistry.Register(ConventionPackName, pack, _ => true); + } +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ProjectableExpressiveSqlTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ProjectableExpressiveSqlTests.cs new file mode 100644 index 0000000..63441f2 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ProjectableExpressiveSqlTests.cs @@ -0,0 +1,126 @@ +using ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Tests.Sqlite; + +/// +/// EF Core SQLite tests for [Expressive(Projectable = true)]. Uses a self-contained +/// DbContext with a Projectable entity so the test doesn't depend on shared scenario models. +/// Verifies: +/// +/// The Projectable property is auto-Ignored in the EF model (no column is generated). +/// Queries referencing the property emit SQL with the inlined formula. +/// Projection into new T { Member = ... } materializes via the init accessor. +/// +/// +[TestClass] +public class ProjectableExpressiveSqlTests +{ + private TestContextFactories.SqliteContextHandle _handle = null!; + + private ProjectableDbContext Context => _handle.Context; + + [TestInitialize] + public async Task InitContext() + { + _handle = TestContextFactories.CreateSqlite(o => new ProjectableDbContext(o)); + await Context.Database.EnsureCreatedAsync(); + Context.People.AddRange( + new ProjectablePerson { Id = 1, FirstName = "Ada", LastName = "Lovelace" }, + new ProjectablePerson { Id = 2, FirstName = "Alan", LastName = "Turing" }); + await Context.SaveChangesAsync(); + } + + [TestCleanup] + public async Task CleanupContext() => await _handle.DisposeAsync(); + + [TestMethod] + public void ProjectableProperty_IsAutoIgnored_NoColumnInModel() + { + // The ExpressivePropertiesNotMappedConvention calls Ignore() for every [Expressive] + // member. This is load-bearing for Projectable properties because they have writable + // accessors — without the Ignore, EF would try to create a real column and migrations + // would include a FullName column. + var entity = Context.Model.FindEntityType(typeof(ProjectablePerson))!; + Assert.IsNull(entity.FindProperty(nameof(ProjectablePerson.FullName)), + "Projectable property must not be mapped as a column"); + } + + [TestMethod] + public async Task ProjectableProperty_SelectInlinesFormulaIntoSql() + { + var labels = await Context.People + .OrderBy(p => p.Id) + .Select(p => p.FullName) + .ToListAsync(); + + Assert.AreEqual(2, labels.Count); + Assert.AreEqual("Lovelace, Ada", labels[0]); + Assert.AreEqual("Turing, Alan", labels[1]); + } + + [TestMethod] + public async Task ProjectableProperty_MemberInitProjection_MaterializesStoredValue() + { + // The HotChocolate / AutoMapper projection pattern: `new T { Member = src.Member }`. + // The ExpressiveReplacer rewrites `p.FullName` on the RHS to the formula, EF emits + // the formula as SQL, the result is written via the init accessor, and the stored + // value is returned on subsequent reads. + var projected = await Context.People + .OrderBy(p => p.Id) + .Select(p => new ProjectablePerson + { + Id = p.Id, + FullName = p.FullName, + }) + .ToListAsync(); + + Assert.AreEqual(2, projected.Count); + Assert.AreEqual("Lovelace, Ada", projected[0].FullName); + Assert.AreEqual("Turing, Alan", projected[1].FullName); + } + + [TestMethod] + public async Task ProjectableProperty_WhereClauseFiltersOnFormula() + { + // The Projectable property can appear in Where clauses — after rewriting, EF evaluates + // the formula server-side and filters rows. + var filtered = await Context.People + .Where(p => p.FullName.StartsWith("Turing")) + .ToListAsync(); + + Assert.AreEqual(1, filtered.Count); + Assert.AreEqual("Alan", filtered[0].FirstName); + } +} + +/// Self-contained entity for Projectable EF Core tests. +public class ProjectablePerson +{ + public int Id { get; set; } + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } +} + +/// Self-contained DbContext for Projectable EF Core tests. +public class ProjectableDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet People => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedNever(); + }); + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.ProjectableWithSetAccessor.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.ProjectableWithSetAccessor.verified.txt new file mode 100644 index 0000000..2e390fe --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.ProjectableWithSetAccessor.verified.txt @@ -0,0 +1,23 @@ +// +#nullable disable + +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_User + { + // [Expressive(Projectable = true)] + // public string FullName { get => field ?? (LastName + ", " + FirstName); set => field = value; } + static global::System.Linq.Expressions.Expression> FullName_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.User), "@this"); + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.User).GetProperty("LastName")); // LastName + var expr_3 = global::System.Linq.Expressions.Expression.Constant(", ", typeof(string)); // ", " + var expr_1 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_2, expr_3); + var expr_4 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.User).GetProperty("FirstName")); // FirstName + var expr_0 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_1, expr_4); + return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); + } + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_FieldKeyword.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_FieldKeyword.verified.txt new file mode 100644 index 0000000..aabb3fb --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_FieldKeyword.verified.txt @@ -0,0 +1,23 @@ +// +#nullable disable + +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_User + { + // [Expressive(Projectable = true)] + // public string FullName { get => field ?? (LastName + ", " + FirstName); init => field = value; } + static global::System.Linq.Expressions.Expression> FullName_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.User), "@this"); + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.User).GetProperty("LastName")); // LastName + var expr_3 = global::System.Linq.Expressions.Expression.Constant(", ", typeof(string)); // ", " + var expr_1 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_2, expr_3); + var expr_4 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.User).GetProperty("FirstName")); // FirstName + var expr_0 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_1, expr_4); + return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); + } + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_ManualBackingField.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_ManualBackingField.verified.txt new file mode 100644 index 0000000..c2c2905 --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_ManualBackingField.verified.txt @@ -0,0 +1,23 @@ +// +#nullable disable + +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_User + { + // [Expressive(Projectable = true)] + // public string FullName { get => _fullName ?? (LastName + ", " + FirstName); init => _fullName = value; } + static global::System.Linq.Expressions.Expression> FullName_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.User), "@this"); + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.User).GetProperty("LastName")); // LastName + var expr_3 = global::System.Linq.Expressions.Expression.Constant(", ", typeof(string)); // ", " + var expr_1 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_2, expr_3); + var expr_4 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.User).GetProperty("FirstName")); // FirstName + var expr_0 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_1, expr_4); + return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); + } + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.cs new file mode 100644 index 0000000..0ae04aa --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.cs @@ -0,0 +1,370 @@ +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using VerifyMSTest; +using ExpressiveSharp.Generator.Tests.Infrastructure; + +namespace ExpressiveSharp.Generator.Tests.ExpressiveGenerator; + +/// +/// Tests for [Expressive(Projectable = true)] — a variant of [Expressive] that operates on a +/// writable auto-property using the C# 14 field keyword (or a manually declared private +/// nullable backing field). The formula is the right operand of the ?? coalesce in the +/// get accessor. +/// +[TestClass] +public class ProjectableTests : GeneratorTestBase +{ + // ── Happy paths ───────────────────────────────────────────────────────── + + [TestMethod] + public Task SimpleProjectableProperty_FieldKeyword() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class User { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(1, result.GeneratedTrees.Length); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [TestMethod] + public Task SimpleProjectableProperty_ManualBackingField() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class User { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + private string? _fullName; + + [Expressive(Projectable = true)] + public string FullName + { + get => _fullName ?? (LastName + ", " + FirstName); + init => _fullName = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(1, result.GeneratedTrees.Length); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [TestMethod] + public Task ProjectableWithSetAccessor() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class User { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName + { + get => field ?? (LastName + ", " + FirstName); + set => field = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(1, result.GeneratedTrees.Length); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [TestMethod] + public void ProjectableRegistryKeyIsPropertyGetter() + { + // Load-bearing correctness check: the ExpressionRegistry must key the lambda against the + // property's getter handle (typeof(User).GetProperty("FullName")?.GetMethod), NOT the + // backing field's name. If the registry were keyed off the backing field, the runtime + // ExpressiveReplacer.VisitMember lookup would silently never fire. + var compilation = CreateCompilation( + """ + namespace Foo { + class User { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.IsNotNull(result.RegistryTree, "Registry should be generated"); + + var registryText = result.RegistryTree!.GetText().ToString(); + StringAssert.Contains(registryText, "GetProperty(\"FullName\"", + "Registry must key the lambda on the property's name (FullName), not the backing field's name"); + Assert.IsFalse(registryText.Contains("k__BackingField"), + "Registry must NOT reference any compiler-generated backing field name"); + Assert.IsFalse(registryText.Contains("_fullName") || registryText.Contains("_FullName"), + "Registry must NOT reference any manually-declared backing field name"); + } + + // ── Diagnostic Tests ──────────────────────────────────────────────────── + + [TestMethod] + public void MissingWritableAccessor_EXP0021() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class User { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName => field ?? (LastName + ", " + FirstName); + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0021")); + } + + [TestMethod] + public void NonCoalesceGetBody_EXP0022() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class User { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName + { + get => LastName + ", " + FirstName; + init => field = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0022")); + } + + [TestMethod] + public void SetterDoesNotStoreValue_EXP0023() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class User { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value?.Trim() ?? ""; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0023")); + } + + [TestMethod] + public void NullablePropertyType_EXP0024() + { + var compilation = CreateCompilation( + """ + #nullable enable + namespace Foo { + class User { + public string? FirstName { get; set; } + public string? LastName { get; set; } + + [Expressive(Projectable = true)] + public string? FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0024")); + } + + [TestMethod] + public void ManualBackingFieldWrongType_EXP0025() + { + // Backing field is `int?` but property is `string` — type mismatch. + var compilation = CreateCompilation( + """ + namespace Foo { + class User { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + private int? _wrong; + + [Expressive(Projectable = true)] + public string FullName + { + #pragma warning disable CS8603 + get => (_wrong.HasValue ? _wrong.ToString() : null) ?? (LastName + ", " + FirstName); + #pragma warning restore CS8603 + init => field = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + // The top-level ?? has a method-call left side, not a field reference. Should be EXP0022. + Assert.IsTrue( + result.Diagnostics.Any(d => d.Id == "EXP0022" || d.Id == "EXP0025"), + "Expected either EXP0022 (pattern mismatch) or EXP0025 (backing field type mismatch)"); + } + + [TestMethod] + public void StaticBackingField_EXP0022() + { + // A static backing field would share materialized state across all instances. + // It must be rejected so per-entity semantics are preserved. + var compilation = CreateCompilation( + """ + namespace Foo { + class User { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + private static string? _shared; + + [Expressive(Projectable = true)] + public string FullName + { + get => _shared ?? (LastName + ", " + FirstName); + init => _shared = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.IsTrue( + result.Diagnostics.Any(d => d.Id == "EXP0022"), + "Static backing fields must be rejected with EXP0022 (pattern mismatch)"); + } + + [TestMethod] + public void RequiredModifier_EXP0026() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class User { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public required string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0026")); + } + + [TestMethod] + public void InterfaceProperty_EXP0028() + { + var compilation = CreateCompilation( + """ + namespace Foo { + interface IUser { + string FirstName { get; } + string LastName { get; } + + [Expressive(Projectable = true)] + string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0028")); + } + + [TestMethod] + public void OverrideProperty_EXP0029() + { + var compilation = CreateCompilation( + """ + namespace Foo { + class UserBase { + public virtual string FullName { get; init; } = ""; + } + class User : UserBase { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public override string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0029")); + } +} diff --git a/tests/ExpressiveSharp.IntegrationTests/Tests/ProjectableExpressiveTests.cs b/tests/ExpressiveSharp.IntegrationTests/Tests/ProjectableExpressiveTests.cs new file mode 100644 index 0000000..caefacc --- /dev/null +++ b/tests/ExpressiveSharp.IntegrationTests/Tests/ProjectableExpressiveTests.cs @@ -0,0 +1,141 @@ +using System.Linq.Expressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.IntegrationTests.Tests; + +/// +/// Provider-agnostic tests for [Expressive(Projectable = true)]. Verifies the dual-direction +/// runtime behavior: in-memory reads evaluate the formula (because the backing field is null), +/// while values assigned through the init accessor are stored and returned verbatim. +/// +[TestClass] +public class ProjectableExpressiveTests +{ + // ── In-memory runtime behavior ────────────────────────────────────────── + + [TestMethod] + public void InMemoryConstruction_ReadsComputeFromFormula() + { + // Cognitive-trap regression guard: if we ever regressed back to the partial-property + // design, this would return the default (empty string) instead of the formula. + var entity = new ProjectableEntity { Name = "Ada", Email = "ada@example.com" }; + + Assert.AreEqual("Ada ", entity.DisplayLabel); + } + + [TestMethod] + public void InMemoryMutation_FormulaReflectsChanges() + { + var entity = new ProjectableEntity { Name = "Ada", Email = "ada@example.com" }; + var firstRead = entity.DisplayLabel; + entity.Name = "Augusta"; + var secondRead = entity.DisplayLabel; + + Assert.AreEqual("Ada ", firstRead); + Assert.AreEqual("Augusta ", secondRead, + "Mutating dependencies before materialization must propagate to the formula"); + } + + [TestMethod] + public void InitAccessor_StoredValueWins() + { + // When the property is assigned via init (as EF or HC does after materialization from SQL), + // the stored value should take precedence over the formula on subsequent reads. + var entity = new ProjectableEntity + { + Name = "Ada", + Email = "ada@example.com", + DisplayLabel = "Custom Label", + }; + + Assert.AreEqual("Custom Label", entity.DisplayLabel, + "Once materialized via init, the stored value must win over the formula"); + } + + [TestMethod] + public void InitAccessor_StoredValueSurvivesDependencyMutation() + { + var entity = new ProjectableEntity + { + Name = "Ada", + Email = "ada@example.com", + DisplayLabel = "Frozen Label", + }; + + entity.Name = "Augusta"; + + Assert.AreEqual("Frozen Label", entity.DisplayLabel, + "After materialization the stored field wins; mutating dependencies is a no-op"); + } + + [TestMethod] + public void NullDependencies_FormulaUsesFallbacks() + { + var entity = new ProjectableEntity { Name = null, Email = null }; + + Assert.AreEqual("(unnamed) ", entity.DisplayLabel); + } + + // ── Expression-tree expansion ────────────────────────────────────────── + + [TestMethod] + public void ExpandExpressives_Select_RewritesProjectableToFormula() + { + var source = new List + { + new() { Name = "Ada", Email = "ada@example.com" }, + new() { Name = "Alan", Email = "alan@example.com" }, + }.AsQueryable(); + + Expression> labelExpr = c => c.DisplayLabel; + var expanded = (Expression>)labelExpr.ExpandExpressives(); + + // After expansion, the body is the formula — no reference to c.DisplayLabel remains. + var labels = source.Select(expanded.Compile()).ToList(); + + Assert.AreEqual(2, labels.Count); + Assert.AreEqual("Ada ", labels[0]); + Assert.AreEqual("Alan ", labels[1]); + } + + [TestMethod] + public void ExpandExpressives_MemberInit_RewritesRhsOfProjection() + { + // Projection middleware pattern: `new T { DisplayLabel = src.DisplayLabel }`. + // The RHS references a Projectable member and must be rewritten. + var source = new List + { + new() { Name = "Ada", Email = "ada@example.com" }, + }.AsQueryable(); + + Expression> projectExpr = c => new ProjectableEntity + { + Name = c.Name, + Email = c.Email, + DisplayLabel = c.DisplayLabel, + }; + var expanded = (Expression>)projectExpr.ExpandExpressives(); + var projected = source.Select(expanded.Compile()).ToList(); + + Assert.AreEqual(1, projected.Count); + // The init accessor stored the formula's result; the stored value wins on read. + Assert.AreEqual("Ada ", projected[0].DisplayLabel); + } +} + +/// +/// Test-local fixture with a Projectable property. Declared here to keep the +/// Projectable dependency out of the shared Store scenario models. +/// +public class ProjectableEntity +{ + public string? Name { get; set; } + public string? Email { get; set; } + + [Expressive(Projectable = true)] + public string DisplayLabel + { + get => field ?? ((Name ?? "(unnamed)") + " <" + (Email ?? "no-email") + ">"); + init => field = value; + } +} diff --git a/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/ProjectableMongoIgnoreTests.cs b/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/ProjectableMongoIgnoreTests.cs new file mode 100644 index 0000000..57c7e10 --- /dev/null +++ b/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/ProjectableMongoIgnoreTests.cs @@ -0,0 +1,112 @@ +using ExpressiveSharp.MongoDB.Extensions; +using ExpressiveSharp.MongoDB.Infrastructure; +using ExpressiveSharp.MongoDB.IntegrationTests.Infrastructure; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; + +namespace ExpressiveSharp.MongoDB.IntegrationTests.Tests; + +/// +/// Verifies that [Expressive(Projectable = true)] properties are unmapped from BSON +/// serialization by the ExpressiveMongoIgnoreConvention, and that the formula is +/// correctly rewritten when referenced inside LINQ queries against the MongoDB provider. +/// +[TestClass] +public class ProjectableMongoIgnoreTests +{ + private MongoClient? _client; + private string? _dbName; + private IMongoCollection _collection = null!; + + [TestInitialize] + public async Task InitMongo() + { + if (!MongoContainerFixture.IsDockerAvailable) + Assert.Inconclusive("Docker not available"); + + // IMPORTANT: The ignore convention must be registered BEFORE the first call to + // IMongoDatabase.GetCollection(), which builds and caches the BSON class map for + // T eagerly. A convention registered afterward would not apply to the cached map. + // Users who want [Expressive] properties unmapped from BSON must either call + // EnsureRegistered() before accessing collections, or go through + // ExpressiveMongoCollection/AsExpressive() *before* the first GetCollection. + ExpressiveMongoIgnoreConvention.EnsureRegistered(); + + _client = new MongoClient(MongoContainerFixture.ConnectionString); + _dbName = $"test_{Guid.NewGuid():N}"; + var database = _client.GetDatabase(_dbName); + _collection = database.GetCollection("people"); + + await _collection.InsertManyAsync( + [ + new ProjectableMongoDocument { Id = 1, FirstName = "Ada", LastName = "Lovelace" }, + new ProjectableMongoDocument { Id = 2, FirstName = "Alan", LastName = "Turing" }, + ]); + } + + [TestCleanup] + public async Task CleanupMongo() + { + if (_client is not null && _dbName is not null) + await _client.DropDatabaseAsync(_dbName); + } + + [TestMethod] + public void ProjectableProperty_IsNotInClassMap() + { + // Force-build the class map and confirm the convention unmapped FullName. + var classMap = BsonClassMap.LookupClassMap(typeof(ProjectableMongoDocument)); + var mappedNames = classMap.AllMemberMaps.Select(m => m.MemberName).ToArray(); + + CollectionAssert.Contains(mappedNames, nameof(ProjectableMongoDocument.FirstName)); + CollectionAssert.Contains(mappedNames, nameof(ProjectableMongoDocument.LastName)); + CollectionAssert.DoesNotContain(mappedNames, nameof(ProjectableMongoDocument.FullName), + "Projectable property FullName must be unmapped from the BsonClassMap"); + } + + [TestMethod] + public async Task ProjectableProperty_IsNotPersistedToBsonDocument() + { + // Query the raw BSON document to verify the Projectable property's backing field + // is not serialized. Without the ExpressiveMongoIgnoreConvention, the writable + // FullName property would be serialized to the document as a real field. + var rawCollection = _collection.Database.GetCollection("people"); + var rawDocument = await rawCollection.Find(FilterDefinition.Empty).FirstAsync(); + + Assert.IsTrue(rawDocument.Contains("FirstName"), "FirstName should be persisted"); + Assert.IsTrue(rawDocument.Contains("LastName"), "LastName should be persisted"); + Assert.IsFalse(rawDocument.Contains("FullName"), + "Projectable property FullName must NOT be persisted to the BSON document"); + } + + [TestMethod] + public async Task ProjectableProperty_RoundTrip_RetainsDependenciesOnly() + { + // Insert a document, read it back, confirm FirstName/LastName survived materialization + // and FullName (on the re-read instance) is computed from the formula since the backing + // field is null for freshly-deserialized documents. + var retrieved = await _collection.Find(d => d.Id == 1).FirstAsync(); + + Assert.AreEqual("Ada", retrieved.FirstName); + Assert.AreEqual("Lovelace", retrieved.LastName); + Assert.AreEqual("Lovelace, Ada", retrieved.FullName, + "After BSON deserialization (which skips the ignored FullName), reading FullName falls through to the formula"); + } +} + +/// Self-contained document for Projectable Mongo tests. +public class ProjectableMongoDocument +{ + public int Id { get; set; } + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } +} diff --git a/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs b/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs index ba88eb7..ee360c7 100644 --- a/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs +++ b/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs @@ -85,6 +85,74 @@ public void FindGeneratedExpression_PropertyBody_ContainsMultiply() "Expected Product.Total expression to contain a Multiply node"); } + // ── [Expressive(Projectable = true)] ──────────────────────────────────── + // + // The most load-bearing correctness point in the Projectable design: the generator must + // register the formula lambda under the property's getter MethodHandle, not under the + // backing field's. If the registry were keyed off the backing field, `ExpressiveReplacer` + // would never find a match at runtime (it looks up via `PropertyInfo.GetMethod`) and the + // rewrite would silently never fire. + + [TestMethod] + public void FindGeneratedExpression_ProjectableProperty_ResolvesByPropertyGetter() + { + var propertyInfo = typeof(ProjectableCustomer).GetProperty(nameof(ProjectableCustomer.FullName))!; + + var result = _resolver.FindGeneratedExpression(propertyInfo); + + Assert.IsNotNull(result, "Resolver must return a lambda for a Projectable property"); + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public void FindGeneratedExpression_ProjectableProperty_BodyIsFormulaOnly() + { + var propertyInfo = typeof(ProjectableCustomer).GetProperty(nameof(ProjectableCustomer.FullName))!; + + var result = _resolver.FindGeneratedExpression(propertyInfo); + + Assert.IsNotNull(result); + // The body must be the formula (string.Concat chain) — NOT the raw accessor body, + // which would have had a CoalesceExpression wrapping a field reference. + Assert.IsFalse(ContainsNodeType(result.Body, ExpressionType.Coalesce), + "Projectable expression body must be the formula only, not the wrapping '??' coalesce"); + Assert.IsTrue(ContainsMemberAccess(result.Body, nameof(ProjectableCustomer.LastName)) + && ContainsMemberAccess(result.Body, nameof(ProjectableCustomer.FirstName)), + "Projectable expression body must reference both dependencies of the formula"); + } + + [TestMethod] + public void FindGeneratedExpression_ProjectableProperty_BackingFieldIsNotInRegistry() + { + // Reflect across the compiler-synthesized backing field for FullName. Calling + // FindGeneratedExpressionViaReflection on it should return null — the registry is + // keyed on the property's getter, not on the backing field. + var backingField = typeof(ProjectableCustomer).GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(f => f.Name.Contains("FullName")); + + if (backingField is null) + { + Assert.Inconclusive("Backing field not found via reflection; skipping."); + return; + } + + var result = ExpressiveResolver.FindGeneratedExpressionViaReflection(backingField); + + Assert.IsNull(result, "Registry must NOT resolve an entry for the backing field"); + } + + private static bool ContainsMemberAccess(Expression expr, string memberName) => expr switch + { + MemberExpression m when m.Member.Name == memberName => true, + MemberExpression m => m.Expression is not null && ContainsMemberAccess(m.Expression, memberName), + BinaryExpression b => ContainsMemberAccess(b.Left, memberName) || ContainsMemberAccess(b.Right, memberName), + UnaryExpression u => ContainsMemberAccess(u.Operand, memberName), + LambdaExpression l => ContainsMemberAccess(l.Body, memberName), + MethodCallExpression mc => mc.Arguments.Any(a => ContainsMemberAccess(a, memberName)) + || (mc.Object is not null && ContainsMemberAccess(mc.Object, memberName)), + _ => false + }; + private static bool ContainsNodeType(Expression expr, ExpressionType nodeType) { if (expr.NodeType == nodeType) @@ -95,7 +163,26 @@ private static bool ContainsNodeType(Expression expr, ExpressionType nodeType) BinaryExpression b => ContainsNodeType(b.Left, nodeType) || ContainsNodeType(b.Right, nodeType), UnaryExpression u => ContainsNodeType(u.Operand, nodeType), LambdaExpression l => ContainsNodeType(l.Body, nodeType), + MethodCallExpression mc => mc.Arguments.Any(a => ContainsNodeType(a, nodeType)) + || (mc.Object is not null && ContainsNodeType(mc.Object, nodeType)), _ => false }; } } + +/// +/// Test-local fixture for Projectable resolver tests. Declared here (not in the shared +/// TestFixtures) to keep the Projectable-specific dependency contained. +/// +public class ProjectableCustomer +{ + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [Expressive(Projectable = true)] + public string FullName + { + get => field ?? (LastName + ", " + FirstName); + init => field = value; + } +}