From 71fe807175fc7100e5f185fb882f6ce3652ee8ed Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 20 Apr 2026 00:53:10 +0000 Subject: [PATCH 1/3] Wrap up replacing Projectables with ExpressiveFor with synthesized properties --- docs/.vitepress/config.mts | 1 - docs/guide/integrations/mongodb.md | 2 +- docs/guide/migration-from-projectables.md | 35 +- docs/recipes/projection-middleware.md | 68 +- docs/reference/diagnostics.md | 235 ++---- docs/reference/expressive-attribute.md | 22 - docs/reference/expressive-for.md | 69 +- docs/reference/projectable-properties.md | 227 ------ .../ExpressiveAttribute.cs | 11 - .../Mapping/ExpressiveForAttribute.cs | 9 + ...vertProjectableToTernaryCodeFixProvider.cs | 281 ------- .../Emitter/SynthesizedPropertyEmitter.cs | 104 +++ .../ExpressiveGenerator.cs | 19 + .../Infrastructure/Diagnostics.cs | 78 +- .../ExpressiveForInterpreter.cs | 187 +++++ .../ExpressiveInterpreter.BodyProcessors.cs | 192 ----- .../Interpretation/ExpressiveInterpreter.cs | 4 - .../ProjectablePatternRecognizer.cs | 672 ----------------- .../Models/ExpressiveAttributeData.cs | 7 - .../Models/ExpressiveDescriptor.cs | 6 + .../Models/ExpressiveForAttributeData.cs | 10 + .../Models/SynthesizedPropertySpec.cs | 42 ++ .../ExpressiveMongoIgnoreConvention.cs | 11 +- ...ts.cs => SynthesizedExpressiveSqlTests.cs} | 64 +- ...rojectableToTernaryCodeFixProviderTests.cs | 231 ------ ...eTypeTarget_EmitsCoalesceForm.verified.txt | 41 ++ ...ceTypeTarget_EmitsTernaryForm.verified.txt | 61 ++ ...eTypeTarget_EmitsTernaryForm.verified.txt} | 42 +- ...TypeTarget_EmitsCoalesceForm.verified.txt} | 36 +- .../ExpressiveGenerator/ExpressiveForTests.cs | 185 +++++ ...Property_Ternary_FieldKeyword.verified.txt | 42 -- ...ty_Ternary_ManualBackingField.verified.txt | 42 -- ...Property_Ternary_FieldKeyword.verified.txt | 44 -- ...ts.ProjectableWithSetAccessor.verified.txt | 23 - ...leProperty_ManualBackingField.verified.txt | 23 - .../ExpressiveGenerator/ProjectableTests.cs | 693 ------------------ ...Tests.cs => SynthesizedExpressiveTests.cs} | 91 +-- ...ests.cs => SynthesizedMongoIgnoreTests.cs} | 54 +- .../Services/ExpressiveResolverTests.cs | 55 +- 39 files changed, 1019 insertions(+), 3000 deletions(-) delete mode 100644 docs/reference/projectable-properties.md delete mode 100644 src/ExpressiveSharp.CodeFixers/ConvertProjectableToTernaryCodeFixProvider.cs create mode 100644 src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs delete mode 100644 src/ExpressiveSharp.Generator/Interpretation/ProjectablePatternRecognizer.cs create mode 100644 src/ExpressiveSharp.Generator/Models/SynthesizedPropertySpec.cs rename tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/{ProjectableExpressiveSqlTests.cs => SynthesizedExpressiveSqlTests.cs} (57%) delete mode 100644 tests/ExpressiveSharp.Generator.Tests/CodeFixers/ConvertProjectableToTernaryCodeFixProviderTests.cs create mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt create mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt rename tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/{ProjectableTests.NonNullableValueTypeProperty_Coalesce_ManualNullableBackingField.verified.txt => ExpressiveForTests.Synthesize_NullableValueTypeTarget_EmitsTernaryForm.verified.txt} (70%) rename tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/{ProjectableTests.SimpleProjectableProperty_FieldKeyword.verified.txt => ExpressiveForTests.Synthesize_ReferenceTypeTarget_EmitsCoalesceForm.verified.txt} (59%) delete mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Ternary_FieldKeyword.verified.txt delete mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Ternary_ManualBackingField.verified.txt delete mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NullableValueTypeProperty_Ternary_FieldKeyword.verified.txt delete mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.ProjectableWithSetAccessor.verified.txt delete mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_ManualBackingField.verified.txt delete mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.cs rename tests/ExpressiveSharp.IntegrationTests/Tests/{ProjectableExpressiveTests.cs => SynthesizedExpressiveTests.cs} (56%) rename tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/{ProjectableMongoIgnoreTests.cs => SynthesizedMongoIgnoreTests.cs} (55%) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index d2f344c0..04321129 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -57,7 +57,6 @@ 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' }, diff --git a/docs/guide/integrations/mongodb.md b/docs/guide/integrations/mongodb.md index 5eed76c2..0f830ffb 100644 --- a/docs/guide/integrations/mongodb.md +++ b/docs/guide/integrations/mongodb.md @@ -45,7 +45,7 @@ No custom MQL is emitted — MongoDB's own translator does all the heavy lifting ## `[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. +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 [synthesized properties](../../reference/expressive-for#synthesizing-a-property-with-synthesize-true), 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: diff --git a/docs/guide/migration-from-projectables.md b/docs/guide/migration-from-projectables.md index cc6c4959..5480e0ee 100644 --- a/docs/guide/migration-from-projectables.md +++ b/docs/guide/migration-from-projectables.md @@ -122,7 +122,7 @@ Both the old `Ignore` and `Rewrite` behaviors converge to the same result in Exp | Old Property | Migration | |---|---| -| `UseMemberBody = "SomeMethod"` | Replace with `[Expressive(Projectable = true)]` or `[ExpressiveFor]`. See [Migrating UseMemberBody](#migrating-usememberbody) below. | +| `UseMemberBody = "SomeMethod"` | Replace with `[ExpressiveFor(..., Synthesize = true)]` or plain `[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. | @@ -131,37 +131,36 @@ Both the old `Ignore` and `Rewrite` behaviors converge to the same result in Exp 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 offers **two replacement options**, depending on your scenario: +ExpressiveSharp offers **two replacement shapes**, 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). +- **`[ExpressiveFor(..., Synthesize = true)]`** -- the closest analogue: you write only the formula; the generator synthesizes the settable target property on a `partial` class. The property participates in projection middleware because it has an `init` accessor. Best fit when you want a dedicated property backed purely by an expression. +- **Plain `[ExpressiveFor]`** -- when the target property already exists (or lives on an external type you do not own). No property is synthesized; the stub maps to an existing member. -Either is correct; pick based on ergonomic preference and whether you need the cross-type capability. +Pick based on whether you want the generator to declare the target property for you. -::: 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): +**Option A -- `[ExpressiveFor(..., Synthesize = true)]`** (formula-only, property is generated): ```csharp // Before (Projectables) [Projectable(UseMemberBody = nameof(FullNameProjection))] -public string FullName { get; init; } +public partial string FullName { get; init; } private string FullNameProjection => LastName + ", " + FirstName; -// After (ExpressiveSharp) -- formula lives on the property -[Expressive(Projectable = true)] -public string FullName +// After (ExpressiveSharp) -- partial class, stub only; FullName is generated +public partial class Customer { - get => field ?? (LastName + ", " + FirstName); - init => field = value; + [ExpressiveFor("FullName", Synthesize = true)] + private string FullNameExpression => LastName + ", " + FirstName; } ``` -See the [Projectable Properties reference](../reference/projectable-properties) and the [Projection Middleware recipe](../recipes/projection-middleware) for the complete feature. +The generator picks between a coalesce shape (non-nullable targets) and a ternary+flag shape (nullable targets) so materialized `null` stays distinguishable from "not materialized." See the [Synthesize section of the `[ExpressiveFor]` reference](../reference/expressive-for#synthesizing-a-property-with-synthesize-true) and the [Projection Middleware recipe](../recipes/projection-middleware). + +::: warning Target name must be a string literal +When `Synthesize = true`, the target property does not yet exist during the generator's pass, so `nameof(FullName)` fails to resolve. Always pass the name as a string literal: `[ExpressiveFor("FullName", Synthesize = true)]`. +::: -**Option B -- `[ExpressiveFor]`** (separate stub, also supports cross-type mapping): +**Option B -- plain `[ExpressiveFor]`** (target property already exists, or lives on an external type): **Scenario 1: Same-type member with an alternative body** diff --git a/docs/recipes/projection-middleware.md b/docs/recipes/projection-middleware.md index e41dc120..05f41a1d 100644 --- a/docs/recipes/projection-middleware.md +++ b/docs/recipes/projection-middleware.md @@ -14,11 +14,11 @@ HotChocolate inspects the `User.FullName` property, finds it is **read-only** (n 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)]` +## The fix: `[ExpressiveFor(..., Synthesize = 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: +Write the formula as a stub on a `partial` class and let the generator synthesize a settable property for you. The synthesized property is 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]`). +- **In memory**, reading the property evaluates the stub 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 @@ -39,52 +39,46 @@ public class User GraphQL response: `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }` -- wrong. SQL emitted: `SELECT 1 FROM Users` -- nothing fetched. -**After** -- `Projectable = true`. +**After** -- `Synthesize = true` on a formula stub. ```csharp -public class User +public partial 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; - } + // The generator emits a settable FullName property whose getter falls through + // to this stub when no value has been materialized yet. + [ExpressiveFor("FullName", Synthesize = true)] + private string FullNameExpression => LastName + ", " + FirstName; } ``` 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. +No HC glue code is required beyond the normal `.UseExpressives()` on the DbContext options. The convention auto-ignores the synthesized property in EF's model (so no `FullName` column is created), and the projection rewrite happens automatically when the query compiler intercepts. + +::: warning Target name must be a string literal +`nameof(FullName)` won't resolve here because `FullName` doesn't exist until the generator emits it. Pass the name as a string literal: `[ExpressiveFor("FullName", Synthesize = true)]`. +::: ## Full HotChocolate example ```csharp // Entity -public class User +public partial 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; - } + [ExpressiveFor("FullName", Synthesize = true)] + private string FullNameExpression => LastName + ", " + FirstName; + + [ExpressiveFor("DisplayLabel", Synthesize = true)] + private string DisplayLabelExpression => FullName + " <" + Email + ">"; } // DbContext @@ -118,11 +112,11 @@ SELECT (u.LastName || ', ' || u.FirstName) || ' <' || u.Email || '>' AS "Display FROM Users u ``` -Notice how `DisplayLabel` composes with `FullName` (which is itself Projectable) -- the transitive rewrite is handled automatically by the expression resolver. +Notice how `DisplayLabel` composes with `FullName` (which is itself synthesized) -- 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: +AutoMapper's `ProjectTo()` emits the same `new T { ... }` pattern as HotChocolate, so synthesized members work the same way: ```csharp var config = new MapperConfiguration(cfg => @@ -140,30 +134,26 @@ var users = await db.Users // FROM Users u ``` -## When to use `[ExpressiveFor]` instead +## When the target property already exists -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: +If you already have a settable auto-property (e.g. because it is used for DTO shape or deserialization), use the plain `[ExpressiveFor]` form without `Synthesize`: ```csharp public class User { public string FirstName { get; set; } = ""; public string LastName { get; set; } = ""; - public string FullName { get; set; } = ""; // plain auto-property -} + public string FullName { get; set; } = ""; // existing auto-property -internal static class UserMappings -{ - [ExpressiveFor(typeof(User), nameof(User.FullName))] - private static string FullName(User u) => u.LastName + ", " + u.FirstName; + [ExpressiveFor(nameof(FullName))] + private string FullNameExpression => LastName + ", " + 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. +The two forms produce the same SQL behaviour; the difference is who declares the target property. Use `Synthesize = true` when the property exists only to support projection middleware; use plain `[ExpressiveFor]` when the property has its own reason to exist. ## 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 +- [`[ExpressiveFor]` Mapping](../reference/expressive-for#synthesizing-a-property-with-synthesize-true) -- full `Synthesize = true` reference - [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 431b0585..50d741c7 100644 --- a/docs/reference/diagnostics.md +++ b/docs/reference/diagnostics.md @@ -32,14 +32,9 @@ 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 | -- | +| [EXP0031](#exp0031) | Error | `[ExpressiveFor(Synthesize = true)]` target name is already defined | -- | +| [EXP0032](#exp0032) | Error | `[ExpressiveFor(Synthesize = true)]` requires a partial containing type | -- | +| [EXP0033](#exp0033) | Error | `[ExpressiveFor(Synthesize = true)]` requires the single-argument form | -- | | [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) | @@ -478,249 +473,111 @@ Duplicate [ExpressiveFor] mapping for member '{0}' on type '{1}'; only one stub --- -## Projectable Diagnostics (EXP0021--EXP0029) +## Synthesize Diagnostics (EXP0031--EXP0033) -These diagnostics apply only to properties decorated with `[Expressive(Projectable = true)]`. See [Projectable Properties](./projectable-properties) for the full feature reference. +These diagnostics apply to `[ExpressiveFor(..., Synthesize = true)]` stubs, which ask the generator to emit a new property on the stub's containing type. See [`[ExpressiveFor]` Mapping](./expressive-for#synthesizing-a-property-with-synthesize-true) 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; -} -``` - ---- +::: info Replacing `[Expressive(Projectable = true)]` +`[ExpressiveFor(..., Synthesize = true)]` replaces the now-removed `[Expressive(Projectable = true)]`. Diagnostic codes `EXP0021`--`EXP0030` were retired along with that feature and are not reused. The migration recipe is in [Migration from Projectables](../guide/migration-from-projectables#migrating-usememberbody). +::: -### EXP0022 -- Projectable get accessor pattern {#exp0022} +### EXP0031 -- Synthesize target name is already defined {#exp0031} **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}. +[ExpressiveFor(..., Synthesize = true)] target name '{0}' is already defined on '{1}'. +Remove Synthesize or rename the stub. ``` -**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. +**Cause:** The name passed to `[ExpressiveFor]` already resolves to a member on the containing type. Synthesis would collide with the existing declaration, so the generator refuses to emit. -**Fix:** Rewrite the get accessor to use `??`: +**Fix:** Either remove `Synthesize = true` (and instead use the plain `[ExpressiveFor]` form targeting the existing member), or pick a different target name. ```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} +// Error: Amount already exists on the class +public decimal Amount { get; set; } -**Severity:** Error -**Category:** Design +[ExpressiveFor("Amount", Synthesize = true)] +private decimal AmountExpression => TotalAmount - Discount; -**Message:** +// Fixed: drop Synthesize — target is already declared +[ExpressiveFor("Amount")] +private decimal AmountExpression => TotalAmount - Discount; ``` -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} +### EXP0032 -- Synthesize requires a partial containing type {#exp0032} **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'. +[ExpressiveFor(..., Synthesize = true)] requires the containing type '{0}' to be declared +'partial' so the synthesized property can be emitted into it ``` -**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. +**Cause:** Source generators can only add members to types that are declared `partial`. Synthesized properties are emitted as a partial declaration alongside the user's source, and the compiler merges the two. -**Fix:** Make the property non-nullable: +**Fix:** Add the `partial` modifier to the containing type: ```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 +public class Account { - get => _wrong.ToString() ?? ...; - init => field = value; + [ExpressiveFor("Amount", Synthesize = true)] + private decimal AmountExpression => TotalAmount - Discount; } -// Fixed: use 'field' keyword (preferred) or a matching nullable backing field -[Expressive(Projectable = true)] -public string FullName +// Fixed +public partial class Account { - get => field ?? (LastName + ", " + FirstName); - init => field = value; + [ExpressiveFor("Amount", Synthesize = true)] + private decimal AmountExpression => TotalAmount - Discount; } ``` --- -### EXP0026 -- Projectable incompatible with `required` {#exp0026} +### EXP0033 -- Synthesize requires the single-argument form {#exp0033} **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 +Synthesize = true only applies to same-type stubs; use the single-argument form +[ExpressiveFor(nameof(Member), Synthesize = true)] instead of the two-argument typeof form ``` -**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. +**Cause:** `Synthesize = true` is only meaningful when the stub is on the same type as the property it is synthesizing. The two-argument `[ExpressiveFor(typeof(Other), ...)]` form targets an external type — there is no coherent way to synthesize a member onto it from the stub's location. -**Fix:** Remove the `required` modifier: +**Fix:** Drop the `typeof(...)` argument and put the stub directly on the target type (making it `partial` if needed): ```csharp // Error -[Expressive(Projectable = true)] -public required string FullName +partial class Other {} + +partial class Account { - get => field ?? (LastName + ", " + FirstName); - init => field = value; + [ExpressiveFor(typeof(Other), "FullName", Synthesize = true)] + private string FullNameExpression => ...; } -// Fixed -[Expressive(Projectable = true)] -public string FullName +// Fixed — stub moves to Other, single-argument form +partial class Other { - get => field ?? (LastName + ", " + FirstName); - init => field = value; + [ExpressiveFor("FullName", Synthesize = true)] + private string FullNameExpression => ...; } ``` --- -### 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 6a7fb7f1..19fdd6ce 100644 --- a/docs/reference/expressive-attribute.md +++ b/docs/reference/expressive-attribute.md @@ -56,28 +56,6 @@ 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/expressive-for.md b/docs/reference/expressive-for.md index 2c5fa277..8ce44fc2 100644 --- a/docs/reference/expressive-for.md +++ b/docs/reference/expressive-for.md @@ -133,14 +133,78 @@ public static class MyDtoBuilder } ``` +## Synthesizing a property with `Synthesize = true` + +When you want the target property to exist **purely as an expressive-backed projection** -- no manual declaration, no backing storage to wire up, but still settable so projection middleware (EF Core materialization, HotChocolate's `ProjectTo`, AutoMapper, Mapperly) can populate it from query results -- set `Synthesize = true` on the single-argument form. The generator declares the property for you inside a partial class and wires its accessors to the stub: + +```csharp +public partial class Account +{ + public decimal? TotalAmount { get; set; } + public decimal? Discount { get; set; } + + // The target property Amount is NOT declared here — the generator emits it. + [ExpressiveFor("Amount", Synthesize = true)] + private decimal? AmountExpression => + TotalAmount != null && Discount != null + ? TotalAmount.Value - Discount.Value + : null; +} +``` + +The generator produces the following partial declaration, which the C# compiler merges with your class: + +```csharp +// +namespace YourNamespace +{ + partial class Account + { + private decimal? _amount; + private bool _amountHasValue; + public decimal? Amount + { + get => _amountHasValue ? _amount : AmountExpression; + init + { + _amountHasValue = true; + _amount = value; + } + } + } +} +``` + +### Shape selection + +The generator picks between two shapes based on the target type's nullability: + +- **Coalesce shape** (non-nullable targets -- `string`, `decimal`, `int`, ...): `get => _field ?? stub;`, with a nullable backing field. Minimal overhead; `null` unambiguously means "not yet materialized." +- **Ternary + flag shape** (nullable targets -- `string?`, `decimal?`, `int?`, ...): `get => _hasValue ? _field : stub;`, with a separate `bool` flag. Required because stored `null` is a legitimate value that must be distinguished from "not materialized." + +### Requirements + +- Use the single-argument form `[ExpressiveFor("Name", Synthesize = true)]`. The two-argument `typeof(...)` form is rejected with **EXP0033**; `Synthesize` always targets the stub's containing type. +- Supply the target name as a **string literal**, not `nameof(Name)` -- because `Name` is declared by the generator, `nameof(Name)` fails to resolve during the initial compilation pass. +- The containing type must be declared `partial` (**EXP0032**) so the generator can add the property declaration. +- The target name must not already exist on the containing type (**EXP0031**) -- that would be an ambiguous conflict with a user-written member. +- The stub must be a parameterless instance member (property or method) on the same type. + +### How it interacts with providers + +Because the stub flows through the normal `[ExpressiveFor]` pipeline, the registry is keyed on the **synthesized property's getter**. At query time, `ExpressiveReplacer` rewrites references to `Amount` with the stub's formula -- exactly as if you had written `[ExpressiveFor(nameof(Amount))]` against a manually-declared `Amount` property. The difference is purely who writes the property declaration. + +For EF Core and Mongo, the synthesized property is automatically excluded from mapping by `ExpressivePropertiesNotMappedConvention` and `ExpressiveMongoIgnoreConvention` -- no `[NotMapped]` attribute needed. + ## Properties -Both `[ExpressiveFor]` and `[ExpressiveForConstructor]` support the same optional properties as `[Expressive]`: +Both `[ExpressiveFor]` and `[ExpressiveForConstructor]` support the same optional properties as `[Expressive]`, plus `Synthesize`: | Property | Type | Default | Description | |----------|------|---------|-------------| | `AllowBlockBody` | `bool` | `false` | Enables block-bodied stubs (`if`/`else`, local variables, etc.) | | `Transformers` | `Type[]?` | `null` | Per-mapping transformers applied when expanding the mapped member | +| `Synthesize` | `bool` | `false` | Generates the target property on the stub's containing type (see [Synthesizing a property](#synthesizing-a-property-with-synthesize-true)). `[ExpressiveFor]` only. | ::: expressive-sample db.Orders.Where(o => System.Math.Clamp(o.Items.Count(), 0, 100) > 5) @@ -168,6 +232,9 @@ The following diagnostics are specific to `[ExpressiveFor]` and `[ExpressiveForC | [EXP0017](./diagnostics#exp0017) | Error | Return type of the stub does not match the target member's return type | | [EXP0019](./diagnostics#exp0019) | Error | The target member already has `[Expressive]` -- remove one of the two attributes | | [EXP0020](./diagnostics#exp0020) | Error | Duplicate mapping -- only one stub per target member is allowed | +| [EXP0031](./diagnostics#exp0031) | Error | `Synthesize = true` target name is already defined on the containing type | +| [EXP0032](./diagnostics#exp0032) | Error | `Synthesize = true` requires the containing type to be declared `partial` | +| [EXP0033](./diagnostics#exp0033) | Error | `Synthesize = true` must use the single-argument form, not `typeof(...)` | ::: warning If a member already has `[Expressive]`, adding `[ExpressiveFor]` targeting it is a compile error (EXP0019). `[ExpressiveFor]` is only for members that do not have `[Expressive]`. diff --git a/docs/reference/projectable-properties.md b/docs/reference/projectable-properties.md deleted file mode 100644 index 45e512a2..00000000 --- a/docs/reference/projectable-properties.md +++ /dev/null @@ -1,227 +0,0 @@ -# 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 alternative -- the formula lives in a separate stub (static or co-located instance method) 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 10f5118b..e5ed7703 100644 --- a/src/ExpressiveSharp.Abstractions/ExpressiveAttribute.cs +++ b/src/ExpressiveSharp.Abstractions/ExpressiveAttribute.cs @@ -27,15 +27,4 @@ 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.Abstractions/Mapping/ExpressiveForAttribute.cs b/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs index b58ee5f8..7825ead6 100644 --- a/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs +++ b/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs @@ -39,6 +39,15 @@ public sealed class ExpressiveForAttribute : Attribute /// public Type[]? Transformers { get; set; } + /// + /// When true, the generator synthesizes the target property on the stub's containing + /// type (which must be partial). The stub becomes the formula source and the synthesized + /// property caches materialized values (from query results) using a ?? formula coalesce + /// for non-nullable types or a hasValue ? stored : formula ternary for nullable types. + /// Only valid with the single-argument form [ExpressiveFor(nameof(Member), Synthesize = true)]. + /// + public bool Synthesize { get; set; } + public ExpressiveForAttribute(Type targetType, string memberName) { TargetType = targetType; diff --git a/src/ExpressiveSharp.CodeFixers/ConvertProjectableToTernaryCodeFixProvider.cs b/src/ExpressiveSharp.CodeFixers/ConvertProjectableToTernaryCodeFixProvider.cs deleted file mode 100644 index f4cb6fb5..00000000 --- a/src/ExpressiveSharp.CodeFixers/ConvertProjectableToTernaryCodeFixProvider.cs +++ /dev/null @@ -1,281 +0,0 @@ -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Composition; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Formatting; - -namespace ExpressiveSharp.CodeFixers; - -/// -/// Code fix for EXP0024: the ?? projectable pattern cannot be applied to a nullable -/// property type. Rewrites the getter and setter to the ternary _hasFoo ? field : formula -/// pattern and inserts a private bool _hasFoo flag field into the containing type. -/// -[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ConvertProjectableToTernaryCodeFixProvider))] -[Shared] -public sealed class ConvertProjectableToTernaryCodeFixProvider : CodeFixProvider -{ - public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("EXP0024"); - - public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; - - public override async Task RegisterCodeFixesAsync(CodeFixContext context) - { - var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); - if (root is null) - return; - - foreach (var diagnostic in context.Diagnostics) - { - var node = root.FindNode(diagnostic.Location.SourceSpan); - var property = node.FirstAncestorOrSelf(); - if (property is null) - continue; - - if (!TryExtractCoalesceParts(property, out _, out _, out _, out _)) - continue; - - context.RegisterCodeFix( - CodeAction.Create( - title: "Convert to ternary 'has-value flag' pattern", - createChangedDocument: ct => ApplyFixAsync(context.Document, property, ct), - equivalenceKey: "EXP0024_ConvertToTernary"), - diagnostic); - } - } - - private static async Task ApplyFixAsync( - Document document, - PropertyDeclarationSyntax property, - CancellationToken cancellationToken) - { - var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - if (root is null) - return document; - - if (!TryExtractCoalesceParts(property, out var getAccessor, out var setAccessor, out var backingFieldRef, out var formula)) - return document; - - var containingType = property.FirstAncestorOrSelf(); - if (containingType is null) - return document; - - var flagName = await ResolveUniqueFlagNameAsync(document, containingType, property, cancellationToken).ConfigureAwait(false); - - var flagIdent = SyntaxFactory.IdentifierName(flagName); - - // Build the new get accessor: `flagName ? : ()`. - var ternary = SyntaxFactory.ConditionalExpression( - flagIdent, - backingFieldRef!, - SyntaxFactory.ParenthesizedExpression(formula!)); - - var newGetAccessor = getAccessor! - .WithBody(null) - .WithExpressionBody(SyntaxFactory.ArrowExpressionClause(ternary)) - .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); - - // Build the new set/init accessor body: `{ _hasFoo = true; = value; }`. - var flagAssignment = SyntaxFactory.ExpressionStatement( - SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - flagIdent, - SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression))); - - var valueAssignment = SyntaxFactory.ExpressionStatement( - SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - backingFieldRef!, - SyntaxFactory.IdentifierName("value"))); - - var newSetAccessor = setAccessor! - .WithExpressionBody(null) - .WithSemicolonToken(default) - .WithBody(SyntaxFactory.Block(flagAssignment, valueAssignment)); - - // Replace accessors inside the property's accessor list. - var accessorList = property.AccessorList!; - var newAccessorList = accessorList.ReplaceNodes( - new AccessorDeclarationSyntax[] { getAccessor, setAccessor }, - (original, _) => ReferenceEquals(original, getAccessor) ? (SyntaxNode)newGetAccessor : newSetAccessor); - - var newProperty = property.WithAccessorList((AccessorListSyntax)newAccessorList); - - // Build the flag field declaration: `private bool _hasFoo;`. - var flagField = SyntaxFactory.FieldDeclaration( - SyntaxFactory.VariableDeclaration( - SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.BoolKeyword)), - SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator(flagName)))) - .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PrivateKeyword))) - .WithAdditionalAnnotations(Formatter.Annotation); - - // Insert the flag field immediately before the property declaration so related state - // stays visually grouped. - var members = containingType.Members; - var propertyIndex = members.IndexOf(property); - var newMembers = members - .RemoveAt(propertyIndex) - .Insert(propertyIndex, newProperty) - .Insert(propertyIndex, flagField); - - var newContainingType = containingType.WithMembers(newMembers); - - var newRoot = root.ReplaceNode(containingType, newContainingType); - return document.WithSyntaxRoot(newRoot); - } - - /// - /// Picks a flag-field name that doesn't collide with an existing member on the containing - /// type (including members declared in other partial declarations). The happy path - /// returns _has<PropertyName>; on collision a numeric suffix is appended. - /// - private static async Task ResolveUniqueFlagNameAsync( - Document document, - TypeDeclarationSyntax containingType, - PropertyDeclarationSyntax property, - CancellationToken cancellationToken) - { - var baseName = $"_has{property.Identifier.Text}"; - - var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); - var typeSymbol = semanticModel?.GetDeclaredSymbol(containingType, cancellationToken); - if (typeSymbol is null) - return baseName; - - var existing = new HashSet(typeSymbol.GetMembers().Select(m => m.Name), System.StringComparer.Ordinal); - if (!existing.Contains(baseName)) - return baseName; - - var suffix = 1; - string candidate; - do - { - candidate = $"{baseName}{suffix++}"; - } - while (existing.Contains(candidate)); - return candidate; - } - - /// - /// Inspects the property syntax for the Coalesce-shape Projectable pattern and extracts the - /// get accessor, set/init accessor, the backing field reference (the left operand of - /// ??), and the formula (the right operand). Returns false if the pattern - /// doesn't match — in which case the fix is not offered. - /// - private static bool TryExtractCoalesceParts( - PropertyDeclarationSyntax property, - out AccessorDeclarationSyntax? getAccessor, - out AccessorDeclarationSyntax? setAccessor, - out ExpressionSyntax? backingFieldRef, - out ExpressionSyntax? formula) - { - getAccessor = null; - setAccessor = null; - backingFieldRef = null; - formula = null; - - if (property.AccessorList is null) - return false; - - foreach (var accessor in property.AccessorList.Accessors) - { - if (accessor.IsKind(SyntaxKind.GetAccessorDeclaration)) - getAccessor = accessor; - else if (accessor.IsKind(SyntaxKind.InitAccessorDeclaration) || accessor.IsKind(SyntaxKind.SetAccessorDeclaration)) - setAccessor = accessor; - } - - if (getAccessor is null || setAccessor is null) - return false; - - if (!TryGetSingleExpression(getAccessor, out var getBody)) - return false; - - // getBody must be a coalesce `left ?? right`. - if (getBody is not BinaryExpressionSyntax { RawKind: (int)SyntaxKind.CoalesceExpression } coalesce) - return false; - - // Left must be either the C# 14 `field` keyword (parsed as FieldExpressionSyntax) or a - // manually-declared backing field identifier. The generator's recognizer already - // validated the symbol, so we only need to match the syntax shape here. - if (!IsBackingFieldReference(coalesce.Left)) - return false; - - if (!TryGetSingleAssignmentValue(setAccessor, out var setAssignment)) - return false; - - if (!IsBackingFieldReference(setAssignment.Left) - || !BackingFieldReferencesMatch(coalesce.Left, setAssignment.Left)) - return false; - - backingFieldRef = coalesce.Left; - formula = UnwrapParentheses(coalesce.Right); - return true; - } - - private static bool IsBackingFieldReference(ExpressionSyntax expression) => - expression is IdentifierNameSyntax - || expression.IsKind(SyntaxKind.FieldExpression); - - private static bool BackingFieldReferencesMatch(ExpressionSyntax a, ExpressionSyntax b) - { - // Both `field` keyword references are always equivalent; for identifiers compare text. - if (a.IsKind(SyntaxKind.FieldExpression) && b.IsKind(SyntaxKind.FieldExpression)) - return true; - if (a is IdentifierNameSyntax ai && b is IdentifierNameSyntax bi) - return ai.Identifier.Text == bi.Identifier.Text; - return false; - } - - private static bool TryGetSingleExpression(AccessorDeclarationSyntax accessor, out ExpressionSyntax expression) - { - if (accessor.ExpressionBody is not null) - { - expression = accessor.ExpressionBody.Expression; - return true; - } - - if (accessor.Body is { Statements: { Count: 1 } stmts } - && stmts[0] is ReturnStatementSyntax { Expression: { } ret }) - { - expression = ret; - return true; - } - - expression = null!; - return false; - } - - private static bool TryGetSingleAssignmentValue(AccessorDeclarationSyntax accessor, out AssignmentExpressionSyntax assignment) - { - if (accessor.ExpressionBody is { Expression: AssignmentExpressionSyntax a1 }) - { - assignment = a1; - return true; - } - - if (accessor.Body is { Statements: { Count: 1 } stmts } - && stmts[0] is ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax a2 }) - { - assignment = a2; - return true; - } - - assignment = null!; - return false; - } - - private static ExpressionSyntax UnwrapParentheses(ExpressionSyntax expression) - { - while (expression is ParenthesizedExpressionSyntax paren) - expression = paren.Expression; - return expression; - } -} diff --git a/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs b/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs new file mode 100644 index 00000000..50b7fe1d --- /dev/null +++ b/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs @@ -0,0 +1,104 @@ +using System.Text; +using ExpressiveSharp.Generator.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace ExpressiveSharp.Generator.Emitter; + +/// +/// Emits the user-visible partial-class declaration with the synthesized property when +/// [ExpressiveFor(..., Synthesize = true)] is applied. The property caches materialized +/// values (set by projection middleware / EF Core / HotChocolate) and otherwise delegates to +/// the stub for the formula. +/// +static internal class SynthesizedPropertyEmitter +{ + public static void Emit(SynthesizedPropertySpec spec, string generatedFileName, SourceProductionContext context) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var hasNamespace = spec.ContainingTypeNamespace is not null; + if (hasNamespace) + { + sb.AppendLine($"namespace {spec.ContainingTypeNamespace}"); + sb.AppendLine("{"); + } + + var baseIndent = hasNamespace ? " " : ""; + var nestingDepth = spec.ContainingTypePath.Count; + + // Emit partial-class wrappers for any enclosing (nested) types, from outermost in. + // The innermost is the type we attach the synthesized member to. + for (var i = 0; i < nestingDepth; i++) + { + var indent = baseIndent + new string(' ', i * 4); + var name = spec.ContainingTypePath[i]; + // Only the innermost knows its keyword; enclosing nests are "partial class" as a safe default. + var keyword = i == nestingDepth - 1 ? spec.ContainingTypeKeyword : "class"; + sb.AppendLine($"{indent}partial {keyword} {name}"); + sb.AppendLine($"{indent}{{"); + } + + var memberIndent = baseIndent + new string(' ', nestingDepth * 4); + var body = spec.UseTernaryShape ? BuildTernaryBody(spec, memberIndent) : BuildCoalesceBody(spec, memberIndent); + sb.Append(body); + + for (var i = nestingDepth - 1; i >= 0; i--) + { + var indent = baseIndent + new string(' ', i * 4); + sb.AppendLine($"{indent}}}"); + } + + if (hasNamespace) + { + sb.AppendLine("}"); + } + + context.AddSource(generatedFileName, SourceText.From(sb.ToString(), Encoding.UTF8)); + } + + private static string BuildCoalesceBody(SynthesizedPropertySpec spec, string indent) + { + var fieldName = MakeBackingFieldName(spec.PropertyName); + var stubCall = spec.StubIsMethod ? $"{spec.StubMemberName}()" : spec.StubMemberName; + + var sb = new StringBuilder(); + sb.AppendLine($"{indent}private {spec.BackingFieldTypeFqn} {fieldName};"); + sb.AppendLine($"{indent}public {spec.PropertyTypeFqn} {spec.PropertyName}"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} get => {fieldName} ?? {stubCall};"); + sb.AppendLine($"{indent} init => {fieldName} = value;"); + sb.AppendLine($"{indent}}}"); + return sb.ToString(); + } + + private static string BuildTernaryBody(SynthesizedPropertySpec spec, string indent) + { + var fieldName = MakeBackingFieldName(spec.PropertyName); + var flagName = MakeHasValueFlagName(spec.PropertyName); + var stubCall = spec.StubIsMethod ? $"{spec.StubMemberName}()" : spec.StubMemberName; + + var sb = new StringBuilder(); + sb.AppendLine($"{indent}private {spec.BackingFieldTypeFqn} {fieldName};"); + sb.AppendLine($"{indent}private bool {flagName};"); + sb.AppendLine($"{indent}public {spec.PropertyTypeFqn} {spec.PropertyName}"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} get => {flagName} ? {fieldName} : {stubCall};"); + sb.AppendLine($"{indent} init"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} {flagName} = true;"); + sb.AppendLine($"{indent} {fieldName} = value;"); + sb.AppendLine($"{indent} }}"); + sb.AppendLine($"{indent}}}"); + return sb.ToString(); + } + + private static string MakeBackingFieldName(string propertyName) => + "_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); + + private static string MakeHasValueFlagName(string propertyName) => + "_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1) + "HasValue"; +} diff --git a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs index b061fffa..ef339810 100644 --- a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs +++ b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs @@ -443,6 +443,15 @@ private static void ExecuteFor( throw new InvalidOperationException("ExpressionTreeEmission must be set"); EmitExpressionTreeSource(descriptor, generatedClassName, methodSuffix, generatedFileName, stubMember, compilation, context); + + // [ExpressiveFor(..., Synthesize = true)] also emits a user-facing partial class + // declaring the synthesized property. The file goes alongside the expression-factory file + // but in the user's namespace so the C# compiler merges it with their declaration. + if (descriptor.SynthesisSpec is { } synthesisSpec) + { + var synthesizedFileName = $"{generatedClassName}.{methodSuffix}.Synthesized.g.cs"; + Emitter.SynthesizedPropertyEmitter.Emit(synthesisSpec, synthesizedFileName, context); + } } /// @@ -504,6 +513,15 @@ private static void ExecuteFor( if (memberName is null) return null; + // [ExpressiveFor(..., Synthesize = true)] — the target member doesn't exist yet; + // the registry entry is always a property keyed on the synthesized name. + if (attribute.Synthesize) + { + memberKind = ExpressionRegistryMemberType.Property; + memberLookupName = memberName; + } + else + { // Property stubs can only target properties; method stubs may target either. var isProperty = stubIsProperty || targetType.GetMembers(memberName).OfType().Any(); @@ -535,6 +553,7 @@ private static void ExecuteFor( ..targetMethod.Parameters.Select(p => p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) ]; } + } } // Build generated class name using the target type's path (matching main's new API) diff --git a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs index d51ec6d7..935e2b73 100644 --- a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs +++ b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs @@ -163,78 +163,32 @@ static internal class Diagnostics DiagnosticSeverity.Error, isEnabledByDefault: true); - // ── [Expressive(Projectable = true)] Diagnostics ──────────────────────── + // NOTE: EXP0021–EXP0030 (Projectable diagnostics) were retired when the + // [Expressive(Projectable = true)] feature was superseded by + // [ExpressiveFor(nameof(X), Synthesize = true)]. The codes are not reused. - 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 ?? ()', '=> _backingField ?? ()', or '=> _hasValueFlag ? field : ()' where _backingField is a private non-static instance field on the same type and _hasValueFlag is a private non-static non-readonly instance bool 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 (and, for the ternary form, also set the has-value flag to true). Found: {0}.", - category: "Design", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - public readonly static DiagnosticDescriptor ProjectableRequiresNonNullablePropertyType = new DiagnosticDescriptor( - id: "EXP0024", - title: "Projectable coalesce pattern requires non-nullable property type", - messageFormat: "The '??' projectable pattern cannot be applied to a property with a nullable type ('{0}') because null cannot distinguish 'not materialized' from 'materialized to null'. Use the ternary form '=> _hasValue ? field : ()' (or '=> _hasValue ? _backingField : ()' with a manual backing field) with a private bool flag instead.", - 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. + // ── [ExpressiveFor(..., Synthesize = true)] Diagnostics ───────────────── - 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", + public readonly static DiagnosticDescriptor ExpressiveForSynthesizeTargetExists = new DiagnosticDescriptor( + id: "EXP0031", + title: "[ExpressiveFor(Synthesize = true)] target name is already defined", + messageFormat: "[ExpressiveFor(..., Synthesize = true)] target name '{0}' is already defined on '{1}'. Remove Synthesize or rename the stub.", 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", + public readonly static DiagnosticDescriptor ExpressiveForSynthesizeRequiresPartial = new DiagnosticDescriptor( + id: "EXP0032", + title: "[ExpressiveFor(Synthesize = true)] requires a partial containing type", + messageFormat: "[ExpressiveFor(..., Synthesize = true)] requires the containing type '{0}' to be declared 'partial' so the synthesized property can be emitted into it", category: "Design", DiagnosticSeverity.Error, isEnabledByDefault: true); - public readonly static DiagnosticDescriptor ProjectableInconsistentGetSetBacking = new DiagnosticDescriptor( - id: "EXP0030", - title: "Projectable getter and setter reference inconsistent backing storage", - messageFormat: "The init/set accessor of a Projectable property must reference the same backing field (and has-value flag, for the ternary form) as the get accessor: {0}", + public readonly static DiagnosticDescriptor ExpressiveForSynthesizeRequiresSameType = new DiagnosticDescriptor( + id: "EXP0033", + title: "[ExpressiveFor(Synthesize = true)] requires the single-argument form", + messageFormat: "Synthesize = true only applies to same-type stubs; use the single-argument form [ExpressiveFor(nameof(Member), Synthesize = true)] instead of the two-argument typeof form", category: "Design", DiagnosticSeverity.Error, isEnabledByDefault: true); diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs index e20057ad..0b856cdc 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs @@ -37,6 +37,15 @@ static internal class ExpressiveForInterpreter INamedTypeSymbol? targetType; if (attributeData.TargetTypeMetadataName is not null) { + // EXP0033: Synthesize requires the single-arg form (same-type stub). + if (attributeData.Synthesize) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressiveForSynthesizeRequiresSameType, + stubIdentifierLocation)); + return null; + } + targetType = compilation.GetTypeByMetadataName(attributeData.TargetTypeMetadataName); if (targetType is null) { @@ -52,6 +61,14 @@ static internal class ExpressiveForInterpreter targetType = stubSymbol.ContainingType; } + // [ExpressiveFor(..., Synthesize = true)] branch — takes priority over the normal + // resolution path because the target member does not yet exist on the target type. + if (attributeData.Synthesize) + { + return ResolveSynthesize(semanticModel, stubMember, stubSymbol, attributeData, + globalOptions, context, targetType, stubIdentifierLocation); + } + // Property stubs can only target properties (no parameter list to carry method args). // Only the ExpressiveFor (MethodOrProperty) pipeline reaches this branch; the constructor // attribute is method-target-only at the AttributeUsage level. @@ -132,6 +149,176 @@ static internal class ExpressiveForInterpreter return null; } + private static ExpressiveDescriptor? ResolveSynthesize( + SemanticModel semanticModel, + MemberDeclarationSyntax stubMember, + ISymbol stubSymbol, + ExpressiveForAttributeData attributeData, + ExpressiveGlobalOptions globalOptions, + SourceProductionContext context, + INamedTypeSymbol targetType, + Location stubIdentifierLocation) + { + var memberName = attributeData.MemberName; + if (memberName is null) + return null; + + // EXP0032: the containing class must be partial so we can emit the synthesized property + // into a generated file. + if (!IsPartialType(targetType)) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressiveForSynthesizeRequiresPartial, + stubIdentifierLocation, + targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + return null; + } + + // EXP0031: a member with the target name must not already exist on the target type. + // We look at the target type's direct members (including generated ones aren't visible + // yet — partial declarations from other files ARE visible through the symbol model). + if (targetType.GetMembers(memberName).Any(m => m is IPropertySymbol or IMethodSymbol or IFieldSymbol or IEventSymbol)) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressiveForSynthesizeTargetExists, + stubIdentifierLocation, + memberName, + targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + return null; + } + + // The stub must be a parameterless same-type member (property or method) so the + // synthesized property can delegate to it with no arguments. For non-property stubs + // we still accept zero-param instance methods. + ITypeSymbol stubReturnType; + bool stubIsProperty; + switch (stubSymbol) + { + case IPropertySymbol propSym when propSym.Parameters.Length == 0 && !propSym.IsStatic: + stubReturnType = propSym.Type; + stubIsProperty = true; + break; + case IMethodSymbol methodSym when methodSym.Parameters.Length == 0 && !methodSym.IsStatic: + stubReturnType = methodSym.ReturnType; + stubIsProperty = false; + break; + default: + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressiveForMemberNotFound, + stubIdentifierLocation, + memberName, + targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + return null; + } + + // Build the descriptor from the stub body first (standard pipeline). + ExpressiveDescriptor? descriptor = stubMember switch + { + PropertyDeclarationSyntax stubProp when stubSymbol is IPropertySymbol stubPropSym => + BuildDescriptorFromPropertyStub(semanticModel, stubProp, stubPropSym, attributeData, + globalOptions, context, targetType, memberName), + MethodDeclarationSyntax stubMethod when stubSymbol is IMethodSymbol stubMethodSym => + BuildDescriptorFromStub(semanticModel, stubMethod, stubMethodSym, attributeData, + globalOptions, context, targetType, memberName, + targetParameters: System.Collections.Immutable.ImmutableArray.Empty, + isInstanceMember: true), + _ => null + }; + + if (descriptor is null) + return null; + + // Attach the synthesis spec so the generator emits the partial property. + var useTernary = IsNullablePropertyType(stubReturnType); + var propertyTypeFqn = stubReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var backingFieldTypeFqn = useTernary + ? propertyTypeFqn + : MakeNullableTypeFqn(stubReturnType); + + descriptor.SynthesisSpec = new SynthesizedPropertySpec + { + PropertyTypeFqn = propertyTypeFqn, + PropertyName = memberName, + StubMemberName = stubSymbol.Name, + StubIsMethod = !stubIsProperty, + UseTernaryShape = useTernary, + BackingFieldTypeFqn = backingFieldTypeFqn, + ContainingTypeName = targetType.Name, + ContainingTypeNamespace = targetType.ContainingNamespace.IsGlobalNamespace + ? null + : targetType.ContainingNamespace.ToDisplayString(), + ContainingTypePath = GetNestedInClassPath(targetType).ToList(), + ContainingTypeKeyword = GetTypeKeyword(targetType), + }; + + return descriptor; + } + + /// + /// True when the declared property type is nullable (annotated reference type or + /// Nullable<T>). Non-nullable types can use the simpler coalesce shape because + /// null in the backing field unambiguously means "not materialized". + /// + 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; + } + + /// + /// Formats as its nullable form for backing-field declaration. + /// For value types, wraps in Nullable<T>; for reference types, appends ?. + /// + private static string MakeNullableTypeFqn(ITypeSymbol type) + { + var fqn = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (type.IsValueType) + return $"global::System.Nullable<{fqn}>"; + return fqn.EndsWith("?") ? fqn : fqn + "?"; + } + + /// + /// True when at least one declaration of carries the partial keyword. + /// + private static bool IsPartialType(INamedTypeSymbol type) + { + foreach (var reference in type.DeclaringSyntaxReferences) + { + if (reference.GetSyntax() is TypeDeclarationSyntax typeDecl + && typeDecl.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + return true; + } + } + return false; + } + + private static string GetTypeKeyword(INamedTypeSymbol type) + { + // Prefer a declaration-based check so we emit the exact keyword used in source. + foreach (var reference in type.DeclaringSyntaxReferences) + { + switch (reference.GetSyntax()) + { + case ClassDeclarationSyntax: return "class"; + case RecordDeclarationSyntax rec: + return rec.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword) + ? "record struct" + : "record"; + case StructDeclarationSyntax: return "struct"; + case InterfaceDeclarationSyntax: return "interface"; + } + } + return type.TypeKind switch + { + TypeKind.Struct => "struct", + TypeKind.Interface => "interface", + _ => "class", + }; + } + private static ExpressiveDescriptor? ResolveConstructor( SemanticModel semanticModel, MethodDeclarationSyntax stubMethod, diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs index db5f8fb9..f3efc1a5 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs @@ -129,198 +129,6 @@ 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 is now deferred until after pattern recognition: it applies only to the - // Coalesce shape, because the Ternary shape's has-value flag independently distinguishes - // "not materialized" from "materialized to null" and thus supports nullable property types. - - // 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 getResult)) - { - return false; - } - - // EXP0024: the Coalesce shape requires a non-nullable property type, because `null` in the - // backing field is the "not materialized" sentinel. The Ternary shape does not have this - // constraint — its has-value flag carries the sentinel independently. - if (getResult.Shape == ProjectablePatternRecognizer.ProjectableGetShape.Coalesce - && IsNullablePropertyType(propertySymbol.Type)) - { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.ProjectableRequiresNonNullablePropertyType, propertyLocation, - propertySymbol.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); - 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, getResult, context, setAccessor.GetLocation())) - { - return false; - } - - // ── Step 5: Feed the formula through the regular emission pipeline ── - - // The formula's Syntax node is the "not materialized" branch of the get accessor (the - // right operand of `??` for Coalesce, or the else-branch of the ternary for Ternary). - // 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 = getResult.Formula.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 4a0497d5..89772cba 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs @@ -51,10 +51,6 @@ 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 deleted file mode 100644 index ba22666d..00000000 --- a/src/ExpressiveSharp.Generator/Interpretation/ProjectablePatternRecognizer.cs +++ /dev/null @@ -1,672 +0,0 @@ -using ExpressiveSharp.Generator.Infrastructure; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Operations; - -namespace ExpressiveSharp.Generator.Interpretation; - -/// -/// Recognizes the IOperation shapes produced by a [Expressive(Projectable = true)] property. -/// -/// Two get-accessor shapes are supported: -/// -/// -/// -/// -/// Coalesce: => field ?? (<formula>) or -/// => _backingField ?? (<formula>). The backing field is either the C# 14 -/// synthesized property backing field or a manually declared private instance field of the -/// corresponding type; the C# compiler enforces ?? legality on the operand types. -/// The set/init accessor must be a single field = value assignment. -/// -/// -/// -/// -/// Ternary: => _hasValue ? field : (<formula>) where -/// _hasValue is a private non-readonly instance bool field on the same -/// containing type. The backing field may be either the C# 14 synthesized backing field -/// or a manually declared private instance field of type T (or T?) matching -/// the property type. The set/init accessor must be a two-statement block that assigns -/// true to the flag and value to the backing field. This shape is required -/// for projectable properties whose type is nullable — the flag distinguishes -/// "not materialized" from "materialized to null". -/// -/// -/// -/// -static internal class ProjectablePatternRecognizer -{ - internal enum ProjectableGetShape - { - Coalesce, - Ternary, - } - - /// - /// Outcome of recognizing the get accessor pattern. For the - /// shape, is null. For the - /// shape, all three symbol/operation fields are populated. - /// - internal readonly record struct ProjectableGetResult( - ProjectableGetShape Shape, - IFieldSymbol BackingField, - IFieldSymbol? HasValueFlag, - IOperation Formula); - - /// - /// Inspects the get accessor's IOperation. On success, returns true and populates - /// with the shape, backing field, optional has-value flag, and - /// the formula operation (the branch that evaluates when the property is not yet - /// materialized). Reports EXP0022 or EXP0025 on mismatch. - /// - public static bool TryRecognizeGetPattern( - IPropertySymbol property, - IOperation getAccessorOperation, - SourceProductionContext context, - Location diagnosticLocation, - out ProjectableGetResult result) - { - result = default; - - var body = UnwrapToReturnExpression(getAccessorOperation); - if (body is null) - { - ReportGetAccessorPattern(context, diagnosticLocation, "get accessor body is empty or not a single return expression"); - return false; - } - - return body switch - { - ICoalesceOperation coalesce => TryRecognizeCoalesce(property, coalesce, context, diagnosticLocation, out result), - IConditionalOperation conditional => TryRecognizeTernary(property, conditional, context, diagnosticLocation, out result), - _ => ReportAndFail(context, diagnosticLocation, DescribeOperation(body)), - }; - } - - private static bool TryRecognizeCoalesce( - IPropertySymbol property, - ICoalesceOperation coalesce, - SourceProductionContext context, - Location diagnosticLocation, - out ProjectableGetResult result) - { - result = default; - - if (!TryMatchBackingFieldReference(coalesce.Value, property, context, diagnosticLocation, out var backingField)) - { - return false; - } - - // Coalesce requires the backing field to be nullable-of-property-type (the original rule): - // the C# 14 synthesized field already matches the property type; a manual field must be T?. - if (!IsValidCoalesceBackingFieldType(backingField!.Type, property.Type)) - { - ReportBackingFieldTypeMismatch(context, diagnosticLocation, - property.Name, - property.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), - backingField.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)); - return false; - } - - var formula = coalesce.WhenNull; - if (formula is null) - { - ReportGetAccessorPattern(context, diagnosticLocation, "coalesce '??' is missing its right-hand side"); - return false; - } - - result = new ProjectableGetResult(ProjectableGetShape.Coalesce, backingField!, HasValueFlag: null, Formula: formula); - return true; - } - - private static bool TryRecognizeTernary( - IPropertySymbol property, - IConditionalOperation conditional, - SourceProductionContext context, - Location diagnosticLocation, - out ProjectableGetResult result) - { - result = default; - - // Only the bare `flag ? field : formula` shape is supported in v1 — reject the inverted - // `!flag ? formula : field` form with a pointed message. - if (conditional.Condition is IUnaryOperation { OperatorKind: UnaryOperatorKind.Not }) - { - ReportGetAccessorPattern(context, diagnosticLocation, - "ternary projectable pattern with inverted condition (e.g. '!_hasValue ? formula : field') is not supported; write it as '_hasValue ? field : formula'"); - return false; - } - - if (!TryMatchFlagFieldReference(conditional.Condition, property, context, diagnosticLocation, out var flagField)) - { - return false; - } - - if (!TryMatchBackingFieldReference(conditional.WhenTrue, property, context, diagnosticLocation, out var backingField)) - { - return false; - } - - // Ternary accepts a broader backing-field type: either T or T? where T is the property type. - // The cached-value branch is the raw `field` reference, which for a non-nullable property can - // legitimately be either T (simple cache) or T? (auto-property with `field` keyword where the - // property is nullable). - if (!IsValidTernaryBackingFieldType(backingField!.Type, property.Type)) - { - ReportBackingFieldTypeMismatch(context, diagnosticLocation, - property.Name, - property.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), - backingField.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)); - return false; - } - - var formula = conditional.WhenFalse; - if (formula is null) - { - ReportGetAccessorPattern(context, diagnosticLocation, "ternary '?:' is missing its else-branch (the formula)"); - return false; - } - - result = new ProjectableGetResult(ProjectableGetShape.Ternary, backingField!, flagField, formula); - return true; - } - - /// - /// Validates that the init/set accessor body matches the pattern implied by - /// . For the Coalesce shape: a single assignment - /// backingField = value. For the Ternary shape: a two-statement block assigning - /// true to the has-value flag and value to the backing field (order - /// independent). Reports EXP0023 or EXP0030 on mismatch. - /// - public static bool ValidateSetterPattern( - IOperation setterAccessorOperation, - ProjectableGetResult getResult, - SourceProductionContext context, - Location diagnosticLocation) - { - var body = UnwrapToBody(setterAccessorOperation); - if (body is null) - { - ReportSetterPattern(context, diagnosticLocation, - "init/set accessor has no analyzable body"); - return false; - } - - return getResult.Shape switch - { - ProjectableGetShape.Coalesce => ValidateCoalesceSetter(body, getResult.BackingField, context, diagnosticLocation), - ProjectableGetShape.Ternary => ValidateTernarySetter(body, getResult.BackingField, getResult.HasValueFlag!, context, diagnosticLocation), - _ => false, - }; - } - - private static bool ValidateCoalesceSetter( - IOperation body, - IFieldSymbol expectedBackingField, - SourceProductionContext context, - Location diagnosticLocation) - { - var statement = ExtractSingleStatement(body); - if (statement is null) - { - ReportSetterPattern(context, diagnosticLocation, - "init/set accessor must contain exactly one assignment statement"); - return false; - } - - if (!TryGetSimpleAssignment(statement, out var assignment)) - { - ReportSetterPattern(context, diagnosticLocation, - $"init/set accessor must be a simple assignment, found {DescribeOperation(statement)}"); - return false; - } - - if (!IsAssignmentToField(assignment!, expectedBackingField, out var mismatchReason)) - { - if (mismatchReason == AssignmentFieldMismatch.WrongField) - { - ReportInconsistentBacking(context, diagnosticLocation, - $"Getter reads from '{expectedBackingField.Name}' but setter writes to a different field."); - return false; - } - ReportSetterPattern(context, diagnosticLocation, - $"init/set accessor must assign into backing field '{expectedBackingField.Name}'"); - return false; - } - - if (!IsPlainValueParameterReference(assignment!.Value)) - { - 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; - } - - private static bool ValidateTernarySetter( - IOperation body, - IFieldSymbol expectedBackingField, - IFieldSymbol expectedFlagField, - SourceProductionContext context, - Location diagnosticLocation) - { - if (body is not IBlockOperation block || block.Operations.Length != 2) - { - ReportSetterPattern(context, diagnosticLocation, - "init/set accessor for a ternary-form Projectable property must be a block with exactly two statements: set the has-value flag to true and assign value to the backing field"); - return false; - } - - if (!TryGetSimpleAssignment(block.Operations[0], out var firstAssignment) - || !TryGetSimpleAssignment(block.Operations[1], out var secondAssignment)) - { - ReportSetterPattern(context, diagnosticLocation, - "init/set accessor for a ternary-form Projectable property must consist of two simple assignment statements"); - return false; - } - - // Classify each statement as either the flag-assignment-to-true or the value-assignment-to-backing-field. - // Accept either order. - var (flagAssignment, valueAssignment) = ClassifyTernaryAssignments( - firstAssignment!, secondAssignment!, expectedBackingField, expectedFlagField); - - if (flagAssignment is null || valueAssignment is null) - { - ReportInconsistentBacking(context, diagnosticLocation, - $"Expected one assignment of 'true' to flag '{expectedFlagField.Name}' and one assignment of 'value' to backing field '{expectedBackingField.Name}'."); - return false; - } - - if (!IsBooleanLiteralTrue(flagAssignment.Value)) - { - ReportSetterPattern(context, diagnosticLocation, - $"init/set accessor must assign the literal 'true' to has-value flag '{expectedFlagField.Name}'"); - return false; - } - - if (!IsPlainValueParameterReference(valueAssignment.Value)) - { - ReportSetterPattern(context, diagnosticLocation, - "init/set accessor value must be a plain reference to the implicit 'value' parameter; transformations are not supported"); - return false; - } - - return true; - } - - // ── Helpers: reference matching ──────────────────────────────────────── - - private static bool TryMatchBackingFieldReference( - IOperation operation, - IPropertySymbol property, - SourceProductionContext context, - Location diagnosticLocation, - out IFieldSymbol? backingField) - { - backingField = null; - - if (operation is not IFieldReferenceOperation fieldRef) - { - ReportGetAccessorPattern(context, diagnosticLocation, - $"expected a backing field reference, found {DescribeOperation(operation)}"); - return false; - } - - 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; - } - - if (!FieldMatchesPatternAOrB(property, fieldRef.Field, context, diagnosticLocation)) - { - return false; - } - - backingField = fieldRef.Field; - return true; - } - - private static bool TryMatchFlagFieldReference( - IOperation operation, - IPropertySymbol property, - SourceProductionContext context, - Location diagnosticLocation, - out IFieldSymbol? flagField) - { - flagField = null; - - if (operation is not IFieldReferenceOperation fieldRef) - { - ReportGetAccessorPattern(context, diagnosticLocation, - $"the ternary condition must be a reference to a bool field, found {DescribeOperation(operation)}"); - return false; - } - - if (fieldRef.Instance is not null and not IInstanceReferenceOperation) - { - ReportGetAccessorPattern(context, diagnosticLocation, - "the has-value flag must have an implicit 'this' receiver"); - return false; - } - - var field = fieldRef.Field; - - if (field.Type.SpecialType != SpecialType.System_Boolean) - { - ReportGetAccessorPattern(context, diagnosticLocation, - $"the has-value flag '{field.Name}' must be of type 'bool', found '{field.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)}'"); - return false; - } - - if (field.IsReadOnly) - { - ReportSetterPattern(context, diagnosticLocation, - $"the has-value flag '{field.Name}' must not be readonly"); - return false; - } - - if (field.IsStatic != property.IsStatic - || !SymbolEqualityComparer.Default.Equals(field.ContainingType, property.ContainingType)) - { - ReportGetAccessorPattern(context, diagnosticLocation, - $"the has-value flag '{field.Name}' must be an instance field on '{property.ContainingType.Name}' with the same static-ness as the property"); - return false; - } - - flagField = field; - return true; - } - - private static bool FieldMatchesPatternAOrB( - IPropertySymbol property, - IFieldSymbol field, - SourceProductionContext context, - Location diagnosticLocation) - { - // Pattern A: C# 14 `field` keyword — the synthesized backing field whose AssociatedSymbol - // is the containing property. - if (field.IsImplicitlyDeclared - && SymbolEqualityComparer.Default.Equals(field.AssociatedSymbol, property)) - { - return true; - } - - // Pattern B: manually declared private instance field on the same type. - 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; - } - - return true; - } - - private static bool IsValidCoalesceBackingFieldType(ITypeSymbol fieldType, ITypeSymbol propertyType) - { - // Field type matches property type exactly. Covers: - // - `string? FullName` + `field` keyword — both string? (nullable ref). - // - `decimal? Amount` + `field` keyword — both Nullable (nullable value). - // - `string FullName` + `field` keyword in nullable-oblivious contexts. - // In all these cases the C# compiler already verified the `??` is valid, so no further - // nullability check is needed here. If the property type itself is nullable, EXP0024 - // fires later (in the dispatcher) with a more actionable diagnostic. - if (SymbolEqualityComparer.Default.Equals(fieldType, propertyType)) - { - return true; - } - - if (propertyType.IsValueType) - { - // Non-nullable value type: manual backing field must be Nullable where T matches - // the property type. - if (fieldType is INamedTypeSymbol named - && named.IsGenericType - && named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T - && SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], propertyType)) - { - return true; - } - return false; - } - - // Reference-type property: the field must be the same underlying type (annotations - // ignored — the compiler has already verified `??` is valid, so the field is nullable - // in whatever sense it needs to be). - return SymbolEqualityComparer.Default.Equals(fieldType.OriginalDefinition, propertyType.OriginalDefinition); - } - - private static bool IsValidTernaryBackingFieldType(ITypeSymbol fieldType, ITypeSymbol propertyType) - { - // Ternary accepts either T or T? for the backing field: the true-branch is the cached - // value, which can be either the property type itself (non-nullable cache of a non-nullable - // property) or Nullable (e.g. field keyword on a `decimal?` property, or a manual `decimal? _x` - // paired with a `decimal Amount` — users legitimately do this). - if (SymbolEqualityComparer.Default.Equals(fieldType.OriginalDefinition, propertyType.OriginalDefinition)) - { - return true; - } - - if (propertyType.IsValueType - && fieldType is INamedTypeSymbol named - && named.IsGenericType - && named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T - && SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], propertyType)) - { - return true; - } - - // Reference-type property: T? as a field type still has the same OriginalDefinition, so the - // first check already covers it. - return false; - } - - // ── Helpers: assignment pattern matching ──────────────────────────────── - - private enum AssignmentFieldMismatch - { - None, - NotFieldTarget, - WrongField, - } - - private static bool TryGetSimpleAssignment(IOperation statement, out ISimpleAssignmentOperation? assignment) - { - if (statement is IExpressionStatementOperation { Operation: ISimpleAssignmentOperation simple }) - { - assignment = simple; - return true; - } - assignment = null; - return false; - } - - private static bool IsAssignmentToField( - ISimpleAssignmentOperation assignment, - IFieldSymbol expectedField, - out AssignmentFieldMismatch reason) - { - if (assignment.Target is not IFieldReferenceOperation targetFieldRef) - { - reason = AssignmentFieldMismatch.NotFieldTarget; - return false; - } - if (!SymbolEqualityComparer.Default.Equals(targetFieldRef.Field, expectedField)) - { - reason = AssignmentFieldMismatch.WrongField; - return false; - } - reason = AssignmentFieldMismatch.None; - return true; - } - - private static (ISimpleAssignmentOperation? Flag, ISimpleAssignmentOperation? Value) ClassifyTernaryAssignments( - ISimpleAssignmentOperation first, - ISimpleAssignmentOperation second, - IFieldSymbol expectedBackingField, - IFieldSymbol expectedFlagField) - { - var firstTarget = (first.Target as IFieldReferenceOperation)?.Field; - var secondTarget = (second.Target as IFieldReferenceOperation)?.Field; - - if (firstTarget is null || secondTarget is null) - { - return (null, null); - } - - if (SymbolEqualityComparer.Default.Equals(firstTarget, expectedFlagField) - && SymbolEqualityComparer.Default.Equals(secondTarget, expectedBackingField)) - { - return (first, second); - } - - if (SymbolEqualityComparer.Default.Equals(firstTarget, expectedBackingField) - && SymbolEqualityComparer.Default.Equals(secondTarget, expectedFlagField)) - { - return (second, first); - } - - return (null, null); - } - - private static bool IsPlainValueParameterReference(IOperation operation) - { - // Peek through implicit conversions: `field = value` where field is `T?` and value is `T` - // produces an IConversionOperation wrapping the parameter reference. Explicit conversions - // are intentionally still rejected. - while (operation is IConversionOperation { IsImplicit: true } conversion) - { - operation = conversion.Operand; - } - - return operation is IParameterReferenceOperation paramRef - && paramRef.Parameter.Name == "value" - && paramRef.Parameter.IsImplicitlyDeclared; - } - - private static bool IsBooleanLiteralTrue(IOperation operation) - { - // Peek through implicit conversions (unlikely for `true`, but harmless). - while (operation is IConversionOperation { IsImplicit: true } conversion) - { - operation = conversion.Operand; - } - - return operation is ILiteralOperation literal - && literal.ConstantValue.HasValue - && literal.ConstantValue.Value is true; - } - - // ── Helpers: body unwrapping ──────────────────────────────────────────── - - private static IOperation? UnwrapToReturnExpression(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; - current = block.Operations[0]; - break; - - case IReturnOperation ret: - if (ret.ReturnedValue is null) return null; - current = ret.ReturnedValue; - break; - - // Peek through implicit conversions wrapping the body expression. This matters - // when the coalesce/ternary result type narrower than the property type (e.g. - // `decimal? Amount => field ?? TotalAmount ?? 0m` where the coalesce is `decimal` - // and gets implicitly converted to `decimal?`). Without this, the recognizer - // would see the conversion instead of the expected ICoalesceOperation/IConditionalOperation. - case IConversionOperation conversion when conversion.IsImplicit: - if (conversion.Operand is null) return current; - current = conversion.Operand; - break; - - default: - return current; - } - } - } - - /// - /// Unwraps an accessor's IOperation down to the user-written body, which is either an - /// (single statement or multi-statement block) or a single - /// statement operation. Used by setter validation where both shapes are legal. - /// - private static IOperation? UnwrapToBody(IOperation operation) - { - if (operation is IMethodBodyOperation methodBody) - { - return methodBody.BlockBody ?? methodBody.ExpressionBody; - } - return operation; - } - - private static IOperation? ExtractSingleStatement(IOperation body) - { - if (body is IBlockOperation block) - { - return block.Operations.Length == 1 ? block.Operations[0] : null; - } - return body; - } - - // ── Helpers: diagnostics ──────────────────────────────────────────────── - - private static string DescribeOperation(IOperation? operation) => operation switch - { - null => "", - ICoalesceOperation => "coalesce (??)", - IConditionalOperation => "ternary (?:)", - IBinaryOperation bin => $"binary operator '{bin.OperatorKind}'", - IUnaryOperation un => $"unary operator '{un.OperatorKind}'", - 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 bool ReportAndFail(SourceProductionContext context, Location location, string detail) - { - ReportGetAccessorPattern(context, location, detail); - return false; - } - - 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)); - - private static void ReportInconsistentBacking( - SourceProductionContext context, Location location, string detail) => - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.ProjectableInconsistentGetSetBacking, location, detail)); -} diff --git a/src/ExpressiveSharp.Generator/Models/ExpressiveAttributeData.cs b/src/ExpressiveSharp.Generator/Models/ExpressiveAttributeData.cs index 9ab7ff6c..5d9e4698 100644 --- a/src/ExpressiveSharp.Generator/Models/ExpressiveAttributeData.cs +++ b/src/ExpressiveSharp.Generator/Models/ExpressiveAttributeData.cs @@ -9,15 +9,12 @@ 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) @@ -29,9 +26,6 @@ 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) { @@ -49,7 +43,6 @@ public ExpressiveAttributeData(AttributeData attribute) } AllowBlockBody = allowBlockBody; - Projectable = projectable; TransformerTypeNames = transformerTypeNames.ToArray(); } } diff --git a/src/ExpressiveSharp.Generator/Models/ExpressiveDescriptor.cs b/src/ExpressiveSharp.Generator/Models/ExpressiveDescriptor.cs index 271a9a45..11b5e7f5 100644 --- a/src/ExpressiveSharp.Generator/Models/ExpressiveDescriptor.cs +++ b/src/ExpressiveSharp.Generator/Models/ExpressiveDescriptor.cs @@ -45,4 +45,10 @@ internal class ExpressiveDescriptor /// declared via the [Expressive] attribute's built-in flags and Transformers property. /// public List DeclaredTransformerTypeNames { get; } = new(); + + /// + /// When [ExpressiveFor(..., Synthesize = true)] is applied, this carries the + /// instructions for emitting the synthesized property on the stub's containing type. + /// + public SynthesizedPropertySpec? SynthesisSpec { get; set; } } diff --git a/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs b/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs index 20202e47..97908ddc 100644 --- a/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs +++ b/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs @@ -32,10 +32,16 @@ readonly internal record struct ExpressiveForAttributeData public IReadOnlyList TransformerTypeNames { get; } + /// + /// When true, the target property is synthesized on the stub's containing type. + /// + public bool Synthesize { get; } + public ExpressiveForAttributeData(AttributeData attribute, ExpressiveForMemberKind memberKind) { MemberKind = memberKind; bool? allowBlockBody = null; + var synthesize = false; var transformerTypeNames = new List(); // Extract target type from first constructor argument. @@ -92,10 +98,14 @@ public ExpressiveForAttributeData(AttributeData attribute, ExpressiveForMemberKi } } break; + case "Synthesize": + synthesize = value.Value is true; + break; } } AllowBlockBody = allowBlockBody; + Synthesize = synthesize; TransformerTypeNames = transformerTypeNames.ToArray(); } diff --git a/src/ExpressiveSharp.Generator/Models/SynthesizedPropertySpec.cs b/src/ExpressiveSharp.Generator/Models/SynthesizedPropertySpec.cs new file mode 100644 index 00000000..b5cd6df3 --- /dev/null +++ b/src/ExpressiveSharp.Generator/Models/SynthesizedPropertySpec.cs @@ -0,0 +1,42 @@ +namespace ExpressiveSharp.Generator.Models; + +/// +/// Side-information attached to an when +/// [ExpressiveFor(..., Synthesize = true)] is applied. Instructs the generator to emit +/// an additional partial-class file declaring the synthesized property on the target type. +/// +internal sealed class SynthesizedPropertySpec +{ + /// Fully-qualified property type (e.g. decimal, string?, global::System.Nullable<decimal>). + public string PropertyTypeFqn { get; set; } = ""; + + /// The synthesized property's name (the first argument of [ExpressiveFor(nameof(X))]). + public string PropertyName { get; set; } = ""; + + /// Name of the stub member whose body produces the formula (called from the getter's fallback branch). + public string StubMemberName { get; set; } = ""; + + /// true when the stub is a method (AmountExpression()); false when it is a property (AmountExpression). + public bool StubIsMethod { get; set; } + + /// When true, emit the ternary+flag shape. When false, emit the coalesce shape. + public bool UseTernaryShape { get; set; } + + /// + /// Type to use for the backing field. For coalesce: nullable form of . + /// For ternary: same as . + /// + public string BackingFieldTypeFqn { get; set; } = ""; + + /// Name of the containing class (e.g. Account). + public string ContainingTypeName { get; set; } = ""; + + /// Containing class's namespace, or null for the global namespace. + public string? ContainingTypeNamespace { get; set; } + + /// Containing type path from outermost to target (for nested types). + public IReadOnlyList ContainingTypePath { get; set; } = System.Array.Empty(); + + /// Keyword for the containing type declaration (class, record, struct, etc.). + public string ContainingTypeKeyword { get; set; } = "class"; +} diff --git a/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs b/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs index 7a896bde..68b798c3 100644 --- a/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs +++ b/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs @@ -6,11 +6,12 @@ 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. +/// (and every property synthesized by +/// [ExpressiveFor(..., Synthesize = true)]) from its containing class map. This is +/// the Mongo counterpart of the EF Core ExpressivePropertiesNotMappedConvention: +/// without it, a synthesized property would be serialized to its BSON document as a real +/// field (because the generated property has a writable accessor) and the backing field's +/// default value would leak into storage. /// /// /// diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ProjectableExpressiveSqlTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/SynthesizedExpressiveSqlTests.cs similarity index 57% rename from tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ProjectableExpressiveSqlTests.cs rename to tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/SynthesizedExpressiveSqlTests.cs index 63441f29..97965e16 100644 --- a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ProjectableExpressiveSqlTests.cs +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/SynthesizedExpressiveSqlTests.cs @@ -1,34 +1,35 @@ using ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Infrastructure; +using ExpressiveSharp.Mapping; 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. +/// EF Core SQLite tests for [ExpressiveFor(..., Synthesize = true)]. Uses a self-contained +/// DbContext with a synthesized 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). +/// The synthesized 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 +public class SynthesizedExpressiveSqlTests { - private TestContextFactories.SqliteContextHandle _handle = null!; + private TestContextFactories.SqliteContextHandle _handle = null!; - private ProjectableDbContext Context => _handle.Context; + private SynthesizedDbContext Context => _handle.Context; [TestInitialize] public async Task InitContext() { - _handle = TestContextFactories.CreateSqlite(o => new ProjectableDbContext(o)); + _handle = TestContextFactories.CreateSqlite(o => new SynthesizedDbContext(o)); await Context.Database.EnsureCreatedAsync(); Context.People.AddRange( - new ProjectablePerson { Id = 1, FirstName = "Ada", LastName = "Lovelace" }, - new ProjectablePerson { Id = 2, FirstName = "Alan", LastName = "Turing" }); + new SynthesizedPerson { Id = 1, FirstName = "Ada", LastName = "Lovelace" }, + new SynthesizedPerson { Id = 2, FirstName = "Alan", LastName = "Turing" }); await Context.SaveChangesAsync(); } @@ -36,19 +37,18 @@ public async Task InitContext() public async Task CleanupContext() => await _handle.DisposeAsync(); [TestMethod] - public void ProjectableProperty_IsAutoIgnored_NoColumnInModel() + public void SynthesizedProperty_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"); + // The ExpressivePropertiesNotMappedConvention calls Ignore() for every member backed by + // a registry expression. This is load-bearing for synthesized properties because they + // have writable accessors — without the Ignore, EF would try to create a real column. + var entity = Context.Model.FindEntityType(typeof(SynthesizedPerson))!; + Assert.IsNull(entity.FindProperty(nameof(SynthesizedPerson.FullName)), + "Synthesized property must not be mapped as a column"); } [TestMethod] - public async Task ProjectableProperty_SelectInlinesFormulaIntoSql() + public async Task SynthesizedProperty_SelectInlinesFormulaIntoSql() { var labels = await Context.People .OrderBy(p => p.Id) @@ -61,7 +61,7 @@ public async Task ProjectableProperty_SelectInlinesFormulaIntoSql() } [TestMethod] - public async Task ProjectableProperty_MemberInitProjection_MaterializesStoredValue() + public async Task SynthesizedProperty_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 @@ -69,7 +69,7 @@ public async Task ProjectableProperty_MemberInitProjection_MaterializesStoredVal // value is returned on subsequent reads. var projected = await Context.People .OrderBy(p => p.Id) - .Select(p => new ProjectablePerson + .Select(p => new SynthesizedPerson { Id = p.Id, FullName = p.FullName, @@ -82,10 +82,8 @@ public async Task ProjectableProperty_MemberInitProjection_MaterializesStoredVal } [TestMethod] - public async Task ProjectableProperty_WhereClauseFiltersOnFormula() + public async Task SynthesizedProperty_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(); @@ -95,29 +93,25 @@ public async Task ProjectableProperty_WhereClauseFiltersOnFormula() } } -/// Self-contained entity for Projectable EF Core tests. -public class ProjectablePerson +/// Self-contained entity for synthesized-property EF Core tests. +public partial class SynthesizedPerson { 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; - } + [ExpressiveFor("FullName", Synthesize = true)] + private string FullNameExpression => LastName + ", " + FirstName; } -/// Self-contained DbContext for Projectable EF Core tests. -public class ProjectableDbContext(DbContextOptions options) : DbContext(options) +/// Self-contained DbContext for synthesized-property EF Core tests. +public class SynthesizedDbContext(DbContextOptions options) : DbContext(options) { - public DbSet People => Set(); + public DbSet People => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Id).ValueGeneratedNever(); diff --git a/tests/ExpressiveSharp.Generator.Tests/CodeFixers/ConvertProjectableToTernaryCodeFixProviderTests.cs b/tests/ExpressiveSharp.Generator.Tests/CodeFixers/ConvertProjectableToTernaryCodeFixProviderTests.cs deleted file mode 100644 index 332ddb6f..00000000 --- a/tests/ExpressiveSharp.Generator.Tests/CodeFixers/ConvertProjectableToTernaryCodeFixProviderTests.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ExpressiveSharp.CodeFixers; -using ExpressiveSharp.Generator.Infrastructure; -using ExpressiveSharp.Generator.Tests.Infrastructure; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Text; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace ExpressiveSharp.Generator.Tests.CodeFixers; - -/// -/// Verifies the code fix for EXP0024 rewrites a nullable [Expressive(Projectable = true)] -/// property from the coalesce shape to the ternary + has-value-flag shape, inserting a private -/// _has<PropertyName> bool field alongside. -/// -[TestClass] -public sealed class ConvertProjectableToTernaryCodeFixProviderTests : GeneratorTestBase -{ - [TestMethod] - public async Task ConvertsNullableReferenceProperty_FieldKeyword_ExpressionBodied() - { - const string source = """ - #nullable enable - using ExpressiveSharp; - namespace Foo - { - partial class User - { - public string? Name { get; set; } - - [Expressive(Projectable = true)] - public string? UpperName - { - get => field ?? (Name != null ? Name.ToUpper() : "(UNNAMED)"); - init => field = value; - } - } - } - """; - - var fixedSource = await ApplyCodeFixAsync(source); - - StringAssert.Contains(fixedSource, "private bool _hasUpperName;", - "Expected private bool flag field to be inserted"); - StringAssert.Contains(fixedSource, - "get => _hasUpperName ? field : (Name != null ? Name.ToUpper() : \"(UNNAMED)\");", - "Expected get accessor to be rewritten to the ternary form"); - StringAssert.Contains(fixedSource, "_hasUpperName = true;", - "Expected init accessor to set the flag"); - StringAssert.Contains(fixedSource, "field = value;", - "Expected init accessor to still assign the backing field"); - } - - [TestMethod] - public async Task ConvertsNullableValueProperty_FieldKeyword_ExpressionBodied() - { - const string source = """ - using ExpressiveSharp; - namespace Foo - { - partial class Account - { - public decimal? TotalAmount { get; set; } - - [Expressive(Projectable = true)] - public decimal? Amount - { - get => field ?? (TotalAmount ?? 0m); - init => field = value; - } - } - } - """; - - var fixedSource = await ApplyCodeFixAsync(source); - - StringAssert.Contains(fixedSource, "private bool _hasAmount;"); - StringAssert.Contains(fixedSource, "get => _hasAmount ? field : (TotalAmount ?? 0m);"); - StringAssert.Contains(fixedSource, "_hasAmount = true;"); - StringAssert.Contains(fixedSource, "field = value;"); - } - - [TestMethod] - public async Task ConvertsNullableProperty_ManualBackingField() - { - // Manual backing field: the fixer must reference `_fullName`, not `field`, in the ternary's - // true-branch, and the setter must still assign to `_fullName`. - const string source = """ - #nullable enable - using ExpressiveSharp; - 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 ?? (FirstName + " " + LastName); - init => _fullName = value; - } - } - } - """; - - var fixedSource = await ApplyCodeFixAsync(source); - - StringAssert.Contains(fixedSource, "private bool _hasFullName;"); - StringAssert.Contains(fixedSource, "get => _hasFullName ? _fullName : (FirstName + \" \" + LastName);"); - StringAssert.Contains(fixedSource, "_hasFullName = true;"); - StringAssert.Contains(fixedSource, "_fullName = value;"); - } - - [TestMethod] - public async Task ConvertsNullableProperty_SetAccessor() - { - // `set` instead of `init` should be rewritten the same way. - const string source = """ - #nullable enable - using ExpressiveSharp; - namespace Foo - { - partial class User - { - public string? Name { get; set; } - - [Expressive(Projectable = true)] - public string? UpperName - { - get => field ?? (Name != null ? Name.ToUpper() : ""); - set => field = value; - } - } - } - """; - - var fixedSource = await ApplyCodeFixAsync(source); - - StringAssert.Contains(fixedSource, "private bool _hasUpperName;"); - StringAssert.Contains(fixedSource, "get => _hasUpperName ? field : (Name != null ? Name.ToUpper() : \"\");"); - StringAssert.Contains(fixedSource, "_hasUpperName = true;"); - } - - [TestMethod] - public async Task PicksUniqueFlagName_WhenHasPropertyNameAlreadyDefined() - { - // The containing type already declares `_hasAmount`, so the fixer must pick a - // non-colliding name (`_hasAmount1`) instead of producing uncompilable code. - const string source = """ - using ExpressiveSharp; - namespace Foo - { - partial class Account - { - public decimal? TotalAmount { get; set; } - private int _hasAmount; - - [Expressive(Projectable = true)] - public decimal? Amount - { - get => field ?? (TotalAmount ?? 0m); - init => field = value; - } - } - } - """; - - var fixedSource = await ApplyCodeFixAsync(source); - - StringAssert.Contains(fixedSource, "private bool _hasAmount1;"); - StringAssert.Contains(fixedSource, "get => _hasAmount1 ? field : (TotalAmount ?? 0m);"); - StringAssert.Contains(fixedSource, "_hasAmount1 = true;"); - } - - // ── Helpers ────────────────────────────────────────────────────────────── - - private async Task ApplyCodeFixAsync(string source) - { - using var workspace = new AdhocWorkspace(); - var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); - - var projectId = ProjectId.CreateNewId(); - var projectInfo = ProjectInfo.Create( - projectId, - VersionStamp.Create(), - "TestProject", - "TestProject", - LanguageNames.CSharp, - compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary), - parseOptions: parseOptions, - metadataReferences: GetDefaultReferences()); - - var project = workspace.AddProject(projectInfo); - var document = workspace.AddDocument(project.Id, "TestFile.cs", SourceText.From(source)); - - // Run the generator (it's what reports EXP0024). - var compilation = await document.Project.GetCompilationAsync() - ?? throw new System.Exception("Failed to get compilation"); - - var result = RunExpressiveGenerator(compilation); - var diagnostic = result.Diagnostics.FirstOrDefault(d => d.Id == "EXP0024"); - Assert.IsNotNull(diagnostic, "Expected EXP0024 to be emitted by the generator"); - - var codeFix = new ConvertProjectableToTernaryCodeFixProvider(); - var actions = new System.Collections.Generic.List(); - var fixContext = new CodeFixContext( - document, - diagnostic, - (action, _) => actions.Add(action), - CancellationToken.None); - - await codeFix.RegisterCodeFixesAsync(fixContext); - Assert.IsTrue(actions.Count > 0, "Expected at least one code fix action"); - - var operations = await actions[0].GetOperationsAsync(CancellationToken.None); - var applyOperation = operations.OfType().First(); - var fixedSolution = applyOperation.ChangedSolution; - var fixedDocument = fixedSolution.GetDocument(document.Id)!; - - return (await fixedDocument.GetTextAsync()).ToString(); - } -} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt new file mode 100644 index 00000000..ea31a480 --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt @@ -0,0 +1,41 @@ +// +#nullable disable + +using ExpressiveSharp.Mapping; +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_Account + { + // [ExpressiveFor("Amount", Synthesize = true)] + // private decimal AmountExpression => TotalAmount - Discount; + static global::System.Linq.Expressions.Expression> Amount_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.Account), "@this"); + var expr_1 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("TotalAmount")); // TotalAmount + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("Discount")); // Discount + var expr_0 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Subtract, expr_1, expr_2); + return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); + } + } +} + + +// === + +// +#nullable enable + +namespace Foo +{ + partial class Account + { + private global::System.Nullable _amount; + public decimal Amount + { + get => _amount ?? AmountExpression; + init => _amount = value; + } + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt new file mode 100644 index 00000000..095ad19f --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt @@ -0,0 +1,61 @@ +// +#nullable disable + +#nullable enable +using ExpressiveSharp.Mapping; +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_Account + { + // [ExpressiveFor("FullName", Synthesize = true)] + // private string? FullNameExpression => LastName is null || FirstName is null ? null : (LastName + ", " + FirstName); + static global::System.Linq.Expressions.Expression> FullName_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.Account), "@this"); + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("LastName")); // LastName + var expr_5 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null + var expr_4 = global::System.Linq.Expressions.Expression.Convert(expr_5, typeof(string)); + var expr_3 = global::System.Linq.Expressions.Expression.Equal(expr_2, expr_4); + var expr_6 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("FirstName")); // FirstName + var expr_9 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null + var expr_8 = global::System.Linq.Expressions.Expression.Convert(expr_9, typeof(string)); + var expr_7 = global::System.Linq.Expressions.Expression.Equal(expr_6, expr_8); + var expr_1 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.OrElse, expr_3, expr_7); + var expr_11 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null + var expr_10 = global::System.Linq.Expressions.Expression.Convert(expr_11, typeof(string)); + var expr_14 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("LastName")); // LastName + var expr_15 = global::System.Linq.Expressions.Expression.Constant(", ", typeof(string)); // ", " + var expr_13 = 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_14, expr_15); + var expr_16 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("FirstName")); // FirstName + var expr_12 = 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_13, expr_16); + var expr_0 = global::System.Linq.Expressions.Expression.Condition(expr_1, expr_10, expr_12, typeof(string)); + return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); + } + } +} + + +// === + +// +#nullable enable + +namespace Foo +{ + partial class Account + { + private string _fullName; + private bool _fullNameHasValue; + public string FullName + { + get => _fullNameHasValue ? _fullName : FullNameExpression; + init + { + _fullNameHasValue = true; + _fullName = value; + } + } + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Coalesce_ManualNullableBackingField.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableValueTypeTarget_EmitsTernaryForm.verified.txt similarity index 70% rename from tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Coalesce_ManualNullableBackingField.verified.txt rename to tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableValueTypeTarget_EmitsTernaryForm.verified.txt index 6bc01828..b532ef32 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Coalesce_ManualNullableBackingField.verified.txt +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableValueTypeTarget_EmitsTernaryForm.verified.txt @@ -1,15 +1,17 @@ // #nullable disable +#nullable enable +using ExpressiveSharp.Mapping; using Foo; namespace ExpressiveSharp.Generated { static partial class Foo_Account { - // [Expressive(Projectable = true)] - // public decimal Amount { get => _amount ?? (TotalAmount != null && Discount != null ? System.Math.Round(TotalAmount.Value - Discount.Value, 2) : 0m); init => _amount = value; } - static global::System.Linq.Expressions.Expression> Amount_Expression() + // [ExpressiveFor("Amount", Synthesize = true)] + // private decimal? AmountExpression => TotalAmount != null && Discount != null ? TotalAmount.Value - Discount.Value : (decimal? )null; + static global::System.Linq.Expressions.Expression> Amount_Expression() { var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.Account), "@this"); var expr_3 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("TotalAmount")); // TotalAmount @@ -24,11 +26,35 @@ namespace ExpressiveSharp.Generated var expr_13 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("Discount")); // Discount var expr_12 = global::System.Linq.Expressions.Expression.Property(expr_13, typeof(decimal?).GetProperty("Value")); var expr_9 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Subtract, expr_10, expr_12); - var expr_14 = global::System.Linq.Expressions.Expression.Constant(2, typeof(int)); // 2 - var expr_8 = global::System.Linq.Expressions.Expression.Call(typeof(global::System.Math).GetMethod("Round", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(decimal), typeof(int) }, null), new global::System.Linq.Expressions.Expression[] { expr_9, expr_14 }); - var expr_15 = global::System.Linq.Expressions.Expression.Constant(0m, typeof(decimal)); // 0m - var expr_0 = global::System.Linq.Expressions.Expression.Condition(expr_1, expr_8, expr_15, typeof(decimal)); - return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); + var expr_8 = global::System.Linq.Expressions.Expression.Convert(expr_9, typeof(decimal?)); + var expr_15 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null + var expr_14 = global::System.Linq.Expressions.Expression.Convert(expr_15, typeof(decimal?)); + var expr_0 = global::System.Linq.Expressions.Expression.Condition(expr_1, expr_8, expr_14, typeof(decimal?)); + return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); + } + } +} + + +// === + +// +#nullable enable + +namespace Foo +{ + partial class Account + { + private decimal? _amount; + private bool _amountHasValue; + public decimal? Amount + { + get => _amountHasValue ? _amount : AmountExpression; + init + { + _amountHasValue = true; + _amount = value; + } } } } diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_FieldKeyword.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_ReferenceTypeTarget_EmitsCoalesceForm.verified.txt similarity index 59% rename from tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_FieldKeyword.verified.txt rename to tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_ReferenceTypeTarget_EmitsCoalesceForm.verified.txt index aabb3fb7..bc200696 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_FieldKeyword.verified.txt +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_ReferenceTypeTarget_EmitsCoalesceForm.verified.txt @@ -1,23 +1,43 @@ // #nullable disable +using ExpressiveSharp.Mapping; using Foo; namespace ExpressiveSharp.Generated { - static partial class Foo_User + static partial class Foo_Account { - // [Expressive(Projectable = true)] - // public string FullName { get => field ?? (LastName + ", " + FirstName); init => field = value; } - static global::System.Linq.Expressions.Expression> FullName_Expression() + // [ExpressiveFor("FullName", Synthesize = true)] + // private string FullNameExpression => LastName + ", " + FirstName; + 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 p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.Account), "@this"); + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).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_4 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).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); + return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); + } + } +} + + +// === + +// +#nullable enable + +namespace Foo +{ + partial class Account + { + private string? _fullName; + public string FullName + { + get => _fullName ?? FullNameExpression; + init => _fullName = value; } } } diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs index 0d3f936e..b0f7fcab 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs @@ -602,6 +602,191 @@ class MyType { Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); } + // ── Synthesize snapshot tests ─────────────────────────────────────────── + + [TestMethod] + public Task Synthesize_ReferenceTypeTarget_EmitsCoalesceForm() + { + // Non-nullable reference target — coalesce shape. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [ExpressiveFor("FullName", Synthesize = true)] + private string FullNameExpression => LastName + ", " + FirstName; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + // Two generated files: expression factory + synthesized partial. + Assert.AreEqual(2, result.GeneratedTrees.Length); + + return Verifier.Verify(string.Join("\n\n// ===\n\n", + result.GeneratedTrees.Select(t => t.ToString()))); + } + + [TestMethod] + public Task Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm() + { + // Nullable reference target — ternary+flag shape (coalesce would fail for stored nulls). + var compilation = CreateCompilation( + """ + #nullable enable + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public string? FirstName { get; set; } + public string? LastName { get; set; } + + [ExpressiveFor("FullName", Synthesize = true)] + private string? FullNameExpression => + LastName is null || FirstName is null ? null : (LastName + ", " + FirstName); + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(2, result.GeneratedTrees.Length); + + return Verifier.Verify(string.Join("\n\n// ===\n\n", + result.GeneratedTrees.Select(t => t.ToString()))); + } + + [TestMethod] + public Task Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm() + { + // Non-nullable value target — coalesce shape with Nullable backing field. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public decimal TotalAmount { get; set; } + public decimal Discount { get; set; } + + [ExpressiveFor("Amount", Synthesize = true)] + private decimal AmountExpression => TotalAmount - Discount; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(2, result.GeneratedTrees.Length); + + return Verifier.Verify(string.Join("\n\n// ===\n\n", + result.GeneratedTrees.Select(t => t.ToString()))); + } + + [TestMethod] + public Task Synthesize_NullableValueTypeTarget_EmitsTernaryForm() + { + // Nullable value target — ternary+flag shape (issue #35 scenario). + var compilation = CreateCompilation( + """ + #nullable enable + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public decimal? TotalAmount { get; set; } + public decimal? Discount { get; set; } + + [ExpressiveFor("Amount", Synthesize = true)] + private decimal? AmountExpression => + TotalAmount != null && Discount != null + ? TotalAmount.Value - Discount.Value + : (decimal?)null; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(2, result.GeneratedTrees.Length); + + return Verifier.Verify(string.Join("\n\n// ===\n\n", + result.GeneratedTrees.Select(t => t.ToString()))); + } + + [TestMethod] + public void Synthesize_TargetAlreadyExists_ReportsEXP0031() + { + // Target name clashes with an existing member on the same type. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public string FullName { get; set; } = ""; + + [ExpressiveFor("FullName", Synthesize = true)] + private string FullNameExpression => "x"; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0031")); + } + + [TestMethod] + public void Synthesize_NonPartialContainer_ReportsEXP0032() + { + // Containing type is not partial — cannot emit the synthesized property into it. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class Account { + public string FirstName { get; set; } = ""; + + [ExpressiveFor("FullName", Synthesize = true)] + private string FullNameExpression => FirstName; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0032")); + } + + [TestMethod] + public void Synthesize_WithTwoArgForm_ReportsEXP0033() + { + // Synthesize + two-arg form is invalid — Synthesize always targets the stub's own type. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Other {} + + partial class Account { + public string FirstName { get; set; } = ""; + + [ExpressiveFor(typeof(Other), "FullName", Synthesize = true)] + private string FullNameExpression => FirstName; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0033")); + } + [TestMethod] public void SingleArgForm_UnknownMember_Rejected_EXP0015() { diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Ternary_FieldKeyword.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Ternary_FieldKeyword.verified.txt deleted file mode 100644 index 2e13c0c7..00000000 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Ternary_FieldKeyword.verified.txt +++ /dev/null @@ -1,42 +0,0 @@ -// -#nullable disable - -using Foo; - -namespace ExpressiveSharp.Generated -{ - static partial class Foo_Account - { - // [Expressive(Projectable = true)] - // public decimal Amount - // { - // get => _amountHasValue ? field : (TotalAmount != null && Discount != null ? System.Math.Round(TotalAmount.Value - Discount.Value, 2) : 0m); - // init - // { - // _amountHasValue = true; - // field = value; - // } - // } - static global::System.Linq.Expressions.Expression> Amount_Expression() - { - var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.Account), "@this"); - var expr_3 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("TotalAmount")); // TotalAmount - var expr_4 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null - var expr_2 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.NotEqual, expr_3, expr_4); - var expr_6 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("Discount")); // Discount - var expr_7 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null - var expr_5 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.NotEqual, expr_6, expr_7); - var expr_1 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.AndAlso, expr_2, expr_5); - var expr_11 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("TotalAmount")); // TotalAmount - var expr_10 = global::System.Linq.Expressions.Expression.Property(expr_11, typeof(decimal?).GetProperty("Value")); - var expr_13 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("Discount")); // Discount - var expr_12 = global::System.Linq.Expressions.Expression.Property(expr_13, typeof(decimal?).GetProperty("Value")); - var expr_9 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Subtract, expr_10, expr_12); - var expr_14 = global::System.Linq.Expressions.Expression.Constant(2, typeof(int)); // 2 - var expr_8 = global::System.Linq.Expressions.Expression.Call(typeof(global::System.Math).GetMethod("Round", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(decimal), typeof(int) }, null), new global::System.Linq.Expressions.Expression[] { expr_9, expr_14 }); - var expr_15 = global::System.Linq.Expressions.Expression.Constant(0m, typeof(decimal)); // 0m - var expr_0 = global::System.Linq.Expressions.Expression.Condition(expr_1, expr_8, expr_15, typeof(decimal)); - return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); - } - } -} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Ternary_ManualBackingField.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Ternary_ManualBackingField.verified.txt deleted file mode 100644 index 5928bd5d..00000000 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Ternary_ManualBackingField.verified.txt +++ /dev/null @@ -1,42 +0,0 @@ -// -#nullable disable - -using Foo; - -namespace ExpressiveSharp.Generated -{ - static partial class Foo_Account - { - // [Expressive(Projectable = true)] - // public decimal Amount - // { - // get => _amountHasValue ? _amount : (TotalAmount != null && Discount != null ? System.Math.Round(TotalAmount.Value - Discount.Value, 2) : 0m); - // init - // { - // _amountHasValue = true; - // _amount = value; - // } - // } - static global::System.Linq.Expressions.Expression> Amount_Expression() - { - var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.Account), "@this"); - var expr_3 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("TotalAmount")); // TotalAmount - var expr_4 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null - var expr_2 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.NotEqual, expr_3, expr_4); - var expr_6 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("Discount")); // Discount - var expr_7 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null - var expr_5 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.NotEqual, expr_6, expr_7); - var expr_1 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.AndAlso, expr_2, expr_5); - var expr_11 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("TotalAmount")); // TotalAmount - var expr_10 = global::System.Linq.Expressions.Expression.Property(expr_11, typeof(decimal?).GetProperty("Value")); - var expr_13 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("Discount")); // Discount - var expr_12 = global::System.Linq.Expressions.Expression.Property(expr_13, typeof(decimal?).GetProperty("Value")); - var expr_9 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Subtract, expr_10, expr_12); - var expr_14 = global::System.Linq.Expressions.Expression.Constant(2, typeof(int)); // 2 - var expr_8 = global::System.Linq.Expressions.Expression.Call(typeof(global::System.Math).GetMethod("Round", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(decimal), typeof(int) }, null), new global::System.Linq.Expressions.Expression[] { expr_9, expr_14 }); - var expr_15 = global::System.Linq.Expressions.Expression.Constant(0m, typeof(decimal)); // 0m - var expr_0 = global::System.Linq.Expressions.Expression.Condition(expr_1, expr_8, expr_15, typeof(decimal)); - return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); - } - } -} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NullableValueTypeProperty_Ternary_FieldKeyword.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NullableValueTypeProperty_Ternary_FieldKeyword.verified.txt deleted file mode 100644 index a40387e6..00000000 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NullableValueTypeProperty_Ternary_FieldKeyword.verified.txt +++ /dev/null @@ -1,44 +0,0 @@ -// -#nullable disable - -using Foo; - -namespace ExpressiveSharp.Generated -{ - static partial class Foo_Account - { - // [Expressive(Projectable = true)] - // public decimal? Amount - // { - // get => _amountHasValue ? field : (TotalAmount != null && Discount != null ? System.Math.Round(TotalAmount.Value - Discount.Value, 2) : (decimal? )null); - // init - // { - // _amountHasValue = true; - // field = value; - // } - // } - static global::System.Linq.Expressions.Expression> Amount_Expression() - { - var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.Account), "@this"); - var expr_3 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("TotalAmount")); // TotalAmount - var expr_4 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null - var expr_2 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.NotEqual, expr_3, expr_4); - var expr_6 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("Discount")); // Discount - var expr_7 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null - var expr_5 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.NotEqual, expr_6, expr_7); - var expr_1 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.AndAlso, expr_2, expr_5); - var expr_12 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("TotalAmount")); // TotalAmount - var expr_11 = global::System.Linq.Expressions.Expression.Property(expr_12, typeof(decimal?).GetProperty("Value")); - var expr_14 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Account).GetProperty("Discount")); // Discount - var expr_13 = global::System.Linq.Expressions.Expression.Property(expr_14, typeof(decimal?).GetProperty("Value")); - var expr_10 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Subtract, expr_11, expr_13); - var expr_15 = global::System.Linq.Expressions.Expression.Constant(2, typeof(int)); // 2 - var expr_9 = global::System.Linq.Expressions.Expression.Call(typeof(global::System.Math).GetMethod("Round", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(decimal), typeof(int) }, null), new global::System.Linq.Expressions.Expression[] { expr_10, expr_15 }); - var expr_8 = global::System.Linq.Expressions.Expression.Convert(expr_9, typeof(decimal?)); - var expr_17 = global::System.Linq.Expressions.Expression.Constant(null, typeof(object)); // null - var expr_16 = global::System.Linq.Expressions.Expression.Convert(expr_17, typeof(decimal?)); - var expr_0 = global::System.Linq.Expressions.Expression.Condition(expr_1, expr_8, expr_16, typeof(decimal?)); - return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); - } - } -} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.ProjectableWithSetAccessor.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.ProjectableWithSetAccessor.verified.txt deleted file mode 100644 index 2e390fef..00000000 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.ProjectableWithSetAccessor.verified.txt +++ /dev/null @@ -1,23 +0,0 @@ -// -#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_ManualBackingField.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_ManualBackingField.verified.txt deleted file mode 100644 index c2c29055..00000000 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_ManualBackingField.verified.txt +++ /dev/null @@ -1,23 +0,0 @@ -// -#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 deleted file mode 100644 index 9185e73c..00000000 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.cs +++ /dev/null @@ -1,693 +0,0 @@ -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 Task NullableValueTypeProperty_Ternary_FieldKeyword() - { - // Issue #35, attempt 1: `decimal?` property with the ternary + has-value-flag pattern. - // The flag distinguishes "not materialized" from "materialized to null", so nullable - // property types are permitted here (unlike the coalesce shape). - var compilation = CreateCompilation( - """ - #nullable enable - namespace Foo { - partial class Account { - public decimal? TotalAmount { get; init; } - public decimal? Discount { get; init; } - - private bool _amountHasValue; - - [Expressive(Projectable = true)] - public decimal? Amount - { - get => _amountHasValue ? field : ( - TotalAmount != null && Discount != null - ? System.Math.Round(TotalAmount.Value - Discount.Value, 2) - : (decimal?)null); - init { _amountHasValue = true; 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 NonNullableValueTypeProperty_Ternary_FieldKeyword() - { - // Issue #35, attempt 2: `decimal` property with the ternary + has-value-flag pattern. - // Coalesce `field ?? ...` doesn't compile when the backing field is non-nullable; the - // ternary pattern is the supported path here. - var compilation = CreateCompilation( - """ - namespace Foo { - partial class Account { - public decimal? TotalAmount { get; init; } - public decimal? Discount { get; init; } - - private bool _amountHasValue; - - [Expressive(Projectable = true)] - public decimal Amount - { - get => _amountHasValue ? field : ( - TotalAmount != null && Discount != null - ? System.Math.Round(TotalAmount.Value - Discount.Value, 2) - : 0m); - init { _amountHasValue = true; 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 NonNullableValueTypeProperty_Coalesce_ManualNullableBackingField() - { - // Issue #35, attempt 3: `decimal` property with a manual `decimal? _amount` backing field - // used via coalesce. The `_amount = value` assignment in the init accessor wraps `value` - // in an implicit conversion (decimal → decimal?); the setter validator must peek through - // it to see the plain `value` parameter reference. - var compilation = CreateCompilation( - """ - namespace Foo { - class Account { - public decimal? TotalAmount { get; init; } - public decimal? Discount { get; init; } - - private decimal? _amount; - - [Expressive(Projectable = true)] - public decimal Amount - { - get => _amount ?? ( - TotalAmount != null && Discount != null - ? System.Math.Round(TotalAmount.Value - Discount.Value, 2) - : 0m); - init => _amount = 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 NonNullableValueTypeProperty_Ternary_ManualBackingField() - { - // `decimal` property with a manual non-nullable `decimal _amount` backing field plus a - // separate has-value flag. Exercises the ternary + manual-backing-field path. - var compilation = CreateCompilation( - """ - namespace Foo { - class Account { - public decimal? TotalAmount { get; init; } - public decimal? Discount { get; init; } - - private decimal _amount; - private bool _amountHasValue; - - [Expressive(Projectable = true)] - public decimal Amount - { - get => _amountHasValue ? _amount : ( - TotalAmount != null && Discount != null - ? System.Math.Round(TotalAmount.Value - Discount.Value, 2) - : 0m); - init { _amountHasValue = true; _amount = 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 InvertedTernaryCondition_EXP0022() - { - // Only the bare `flag ? field : formula` form is supported in v1. Inverted conditions - // (`!flag ? formula : field`) are rejected with a pointed EXP0022 reason. - var compilation = CreateCompilation( - """ - namespace Foo { - partial class Account { - public decimal? TotalAmount { get; init; } - private bool _amountHasValue; - - [Expressive(Projectable = true)] - public decimal Amount - { - get => !_amountHasValue ? (TotalAmount ?? 0m) : field; - init { _amountHasValue = true; field = value; } - } - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0022")); - } - - [TestMethod] - public void TernaryFlagIsNullableBool_EXP0022() - { - // The has-value flag must be exactly `bool`, not `bool?`. - var compilation = CreateCompilation( - """ - namespace Foo { - partial class Account { - public decimal? TotalAmount { get; init; } - private bool? _amountHasValue; - - [Expressive(Projectable = true)] - public decimal Amount - { - get => _amountHasValue == true ? field : (TotalAmount ?? 0m); - init { _amountHasValue = true; field = value; } - } - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0022")); - } - - [TestMethod] - public void TernaryFlagIsReadonly_EXP0023() - { - // The has-value flag must not be readonly — the setter needs to write to it. - var compilation = CreateCompilation( - """ - namespace Foo { - partial class Account { - public decimal? TotalAmount { get; init; } - private readonly bool _amountHasValue; - - [Expressive(Projectable = true)] - public decimal Amount - { - get => _amountHasValue ? field : (TotalAmount ?? 0m); - init { /* cannot assign to readonly */ field = value; } - } - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0023")); - } - - [TestMethod] - public void TernarySetterMissingFlagWrite_EXP0023() - { - // The ternary form requires exactly two assignments: the flag AND the backing field. - // A setter that only assigns the backing field is rejected (the flag is never set, so - // the getter always falls through to the formula — the cache never activates). - var compilation = CreateCompilation( - """ - namespace Foo { - partial class Account { - public decimal? TotalAmount { get; init; } - private bool _amountHasValue; - - [Expressive(Projectable = true)] - public decimal Amount - { - get => _amountHasValue ? field : (TotalAmount ?? 0m); - init => field = value; - } - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0023")); - } - - [TestMethod] - public void TernarySetterWritesDifferentFlag_EXP0030() - { - // The setter writes to a different flag than the getter reads. - var compilation = CreateCompilation( - """ - namespace Foo { - partial class Account { - public decimal? TotalAmount { get; init; } - private bool _amountHasValue; - private bool _otherFlag; - - [Expressive(Projectable = true)] - public decimal Amount - { - get => _amountHasValue ? field : (TotalAmount ?? 0m); - init { _otherFlag = true; field = value; } - } - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0030")); - } - - [TestMethod] - public void TernarySetterWritesDifferentBackingField_EXP0030() - { - // The setter writes to a different backing field than the getter reads. - var compilation = CreateCompilation( - """ - namespace Foo { - partial class Account { - public decimal? TotalAmount { get; init; } - private bool _amountHasValue; - private decimal _otherField; - - [Expressive(Projectable = true)] - public decimal Amount - { - get => _amountHasValue ? field : (TotalAmount ?? 0m); - init { _amountHasValue = true; _otherField = value; } - } - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0030")); - } - - [TestMethod] - public void NullablePropertyWithCoalescePattern_EXP0024() - { - // Nullable property with the coalesce shape is still rejected: the cache sentinel `null` - // collides with a legitimately stored null value. - var compilation = CreateCompilation( - """ - #nullable enable - namespace Foo { - partial class Account { - public decimal? TotalAmount { get; init; } - - [Expressive(Projectable = true)] - public decimal? Amount - { - get => field ?? (TotalAmount ?? 0m); - init => field = value; - } - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0024")); - } - - [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/SynthesizedExpressiveTests.cs similarity index 56% rename from tests/ExpressiveSharp.IntegrationTests/Tests/ProjectableExpressiveTests.cs rename to tests/ExpressiveSharp.IntegrationTests/Tests/SynthesizedExpressiveTests.cs index 2e6920b1..7542232e 100644 --- a/tests/ExpressiveSharp.IntegrationTests/Tests/ProjectableExpressiveTests.cs +++ b/tests/ExpressiveSharp.IntegrationTests/Tests/SynthesizedExpressiveTests.cs @@ -1,24 +1,24 @@ using System.Linq.Expressions; +using ExpressiveSharp.Mapping; 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. +/// Provider-agnostic tests for [ExpressiveFor(..., Synthesize = true)]. Verifies the +/// dual-direction runtime behavior of the generated property: in-memory reads evaluate the +/// stub (because the backing field is not yet materialized), while values assigned through +/// the synthesized init accessor are stored and returned verbatim. /// [TestClass] -public class ProjectableExpressiveTests +public class SynthesizedExpressiveTests { // ── 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" }; + var entity = new SynthesizedEntity { Name = "Ada", Email = "ada@example.com" }; Assert.AreEqual("Ada ", entity.DisplayLabel); } @@ -26,7 +26,7 @@ public void InMemoryConstruction_ReadsComputeFromFormula() [TestMethod] public void InMemoryMutation_FormulaReflectsChanges() { - var entity = new ProjectableEntity { Name = "Ada", Email = "ada@example.com" }; + var entity = new SynthesizedEntity { Name = "Ada", Email = "ada@example.com" }; var firstRead = entity.DisplayLabel; entity.Name = "Augusta"; var secondRead = entity.DisplayLabel; @@ -39,9 +39,7 @@ public void InMemoryMutation_FormulaReflectsChanges() [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 + var entity = new SynthesizedEntity { Name = "Ada", Email = "ada@example.com", @@ -55,7 +53,7 @@ public void InitAccessor_StoredValueWins() [TestMethod] public void InitAccessor_StoredValueSurvivesDependencyMutation() { - var entity = new ProjectableEntity + var entity = new SynthesizedEntity { Name = "Ada", Email = "ada@example.com", @@ -71,7 +69,7 @@ public void InitAccessor_StoredValueSurvivesDependencyMutation() [TestMethod] public void NullDependencies_FormulaUsesFallbacks() { - var entity = new ProjectableEntity { Name = null, Email = null }; + var entity = new SynthesizedEntity { Name = null, Email = null }; Assert.AreEqual("(unnamed) ", entity.DisplayLabel); } @@ -79,18 +77,17 @@ public void NullDependencies_FormulaUsesFallbacks() // ── Expression-tree expansion ────────────────────────────────────────── [TestMethod] - public void ExpandExpressives_Select_RewritesProjectableToFormula() + public void ExpandExpressives_Select_RewritesSynthesizedToFormula() { - var source = new List + 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(); + 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); @@ -101,14 +98,14 @@ public void ExpandExpressives_Select_RewritesProjectableToFormula() [TestMethod] public void Ternary_ExpandExpressives_Select_RewritesToFormula() { - var source = new List + var source = new List { new() { TotalAmount = 100m, Discount = 20m }, new() { TotalAmount = 50m, Discount = 5m }, }.AsQueryable(); - Expression> expr = c => c.DiscountedAmount; - var expanded = (Expression>)expr.ExpandExpressives(); + Expression> expr = c => c.DiscountedAmount; + var expanded = (Expression>)expr.ExpandExpressives(); var values = source.Select(expanded.Compile()).ToList(); @@ -121,68 +118,52 @@ public void Ternary_ExpandExpressives_Select_RewritesToFormula() 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 + // The RHS references a synthesized member and must be rewritten. + var source = new List { new() { Name = "Ada", Email = "ada@example.com" }, }.AsQueryable(); - Expression> projectExpr = c => new ProjectableEntity + Expression> projectExpr = c => new SynthesizedEntity { Name = c.Name, Email = c.Email, DisplayLabel = c.DisplayLabel, }; - var expanded = (Expression>)projectExpr.ExpandExpressives(); + 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. +/// Non-nullable reference-type target — exercises the coalesce shape of the synthesized property. /// -public class ProjectableEntity +public partial class SynthesizedEntity { 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; - } + [ExpressiveFor("DisplayLabel", Synthesize = true)] + private string DisplayLabelExpression => + (Name ?? "(unnamed)") + " <" + (Email ?? "no-email") + ">"; } /// -/// Exercises the ternary + has-value-flag projectable pattern with a nullable value-type -/// property (decimal?). The flag distinguishes "not materialized" from "materialized -/// to null", which the coalesce shape cannot do for nullable property types. +/// Nullable value-type target — exercises the ternary+flag shape of the synthesized property. +/// The flag distinguishes "not materialized" from "materialized to null", which the coalesce +/// shape cannot do for nullable property types. /// -public class DiscountedProjectableEntity +public partial class DiscountedSynthesizedEntity { public decimal? TotalAmount { get; set; } public decimal? Discount { get; set; } - private bool _discountedAmountHasValue; - - [Expressive(Projectable = true)] - public decimal? DiscountedAmount - { - get => _discountedAmountHasValue - ? field - : (TotalAmount != null && Discount != null - ? TotalAmount.Value - Discount.Value - : (decimal?)null); - init - { - _discountedAmountHasValue = true; - field = value; - } - } + [ExpressiveFor("DiscountedAmount", Synthesize = true)] + private decimal? DiscountedAmountExpression => + TotalAmount != null && Discount != null + ? TotalAmount.Value - Discount.Value + : (decimal?)null; } diff --git a/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/ProjectableMongoIgnoreTests.cs b/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/SynthesizedMongoIgnoreTests.cs similarity index 55% rename from tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/ProjectableMongoIgnoreTests.cs rename to tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/SynthesizedMongoIgnoreTests.cs index 57c7e103..d6921ad6 100644 --- a/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/ProjectableMongoIgnoreTests.cs +++ b/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/SynthesizedMongoIgnoreTests.cs @@ -1,4 +1,4 @@ -using ExpressiveSharp.MongoDB.Extensions; +using ExpressiveSharp.Mapping; using ExpressiveSharp.MongoDB.Infrastructure; using ExpressiveSharp.MongoDB.IntegrationTests.Infrastructure; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -9,16 +9,16 @@ namespace ExpressiveSharp.MongoDB.IntegrationTests.Tests; /// -/// Verifies that [Expressive(Projectable = true)] properties are unmapped from BSON +/// Verifies that [ExpressiveFor(..., Synthesize = 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 +public class SynthesizedMongoIgnoreTests { private MongoClient? _client; private string? _dbName; - private IMongoCollection _collection = null!; + private IMongoCollection _collection = null!; [TestInitialize] public async Task InitMongo() @@ -29,20 +29,17 @@ public async Task InitMongo() // 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"); + _collection = database.GetCollection("people"); await _collection.InsertManyAsync( [ - new ProjectableMongoDocument { Id = 1, FirstName = "Ada", LastName = "Lovelace" }, - new ProjectableMongoDocument { Id = 2, FirstName = "Alan", LastName = "Turing" }, + new SynthesizedMongoDocument { Id = 1, FirstName = "Ada", LastName = "Lovelace" }, + new SynthesizedMongoDocument { Id = 2, FirstName = "Alan", LastName = "Turing" }, ]); } @@ -54,39 +51,32 @@ public async Task CleanupMongo() } [TestMethod] - public void ProjectableProperty_IsNotInClassMap() + public void SynthesizedProperty_IsNotInClassMap() { - // Force-build the class map and confirm the convention unmapped FullName. - var classMap = BsonClassMap.LookupClassMap(typeof(ProjectableMongoDocument)); + var classMap = BsonClassMap.LookupClassMap(typeof(SynthesizedMongoDocument)); 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"); + CollectionAssert.Contains(mappedNames, nameof(SynthesizedMongoDocument.FirstName)); + CollectionAssert.Contains(mappedNames, nameof(SynthesizedMongoDocument.LastName)); + CollectionAssert.DoesNotContain(mappedNames, nameof(SynthesizedMongoDocument.FullName), + "Synthesized property FullName must be unmapped from the BsonClassMap"); } [TestMethod] - public async Task ProjectableProperty_IsNotPersistedToBsonDocument() + public async Task SynthesizedProperty_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"); + "Synthesized property FullName must NOT be persisted to the BSON document"); } [TestMethod] - public async Task ProjectableProperty_RoundTrip_RetainsDependenciesOnly() + public async Task SynthesizedProperty_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); @@ -96,17 +86,13 @@ public async Task ProjectableProperty_RoundTrip_RetainsDependenciesOnly() } } -/// Self-contained document for Projectable Mongo tests. -public class ProjectableMongoDocument +/// Self-contained document for synthesized-property Mongo tests. +public partial class SynthesizedMongoDocument { 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; - } + [ExpressiveFor("FullName", Synthesize = true)] + private string FullNameExpression => LastName + ", " + FirstName; } diff --git a/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs b/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs index ee360c75..ccb3a8ac 100644 --- a/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs +++ b/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs @@ -85,50 +85,49 @@ public void FindGeneratedExpression_PropertyBody_ContainsMultiply() "Expected Product.Total expression to contain a Multiply node"); } - // ── [Expressive(Projectable = true)] ──────────────────────────────────── + // ── [ExpressiveFor(..., Synthesize = 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. + // The most load-bearing correctness point: the generator must register the formula lambda + // under the synthesized property's getter MethodHandle. If the registry were keyed off the + // stub member (e.g. FullNameExpression), `ExpressiveReplacer` would never find a match at + // runtime when user code accesses the public property and the rewrite would silently never fire. [TestMethod] - public void FindGeneratedExpression_ProjectableProperty_ResolvesByPropertyGetter() + public void FindGeneratedExpression_SynthesizedProperty_ResolvesByPropertyGetter() { - var propertyInfo = typeof(ProjectableCustomer).GetProperty(nameof(ProjectableCustomer.FullName))!; + var propertyInfo = typeof(SynthesizedCustomer).GetProperty(nameof(SynthesizedCustomer.FullName))!; var result = _resolver.FindGeneratedExpression(propertyInfo); - Assert.IsNotNull(result, "Resolver must return a lambda for a Projectable property"); + Assert.IsNotNull(result, "Resolver must return a lambda for a synthesized property"); Assert.IsInstanceOfType(result); } [TestMethod] - public void FindGeneratedExpression_ProjectableProperty_BodyIsFormulaOnly() + public void FindGeneratedExpression_SynthesizedProperty_BodyIsFormulaOnly() { - var propertyInfo = typeof(ProjectableCustomer).GetProperty(nameof(ProjectableCustomer.FullName))!; + var propertyInfo = typeof(SynthesizedCustomer).GetProperty(nameof(SynthesizedCustomer.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. + // The body must be the stub's formula — the synthesized property's own get accessor + // (which contains the `??` coalesce) is invisible to the registry. 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"); + "Synthesized-property expression body must be the formula only, not the wrapping '??' coalesce"); + Assert.IsTrue(ContainsMemberAccess(result.Body, nameof(SynthesizedCustomer.LastName)) + && ContainsMemberAccess(result.Body, nameof(SynthesizedCustomer.FirstName)), + "Synthesized-property expression body must reference both dependencies of the formula"); } [TestMethod] - public void FindGeneratedExpression_ProjectableProperty_BackingFieldIsNotInRegistry() + public void FindGeneratedExpression_SynthesizedProperty_BackingFieldIsNotInRegistry() { - // Reflect across the compiler-synthesized backing field for FullName. Calling + // Reflect across the generator-emitted 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")); + var backingField = typeof(SynthesizedCustomer).GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(f => f.Name.Contains("fullName", StringComparison.OrdinalIgnoreCase)); if (backingField is null) { @@ -171,18 +170,14 @@ private static bool ContainsNodeType(Expression expr, ExpressionType nodeType) } /// -/// Test-local fixture for Projectable resolver tests. Declared here (not in the shared -/// TestFixtures) to keep the Projectable-specific dependency contained. +/// Test-local fixture for synthesized-property resolver tests. Declared here (not in the shared +/// TestFixtures) to keep the [ExpressiveFor(..., Synthesize = true)] dependency contained. /// -public class ProjectableCustomer +public partial class SynthesizedCustomer { public string FirstName { get; set; } = ""; public string LastName { get; set; } = ""; - [Expressive(Projectable = true)] - public string FullName - { - get => field ?? (LastName + ", " + FirstName); - init => field = value; - } + [ExpressiveSharp.Mapping.ExpressiveFor("FullName", Synthesize = true)] + private string FullNameExpression => LastName + ", " + FirstName; } From a03c1f9c5cf7e511478a9deac4f137ecebead0e5 Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 20 Apr 2026 23:42:18 +0000 Subject: [PATCH 2/3] Introduce [ExpressiveProperty] for synthesizing projection-friendly properties A dedicated attribute that declares a settable property on a partial type using an instance expression-bodied stub as the formula source. Replaces the need for a flag on [ExpressiveFor] and gives the feature a clear, intent-named entry point alongside [Expressive] and [ExpressiveFor]. Rules enforced by EXP0031-EXP0035: - stub must be an expression-bodied property (EXP0033) - stub must be an instance member (EXP0034) - containing type must be declared partial (EXP0032) - target name must not exist on the type (EXP0031, suggests [ExpressiveFor]) - target name must not shadow an inherited member (EXP0035) Generator picks coalesce shape for non-nullable targets and ternary+flag for nullable targets so materialized null stays distinguishable from "not materialized". Synthesized properties are always public with an init accessor. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/.vitepress/config.mts | 1 + docs/guide/migration-from-projectables.md | 12 +- docs/recipes/projection-middleware.md | 18 +- docs/reference/diagnostics.md | 111 ++++--- docs/reference/expressive-for.md | 69 +--- docs/reference/expressive-property.md | 102 ++++++ .../Mapping/ExpressiveForAttribute.cs | 9 - .../Mapping/ExpressivePropertyAttribute.cs | 36 +++ .../Emitter/SynthesizedPropertyEmitter.cs | 6 +- .../ExpressiveGenerator.cs | 153 +++++++-- .../Infrastructure/Diagnostics.cs | 40 ++- .../ExpressiveForInterpreter.cs | 187 ----------- .../ExpressivePropertyInterpreter.cs | 270 ++++++++++++++++ .../Models/ExpressiveDescriptor.cs | 4 +- .../Models/ExpressiveForAttributeData.cs | 10 - .../Models/ExpressivePropertyAttributeData.cs | 47 +++ .../Models/SynthesizedPropertySpec.cs | 4 +- .../ExpressiveMongoIgnoreConvention.cs | 8 +- .../Sqlite/SynthesizedExpressiveSqlTests.cs | 4 +- .../ExpressiveGenerator/ExpressiveForTests.cs | 185 ----------- ...TypeTarget_EmitsCoalesceForm.verified.txt} | 2 +- ...eTypeTarget_EmitsTernaryForm.verified.txt} | 2 +- ...eTypeTarget_EmitsTernaryForm.verified.txt} | 2 +- ....PartialRecord_EmitsCorrectly.verified.txt | 43 +++ ....PartialStruct_EmitsCorrectly.verified.txt | 46 +++ ...TypeTarget_EmitsCoalesceForm.verified.txt} | 2 +- .../ExpressivePropertyTests.cs | 299 ++++++++++++++++++ .../Tests/SynthesizedExpressiveTests.cs | 6 +- .../Tests/SynthesizedMongoIgnoreTests.cs | 4 +- .../Services/ExpressiveResolverTests.cs | 6 +- 30 files changed, 1119 insertions(+), 569 deletions(-) create mode 100644 docs/reference/expressive-property.md create mode 100644 src/ExpressiveSharp.Abstractions/Mapping/ExpressivePropertyAttribute.cs create mode 100644 src/ExpressiveSharp.Generator/Interpretation/ExpressivePropertyInterpreter.cs create mode 100644 src/ExpressiveSharp.Generator/Models/ExpressivePropertyAttributeData.cs rename tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/{ExpressiveForTests.Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt => ExpressivePropertyTests.NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt} (96%) rename tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/{ExpressiveForTests.Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt => ExpressivePropertyTests.NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt} (98%) rename tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/{ExpressiveForTests.Synthesize_NullableValueTypeTarget_EmitsTernaryForm.verified.txt => ExpressivePropertyTests.NullableValueTypeTarget_EmitsTernaryForm.verified.txt} (98%) create mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.PartialRecord_EmitsCorrectly.verified.txt create mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.PartialStruct_EmitsCorrectly.verified.txt rename tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/{ExpressiveForTests.Synthesize_ReferenceTypeTarget_EmitsCoalesceForm.verified.txt => ExpressivePropertyTests.ReferenceTypeTarget_EmitsCoalesceForm.verified.txt} (97%) create mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.cs diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 04321129..fd8297a4 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -58,6 +58,7 @@ const sidebar: DefaultTheme.Sidebar = { items: [ { text: '[Expressive] Attribute', link: '/reference/expressive-attribute' }, { text: '[ExpressiveFor] Mapping', link: '/reference/expressive-for' }, + { text: '[ExpressiveProperty] Attribute', link: '/reference/expressive-property' }, { text: 'Null-Conditional Rewrite', link: '/reference/null-conditional-rewrite' }, { text: 'Pattern Matching', link: '/reference/pattern-matching' }, { text: 'Switch Expressions', link: '/reference/switch-expressions' }, diff --git a/docs/guide/migration-from-projectables.md b/docs/guide/migration-from-projectables.md index 5480e0ee..772cba55 100644 --- a/docs/guide/migration-from-projectables.md +++ b/docs/guide/migration-from-projectables.md @@ -122,7 +122,7 @@ Both the old `Ignore` and `Rewrite` behaviors converge to the same result in Exp | Old Property | Migration | |---|---| -| `UseMemberBody = "SomeMethod"` | Replace with `[ExpressiveFor(..., Synthesize = true)]` or plain `[ExpressiveFor]`. See [Migrating UseMemberBody](#migrating-usememberbody) below. | +| `UseMemberBody = "SomeMethod"` | Replace with `[ExpressiveProperty]` or plain `[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. | @@ -133,12 +133,12 @@ In Projectables, `UseMemberBody` let you point one member's expression body at a ExpressiveSharp offers **two replacement shapes**, depending on your scenario: -- **`[ExpressiveFor(..., Synthesize = true)]`** -- the closest analogue: you write only the formula; the generator synthesizes the settable target property on a `partial` class. The property participates in projection middleware because it has an `init` accessor. Best fit when you want a dedicated property backed purely by an expression. +- **`[ExpressiveProperty]`** -- the closest analogue: you write only the formula; the generator synthesizes the settable target property on a `partial` class. The property participates in projection middleware because it has an `init` accessor. Best fit when you want a dedicated property backed purely by an expression. - **Plain `[ExpressiveFor]`** -- when the target property already exists (or lives on an external type you do not own). No property is synthesized; the stub maps to an existing member. Pick based on whether you want the generator to declare the target property for you. -**Option A -- `[ExpressiveFor(..., Synthesize = true)]`** (formula-only, property is generated): +**Option A -- `[ExpressiveProperty]`** (formula-only, property is generated): ```csharp // Before (Projectables) @@ -149,15 +149,15 @@ private string FullNameProjection => LastName + ", " + FirstName; // After (ExpressiveSharp) -- partial class, stub only; FullName is generated public partial class Customer { - [ExpressiveFor("FullName", Synthesize = true)] + [ExpressiveProperty("FullName")] private string FullNameExpression => LastName + ", " + FirstName; } ``` -The generator picks between a coalesce shape (non-nullable targets) and a ternary+flag shape (nullable targets) so materialized `null` stays distinguishable from "not materialized." See the [Synthesize section of the `[ExpressiveFor]` reference](../reference/expressive-for#synthesizing-a-property-with-synthesize-true) and the [Projection Middleware recipe](../recipes/projection-middleware). +The generator picks between a coalesce shape (non-nullable targets) and a ternary+flag shape (nullable targets) so materialized `null` stays distinguishable from "not materialized." See the [`[ExpressiveProperty]` reference](../reference/expressive-property) and the [Projection Middleware recipe](../recipes/projection-middleware). ::: warning Target name must be a string literal -When `Synthesize = true`, the target property does not yet exist during the generator's pass, so `nameof(FullName)` fails to resolve. Always pass the name as a string literal: `[ExpressiveFor("FullName", Synthesize = true)]`. +The target property does not exist during the generator's pass, so `nameof(FullName)` fails to resolve. Always pass the name as a string literal: `[ExpressiveProperty("FullName")]`. ::: **Option B -- plain `[ExpressiveFor]`** (target property already exists, or lives on an external type): diff --git a/docs/recipes/projection-middleware.md b/docs/recipes/projection-middleware.md index 05f41a1d..14185547 100644 --- a/docs/recipes/projection-middleware.md +++ b/docs/recipes/projection-middleware.md @@ -14,7 +14,7 @@ HotChocolate inspects the `User.FullName` property, finds it is **read-only** (n 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: `[ExpressiveFor(..., Synthesize = true)]` +## The fix: `[ExpressiveProperty]` Write the formula as a stub on a `partial` class and let the generator synthesize a settable property for you. The synthesized property is writable (so the projection middleware emits a binding) while still registering the formula for SQL translation. The dual-direction runtime behavior is: @@ -39,7 +39,7 @@ public class User GraphQL response: `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }` -- wrong. SQL emitted: `SELECT 1 FROM Users` -- nothing fetched. -**After** -- `Synthesize = true` on a formula stub. +**After** -- `[ExpressiveProperty]` on a formula stub. ```csharp public partial class User @@ -49,7 +49,7 @@ public partial class User // The generator emits a settable FullName property whose getter falls through // to this stub when no value has been materialized yet. - [ExpressiveFor("FullName", Synthesize = true)] + [ExpressiveProperty("FullName")] private string FullNameExpression => LastName + ", " + FirstName; } ``` @@ -60,7 +60,7 @@ SQL emitted: `SELECT u.LastName || ', ' || u.FirstName AS "FullName" FROM Users No HC glue code is required beyond the normal `.UseExpressives()` on the DbContext options. The convention auto-ignores the synthesized property in EF's model (so no `FullName` column is created), and the projection rewrite happens automatically when the query compiler intercepts. ::: warning Target name must be a string literal -`nameof(FullName)` won't resolve here because `FullName` doesn't exist until the generator emits it. Pass the name as a string literal: `[ExpressiveFor("FullName", Synthesize = true)]`. +`nameof(FullName)` won't resolve here because `FullName` doesn't exist until the generator emits it. Pass the name as a string literal: `[ExpressiveProperty("FullName")]`. ::: ## Full HotChocolate example @@ -74,10 +74,10 @@ public partial class User public string LastName { get; set; } = ""; public string Email { get; set; } = ""; - [ExpressiveFor("FullName", Synthesize = true)] + [ExpressiveProperty("FullName")] private string FullNameExpression => LastName + ", " + FirstName; - [ExpressiveFor("DisplayLabel", Synthesize = true)] + [ExpressiveProperty("DisplayLabel")] private string DisplayLabelExpression => FullName + " <" + Email + ">"; } @@ -136,7 +136,7 @@ var users = await db.Users ## When the target property already exists -If you already have a settable auto-property (e.g. because it is used for DTO shape or deserialization), use the plain `[ExpressiveFor]` form without `Synthesize`: +If you already have a settable auto-property (e.g. because it is used for DTO shape or deserialization), use the plain `[ExpressiveFor]` form: ```csharp public class User @@ -150,10 +150,10 @@ public class User } ``` -The two forms produce the same SQL behaviour; the difference is who declares the target property. Use `Synthesize = true` when the property exists only to support projection middleware; use plain `[ExpressiveFor]` when the property has its own reason to exist. +The two forms produce the same SQL behaviour; the difference is who declares the target property. Use `[ExpressiveProperty]` when the property exists only to support projection middleware; use plain `[ExpressiveFor]` when the property has its own reason to exist. ## See Also -- [`[ExpressiveFor]` Mapping](../reference/expressive-for#synthesizing-a-property-with-synthesize-true) -- full `Synthesize = true` reference +- [`[ExpressiveProperty]` Attribute](../reference/expressive-property) -- full reference - [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 50d741c7..d2a33c86 100644 --- a/docs/reference/diagnostics.md +++ b/docs/reference/diagnostics.md @@ -32,9 +32,11 @@ 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 | -- | -| [EXP0031](#exp0031) | Error | `[ExpressiveFor(Synthesize = true)]` target name is already defined | -- | -| [EXP0032](#exp0032) | Error | `[ExpressiveFor(Synthesize = true)]` requires a partial containing type | -- | -| [EXP0033](#exp0033) | Error | `[ExpressiveFor(Synthesize = true)]` requires the single-argument form | -- | +| [EXP0031](#exp0031) | Error | `[ExpressiveProperty]` target name is already defined | -- | +| [EXP0032](#exp0032) | Error | `[ExpressiveProperty]` requires a partial containing type | -- | +| [EXP0033](#exp0033) | Error | `[ExpressiveProperty]` requires an expression-bodied property stub | -- | +| [EXP0034](#exp0034) | Error | `[ExpressiveProperty]` requires an instance stub | -- | +| [EXP0035](#exp0035) | Error | `[ExpressiveProperty]` target shadows inherited member | -- | | [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) | @@ -473,108 +475,139 @@ Duplicate [ExpressiveFor] mapping for member '{0}' on type '{1}'; only one stub --- -## Synthesize Diagnostics (EXP0031--EXP0033) +## `[ExpressiveProperty]` Diagnostics (EXP0031--EXP0035) -These diagnostics apply to `[ExpressiveFor(..., Synthesize = true)]` stubs, which ask the generator to emit a new property on the stub's containing type. See [`[ExpressiveFor]` Mapping](./expressive-for#synthesizing-a-property-with-synthesize-true) for the full feature reference. +These diagnostics apply to `[ExpressiveProperty]` stubs, which ask the generator to emit a new property on the stub's containing partial type. See [`[ExpressiveProperty]` Attribute](./expressive-property) for the full feature reference. ::: info Replacing `[Expressive(Projectable = true)]` -`[ExpressiveFor(..., Synthesize = true)]` replaces the now-removed `[Expressive(Projectable = true)]`. Diagnostic codes `EXP0021`--`EXP0030` were retired along with that feature and are not reused. The migration recipe is in [Migration from Projectables](../guide/migration-from-projectables#migrating-usememberbody). +`[ExpressiveProperty]` replaces the now-removed `[Expressive(Projectable = true)]`. Diagnostic codes `EXP0021`--`EXP0030` were retired along with that feature and are not reused. The migration recipe is in [Migration from Projectables](../guide/migration-from-projectables#migrating-usememberbody). ::: -### EXP0031 -- Synthesize target name is already defined {#exp0031} +### EXP0031 -- Target name is already defined {#exp0031} **Severity:** Error **Category:** Design **Message:** ``` -[ExpressiveFor(..., Synthesize = true)] target name '{0}' is already defined on '{1}'. -Remove Synthesize or rename the stub. +[ExpressiveProperty] target name '{0}' is already defined on '{1}' — rename the stub, +or use [ExpressiveFor(nameof({0}))] to map onto the existing member instead ``` -**Cause:** The name passed to `[ExpressiveFor]` already resolves to a member on the containing type. Synthesis would collide with the existing declaration, so the generator refuses to emit. +**Cause:** The name passed to `[ExpressiveProperty]` already resolves to a member on the containing type. Synthesis would collide with the existing declaration. -**Fix:** Either remove `Synthesize = true` (and instead use the plain `[ExpressiveFor]` form targeting the existing member), or pick a different target name. +**Fix:** Either rename the stub to pick a different target, or — if you want to bind to the existing property — drop `[ExpressiveProperty]` and switch to plain `[ExpressiveFor(nameof(X))]`: ```csharp // Error: Amount already exists on the class public decimal Amount { get; set; } -[ExpressiveFor("Amount", Synthesize = true)] +[ExpressiveProperty("Amount")] private decimal AmountExpression => TotalAmount - Discount; -// Fixed: drop Synthesize — target is already declared -[ExpressiveFor("Amount")] +// Fixed: map onto the existing property with [ExpressiveFor] +[ExpressiveFor(nameof(Amount))] private decimal AmountExpression => TotalAmount - Discount; ``` --- -### EXP0032 -- Synthesize requires a partial containing type {#exp0032} +### EXP0032 -- Requires a partial containing type {#exp0032} **Severity:** Error **Category:** Design **Message:** ``` -[ExpressiveFor(..., Synthesize = true)] requires the containing type '{0}' to be declared -'partial' so the synthesized property can be emitted into it +[ExpressiveProperty] requires the containing type '{0}' to be declared 'partial' +(applies to class, struct, and record) ``` -**Cause:** Source generators can only add members to types that are declared `partial`. Synthesized properties are emitted as a partial declaration alongside the user's source, and the compiler merges the two. +**Cause:** Source generators can only add members to types declared `partial`. Synthesized properties are emitted as a separate partial declaration alongside the user's source. -**Fix:** Add the `partial` modifier to the containing type: +**Fix:** Add the `partial` modifier: ```csharp // Error public class Account { - [ExpressiveFor("Amount", Synthesize = true)] + [ExpressiveProperty("Amount")] private decimal AmountExpression => TotalAmount - Discount; } // Fixed public partial class Account { - [ExpressiveFor("Amount", Synthesize = true)] + [ExpressiveProperty("Amount")] private decimal AmountExpression => TotalAmount - Discount; } ``` --- -### EXP0033 -- Synthesize requires the single-argument form {#exp0033} +### EXP0033 -- Requires an expression-bodied property stub {#exp0033} **Severity:** Error **Category:** Design **Message:** ``` -Synthesize = true only applies to same-type stubs; use the single-argument form -[ExpressiveFor(nameof(Member), Synthesize = true)] instead of the two-argument typeof form +[ExpressiveProperty] must be placed on a property with an expression body '=> expr' — +accessor-list forms and method stubs are not supported ``` -**Cause:** `Synthesize = true` is only meaningful when the stub is on the same type as the property it is synthesizing. The two-argument `[ExpressiveFor(typeof(Other), ...)]` form targets an external type — there is no coherent way to synthesize a member onto it from the stub's location. +**Cause:** The attribute was placed on a method, an accessor-list property (`{ get => expr; }`), or a full `{ get; set; }` shape. -**Fix:** Drop the `typeof(...)` argument and put the stub directly on the target type (making it `partial` if needed): +**Fix:** Rewrite the stub as a top-level expression-bodied property: ```csharp -// Error -partial class Other {} +// Error: method stub +[ExpressiveProperty("Amount")] +private decimal AmountExpression() => TotalAmount - Discount; -partial class Account -{ - [ExpressiveFor(typeof(Other), "FullName", Synthesize = true)] - private string FullNameExpression => ...; -} +// Error: accessor-list form +[ExpressiveProperty("Amount")] +private decimal AmountExpression { get => TotalAmount - Discount; } -// Fixed — stub moves to Other, single-argument form -partial class Other -{ - [ExpressiveFor("FullName", Synthesize = true)] - private string FullNameExpression => ...; -} +// Fixed: top-level expression body +[ExpressiveProperty("Amount")] +private decimal AmountExpression => TotalAmount - Discount; +``` + +--- + +### EXP0034 -- Requires an instance stub {#exp0034} + +**Severity:** Error +**Category:** Design + +**Message:** ``` +[ExpressiveProperty] is not supported on static stubs — stub '{0}' must be declared as +an instance member +``` + +**Cause:** The decorated stub is `static`. Synthesis is an instance-only feature. + +**Fix:** Drop the `static` modifier. For a static computed value, use plain `[Expressive]` on a read-only member instead. + +--- + +### EXP0035 -- Target shadows inherited member {#exp0035} + +**Severity:** Error +**Category:** Design + +**Message:** +``` +[ExpressiveProperty] target name '{0}' shadows an inherited member on '{1}' — rename +the target to avoid silent hiding, or drop [ExpressiveProperty] and use [Expressive] +on an override +``` + +**Cause:** The target name matches a member inherited from a base class. Synthesizing a hidden member would silently shadow the base declaration, which is surprising and error-prone. + +**Fix:** Either pick a different target name, or drop `[ExpressiveProperty]` and make the computed value an `override` decorated with plain `[Expressive]`. --- diff --git a/docs/reference/expressive-for.md b/docs/reference/expressive-for.md index 8ce44fc2..95e3e9d5 100644 --- a/docs/reference/expressive-for.md +++ b/docs/reference/expressive-for.md @@ -133,78 +133,18 @@ public static class MyDtoBuilder } ``` -## Synthesizing a property with `Synthesize = true` +## Synthesizing a new property -When you want the target property to exist **purely as an expressive-backed projection** -- no manual declaration, no backing storage to wire up, but still settable so projection middleware (EF Core materialization, HotChocolate's `ProjectTo`, AutoMapper, Mapperly) can populate it from query results -- set `Synthesize = true` on the single-argument form. The generator declares the property for you inside a partial class and wires its accessors to the stub: - -```csharp -public partial class Account -{ - public decimal? TotalAmount { get; set; } - public decimal? Discount { get; set; } - - // The target property Amount is NOT declared here — the generator emits it. - [ExpressiveFor("Amount", Synthesize = true)] - private decimal? AmountExpression => - TotalAmount != null && Discount != null - ? TotalAmount.Value - Discount.Value - : null; -} -``` - -The generator produces the following partial declaration, which the C# compiler merges with your class: - -```csharp -// -namespace YourNamespace -{ - partial class Account - { - private decimal? _amount; - private bool _amountHasValue; - public decimal? Amount - { - get => _amountHasValue ? _amount : AmountExpression; - init - { - _amountHasValue = true; - _amount = value; - } - } - } -} -``` - -### Shape selection - -The generator picks between two shapes based on the target type's nullability: - -- **Coalesce shape** (non-nullable targets -- `string`, `decimal`, `int`, ...): `get => _field ?? stub;`, with a nullable backing field. Minimal overhead; `null` unambiguously means "not yet materialized." -- **Ternary + flag shape** (nullable targets -- `string?`, `decimal?`, `int?`, ...): `get => _hasValue ? _field : stub;`, with a separate `bool` flag. Required because stored `null` is a legitimate value that must be distinguished from "not materialized." - -### Requirements - -- Use the single-argument form `[ExpressiveFor("Name", Synthesize = true)]`. The two-argument `typeof(...)` form is rejected with **EXP0033**; `Synthesize` always targets the stub's containing type. -- Supply the target name as a **string literal**, not `nameof(Name)` -- because `Name` is declared by the generator, `nameof(Name)` fails to resolve during the initial compilation pass. -- The containing type must be declared `partial` (**EXP0032**) so the generator can add the property declaration. -- The target name must not already exist on the containing type (**EXP0031**) -- that would be an ambiguous conflict with a user-written member. -- The stub must be a parameterless instance member (property or method) on the same type. - -### How it interacts with providers - -Because the stub flows through the normal `[ExpressiveFor]` pipeline, the registry is keyed on the **synthesized property's getter**. At query time, `ExpressiveReplacer` rewrites references to `Amount` with the stub's formula -- exactly as if you had written `[ExpressiveFor(nameof(Amount))]` against a manually-declared `Amount` property. The difference is purely who writes the property declaration. - -For EF Core and Mongo, the synthesized property is automatically excluded from mapping by `ExpressivePropertiesNotMappedConvention` and `ExpressiveMongoIgnoreConvention` -- no `[NotMapped]` attribute needed. +`[ExpressiveFor]` maps onto an **existing** member. If the target property does not yet exist and you want the generator to declare it for you (for example, so HotChocolate or EF Core projection middleware can bind to a settable member), use [`[ExpressiveProperty]`](./expressive-property) instead — it's the focused attribute for that case. ## Properties -Both `[ExpressiveFor]` and `[ExpressiveForConstructor]` support the same optional properties as `[Expressive]`, plus `Synthesize`: +Both `[ExpressiveFor]` and `[ExpressiveForConstructor]` support the same optional properties as `[Expressive]`: | Property | Type | Default | Description | |----------|------|---------|-------------| | `AllowBlockBody` | `bool` | `false` | Enables block-bodied stubs (`if`/`else`, local variables, etc.) | | `Transformers` | `Type[]?` | `null` | Per-mapping transformers applied when expanding the mapped member | -| `Synthesize` | `bool` | `false` | Generates the target property on the stub's containing type (see [Synthesizing a property](#synthesizing-a-property-with-synthesize-true)). `[ExpressiveFor]` only. | ::: expressive-sample db.Orders.Where(o => System.Math.Clamp(o.Items.Count(), 0, 100) > 5) @@ -232,9 +172,6 @@ The following diagnostics are specific to `[ExpressiveFor]` and `[ExpressiveForC | [EXP0017](./diagnostics#exp0017) | Error | Return type of the stub does not match the target member's return type | | [EXP0019](./diagnostics#exp0019) | Error | The target member already has `[Expressive]` -- remove one of the two attributes | | [EXP0020](./diagnostics#exp0020) | Error | Duplicate mapping -- only one stub per target member is allowed | -| [EXP0031](./diagnostics#exp0031) | Error | `Synthesize = true` target name is already defined on the containing type | -| [EXP0032](./diagnostics#exp0032) | Error | `Synthesize = true` requires the containing type to be declared `partial` | -| [EXP0033](./diagnostics#exp0033) | Error | `Synthesize = true` must use the single-argument form, not `typeof(...)` | ::: warning If a member already has `[Expressive]`, adding `[ExpressiveFor]` targeting it is a compile error (EXP0019). `[ExpressiveFor]` is only for members that do not have `[Expressive]`. diff --git a/docs/reference/expressive-property.md b/docs/reference/expressive-property.md new file mode 100644 index 00000000..54703b6d --- /dev/null +++ b/docs/reference/expressive-property.md @@ -0,0 +1,102 @@ +# `[ExpressiveProperty]` Attribute + +`[ExpressiveProperty]` synthesizes a **settable** property on the decorated stub's containing partial type. The stub supplies the formula; the generator emits the target property with a backing field and an `init` accessor, so projection middleware (HotChocolate `[UseProjection]`, AutoMapper `ProjectTo`, Mapperly, EF Core materialization) can bind to it. In-memory reads fall through to the stub until a value is materialized; after materialization, the stored value wins. + +## Namespace + +```csharp +using ExpressiveSharp.Mapping; +``` + +## Minimum example + +```csharp +public partial class Account +{ + public decimal? TotalAmount { get; set; } + public decimal? Discount { get; set; } + + // Amount is NOT declared here — the generator emits it. + [ExpressiveProperty("Amount")] + private decimal? AmountExpression => + TotalAmount != null && Discount != null + ? TotalAmount.Value - Discount.Value + : null; +} +``` + +The generator emits, alongside the expression-tree factory: + +```csharp +// +namespace YourNamespace +{ + partial class Account + { + private decimal? _amount; + private bool _amountHasValue; + public decimal? Amount + { + get => _amountHasValue ? _amount : AmountExpression; + init + { + _amountHasValue = true; + _amount = value; + } + } + } +} +``` + +## Rules + +1. **Placement.** Only valid on a property declaration with a top-level expression body (`=> expr`). Method stubs and accessor-list forms (`{ get => expr; }`) are rejected. This keeps the feature's surface small and unambiguous. +2. **Instance only.** Static stubs are rejected. If you need a static computed value, use plain `[Expressive]` on a read-only member. +3. **Explicit target name.** Pass the name as a **string literal**: `[ExpressiveProperty("Amount")]`. `nameof(Amount)` fails to resolve because `Amount` does not yet exist during the generator's pass. +4. **Partial type.** The containing `class`, `struct`, or `record` must be declared `partial` so the generator can emit into it. +5. **Unique name.** The target name must not already exist on the containing type *or any of its base types*. Use plain `[ExpressiveFor(nameof(X))]` to map onto an existing member instead. +6. **One target per stub.** `AllowMultiple = false`; the attribute cannot be stacked to alias one stub to multiple targets. + +## Shape selection + +The generator picks between two shapes based on the target type's nullability: + +| Target type | Shape | Backing field | Flag field | +|-------------|-------|---------------|------------| +| Non-nullable ref (`string`) | Coalesce | `string?` | — | +| Non-nullable value (`decimal`) | Coalesce | `Nullable` | — | +| Nullable ref (`string?`) | Ternary | `string?` | `bool` | +| Nullable value (`decimal?`) | Ternary | `decimal?` | `bool` | + +Coalesce uses `get => _x ?? stub`. Ternary uses `get => _xHasValue ? _x : stub`. The ternary shape is necessary whenever `null` is a legitimate stored value — otherwise the cache sentinel and the materialized value collide. + +## Visibility + +The synthesized property is always `public` with an `init` accessor. Projection middleware needs the property to be externally reachable and writable-once — `public` + `init` satisfies both with no opt-in surface. + +If you need an `internal` synthesized property, cap the effective visibility by placing the whole type inside an `internal partial class`. For a non-materializable read-only computed value, use `[Expressive]` directly. + +If you need a mutable `set` (not just `init`), that's a future addition; open an issue describing the scenario. + +## When to use `[ExpressiveFor]` instead + +| Scenario | Use | +|----------|-----| +| Target property **already exists** on your type (e.g. it also has runtime state) | [`[ExpressiveFor(nameof(X))]`](./expressive-for) | +| Target lives on an **external type** (BCL, third-party) | [`[ExpressiveFor(typeof(T), "X")]`](./expressive-for) | +| Target **does not exist** and you want a settable property for projection middleware | `[ExpressiveProperty("X")]` (this page) | +| Target is a **computed read-only** value, no materialization needed | [`[Expressive]`](./expressive-attribute) | + +## Diagnostics + +| Code | Cause | +|------|-------| +| [EXP0031](./diagnostics#exp0031) | Target name already defined on the containing type. Rename the stub, or switch to `[ExpressiveFor(nameof(X))]`. | +| [EXP0032](./diagnostics#exp0032) | Containing type is not `partial`. | +| [EXP0033](./diagnostics#exp0033) | Stub is not a property with top-level expression body. | +| [EXP0034](./diagnostics#exp0034) | Stub is `static`. | +| [EXP0035](./diagnostics#exp0035) | Target name shadows an inherited member on a base type. | + +## EF Core and MongoDB integration + +Synthesized properties are automatically excluded from EF Core's model (`ExpressivePropertiesNotMappedConvention`) and from MongoDB's BSON class map (`ExpressiveMongoIgnoreConvention`). You do **not** need `[NotMapped]` or `[BsonIgnore]`. The registry entry is keyed on the synthesized property's getter, so `ExpressiveReplacer` rewrites references to it into the stub formula at query time. diff --git a/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs b/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs index 7825ead6..b58ee5f8 100644 --- a/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs +++ b/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs @@ -39,15 +39,6 @@ public sealed class ExpressiveForAttribute : Attribute /// public Type[]? Transformers { get; set; } - /// - /// When true, the generator synthesizes the target property on the stub's containing - /// type (which must be partial). The stub becomes the formula source and the synthesized - /// property caches materialized values (from query results) using a ?? formula coalesce - /// for non-nullable types or a hasValue ? stored : formula ternary for nullable types. - /// Only valid with the single-argument form [ExpressiveFor(nameof(Member), Synthesize = true)]. - /// - public bool Synthesize { get; set; } - public ExpressiveForAttribute(Type targetType, string memberName) { TargetType = targetType; diff --git a/src/ExpressiveSharp.Abstractions/Mapping/ExpressivePropertyAttribute.cs b/src/ExpressiveSharp.Abstractions/Mapping/ExpressivePropertyAttribute.cs new file mode 100644 index 00000000..66c444fe --- /dev/null +++ b/src/ExpressiveSharp.Abstractions/Mapping/ExpressivePropertyAttribute.cs @@ -0,0 +1,36 @@ +namespace ExpressiveSharp.Mapping; + +/// +/// Declares a synthesized, settable property on the containing partial type whose read-side +/// formula is supplied by the decorated stub. The generator emits a property named +/// backed by a private field; read-access evaluates the stub formula +/// until a value is materialized (e.g. by EF Core, HotChocolate [UseProjection], or +/// AutoMapper ProjectTo), after which the stored value wins. +/// +/// +/// The stub must be an instance property with an expression body +/// (=> expr). Method stubs, accessor-list forms, and static stubs are rejected. +/// The containing type must be declared partial (class, struct, or record). +/// To map a stub onto an existing member instead of synthesizing a new one, +/// use . +/// +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public sealed class ExpressivePropertyAttribute : Attribute +{ + /// + /// Name of the property to synthesize on the stub's containing type. Must be supplied as a + /// string literal — nameof(X) cannot resolve because X doesn't exist yet. + /// + public string TargetName { get; } + + /// + /// Additional types to apply at runtime. + /// Each type must have a parameterless constructor. + /// + public Type[]? Transformers { get; set; } + + public ExpressivePropertyAttribute(string targetName) + { + TargetName = targetName; + } +} diff --git a/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs b/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs index 50b7fe1d..0b339faf 100644 --- a/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs +++ b/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs @@ -7,9 +7,9 @@ namespace ExpressiveSharp.Generator.Emitter; /// /// Emits the user-visible partial-class declaration with the synthesized property when -/// [ExpressiveFor(..., Synthesize = true)] is applied. The property caches materialized -/// values (set by projection middleware / EF Core / HotChocolate) and otherwise delegates to -/// the stub for the formula. +/// [ExpressiveProperty] is applied. The property caches materialized values (set by +/// projection middleware / EF Core / HotChocolate) and otherwise delegates to the stub for +/// the formula. /// static internal class SynthesizedPropertyEmitter { diff --git a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs index ef339810..e7cbf3ec 100644 --- a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs +++ b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs @@ -18,6 +18,7 @@ public class ExpressiveGenerator : IIncrementalGenerator private const string ExpressiveAttributeName = "ExpressiveSharp.ExpressiveAttribute"; private const string ExpressiveForAttributeName = "ExpressiveSharp.Mapping.ExpressiveForAttribute"; private const string ExpressiveForConstructorAttributeName = "ExpressiveSharp.Mapping.ExpressiveForConstructorAttribute"; + private const string ExpressivePropertyAttributeName = "ExpressiveSharp.Mapping.ExpressivePropertyAttribute"; public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -97,19 +98,28 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var expressiveForConstructorRegistryEntries = expressiveForConstructorDeclarations.Select( static (source, _) => ExtractRegistryEntryForExternal(source)); + // ── [ExpressiveProperty] pipeline ─────────────────────────────────────── + + var expressivePropertyDeclarations = CreateExpressivePropertyPipeline(context); + + var expressivePropertyRegistryEntries = expressivePropertyDeclarations.Select( + static (source, _) => ExtractRegistryEntryForExpressiveProperty(source)); + // ── Merged registry ───────────────────────────────────────────────────── var allRegistryEntries = registryEntries.Collect() .Combine(expressiveForRegistryEntries.Collect()) .Combine(expressiveForConstructorRegistryEntries.Collect()) + .Combine(expressivePropertyRegistryEntries.Collect()) .Select(static (pair, _) => { - var ((expressiveEntries, forEntries), forCtorEntries) = pair; + var (((expressiveEntries, forEntries), forCtorEntries), propEntries) = pair; var builder = ImmutableArray.CreateBuilder( - expressiveEntries.Length + forEntries.Length + forCtorEntries.Length); + expressiveEntries.Length + forEntries.Length + forCtorEntries.Length + propEntries.Length); builder.AddRange(expressiveEntries); builder.AddRange(forEntries); builder.AddRange(forCtorEntries); + builder.AddRange(propEntries); return builder.ToImmutable(); }); @@ -443,15 +453,6 @@ private static void ExecuteFor( throw new InvalidOperationException("ExpressionTreeEmission must be set"); EmitExpressionTreeSource(descriptor, generatedClassName, methodSuffix, generatedFileName, stubMember, compilation, context); - - // [ExpressiveFor(..., Synthesize = true)] also emits a user-facing partial class - // declaring the synthesized property. The file goes alongside the expression-factory file - // but in the user's namespace so the C# compiler merges it with their declaration. - if (descriptor.SynthesisSpec is { } synthesisSpec) - { - var synthesizedFileName = $"{generatedClassName}.{methodSuffix}.Synthesized.g.cs"; - Emitter.SynthesizedPropertyEmitter.Emit(synthesisSpec, synthesizedFileName, context); - } } /// @@ -513,15 +514,6 @@ private static void ExecuteFor( if (memberName is null) return null; - // [ExpressiveFor(..., Synthesize = true)] — the target member doesn't exist yet; - // the registry entry is always a property keyed on the synthesized name. - if (attribute.Synthesize) - { - memberKind = ExpressionRegistryMemberType.Property; - memberLookupName = memberName; - } - else - { // Property stubs can only target properties; method stubs may target either. var isProperty = stubIsProperty || targetType.GetMembers(memberName).OfType().Any(); @@ -553,7 +545,6 @@ private static void ExecuteFor( ..targetMethod.Parameters.Select(p => p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) ]; } - } } // Build generated class name using the target type's path (matching main's new API) @@ -602,4 +593,124 @@ private static IEnumerable GetRegistryNestedTypePath(INamedTypeSymbol ty } yield return typeSymbol.Name; } + + /// + /// Incremental pipeline for [ExpressiveProperty]. Discovers property stubs, runs the + /// interpreter, and emits both the expression-tree factory and the synthesized partial-class + /// declaration. + /// + private static IncrementalValuesProvider<((PropertyDeclarationSyntax Stub, ExpressivePropertyAttributeData Attribute), Compilation)> + CreateExpressivePropertyPipeline(IncrementalGeneratorInitializationContext context) + { + var declarations = context.SyntaxProvider + .ForAttributeWithMetadataName( + ExpressivePropertyAttributeName, + predicate: static (s, _) => s is PropertyDeclarationSyntax, + transform: static (c, _) => ( + Stub: (PropertyDeclarationSyntax)c.TargetNode, + Attribute: new ExpressivePropertyAttributeData(c.Attributes[0]) + )); + + var compilationAndPairs = declarations.Combine(context.CompilationProvider); + + context.RegisterSourceOutput(compilationAndPairs.Collect(), + static (spc, items) => + { + var emittedFileNames = new HashSet(); + foreach (var source in items) + { + var ((stub, attribute), compilation) = source; + var semanticModel = compilation.GetSemanticModel(stub.SyntaxTree); + if (semanticModel.GetDeclaredSymbol(stub) is not IPropertySymbol stubSymbol) + continue; + + ExecuteExpressiveProperty(stub, stubSymbol, semanticModel, attribute, spc, emittedFileNames); + } + }); + + return compilationAndPairs; + } + + /// + /// Runs the [ExpressiveProperty] interpreter and emits both the expression-tree factory + /// file and the user-facing partial-class declaration with the synthesized property. + /// + private static void ExecuteExpressiveProperty( + PropertyDeclarationSyntax stub, + IPropertySymbol stubSymbol, + SemanticModel semanticModel, + ExpressivePropertyAttributeData attribute, + SourceProductionContext context, + HashSet emittedFileNames) + { + var result = ExpressivePropertyInterpreter.GetDescriptor( + semanticModel, stub, stubSymbol, attribute, context); + if (result is null) return; + + var (descriptor, synthesisSpec) = result.Value; + + if (descriptor.MemberName is null) + throw new InvalidOperationException("Expected a memberName here"); + if (descriptor.ExpressionTreeEmission is null) + throw new InvalidOperationException("ExpressionTreeEmission must be set"); + + var generatedClassName = ExpressionClassNameGenerator.GenerateClassName( + descriptor.ClassNamespace, descriptor.NestedInClassNames); + var methodSuffix = ExpressionClassNameGenerator.GenerateMethodSuffix( + descriptor.MemberName, descriptor.ParameterTypeNames); + var generatedFileName = $"{generatedClassName}.{methodSuffix}.g.cs"; + + if (!emittedFileNames.Add(generatedFileName)) + return; + + EmitExpressionTreeSource(descriptor, generatedClassName, methodSuffix, generatedFileName, + stub, compilation: null, context); + + var synthesizedFileName = $"{generatedClassName}.{methodSuffix}.Synthesized.g.cs"; + Emitter.SynthesizedPropertyEmitter.Emit(synthesisSpec, synthesizedFileName, context); + } + + /// + /// Extracts a registry entry for an [ExpressiveProperty] stub. The entry is always + /// property-kind and keyed on the synthesized target name (which doesn't exist on the target + /// type yet — the synthesized partial declaration fills it in). + /// + private static ExpressionRegistryEntry? ExtractRegistryEntryForExpressiveProperty( + ((PropertyDeclarationSyntax Stub, ExpressivePropertyAttributeData Attribute), Compilation) source) + { + var ((stub, attribute), compilation) = source; + if (attribute.TargetName is null || string.IsNullOrWhiteSpace(attribute.TargetName)) + return null; + + var semanticModel = compilation.GetSemanticModel(stub.SyntaxTree); + if (semanticModel.GetDeclaredSymbol(stub) is not IPropertySymbol stubSymbol) + return null; + + var containingType = stubSymbol.ContainingType; + if (containingType.TypeParameters.Length > 0) + return null; + + var classNamespace = containingType.ContainingNamespace.IsGlobalNamespace + ? null + : containingType.ContainingNamespace.ToDisplayString(); + var nestedTypePath = GetRegistryNestedTypePath(containingType); + + var generatedClassFullName = ExpressionClassNameGenerator.GenerateClassFullName( + classNamespace, nestedTypePath); + var methodSuffix = ExpressionClassNameGenerator.GenerateMethodSuffix( + attribute.TargetName, parameterTypeNames: null); + var expressionMethodName = methodSuffix + "_Expression"; + + var stubLocation = stub.Identifier.GetLocation(); + var stubLineSpan = stubLocation.GetLineSpan(); + + return new ExpressionRegistryEntry( + DeclaringTypeFullName: containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + MemberKind: ExpressionRegistryMemberType.Property, + MemberLookupName: attribute.TargetName, + GeneratedClassFullName: generatedClassFullName, + ExpressionMethodName: expressionMethodName, + ParameterTypeNames: ImmutableArray.Empty, + StubLocation: new SourceLocation(stubLineSpan.Path, stubLocation.SourceSpan, stubLineSpan.Span)); + } } diff --git a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs index 935e2b73..779c99d5 100644 --- a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs +++ b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs @@ -164,31 +164,47 @@ static internal class Diagnostics isEnabledByDefault: true); // NOTE: EXP0021–EXP0030 (Projectable diagnostics) were retired when the - // [Expressive(Projectable = true)] feature was superseded by - // [ExpressiveFor(nameof(X), Synthesize = true)]. The codes are not reused. + // [Expressive(Projectable = true)] feature was superseded by [ExpressiveProperty]. + // The codes are not reused. - // ── [ExpressiveFor(..., Synthesize = true)] Diagnostics ───────────────── + // ── [ExpressiveProperty] Diagnostics ───────────────────────────────────── - public readonly static DiagnosticDescriptor ExpressiveForSynthesizeTargetExists = new DiagnosticDescriptor( + public readonly static DiagnosticDescriptor ExpressivePropertyTargetExists = new DiagnosticDescriptor( id: "EXP0031", - title: "[ExpressiveFor(Synthesize = true)] target name is already defined", - messageFormat: "[ExpressiveFor(..., Synthesize = true)] target name '{0}' is already defined on '{1}'. Remove Synthesize or rename the stub.", + title: "[ExpressiveProperty] target name is already defined", + messageFormat: "[ExpressiveProperty] target name '{0}' is already defined on '{1}' — rename the stub, or use [ExpressiveFor(nameof({0}))] to map onto the existing member instead", category: "Design", DiagnosticSeverity.Error, isEnabledByDefault: true); - public readonly static DiagnosticDescriptor ExpressiveForSynthesizeRequiresPartial = new DiagnosticDescriptor( + public readonly static DiagnosticDescriptor ExpressivePropertyRequiresPartial = new DiagnosticDescriptor( id: "EXP0032", - title: "[ExpressiveFor(Synthesize = true)] requires a partial containing type", - messageFormat: "[ExpressiveFor(..., Synthesize = true)] requires the containing type '{0}' to be declared 'partial' so the synthesized property can be emitted into it", + title: "[ExpressiveProperty] requires a partial containing type", + messageFormat: "[ExpressiveProperty] requires the containing type '{0}' to be declared 'partial' (applies to class, struct, and record)", category: "Design", DiagnosticSeverity.Error, isEnabledByDefault: true); - public readonly static DiagnosticDescriptor ExpressiveForSynthesizeRequiresSameType = new DiagnosticDescriptor( + public readonly static DiagnosticDescriptor ExpressivePropertyRequiresExpressionBody = new DiagnosticDescriptor( id: "EXP0033", - title: "[ExpressiveFor(Synthesize = true)] requires the single-argument form", - messageFormat: "Synthesize = true only applies to same-type stubs; use the single-argument form [ExpressiveFor(nameof(Member), Synthesize = true)] instead of the two-argument typeof form", + title: "[ExpressiveProperty] requires an expression-bodied property stub", + messageFormat: "[ExpressiveProperty] must be placed on a property with an expression body '=> expr' — accessor-list forms and method stubs are not supported", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public readonly static DiagnosticDescriptor ExpressivePropertyInstanceOnly = new DiagnosticDescriptor( + id: "EXP0034", + title: "[ExpressiveProperty] requires an instance stub", + messageFormat: "[ExpressiveProperty] is not supported on static stubs — stub '{0}' must be declared as an instance member", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public readonly static DiagnosticDescriptor ExpressivePropertyShadowsInherited = new DiagnosticDescriptor( + id: "EXP0035", + title: "[ExpressiveProperty] target shadows inherited member", + messageFormat: "[ExpressiveProperty] target name '{0}' shadows an inherited member on '{1}' — rename the target to avoid silent hiding, or drop [ExpressiveProperty] and use [Expressive] on an override", category: "Design", DiagnosticSeverity.Error, isEnabledByDefault: true); diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs index 0b856cdc..e20057ad 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs @@ -37,15 +37,6 @@ static internal class ExpressiveForInterpreter INamedTypeSymbol? targetType; if (attributeData.TargetTypeMetadataName is not null) { - // EXP0033: Synthesize requires the single-arg form (same-type stub). - if (attributeData.Synthesize) - { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.ExpressiveForSynthesizeRequiresSameType, - stubIdentifierLocation)); - return null; - } - targetType = compilation.GetTypeByMetadataName(attributeData.TargetTypeMetadataName); if (targetType is null) { @@ -61,14 +52,6 @@ static internal class ExpressiveForInterpreter targetType = stubSymbol.ContainingType; } - // [ExpressiveFor(..., Synthesize = true)] branch — takes priority over the normal - // resolution path because the target member does not yet exist on the target type. - if (attributeData.Synthesize) - { - return ResolveSynthesize(semanticModel, stubMember, stubSymbol, attributeData, - globalOptions, context, targetType, stubIdentifierLocation); - } - // Property stubs can only target properties (no parameter list to carry method args). // Only the ExpressiveFor (MethodOrProperty) pipeline reaches this branch; the constructor // attribute is method-target-only at the AttributeUsage level. @@ -149,176 +132,6 @@ static internal class ExpressiveForInterpreter return null; } - private static ExpressiveDescriptor? ResolveSynthesize( - SemanticModel semanticModel, - MemberDeclarationSyntax stubMember, - ISymbol stubSymbol, - ExpressiveForAttributeData attributeData, - ExpressiveGlobalOptions globalOptions, - SourceProductionContext context, - INamedTypeSymbol targetType, - Location stubIdentifierLocation) - { - var memberName = attributeData.MemberName; - if (memberName is null) - return null; - - // EXP0032: the containing class must be partial so we can emit the synthesized property - // into a generated file. - if (!IsPartialType(targetType)) - { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.ExpressiveForSynthesizeRequiresPartial, - stubIdentifierLocation, - targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); - return null; - } - - // EXP0031: a member with the target name must not already exist on the target type. - // We look at the target type's direct members (including generated ones aren't visible - // yet — partial declarations from other files ARE visible through the symbol model). - if (targetType.GetMembers(memberName).Any(m => m is IPropertySymbol or IMethodSymbol or IFieldSymbol or IEventSymbol)) - { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.ExpressiveForSynthesizeTargetExists, - stubIdentifierLocation, - memberName, - targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); - return null; - } - - // The stub must be a parameterless same-type member (property or method) so the - // synthesized property can delegate to it with no arguments. For non-property stubs - // we still accept zero-param instance methods. - ITypeSymbol stubReturnType; - bool stubIsProperty; - switch (stubSymbol) - { - case IPropertySymbol propSym when propSym.Parameters.Length == 0 && !propSym.IsStatic: - stubReturnType = propSym.Type; - stubIsProperty = true; - break; - case IMethodSymbol methodSym when methodSym.Parameters.Length == 0 && !methodSym.IsStatic: - stubReturnType = methodSym.ReturnType; - stubIsProperty = false; - break; - default: - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.ExpressiveForMemberNotFound, - stubIdentifierLocation, - memberName, - targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); - return null; - } - - // Build the descriptor from the stub body first (standard pipeline). - ExpressiveDescriptor? descriptor = stubMember switch - { - PropertyDeclarationSyntax stubProp when stubSymbol is IPropertySymbol stubPropSym => - BuildDescriptorFromPropertyStub(semanticModel, stubProp, stubPropSym, attributeData, - globalOptions, context, targetType, memberName), - MethodDeclarationSyntax stubMethod when stubSymbol is IMethodSymbol stubMethodSym => - BuildDescriptorFromStub(semanticModel, stubMethod, stubMethodSym, attributeData, - globalOptions, context, targetType, memberName, - targetParameters: System.Collections.Immutable.ImmutableArray.Empty, - isInstanceMember: true), - _ => null - }; - - if (descriptor is null) - return null; - - // Attach the synthesis spec so the generator emits the partial property. - var useTernary = IsNullablePropertyType(stubReturnType); - var propertyTypeFqn = stubReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var backingFieldTypeFqn = useTernary - ? propertyTypeFqn - : MakeNullableTypeFqn(stubReturnType); - - descriptor.SynthesisSpec = new SynthesizedPropertySpec - { - PropertyTypeFqn = propertyTypeFqn, - PropertyName = memberName, - StubMemberName = stubSymbol.Name, - StubIsMethod = !stubIsProperty, - UseTernaryShape = useTernary, - BackingFieldTypeFqn = backingFieldTypeFqn, - ContainingTypeName = targetType.Name, - ContainingTypeNamespace = targetType.ContainingNamespace.IsGlobalNamespace - ? null - : targetType.ContainingNamespace.ToDisplayString(), - ContainingTypePath = GetNestedInClassPath(targetType).ToList(), - ContainingTypeKeyword = GetTypeKeyword(targetType), - }; - - return descriptor; - } - - /// - /// True when the declared property type is nullable (annotated reference type or - /// Nullable<T>). Non-nullable types can use the simpler coalesce shape because - /// null in the backing field unambiguously means "not materialized". - /// - 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; - } - - /// - /// Formats as its nullable form for backing-field declaration. - /// For value types, wraps in Nullable<T>; for reference types, appends ?. - /// - private static string MakeNullableTypeFqn(ITypeSymbol type) - { - var fqn = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (type.IsValueType) - return $"global::System.Nullable<{fqn}>"; - return fqn.EndsWith("?") ? fqn : fqn + "?"; - } - - /// - /// True when at least one declaration of carries the partial keyword. - /// - private static bool IsPartialType(INamedTypeSymbol type) - { - foreach (var reference in type.DeclaringSyntaxReferences) - { - if (reference.GetSyntax() is TypeDeclarationSyntax typeDecl - && typeDecl.Modifiers.Any(SyntaxKind.PartialKeyword)) - { - return true; - } - } - return false; - } - - private static string GetTypeKeyword(INamedTypeSymbol type) - { - // Prefer a declaration-based check so we emit the exact keyword used in source. - foreach (var reference in type.DeclaringSyntaxReferences) - { - switch (reference.GetSyntax()) - { - case ClassDeclarationSyntax: return "class"; - case RecordDeclarationSyntax rec: - return rec.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword) - ? "record struct" - : "record"; - case StructDeclarationSyntax: return "struct"; - case InterfaceDeclarationSyntax: return "interface"; - } - } - return type.TypeKind switch - { - TypeKind.Struct => "struct", - TypeKind.Interface => "interface", - _ => "class", - }; - } - private static ExpressiveDescriptor? ResolveConstructor( SemanticModel semanticModel, MethodDeclarationSyntax stubMethod, diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressivePropertyInterpreter.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressivePropertyInterpreter.cs new file mode 100644 index 00000000..518048d0 --- /dev/null +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressivePropertyInterpreter.cs @@ -0,0 +1,270 @@ +using ExpressiveSharp.Generator.Emitter; +using ExpressiveSharp.Generator.Infrastructure; +using ExpressiveSharp.Generator.Models; +using ExpressiveSharp.Generator.SyntaxRewriters; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ExpressiveSharp.Generator.Interpretation; + +/// +/// Interprets [ExpressiveProperty] stubs: validates placement rules, builds an +/// keyed on the synthesized target property, and attaches a +/// so the generator emits the partial-class declaration. +/// +/// +/// Rules (v1): +/// +/// Stub must be a property with a top-level expression body (=> expr). +/// Stub must be an instance member (static stubs rejected). +/// Target property name must be supplied explicitly as a string literal. +/// +/// +static internal class ExpressivePropertyInterpreter +{ + public static (ExpressiveDescriptor Descriptor, SynthesizedPropertySpec Spec)? GetDescriptor( + SemanticModel semanticModel, + PropertyDeclarationSyntax stubProperty, + IPropertySymbol stubSymbol, + ExpressivePropertyAttributeData attributeData, + SourceProductionContext context) + { + var stubLocation = stubProperty.Identifier.GetLocation(); + var containingType = stubSymbol.ContainingType; + var targetName = attributeData.TargetName; + + if (string.IsNullOrWhiteSpace(targetName)) + { + // Constructor guarantees non-null in the attribute, so a null here means the attribute + // was unparseable (e.g. a literal null argument). Silently ignore — the C# compiler + // has already surfaced its own diagnostic for the null literal. + return null; + } + + // Rule 2: instance only. + if (stubSymbol.IsStatic) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressivePropertyInstanceOnly, + stubLocation, + stubSymbol.Name)); + return null; + } + + // Rule 1: expression body required (top-level `=> expr` form). Reject any accessor list — + // even a `{ get => expr; }` that's semantically equivalent — to keep the surface minimal. + if (stubProperty.ExpressionBody is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressivePropertyRequiresExpressionBody, + stubLocation, + stubSymbol.Name)); + return null; + } + + // Containing type must be partial (class / struct / record / record struct). + if (!IsPartialType(containingType)) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressivePropertyRequiresPartial, + stubLocation, + containingType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + return null; + } + + // Target name must not collide with an existing declared or inherited member on the type. + // Declared members first (EXP0031) because that's the more common mistake and deserves the + // dedicated "use [ExpressiveFor] instead" steering. + if (containingType.GetMembers(targetName!).Any(m => + m is IPropertySymbol or IMethodSymbol or IFieldSymbol or IEventSymbol)) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressivePropertyTargetExists, + stubLocation, + targetName, + containingType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + return null; + } + + // Inherited-member shadowing gets its own diagnostic — silent hiding is a footgun. + if (FindInheritedMember(containingType, targetName!) is { } inheritedFrom) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressivePropertyShadowsInherited, + stubLocation, + targetName, + inheritedFrom.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + return null; + } + + // Build the descriptor from the stub's expression body. + var descriptor = BuildDescriptor( + semanticModel, context, stubProperty, stubSymbol, + attributeData, containingType, targetName!); + if (descriptor is null) return null; + + // Synthesis spec for the partial-class emitter. + var returnType = stubSymbol.Type; + var useTernary = IsNullablePropertyType(returnType); + var propertyTypeFqn = returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var backingFieldTypeFqn = useTernary ? propertyTypeFqn : MakeNullableTypeFqn(returnType); + + var spec = new SynthesizedPropertySpec + { + PropertyTypeFqn = propertyTypeFqn, + PropertyName = targetName!, + StubMemberName = stubSymbol.Name, + StubIsMethod = false, // rule 1 enforces property stub + UseTernaryShape = useTernary, + BackingFieldTypeFqn = backingFieldTypeFqn, + ContainingTypeName = containingType.Name, + ContainingTypeNamespace = containingType.ContainingNamespace.IsGlobalNamespace + ? null + : containingType.ContainingNamespace.ToDisplayString(), + ContainingTypePath = GetNestedInClassPath(containingType).ToList(), + ContainingTypeKeyword = GetTypeKeyword(containingType), + }; + + descriptor.SynthesisSpec = spec; + return (descriptor, spec); + } + + private static ExpressiveDescriptor? BuildDescriptor( + SemanticModel semanticModel, + SourceProductionContext context, + PropertyDeclarationSyntax stubProperty, + IPropertySymbol stubSymbol, + ExpressivePropertyAttributeData attributeData, + INamedTypeSymbol containingType, + string targetName) + { + // Rule 1 guaranteed us an expression body. + var bodySyntax = stubProperty.ExpressionBody!.Expression; + + var rewriter = new DeclarationSyntaxRewriter(semanticModel); + var returnTypeName = rewriter.Visit(stubProperty.Type).ToString(); + + var containingNamespace = containingType.ContainingNamespace.IsGlobalNamespace + ? null + : containingType.ContainingNamespace.ToDisplayString(); + + var descriptor = new ExpressiveDescriptor + { + UsingDirectives = stubProperty.SyntaxTree.GetRoot().DescendantNodes().OfType(), + ClassName = containingType.Name, + ClassNamespace = containingNamespace, + MemberName = targetName, + NestedInClassNames = GetNestedInClassPath(containingType), + TargetClassNamespace = containingNamespace, + TargetNestedInClassNames = GetNestedInClassPath(containingType), + ParametersList = SyntaxFactory.ParameterList(), + ReturnTypeName = returnTypeName, + }; + + foreach (var transformerTypeName in attributeData.TransformerTypeNames) + descriptor.DeclaredTransformerTypeNames.Add(transformerTypeName); + + // Instance stub → prepend @this so IInstanceReferenceOperation binds correctly. + var thisTypeFqn = containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + descriptor.ParametersList = descriptor.ParametersList.AddParameters( + SyntaxFactory.Parameter(SyntaxFactory.Identifier("@this")) + .WithType(SyntaxFactory.ParseTypeName(thisTypeFqn))); + + var emitterParams = new List + { + new EmitterParameter("@this", thisTypeFqn, isThis: true) + }; + + var delegateTypeFqn = $"global::System.Func<{thisTypeFqn}, {returnTypeName}>"; + var emitter = new ExpressionTreeEmitter(semanticModel, context); + descriptor.ExpressionTreeEmission = emitter.Emit(bodySyntax, emitterParams, + returnTypeName, delegateTypeFqn); + + return descriptor; + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + 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; + } + + private static string MakeNullableTypeFqn(ITypeSymbol type) + { + var fqn = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (type.IsValueType) + return $"global::System.Nullable<{fqn}>"; + return fqn.EndsWith("?") ? fqn : fqn + "?"; + } + + private static bool IsPartialType(INamedTypeSymbol type) + { + foreach (var reference in type.DeclaringSyntaxReferences) + { + if (reference.GetSyntax() is TypeDeclarationSyntax typeDecl + && typeDecl.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + return true; + } + } + return false; + } + + private static string GetTypeKeyword(INamedTypeSymbol type) + { + foreach (var reference in type.DeclaringSyntaxReferences) + { + switch (reference.GetSyntax()) + { + case ClassDeclarationSyntax: return "class"; + case RecordDeclarationSyntax rec: + return rec.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword) + ? "record struct" + : "record"; + case StructDeclarationSyntax: return "struct"; + case InterfaceDeclarationSyntax: return "interface"; + } + } + return type.TypeKind switch + { + TypeKind.Struct => "struct", + TypeKind.Interface => "interface", + _ => "class", + }; + } + + private static IEnumerable GetNestedInClassPath(ITypeSymbol typeSymbol) + { + if (typeSymbol.ContainingType is not null) + { + foreach (var name in GetNestedInClassPath(typeSymbol.ContainingType)) + yield return name; + } + yield return typeSymbol.Name; + } + + private static INamedTypeSymbol? FindInheritedMember(INamedTypeSymbol type, string memberName) + { + // Walk the base chain — System.Object itself is not interesting for user-defined collisions + // but we include it for completeness (e.g. `ToString` as a target name). + var current = type.BaseType; + while (current is not null) + { + if (current.GetMembers(memberName).Any(m => + m is IPropertySymbol or IMethodSymbol or IFieldSymbol or IEventSymbol)) + { + return current; + } + current = current.BaseType; + } + return null; + } +} diff --git a/src/ExpressiveSharp.Generator/Models/ExpressiveDescriptor.cs b/src/ExpressiveSharp.Generator/Models/ExpressiveDescriptor.cs index 11b5e7f5..511ebfc5 100644 --- a/src/ExpressiveSharp.Generator/Models/ExpressiveDescriptor.cs +++ b/src/ExpressiveSharp.Generator/Models/ExpressiveDescriptor.cs @@ -47,8 +47,8 @@ internal class ExpressiveDescriptor public List DeclaredTransformerTypeNames { get; } = new(); /// - /// When [ExpressiveFor(..., Synthesize = true)] is applied, this carries the - /// instructions for emitting the synthesized property on the stub's containing type. + /// When [ExpressiveProperty] is applied, this carries the instructions for + /// emitting the synthesized property on the stub's containing type. /// public SynthesizedPropertySpec? SynthesisSpec { get; set; } } diff --git a/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs b/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs index 97908ddc..20202e47 100644 --- a/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs +++ b/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs @@ -32,16 +32,10 @@ readonly internal record struct ExpressiveForAttributeData public IReadOnlyList TransformerTypeNames { get; } - /// - /// When true, the target property is synthesized on the stub's containing type. - /// - public bool Synthesize { get; } - public ExpressiveForAttributeData(AttributeData attribute, ExpressiveForMemberKind memberKind) { MemberKind = memberKind; bool? allowBlockBody = null; - var synthesize = false; var transformerTypeNames = new List(); // Extract target type from first constructor argument. @@ -98,14 +92,10 @@ public ExpressiveForAttributeData(AttributeData attribute, ExpressiveForMemberKi } } break; - case "Synthesize": - synthesize = value.Value is true; - break; } } AllowBlockBody = allowBlockBody; - Synthesize = synthesize; TransformerTypeNames = transformerTypeNames.ToArray(); } diff --git a/src/ExpressiveSharp.Generator/Models/ExpressivePropertyAttributeData.cs b/src/ExpressiveSharp.Generator/Models/ExpressivePropertyAttributeData.cs new file mode 100644 index 00000000..d6bf70a6 --- /dev/null +++ b/src/ExpressiveSharp.Generator/Models/ExpressivePropertyAttributeData.cs @@ -0,0 +1,47 @@ +using Microsoft.CodeAnalysis; + +namespace ExpressiveSharp.Generator.Models; + +/// +/// Plain-data snapshot of an [ExpressiveProperty] attribute's arguments. +/// Immutable record struct — safe for incremental generator caching. +/// +readonly internal record struct ExpressivePropertyAttributeData +{ + /// The target property name passed to the attribute constructor. + public string? TargetName { get; } + + public IReadOnlyList TransformerTypeNames { get; } + + public ExpressivePropertyAttributeData(AttributeData attribute) + { + var transformerTypeNames = new List(); + + if (attribute.ConstructorArguments.Length == 1 && + attribute.ConstructorArguments[0].Value is string name) + { + TargetName = name; + } + else + { + TargetName = null; + } + + foreach (var namedArgument in attribute.NamedArguments) + { + if (namedArgument.Key == "Transformers" && namedArgument.Value.Kind == TypedConstantKind.Array) + { + foreach (var element in namedArgument.Value.Values) + { + if (element.Value is INamedTypeSymbol typeSymbol) + { + transformerTypeNames.Add( + typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + } + } + } + + TransformerTypeNames = transformerTypeNames.ToArray(); + } +} diff --git a/src/ExpressiveSharp.Generator/Models/SynthesizedPropertySpec.cs b/src/ExpressiveSharp.Generator/Models/SynthesizedPropertySpec.cs index b5cd6df3..47fe04a2 100644 --- a/src/ExpressiveSharp.Generator/Models/SynthesizedPropertySpec.cs +++ b/src/ExpressiveSharp.Generator/Models/SynthesizedPropertySpec.cs @@ -2,8 +2,8 @@ namespace ExpressiveSharp.Generator.Models; /// /// Side-information attached to an when -/// [ExpressiveFor(..., Synthesize = true)] is applied. Instructs the generator to emit -/// an additional partial-class file declaring the synthesized property on the target type. +/// [ExpressiveProperty] is applied. Instructs the generator to emit an additional +/// partial-class file declaring the synthesized property on the containing type. /// internal sealed class SynthesizedPropertySpec { diff --git a/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs b/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs index 68b798c3..0f735a8d 100644 --- a/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs +++ b/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs @@ -7,10 +7,10 @@ namespace ExpressiveSharp.MongoDB.Infrastructure; /// /// Mongo that unmaps every property marked with /// (and every property synthesized by -/// [ExpressiveFor(..., Synthesize = true)]) from its containing class map. This is -/// the Mongo counterpart of the EF Core ExpressivePropertiesNotMappedConvention: -/// without it, a synthesized property would be serialized to its BSON document as a real -/// field (because the generated property has a writable accessor) and the backing field's +/// [ExpressiveProperty]) from its containing class map. This is the Mongo +/// counterpart of the EF Core ExpressivePropertiesNotMappedConvention: without it, +/// a synthesized property would be serialized to its BSON document as a real field +/// (because the generated property has a writable accessor) and the backing field's /// default value would leak into storage. /// /// diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/SynthesizedExpressiveSqlTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/SynthesizedExpressiveSqlTests.cs index 97965e16..4fafa577 100644 --- a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/SynthesizedExpressiveSqlTests.cs +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/SynthesizedExpressiveSqlTests.cs @@ -6,7 +6,7 @@ namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Tests.Sqlite; /// -/// EF Core SQLite tests for [ExpressiveFor(..., Synthesize = true)]. Uses a self-contained +/// EF Core SQLite tests for [ExpressiveProperty]. Uses a self-contained /// DbContext with a synthesized entity so the test doesn't depend on shared scenario models. /// Verifies: /// @@ -100,7 +100,7 @@ public partial class SynthesizedPerson public string FirstName { get; set; } = ""; public string LastName { get; set; } = ""; - [ExpressiveFor("FullName", Synthesize = true)] + [ExpressiveProperty("FullName")] private string FullNameExpression => LastName + ", " + FirstName; } diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs index b0f7fcab..0d3f936e 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs @@ -602,191 +602,6 @@ class MyType { Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); } - // ── Synthesize snapshot tests ─────────────────────────────────────────── - - [TestMethod] - public Task Synthesize_ReferenceTypeTarget_EmitsCoalesceForm() - { - // Non-nullable reference target — coalesce shape. - var compilation = CreateCompilation( - """ - using ExpressiveSharp.Mapping; - - namespace Foo { - partial class Account { - public string FirstName { get; set; } = ""; - public string LastName { get; set; } = ""; - - [ExpressiveFor("FullName", Synthesize = true)] - private string FullNameExpression => LastName + ", " + FirstName; - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(0, result.Diagnostics.Length); - // Two generated files: expression factory + synthesized partial. - Assert.AreEqual(2, result.GeneratedTrees.Length); - - return Verifier.Verify(string.Join("\n\n// ===\n\n", - result.GeneratedTrees.Select(t => t.ToString()))); - } - - [TestMethod] - public Task Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm() - { - // Nullable reference target — ternary+flag shape (coalesce would fail for stored nulls). - var compilation = CreateCompilation( - """ - #nullable enable - using ExpressiveSharp.Mapping; - - namespace Foo { - partial class Account { - public string? FirstName { get; set; } - public string? LastName { get; set; } - - [ExpressiveFor("FullName", Synthesize = true)] - private string? FullNameExpression => - LastName is null || FirstName is null ? null : (LastName + ", " + FirstName); - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(0, result.Diagnostics.Length); - Assert.AreEqual(2, result.GeneratedTrees.Length); - - return Verifier.Verify(string.Join("\n\n// ===\n\n", - result.GeneratedTrees.Select(t => t.ToString()))); - } - - [TestMethod] - public Task Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm() - { - // Non-nullable value target — coalesce shape with Nullable backing field. - var compilation = CreateCompilation( - """ - using ExpressiveSharp.Mapping; - - namespace Foo { - partial class Account { - public decimal TotalAmount { get; set; } - public decimal Discount { get; set; } - - [ExpressiveFor("Amount", Synthesize = true)] - private decimal AmountExpression => TotalAmount - Discount; - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(0, result.Diagnostics.Length); - Assert.AreEqual(2, result.GeneratedTrees.Length); - - return Verifier.Verify(string.Join("\n\n// ===\n\n", - result.GeneratedTrees.Select(t => t.ToString()))); - } - - [TestMethod] - public Task Synthesize_NullableValueTypeTarget_EmitsTernaryForm() - { - // Nullable value target — ternary+flag shape (issue #35 scenario). - var compilation = CreateCompilation( - """ - #nullable enable - using ExpressiveSharp.Mapping; - - namespace Foo { - partial class Account { - public decimal? TotalAmount { get; set; } - public decimal? Discount { get; set; } - - [ExpressiveFor("Amount", Synthesize = true)] - private decimal? AmountExpression => - TotalAmount != null && Discount != null - ? TotalAmount.Value - Discount.Value - : (decimal?)null; - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(0, result.Diagnostics.Length); - Assert.AreEqual(2, result.GeneratedTrees.Length); - - return Verifier.Verify(string.Join("\n\n// ===\n\n", - result.GeneratedTrees.Select(t => t.ToString()))); - } - - [TestMethod] - public void Synthesize_TargetAlreadyExists_ReportsEXP0031() - { - // Target name clashes with an existing member on the same type. - var compilation = CreateCompilation( - """ - using ExpressiveSharp.Mapping; - - namespace Foo { - partial class Account { - public string FullName { get; set; } = ""; - - [ExpressiveFor("FullName", Synthesize = true)] - private string FullNameExpression => "x"; - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0031")); - } - - [TestMethod] - public void Synthesize_NonPartialContainer_ReportsEXP0032() - { - // Containing type is not partial — cannot emit the synthesized property into it. - var compilation = CreateCompilation( - """ - using ExpressiveSharp.Mapping; - - namespace Foo { - class Account { - public string FirstName { get; set; } = ""; - - [ExpressiveFor("FullName", Synthesize = true)] - private string FullNameExpression => FirstName; - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0032")); - } - - [TestMethod] - public void Synthesize_WithTwoArgForm_ReportsEXP0033() - { - // Synthesize + two-arg form is invalid — Synthesize always targets the stub's own type. - var compilation = CreateCompilation( - """ - using ExpressiveSharp.Mapping; - - namespace Foo { - partial class Other {} - - partial class Account { - public string FirstName { get; set; } = ""; - - [ExpressiveFor(typeof(Other), "FullName", Synthesize = true)] - private string FullNameExpression => FirstName; - } - } - """); - var result = RunExpressiveGenerator(compilation); - - Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0033")); - } - [TestMethod] public void SingleArgForm_UnknownMember_Rejected_EXP0015() { diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt similarity index 96% rename from tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt rename to tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt index ea31a480..672955b6 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt @@ -8,7 +8,7 @@ namespace ExpressiveSharp.Generated { static partial class Foo_Account { - // [ExpressiveFor("Amount", Synthesize = true)] + // [ExpressiveProperty("Amount")] // private decimal AmountExpression => TotalAmount - Discount; static global::System.Linq.Expressions.Expression> Amount_Expression() { diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt similarity index 98% rename from tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt rename to tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt index 095ad19f..c292c8b6 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt @@ -9,7 +9,7 @@ namespace ExpressiveSharp.Generated { static partial class Foo_Account { - // [ExpressiveFor("FullName", Synthesize = true)] + // [ExpressiveProperty("FullName")] // private string? FullNameExpression => LastName is null || FirstName is null ? null : (LastName + ", " + FirstName); static global::System.Linq.Expressions.Expression> FullName_Expression() { diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableValueTypeTarget_EmitsTernaryForm.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.NullableValueTypeTarget_EmitsTernaryForm.verified.txt similarity index 98% rename from tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableValueTypeTarget_EmitsTernaryForm.verified.txt rename to tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.NullableValueTypeTarget_EmitsTernaryForm.verified.txt index b532ef32..55c2baa4 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableValueTypeTarget_EmitsTernaryForm.verified.txt +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.NullableValueTypeTarget_EmitsTernaryForm.verified.txt @@ -9,7 +9,7 @@ namespace ExpressiveSharp.Generated { static partial class Foo_Account { - // [ExpressiveFor("Amount", Synthesize = true)] + // [ExpressiveProperty("Amount")] // private decimal? AmountExpression => TotalAmount != null && Discount != null ? TotalAmount.Value - Discount.Value : (decimal? )null; static global::System.Linq.Expressions.Expression> Amount_Expression() { diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.PartialRecord_EmitsCorrectly.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.PartialRecord_EmitsCorrectly.verified.txt new file mode 100644 index 00000000..8641d61c --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.PartialRecord_EmitsCorrectly.verified.txt @@ -0,0 +1,43 @@ +// +#nullable disable + +using ExpressiveSharp.Mapping; +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_Person + { + // [ExpressiveProperty("FullName")] + // private string FullNameExpression => LastName + ", " + FirstName; + static global::System.Linq.Expressions.Expression> FullName_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.Person), "@this"); + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Person).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.Person).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); + } + } +} + + +// === + +// +#nullable enable + +namespace Foo +{ + partial record Person + { + private string? _fullName; + public string FullName + { + get => _fullName ?? FullNameExpression; + init => _fullName = value; + } + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.PartialStruct_EmitsCorrectly.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.PartialStruct_EmitsCorrectly.verified.txt new file mode 100644 index 00000000..b5e52550 --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.PartialStruct_EmitsCorrectly.verified.txt @@ -0,0 +1,46 @@ +// +#nullable disable + +using ExpressiveSharp.Mapping; +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_Point + { + // [ExpressiveProperty("Magnitude")] + // private double MagnitudeExpression => System.Math.Sqrt(X * X + Y * Y); + static global::System.Linq.Expressions.Expression> Magnitude_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.Point), "@this"); + var expr_3 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Point).GetProperty("X")); // X + var expr_4 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Point).GetProperty("X")); // X + var expr_2 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Multiply, expr_3, expr_4); + var expr_6 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Point).GetProperty("Y")); // Y + var expr_7 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Point).GetProperty("Y")); // Y + var expr_5 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Multiply, expr_6, expr_7); + var expr_1 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Add, expr_2, expr_5); + var expr_0 = global::System.Linq.Expressions.Expression.Call(typeof(global::System.Math).GetMethod("Sqrt", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(double) }, null), new global::System.Linq.Expressions.Expression[] { expr_1 }); + return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this); + } + } +} + + +// === + +// +#nullable enable + +namespace Foo +{ + partial struct Point + { + private global::System.Nullable _magnitude; + public double Magnitude + { + get => _magnitude ?? MagnitudeExpression; + init => _magnitude = value; + } + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_ReferenceTypeTarget_EmitsCoalesceForm.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.ReferenceTypeTarget_EmitsCoalesceForm.verified.txt similarity index 97% rename from tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_ReferenceTypeTarget_EmitsCoalesceForm.verified.txt rename to tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.ReferenceTypeTarget_EmitsCoalesceForm.verified.txt index bc200696..d7ca988d 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_ReferenceTypeTarget_EmitsCoalesceForm.verified.txt +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.ReferenceTypeTarget_EmitsCoalesceForm.verified.txt @@ -8,7 +8,7 @@ namespace ExpressiveSharp.Generated { static partial class Foo_Account { - // [ExpressiveFor("FullName", Synthesize = true)] + // [ExpressiveProperty("FullName")] // private string FullNameExpression => LastName + ", " + FirstName; static global::System.Linq.Expressions.Expression> FullName_Expression() { diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.cs new file mode 100644 index 00000000..6a1d5271 --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.cs @@ -0,0 +1,299 @@ +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using VerifyMSTest; +using ExpressiveSharp.Generator.Tests.Infrastructure; + +namespace ExpressiveSharp.Generator.Tests.ExpressiveGenerator; + +/// +/// Tests for [ExpressiveProperty] — synthesizes a settable property on the stub's +/// containing partial type. Two shapes: coalesce (non-nullable targets) and ternary+flag +/// (nullable targets, including both ref-nullable and value-nullable). +/// +[TestClass] +public class ExpressivePropertyTests : GeneratorTestBase +{ + // ── Happy-path snapshots ──────────────────────────────────────────────── + + [TestMethod] + public Task ReferenceTypeTarget_EmitsCoalesceForm() + { + // Non-nullable reference target — coalesce shape with a nullable backing field. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + [ExpressiveProperty("FullName")] + private string FullNameExpression => LastName + ", " + FirstName; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + // Two generated files: expression factory + synthesized partial. + Assert.AreEqual(2, result.GeneratedTrees.Length); + + return Verifier.Verify(string.Join("\n\n// ===\n\n", + result.GeneratedTrees.Select(t => t.ToString()))); + } + + [TestMethod] + public Task NullableReferenceTypeTarget_EmitsTernaryForm() + { + // Nullable reference target — ternary+flag shape (coalesce would collide with stored null). + var compilation = CreateCompilation( + """ + #nullable enable + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public string? FirstName { get; set; } + public string? LastName { get; set; } + + [ExpressiveProperty("FullName")] + private string? FullNameExpression => + LastName is null || FirstName is null ? null : (LastName + ", " + FirstName); + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(2, result.GeneratedTrees.Length); + + return Verifier.Verify(string.Join("\n\n// ===\n\n", + result.GeneratedTrees.Select(t => t.ToString()))); + } + + [TestMethod] + public Task NonNullableValueTypeTarget_EmitsCoalesceForm() + { + // Non-nullable value target — coalesce shape with Nullable backing field. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public decimal TotalAmount { get; set; } + public decimal Discount { get; set; } + + [ExpressiveProperty("Amount")] + private decimal AmountExpression => TotalAmount - Discount; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(2, result.GeneratedTrees.Length); + + return Verifier.Verify(string.Join("\n\n// ===\n\n", + result.GeneratedTrees.Select(t => t.ToString()))); + } + + [TestMethod] + public Task NullableValueTypeTarget_EmitsTernaryForm() + { + // Nullable value target — ternary+flag (issue #35 scenario). + var compilation = CreateCompilation( + """ + #nullable enable + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public decimal? TotalAmount { get; set; } + public decimal? Discount { get; set; } + + [ExpressiveProperty("Amount")] + private decimal? AmountExpression => + TotalAmount != null && Discount != null + ? TotalAmount.Value - Discount.Value + : (decimal?)null; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(2, result.GeneratedTrees.Length); + + return Verifier.Verify(string.Join("\n\n// ===\n\n", + result.GeneratedTrees.Select(t => t.ToString()))); + } + + [TestMethod] + public Task PartialRecord_EmitsCorrectly() + { + // Target is a partial record — the synthesized file must emit `partial record`. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial record Person { + public string FirstName { get; init; } = ""; + public string LastName { get; init; } = ""; + + [ExpressiveProperty("FullName")] + private string FullNameExpression => LastName + ", " + FirstName; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(2, result.GeneratedTrees.Length); + + return Verifier.Verify(string.Join("\n\n// ===\n\n", + result.GeneratedTrees.Select(t => t.ToString()))); + } + + [TestMethod] + public Task PartialStruct_EmitsCorrectly() + { + // Target is a partial struct — the synthesized file must emit `partial struct`. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial struct Point { + public double X { get; set; } + public double Y { get; set; } + + [ExpressiveProperty("Magnitude")] + private double MagnitudeExpression => System.Math.Sqrt(X * X + Y * Y); + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(2, result.GeneratedTrees.Length); + + return Verifier.Verify(string.Join("\n\n// ===\n\n", + result.GeneratedTrees.Select(t => t.ToString()))); + } + + // ── Diagnostic tests ──────────────────────────────────────────────────── + + [TestMethod] + public void TargetAlreadyExists_ReportsEXP0031() + { + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public string FullName { get; set; } = ""; + + [ExpressiveProperty("FullName")] + private string FullNameExpression => "x"; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0031")); + } + + [TestMethod] + public void NonPartialContainer_ReportsEXP0032() + { + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class Account { + public string FirstName { get; set; } = ""; + + [ExpressiveProperty("FullName")] + private string FullNameExpression => FirstName; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0032")); + } + + [TestMethod] + public void AccessorListFormRejected_EXP0033() + { + // Accessor-list form (`{ get => expr; }`) is rejected in favor of top-level `=> expr`. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public string FirstName { get; set; } = ""; + + [ExpressiveProperty("FullName")] + private string FullNameExpression { get { return FirstName; } } + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0033")); + } + + [TestMethod] + public void StaticStubRejected_EXP0034() + { + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + partial class Account { + public static string Theme = "dark"; + + [ExpressiveProperty("EffectiveTheme")] + private static string EffectiveThemeExpression => Theme; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0034")); + } + + [TestMethod] + public void ShadowsInheritedMember_ReportsEXP0035() + { + // Target name already exists on the base type — silently hiding it would be a footgun. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class Base { + public string Name { get; set; } = ""; + } + + partial class Derived : Base { + public string Prefix { get; set; } = ""; + + [ExpressiveProperty("Name")] + private string NameExpression => Prefix + "/"; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Count(d => d.Id == "EXP0035")); + } +} diff --git a/tests/ExpressiveSharp.IntegrationTests/Tests/SynthesizedExpressiveTests.cs b/tests/ExpressiveSharp.IntegrationTests/Tests/SynthesizedExpressiveTests.cs index 7542232e..a9850a47 100644 --- a/tests/ExpressiveSharp.IntegrationTests/Tests/SynthesizedExpressiveTests.cs +++ b/tests/ExpressiveSharp.IntegrationTests/Tests/SynthesizedExpressiveTests.cs @@ -5,7 +5,7 @@ namespace ExpressiveSharp.IntegrationTests.Tests; /// -/// Provider-agnostic tests for [ExpressiveFor(..., Synthesize = true)]. Verifies the +/// Provider-agnostic tests for [ExpressiveProperty]. Verifies the /// dual-direction runtime behavior of the generated property: in-memory reads evaluate the /// stub (because the backing field is not yet materialized), while values assigned through /// the synthesized init accessor are stored and returned verbatim. @@ -146,7 +146,7 @@ public partial class SynthesizedEntity public string? Name { get; set; } public string? Email { get; set; } - [ExpressiveFor("DisplayLabel", Synthesize = true)] + [ExpressiveProperty("DisplayLabel")] private string DisplayLabelExpression => (Name ?? "(unnamed)") + " <" + (Email ?? "no-email") + ">"; } @@ -161,7 +161,7 @@ public partial class DiscountedSynthesizedEntity public decimal? TotalAmount { get; set; } public decimal? Discount { get; set; } - [ExpressiveFor("DiscountedAmount", Synthesize = true)] + [ExpressiveProperty("DiscountedAmount")] private decimal? DiscountedAmountExpression => TotalAmount != null && Discount != null ? TotalAmount.Value - Discount.Value diff --git a/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/SynthesizedMongoIgnoreTests.cs b/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/SynthesizedMongoIgnoreTests.cs index d6921ad6..bbf7c8a0 100644 --- a/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/SynthesizedMongoIgnoreTests.cs +++ b/tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/SynthesizedMongoIgnoreTests.cs @@ -9,7 +9,7 @@ namespace ExpressiveSharp.MongoDB.IntegrationTests.Tests; /// -/// Verifies that [ExpressiveFor(..., Synthesize = true)] properties are unmapped from BSON +/// Verifies that [ExpressiveProperty] stubs are unmapped from BSON /// serialization by the ExpressiveMongoIgnoreConvention, and that the formula is /// correctly rewritten when referenced inside LINQ queries against the MongoDB provider. /// @@ -93,6 +93,6 @@ public partial class SynthesizedMongoDocument public string FirstName { get; set; } = ""; public string LastName { get; set; } = ""; - [ExpressiveFor("FullName", Synthesize = true)] + [ExpressiveProperty("FullName")] private string FullNameExpression => LastName + ", " + FirstName; } diff --git a/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs b/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs index ccb3a8ac..50dfdab2 100644 --- a/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs +++ b/tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs @@ -85,7 +85,7 @@ public void FindGeneratedExpression_PropertyBody_ContainsMultiply() "Expected Product.Total expression to contain a Multiply node"); } - // ── [ExpressiveFor(..., Synthesize = true)] ───────────────────────────── + // ── [ExpressiveProperty] ───────────────────────────────────────────────── // // The most load-bearing correctness point: the generator must register the formula lambda // under the synthesized property's getter MethodHandle. If the registry were keyed off the @@ -171,13 +171,13 @@ private static bool ContainsNodeType(Expression expr, ExpressionType nodeType) /// /// Test-local fixture for synthesized-property resolver tests. Declared here (not in the shared -/// TestFixtures) to keep the [ExpressiveFor(..., Synthesize = true)] dependency contained. +/// TestFixtures) to keep the [ExpressiveProperty] dependency contained. /// public partial class SynthesizedCustomer { public string FirstName { get; set; } = ""; public string LastName { get; set; } = ""; - [ExpressiveSharp.Mapping.ExpressiveFor("FullName", Synthesize = true)] + [ExpressiveSharp.Mapping.ExpressiveProperty("FullName")] private string FullNameExpression => LastName + ", " + FirstName; } From 2f906852dd55f43aef628c573ef0d0e6ecd86fdf Mon Sep 17 00:00:00 2001 From: Koen Date: Tue, 21 Apr 2026 00:09:34 +0000 Subject: [PATCH 3/3] fix(EXP0013): suppress warning for members registered via sibling mapping stubs The MissingExpressiveAnalyzer warned when a member referenced in an expressive context looked like it could benefit from [Expressive], even though a sibling stub on the same type already registered an expression for it via either [ExpressiveProperty("X")] or [ExpressiveFor(nameof(X))] / the two-arg self-targeting form. The reference resolves to a member with an expandable body but no direct [Expressive] attribute, so the heuristic fired. Teaches the analyzer to check the containing type for a sibling whose mapping attribute names the referenced member as its target, and treat it as effectively expressive. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MissingExpressiveAnalyzer.cs | 76 +++++++++++++++++++ .../MissingExpressiveDiagnosticTests.cs | 53 +++++++++++++ 2 files changed, 129 insertions(+) diff --git a/src/ExpressiveSharp.CodeFixers/MissingExpressiveAnalyzer.cs b/src/ExpressiveSharp.CodeFixers/MissingExpressiveAnalyzer.cs index d8a0e9f7..943916da 100644 --- a/src/ExpressiveSharp.CodeFixers/MissingExpressiveAnalyzer.cs +++ b/src/ExpressiveSharp.CodeFixers/MissingExpressiveAnalyzer.cs @@ -233,6 +233,11 @@ private static void WarnIfMissingExpressive( if (HasExpressiveAttribute(symbol)) return; + // A sibling stub with [ExpressiveProperty("X")] or [ExpressiveFor(... "X")] + // on the same containing type registers an expression for this member. + if (HasSiblingMappingTargetingMember(symbol)) + return; + if (!HasExpandableBody(symbol, context.CancellationToken)) return; @@ -247,6 +252,77 @@ private static void WarnIfMissingExpressive( symbol.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); } + /// + /// True when the containing type declares a sibling stub whose mapping attribute names + /// as its target — i.e. [ExpressiveProperty("X")] or + /// [ExpressiveFor("X")] / [ExpressiveFor(typeof(ThisType), "X")]. In any of + /// those cases the registry has an entry for the member even though it carries no direct + /// [Expressive] attribute. + /// + private static bool HasSiblingMappingTargetingMember(ISymbol symbol) + { + var containingType = symbol.ContainingType; + if (containingType is null) + return false; + + var targetName = symbol.Name; + + foreach (var sibling in containingType.GetMembers()) + { + if (SymbolEqualityComparer.Default.Equals(sibling, symbol)) + continue; + + foreach (var attr in sibling.GetAttributes()) + { + if (attr.AttributeClass is not { } attrClass) continue; + var ns = attrClass.ContainingNamespace?.ToDisplayString(); + if (ns != "ExpressiveSharp.Mapping") continue; + + if (attrClass.Name == "ExpressivePropertyAttribute" + && ExtractStringArg(attr, 0) == targetName) + { + return true; + } + + if (attrClass.Name == "ExpressiveForAttribute" && MapsExpressiveForTo(attr, containingType, targetName)) + { + return true; + } + } + } + + return false; + } + + /// + /// Resolves [ExpressiveFor]'s target-name argument in either supported form and + /// checks it against , and — for the two-argument form — + /// verifies the typeof(...) argument matches . + /// + private static bool MapsExpressiveForTo(AttributeData attr, INamedTypeSymbol containingType, string targetName) + { + if (attr.ConstructorArguments.Length == 1 && + attr.ConstructorArguments[0].Value is string singleArg) + { + return singleArg == targetName; + } + + if (attr.ConstructorArguments.Length == 2 && + attr.ConstructorArguments[0].Value is INamedTypeSymbol targetType && + attr.ConstructorArguments[1].Value is string twoArgName) + { + return twoArgName == targetName + && SymbolEqualityComparer.Default.Equals(targetType, containingType); + } + + return false; + } + + private static string? ExtractStringArg(AttributeData attr, int index) => + attr.ConstructorArguments.Length > index + ? attr.ConstructorArguments[index].Value as string + : null; + private static bool HasExpressiveAttribute(ISymbol symbol) { foreach (var attr in symbol.GetAttributes()) diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/MissingExpressiveDiagnosticTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/MissingExpressiveDiagnosticTests.cs index eb31f924..720c2fb3 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/MissingExpressiveDiagnosticTests.cs +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/MissingExpressiveDiagnosticTests.cs @@ -506,6 +506,59 @@ void Run(IExpressiveQueryable source) { "Should not warn for enum extension method in IExpressiveQueryable LINQ lambda"); } + // ── Sibling-mapping suppression ───────────────────────────────────────── + // + // When a stub on the same type registers an expression for a member via + // [ExpressiveProperty("X")] or [ExpressiveFor(nameof(X))], the referenced member + // is effectively expressive — EXP0013 must not fire on it. + + [TestMethod] + public async Task PropertyAccess_ToExpressivePropertySynthesizedTarget_DoesNotWarn() + { + var diagnostics = await RunAnalyzerAsync( + """ + using ExpressiveSharp.Mapping; + namespace Foo { + partial class C { + public int Value { get; set; } + + [ExpressiveProperty("Doubled")] + int DoubledExpression => Value * 2; + + [Expressive] + public int Quadrupled => Doubled * 2; + } + } + """); + + Assert.IsFalse(diagnostics.Any(d => d.Id == "EXP0013"), + "Should not warn for a reference to an [ExpressiveProperty]-synthesized target"); + } + + [TestMethod] + public async Task PropertyAccess_ToSameTypeExpressiveForTarget_DoesNotWarn() + { + var diagnostics = await RunAnalyzerAsync( + """ + using ExpressiveSharp.Mapping; + namespace Foo { + class C { + public int Value { get; set; } + public int Doubled { get; set; } + + [ExpressiveFor(nameof(Doubled))] + int DoubledExpression => Value * 2; + + [Expressive] + public int Quadrupled => Doubled * 2; + } + } + """); + + Assert.IsFalse(diagnostics.Any(d => d.Id == "EXP0013"), + "Should not warn for a reference to an [ExpressiveFor]-mapped same-type target"); + } + // ── Helper ────────────────────────────────────────────────────────────── private async Task> RunAnalyzerAsync(string source)