Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ 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: '[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' },
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/integrations/mongodb.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()` 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<T>` call:
Expand Down
35 changes: 17 additions & 18 deletions docs/guide/migration-from-projectables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `[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. |
Expand All @@ -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 ?? (<formula>)` pattern. Closest to Projectables' intent.
- **`[ExpressiveFor]`** -- the verbose but explicit alternative. Works for external types too (scenarios `UseMemberBody` never supported).
- **`[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.

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 -- `[ExpressiveProperty]`** (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;
Comment on lines 145 to 147
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration guide example shows public partial string FullName { get; init; }, but partial isn’t a valid modifier for properties in current C# and will not compile. For the Projectables “before” snippet, the partial should be on the containing type (if needed), not on the property declaration.

Copilot uses AI. Check for mistakes.

// 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;
[ExpressiveProperty("FullName")]
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 [`[ExpressiveProperty]` reference](../reference/expressive-property) and the [Projection Middleware recipe](../recipes/projection-middleware).

::: warning Target name must be a string literal
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 -- `[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**

Expand Down
68 changes: 29 additions & 39 deletions docs/recipes/projection-middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ HotChocolate inspects the `User.FullName` property, finds it is **read-only** (n

The same mechanism affects AutoMapper's `ProjectTo<Entity>`, 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: `[ExpressiveProperty]`

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
Expand All @@ -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** -- `[ExpressiveProperty]` 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.
[ExpressiveProperty("FullName")]
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: `[ExpressiveProperty("FullName")]`.
:::

## 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;
}
[ExpressiveProperty("FullName")]
private string FullNameExpression => LastName + ", " + FirstName;

[ExpressiveProperty("DisplayLabel")]
private string DisplayLabelExpression => FullName + " <" + Email + ">";
}

// DbContext
Expand Down Expand Up @@ -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<T>()` emits the same `new T { ... }` pattern as HotChocolate, so Projectable members work the same way:
AutoMapper's `ProjectTo<T>()` emits the same `new T { ... }` pattern as HotChocolate, so synthesized members work the same way:

```csharp
var config = new MapperConfiguration(cfg =>
Expand All @@ -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:

```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 `[ExpressiveProperty]` 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
- [`[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
Loading
Loading