Skip to content
Merged
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: 2 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const sidebar: DefaultTheme.Sidebar = {
text: 'Reference',
items: [
{ text: '[Expressive] Attribute', link: '/reference/expressive-attribute' },
{ text: 'Projectable Properties', link: '/reference/projectable-properties' },
{ text: '[ExpressiveFor] Mapping', link: '/reference/expressive-for' },
{ text: 'Null-Conditional Rewrite', link: '/reference/null-conditional-rewrite' },
{ text: 'Pattern Matching', link: '/reference/pattern-matching' },
Expand Down Expand Up @@ -85,6 +86,7 @@ const sidebar: DefaultTheme.Sidebar = {
text: 'Recipes',
items: [
{ text: 'Computed Entity Properties', link: '/recipes/computed-properties' },
{ text: 'Projection Middleware (HotChocolate, AutoMapper)', link: '/recipes/projection-middleware' },
{ text: 'DTO Projections with Constructors', link: '/recipes/dto-projections' },
{ text: 'Scoring & Classification', link: '/recipes/scoring-classification' },
{ text: 'Nullable Navigation Properties', link: '/recipes/nullable-navigation' },
Expand Down
21 changes: 21 additions & 0 deletions docs/guide/integrations/mongodb.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ db.Customers

No custom MQL is emitted — MongoDB's own translator does all the heavy lifting after ExpressiveSharp has normalized the tree.

## `[Expressive]` Properties Are Unmapped from BSON

ExpressiveSharp provides a MongoDB `IClassMapConvention` that unmaps every `[Expressive]`-decorated property from the BSON class map, so the property's backing field is not persisted to documents. This matters most for [Projectable properties](../../reference/projectable-properties), which have a writable `init` accessor and would otherwise be serialized as a real BSON field.

::: warning Ordering constraint
MongoDB builds and caches a class map the first time you call `IMongoDatabase.GetCollection<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:

```csharp
using ExpressiveSharp.MongoDB.Infrastructure;

// At application startup, before any GetCollection<T>:
ExpressiveMongoIgnoreConvention.EnsureRegistered();

var client = new MongoClient(connectionString);
var db = client.GetDatabase("shop");
var customers = db.GetCollection<Customer>("customers"); // class map built now
```

The convention is also registered automatically when you construct `ExpressiveMongoCollection<T>` or call `collection.AsExpressive()` — but only if that happens before any `GetCollection<T>` call for a type with `[Expressive]` properties. The explicit `EnsureRegistered()` call is the most reliable pattern.
:::

## Async Methods

All MongoDB async LINQ methods (from `MongoQueryable`) work with modern syntax via interceptors. They are stubs on `IExpressiveMongoQueryable<T>` that forward to their `MongoQueryable` counterparts:
Expand Down
36 changes: 33 additions & 3 deletions docs/guide/migration-from-projectables.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,46 @@ Both the old `Ignore` and `Rewrite` behaviors converge to the same result in Exp

| Old Property | Migration |
|---|---|
| `UseMemberBody = "SomeMethod"` | Replace with `[ExpressiveFor]`. See [Migrating UseMemberBody](#migrating-usememberbody) below. |
| `UseMemberBody = "SomeMethod"` | Replace with `[Expressive(Projectable = true)]` or `[ExpressiveFor]`. See [Migrating UseMemberBody](#migrating-usememberbody) below. |
| `AllowBlockBody = true` | Keep -- block bodies remain opt-in. Set per-member or globally via `Expressive_AllowBlockBody` MSBuild property. |
| `ExpandEnumMethods = true` | Remove -- enum method expansion is enabled by default. |
| `CompatibilityMode.Full / .Limited` | Remove -- only the full approach exists. |

### Migrating `UseMemberBody`

In Projectables, `UseMemberBody` let you point one member's expression body at another member -- typically to work around syntax limitations or to provide an expression-tree-friendly alternative.
In Projectables, `UseMemberBody` let you point one member's expression body at another member -- typically to work around syntax limitations or to provide an expression-tree-friendly alternative for projection middleware (HotChocolate, AutoMapper) that required a writable target.

ExpressiveSharp replaces this with `[ExpressiveFor]` (in the `ExpressiveSharp.Mapping` namespace), which is more explicit and works for external types too.
ExpressiveSharp offers **two replacement options**, depending on your scenario:

- **`[Expressive(Projectable = true)]`** -- the ergonomic fit when your goal was specifically to participate in projection middleware. Keeps the formula on the property itself via the `field ?? (<formula>)` pattern. Closest to Projectables' intent.
- **`[ExpressiveFor]`** -- the verbose but explicit alternative. Works for external types too (scenarios `UseMemberBody` never supported).

Either is correct; pick based on ergonomic preference and whether you need the cross-type capability.

::: info About the `Projectable` name overlap
ExpressiveSharp's `Projectable` attribute property and the EFCore.Projectables library's `[Projectable]` attribute share a name because both describe the same capability -- a computed property that participates in LINQ projections. They are different mechanisms; the shared word is intentional to reduce migration friction.
:::

**Option A -- `[Expressive(Projectable = true)]`** (single declaration):

```csharp
// Before (Projectables)
[Projectable(UseMemberBody = nameof(FullNameProjection))]
public string FullName { get; init; }
private string FullNameProjection => LastName + ", " + FirstName;

// After (ExpressiveSharp) -- formula lives on the property
[Expressive(Projectable = true)]
public string FullName
{
get => field ?? (LastName + ", " + FirstName);
init => field = value;
}
```

See the [Projectable Properties reference](../reference/projectable-properties) and the [Projection Middleware recipe](../recipes/projection-middleware) for the complete feature.

**Option B -- `[ExpressiveFor]`** (separate stub, supports cross-type mapping):

**Scenario 1: Same-type member with an alternative body**

Expand Down
169 changes: 169 additions & 0 deletions docs/recipes/projection-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Computed Properties in Projection Middleware

If your computed property returns an empty value -- or is silently dropped from the response -- when consumed by **HotChocolate**, **AutoMapper's `ProjectTo`**, **Mapperly's projection mode**, or any other framework that emits `Select(src => new Entity { Member = src.Member, ... })` over your entity type, this recipe is the fix.

## Why plain `[Expressive]` isn't enough

HotChocolate's `[UseProjection]` middleware (and similar mechanisms in other libraries) generates a projection expression from the GraphQL selection set. Empirically, when the GraphQL query asks for only `fullName`:

```graphql
query { users { fullName } }
```

HotChocolate inspects the `User.FullName` property, finds it is **read-only** (no setter), and silently drops it from the projection. The generated SQL is `SELECT 1 FROM Users` -- nothing is fetched. At materialization time, HC constructs fresh `User` instances with all fields at their defaults (`FirstName = ""`, `LastName = ""`), calls the `FullName` getter, and the formula returns `", "`. The response looks successful but the data is wrong.

The same mechanism affects AutoMapper's `ProjectTo<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)]`

Turning on `Projectable = true` makes the property writable (so the projection middleware emits a binding) while still registering the formula for SQL translation. The dual-direction runtime behavior is:

- **In memory**, reading the property evaluates the formula from dependencies (same as plain `[Expressive]`).
- **After materialization from SQL**, reading the property returns the stored value (which the middleware's binding wrote via the `init` accessor).

## Before and after

**Before** -- plain `[Expressive]` on a read-only property. Broken for projection middleware.

```csharp
public class User
{
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";

[Expressive]
public string FullName => LastName + ", " + FirstName;
}
```

GraphQL response: `{ "users": [{ "fullName": ", " }, { "fullName": ", " }] }` -- wrong.
SQL emitted: `SELECT 1 FROM Users` -- nothing fetched.

**After** -- `Projectable = true`.

```csharp
public class User
{
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";

[Expressive(Projectable = true)]
public string FullName
{
get => field ?? (LastName + ", " + FirstName);
init => field = value;
}
}
```

GraphQL response: `{ "users": [{ "fullName": "Lovelace, Ada" }, { "fullName": "Turing, Alan" }] }` -- correct.
SQL emitted: `SELECT u.LastName || ', ' || u.FirstName AS "FullName" FROM Users u` -- formula pushed into SQL.

No HC glue code is required beyond the normal `.UseExpressives()` on the DbContext options. The convention auto-ignores the property in EF's model (so no `FullName` column is created), and the projection rewrite happens automatically when the query compiler intercepts.

## Full HotChocolate example

```csharp
// Entity
public class User
{
public int Id { get; set; }
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string Email { get; set; } = "";

[Expressive(Projectable = true)]
public string FullName
{
get => field ?? (LastName + ", " + FirstName);
init => field = value;
}

[Expressive(Projectable = true)]
public string DisplayLabel
{
get => field ?? (FullName + " <" + Email + ">");
init => field = value;
}
}

// DbContext
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
}

// Program.cs
builder.Services.AddDbContext<AppDbContext>(o => o
.UseSqlServer(connectionString)
.UseExpressives());

builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddProjections();

// Query type
public class Query
{
[UseProjection]
public IQueryable<User> GetUsers([Service] AppDbContext db) => db.Users;
}
```

A GraphQL query for `{ users { displayLabel } }` now produces:

```sql
SELECT (u.LastName || ', ' || u.FirstName) || ' <' || u.Email || '>' AS "DisplayLabel"
FROM Users u
```

Notice how `DisplayLabel` composes with `FullName` (which is itself Projectable) -- the transitive rewrite is handled automatically by the expression resolver.

## Full AutoMapper example

AutoMapper's `ProjectTo<T>()` emits the same `new T { ... }` pattern as HotChocolate, so Projectable members work the same way:

```csharp
var config = new MapperConfiguration(cfg =>
{
cfg.CreateMap<User, User>(); // same-type projection
});

var users = await db.Users
.ProjectTo<User>(config)
.ToListAsync();

// SQL emitted: SELECT u.Id, u.FirstName, u.LastName, u.Email,
// (u.LastName || ', ' || u.FirstName) AS "FullName",
// (... || u.Email || '>') AS "DisplayLabel"
// FROM Users u
```

## When to use `[ExpressiveFor]` instead

If your class can't use the C# 14 `field` keyword, or you want to keep the formula in a separate mapping class, the [`[ExpressiveFor]`](../reference/expressive-for) pattern is a verbose alternative:

```csharp
public class User
{
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string FullName { get; set; } = ""; // plain auto-property
}

internal static class UserMappings
{
[ExpressiveFor(typeof(User), nameof(User.FullName))]
private static string FullName(User u) => u.LastName + ", " + u.FirstName;
}
```

Both approaches produce the same SQL and work identically with HotChocolate / AutoMapper. The `Projectable` form is more concise and keeps the formula on the property itself; the `ExpressiveFor` form is explicit about the separation. See the [migration guide](../guide/migration-from-projectables) for a side-by-side comparison.

## See Also

- [Projectable Properties](../reference/projectable-properties) -- full reference including restrictions and runtime semantics
- [`[ExpressiveFor]` Mapping](../reference/expressive-for) -- alternative pattern for scenarios where you can't modify the entity type
- [Migrating from Projectables](../guide/migration-from-projectables) -- side-by-side migration paths for `UseMemberBody`
- [Computed Entity Properties](./computed-properties) -- plain `[Expressive]` computed values for DTO projections
Loading
Loading