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: 1 addition & 1 deletion .agents/skills/alephmapper/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ For further recipes, read [projections.md](references/projections.md), [updates-

## Installation and verification

The documented package is `AlephMapper` 0.7.4, packaged as a compile-time analyzer/source generator targeting `netstandard2.0`. Its consumer compiler host must be compatible with `Microsoft.CodeAnalysis` 4.14 or later; verify requirements for the installed version. A `PackageReference` used in a library should configure generator assets appropriately (see the repository README).
The documented package is `AlephMapper` 0.8.0, packaged as a compile-time analyzer/source generator targeting `netstandard2.0`. It supports Roslyn 4.8 and later through versioned analyzer assets; C# 14 extension members require Roslyn 5.0 or later. Verify requirements for the installed version. A `PackageReference` used in a library should configure generator assets appropriately (see the repository README).

Inspect compiler diagnostics (`AM0001`–`AM0018` as applicable) and, when debugging, emit compiler-generated files. Verify generated signatures rather than inventing them. Do not treat a generated code compilation failure as an EF translation problem.

Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/build-nuget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.400
dotnet-version: |
8.0.x
10.0.400

- name: Cache NuGet packages
uses: actions/cache@v4
Expand All @@ -37,6 +39,9 @@ jobs:
- name: Build solution
run: dotnet build AlephMapper.slnx --configuration Release --no-restore

- name: Verify packing without rebuilding
run: dotnet pack source/AlephMapper.csproj --configuration Release --no-build --no-restore --output artifacts/package-no-build

- name: Run tests
run: dotnet test AlephMapper.slnx --configuration Release --no-build --no-restore -- --report-trx --results-directory artifacts/test-results

Expand All @@ -51,6 +56,9 @@ jobs:
- name: Pack NuGet package
run: dotnet pack source/AlephMapper.csproj --configuration Release --no-restore -p:BuildNumber=${{ github.run_number }} --output artifacts/nuget

- name: Verify Roslyn compiler compatibility
run: python scripts/verify_roslyn_compatibility.py --package-dir artifacts/nuget

- name: Verify package consumption
shell: bash
run: |
Expand Down
1 change: 1 addition & 0 deletions AlephMapper.slnx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<Solution>
<Project Path="source/AlephMapper.csproj" />
<Project Path="source/Roslyn4/AlephMapper.Roslyn4.csproj" />
<Folder Name="/examples/">
<Project Path="examples/SampleApp/SampleApp.csproj" />
</Folder>
Expand Down
138 changes: 134 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ dotnet add package AlephMapper
Using `PackageReference`:

```xml
<PackageReference Include="AlephMapper" Version="0.7.4">
<PackageReference Include="AlephMapper" Version="0.8.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Expand All @@ -82,7 +82,7 @@ With Central Package Management:

```xml
<!-- Directory.Packages.props -->
<PackageVersion Include="AlephMapper" Version="0.7.4" />
<PackageVersion Include="AlephMapper" Version="0.8.0" />

<!-- Project file -->
<PackageReference Include="AlephMapper">
Expand All @@ -103,7 +103,7 @@ When referencing the generator directly from source:

### Compiler compatibility

AlephMapper 0.7.4 requires a Roslyn compiler host compatible with `Microsoft.CodeAnalysis` 4.14 or later. This version uses Roslyn's embedded-marker support so its generated configuration attributes remain private to the consuming assembly, including when `InternalsVisibleTo` is used.
AlephMapper 0.8.0 supports Roslyn 4.8 and later through versioned analyzer assets. Roslyn 4.8 through 4.x supports classic mappings, switch lowering, Update and Adapt; Roslyn 5.0 and later additionally supports C# 14 extension members. The compiler automatically selects one compatible analyzer. Generated configuration attributes remain private to the consuming assembly, including when `InternalsVisibleTo` is used.

### Migration: `[Expressive]` to `[Projectable]`

Expand Down Expand Up @@ -174,6 +174,132 @@ Use the original method in memory:
var person = PersonMapper.MapPerson(employee);
```

## C# 14 extension members

C# 14 instance methods and expression-bodied getter-only properties in extension
blocks can generate ordinary static expression factories in the outer `static
partial` class. The receiver is the expression input; ordinary method arguments
become factory arguments.

```csharp
public static partial class OrderExtensions
{
// Classic extension method remains supported.
[Projectable]
public static decimal Twice(this Order order) => order.Amount * 2m;

extension(Order order)
{
[Projectable]
public decimal AmountWithTax => order.Amount * 1.25m;

[Projectable]
public string Label(int min) => order.Amount >= min ? "high" : "low";
}
}

// Expression<Func<Order, decimal>>
var tax = OrderExtensions.AmountWithTaxExpression();
// Expression<Func<Order, string>>
var label = OrderExtensions.LabelExpression(100);
```

Nested source-defined extension accesses/calls are expanded by symbol identity,
including classic/C# 14 composition, switch bodies, and nested LINQ method calls.
Primitive, interface, struct, nullable-value and reference receivers are supported.
`[Projectable]` on the outer class includes supported extension members.
`[Updatable]` and `[Adapt]` do not automatically apply to computed properties;
direct requests on extension methods receive `AM0021`.

The NuGet package selects a compatible analyzer automatically by the compiler
host version (independent of the application's target framework):

| Compiler host | Selected analyzer | Features |
|---|---|---|
| Roslyn 4.8 through 4.x | `analyzers/dotnet/roslyn4.8/cs/AlephMapper.dll` | Classic mappings, helpers, switches, Update and Adapt |
| Roslyn 5.0 or newer | `analyzers/dotnet/roslyn5.0/cs/AlephMapper.dll` | Classic features plus C# 14 extension members |

Both variants target `netstandard2.0` and share the mapping implementation.
The package contains no compiler DLLs and needs no consumer-side version setting.
Hosts below Roslyn 4.8 are outside the supported floor. IDE builds follow their
compiler host version too; individual IDE versions were not separately tested.
C# 14 source still requires a compiler/language version that supports C# 14.

Package tests execute actual compilers **4.8.0, 4.14.0, 5.0.0**, and the installed
SDK **10.0.403 / Roslyn 5.9**, asserting that exactly one correct analyzer is
selected. The old single-5.0 package was reproduced failing on 4.14 with CS9057.
The new package runs classic net8 consumers under 4.x and extension projections
under 5.x. See the [multiversion plan and evidence](docs/roslyn-multiversion-plan.md).

Mapper classes must be `static partial`. `AM0022` skips a classic mapper with an invalid declaration.
`AM0023` skips a classic expression companion that conflicts with an existing member.
Companion overload checks distinguish nullable value types such as `int` and `int?`, while ignoring nullable reference annotations.

`AM0021` explains unsupported extension forms and skips the affected companion:
generic blocks/methods, block/accessor bodies, query syntax (use LINQ method
syntax), bound extension method groups (use an explicit lambda), static members,
optional/params/ref arguments, ref-like receivers, ref returns, generated factory
signature conflicts, and inaccessible expanded dependencies. Ordinary
`Select(Map)` method groups retain their existing support.

Null policies and the repeated-evaluation tradeoff remain unchanged. Generated
expression correctness does not guarantee provider translation; the SQLite
integration tests execute the documented scalar projection subset. See the
[extension compatibility matrix](docs/extension-members-compatibility-matrix.md).

## Switch expressions: evaluation semantics

AlephMapper lowers supported C# `switch` expressions to nested conditional
expressions so they can appear in LINQ expression trees. This deliberately
matches the conditional-lowering behavior of ExpressiveSharp.

**Important:** the input to a C# `switch` is ordinarily evaluated once, but
lowering to conditional comparisons can evaluate the input **more than once**
(for example, `source.Code.ToLower() switch { "a" => ..., "b" => ..., _ => ... }`).
The generator does **not** reject these repeated input evaluations via
`AM0019`. Prefer stable, side-effect-free expressions as switch inputs,
especially for queries and methods with mutable state. EF Core translation
depends on the provider.

`AM0019` still prevents expression generation for unsupported or
non-exhaustive patterns, including cases whose comparison semantics cannot
be lowered correctly. Inlining helper methods likewise substitutes their
arguments directly; an argument with side effects can be evaluated more than
once, skipped, or evaluated in a different order. Prefer stable, side-effect-free
arguments when generating projections.

Supported patterns include scalar constants, relational/logical patterns, type and
`var` bindings, nested/extended property patterns, fixed-length arrays and
`List<T>`, non-capturing list slices, and positional records whose selected
`Deconstruct` and component properties are compiler synthesized. Captures in
switch arms bind to their actual member or element path, including in `when`.
Nested switches and capture-free `is` patterns reuse the same lowering.

Examples of the generated conditional shapes (formatting simplified):

```csharp
// input switch { { Quantity: > 100 } => 1, _ => 0 }
((object)input != null && input.Quantity > 100) ? 1 : 0

// items switch { [1, .., 3] => 1, _ => 0 }
((object)items != null && items.Length >= 2
&& items[0] == 1 && items[items.Length - 1] == 3) ? 1 : 0

// point switch { (0, 0) => 1, _ => 0 } for record Point(int X, int Y)
((object)point != null && point.X == 0 && point.Y == 0) ? 1 : 0
```

Repeated member/indexer reads share the evaluation tradeoff described above.
SQL translation remains provider dependent; array/list and record support does
not guarantee that a database provider can translate those expressions.

An unguarded final `_` arm is required, including for otherwise exhaustive
switches. `AM0019` also covers NaN constants, boxed/interface scalar comparisons,
custom deconstruction, captured slices, unverified indexable collections,
ref-return properties, `is` captures requiring an outer binding scope, and throw
expressions. Update-only adaptation retains ordinary C# switch behavior.
See the [tested support matrix](docs/switch-parity-matrix.md) for precise limits.

## Composing mappings and predicates

Supported expression-bodied methods can call other mapping or helper methods. AlephMapper substitutes their arguments and expands their bodies into the generated expression:
Expand Down Expand Up @@ -420,7 +546,7 @@ This preserves EF Core's tracked instance:
```csharp
var person = await dbContext.People.FindAsync(id);

PersonMapper.Map(request, target: person);
PersonMapper.Map(request, dest: person);

await dbContext.SaveChangesAsync();
```
Expand Down Expand Up @@ -587,3 +713,7 @@ AlephMapper was inspired by [EntityFrameworkCore.Projectables](https://github.co
- [AutoMapper](https://automapper.org/)
- [Mapster](https://github.com/MapsterMapper/Mapster)
- [Facet](https://github.com/Tim-Maes/Facet)

## Acknowledgment

AlephMapper's extension-member and switch-expression implementations are heavily inspired by [ExpressiveSharp](https://github.com/EFNext/ExpressiveSharp).
4 changes: 2 additions & 2 deletions docs/Adapt-Attribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ public static EmployeeDto MapEmployee(Employee source, int currentYear) =>
YearsActive = currentYear - source.StartYear
};

public static Expression<Func<Employee, int, EmployeeDto>> MapEmployeeExpression() =>
(source, currentYear) => new EmployeeDto
public static Expression<Func<Employee, EmployeeDto>> MapEmployeeExpression(int currentYear) =>
source => new EmployeeDto
{
Id = source.Id,
DisplayName = source.FirstName + " " + source.LastName,
Expand Down
65 changes: 65 additions & 0 deletions docs/expressivesharp-switch-comparison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Switch lowering compared with ExpressiveSharp

Checked 2026-11-03 against ExpressiveSharp main at [`462edb54b2012c4d55f4b603d4a085720a091dc8`](https://github.com/EFNext/ExpressiveSharp/tree/462edb54b2012c4d55f4b603d4a085720a091dc8) and AlephMapper PR #58 (`65b8605` plus the tuple signature normalization fix identified below).

The common subset matches the same conditional-tree design. This is not complete feature or behavioral parity, and upstream edge-case behavior should not become AlephMapper's specification.

## Design and supported subset

AlephMapper rewrites bound syntax to readable C# lambdas. ExpressiveSharp's operation-based emitter writes `Expression.Condition`, `AndAlso`, `TypeIs`, `Convert`, member accesses and other expression factory calls. The resulting switch trees have broadly the same shape: reverse-folded arms, first matching arm wins, guards combined with short-circuit AND.

Both support the tested scalar constants, relational/logical patterns, ordinary type captures and guards, property patterns, nullable comparisons and nested switches. Both can repeat evaluation of governing expressions. Reusing an expression node in an expression tree does not cache its runtime value.

Deliberate differences:

- AlephMapper requires a final unguarded discard arm, including for switches that are otherwise exhaustive. Missing fallback receives AM0019. ExpressiveSharp emits `Default(resultType)` when no discard exists; for an integer result this is zero, unlike C#'s unmatched-switch exception.
- AlephMapper verifies rank-one arrays and exact `List<T>` list shapes. ExpressiveSharp additionally discovers Count/Length and integer indexers on other types. That broader acceptance is not proof of equivalent semantics.
- AlephMapper positional lowering requires source-verifiable synthesized record deconstruction and positional properties. ExpressiveSharp also resolves tuple ItemN fields and approximates custom deconstruction through case-insensitive parameter/property name matching; it does not invoke the selected Deconstruct method in this path.
- AlephMapper rejects captured slices, general boxed scalar comparisons, NaN constants and `is` captures requiring a wider scope. These are explicit AM0019 limitations rather than full language support.
- AlephMapper preserves ordinary C# switches in update-only paths. This comparison concerns generated expression companions.

Implementation references: [upstream patterns](https://github.com/EFNext/ExpressiveSharp/blob/462edb54b2012c4d55f4b603d4a085720a091dc8/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs#L1629), [upstream list lowering](https://github.com/EFNext/ExpressiveSharp/blob/462edb54b2012c4d55f4b603d4a085720a091dc8/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs#L1785), [upstream switch lowering](https://github.com/EFNext/ExpressiveSharp/blob/462edb54b2012c4d55f4b603d4a085720a091dc8/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs#L2194), [AlephMapper lowering](../source/SyntaxRewriters/InliningResolver.SwitchExpressionRewriter.cs), [existing coverage matrix](switch-parity-matrix.md).

## Runtime differential probes

Built the upstream generator from the pinned checkout. An isolated Roslyn 5.9 harness ran each generator on equivalent annotated static mapping methods, compiled the generated expression factories, and compared their compiled delegates with the original C# methods. The upstream registry source was excluded from the probe compilation; the actual generated member factory bodies were retained and invoked. These probes test expression construction and in-memory execution, not SQL translation or upstream runtime registry integration.

Sixteen cases ran against both generators (32 executions):

| Case / input | Original C# | ExpressiveSharp expression | AlephMapper expression |
|---|---|---|---|
| `>= 90`, input 95 | 1 | 1 | 1 |
| `>= 10 and < 20`, input 15 | 1 | 1 | 1 |
| `string text when text.Length > 1`, input `"abc"` | 1 | 1 | 1 |
| `{ Length: > 1 }`, input `"abc"` | 1 | 1 | 1 |
| Nullable `> 0`, input null | 0 | 0 | 0 |
| Nested switch, input 2 | 2 | 2 | 2 |
| Incrementing `Next() switch { 0 => 0, 2 => 2, _ => 3 }`, counter initially 0 | 3 | 2 | 2 |
| `{ Length: var n } when n > 0 => n`, input `"abc"` | 3 | InvalidOperationException | 3 |
| `[var n] when n > 0 => n`, input `[7]` | 7 | InvalidOperationException | 7 |
| `value switch { 1 => 1 }`, input 2 | SwitchExpressionException | 0 | Skipped, AM0019 |
| `[1, .., 3]`, input `[1,9,3]` | 1 | 0 | 1 |
| `[1]`, input null | 0 | NullReferenceException | 0 |
| `var n when n == null`, input null | 1 | 0 | 1 |
| `double.NaN` constant, input NaN | 1 | 0 | Skipped, AM0019 |
| `_ when value > 0 => 1, _ => 0`, input -1 | 0 | 1 | 0 |
| Tuple `(1,2)` pattern, input `(1,2)` | 1 | 1 | Skipped, AM0019 after fix below |

All unspecified switch fallbacks in the table are `_ => 0`.

Source inspection explains the upstream discrepancies: list lowering omits a null guard and uses forward element indices even after a slice; declaration binding only handles a top-level declaration; discard-arm selection ignores its guard; constants use ordinary equality, including NaN. These observations apply to the pinned revision and the tested entry point, not every ExpressiveSharp API or future version.

## AlephMapper regression found and fixed

The first tuple probe reported AM0004 rather than the documented AM0019. The new signature registry assumed every Roslyn tuple symbol has a non-null `TupleUnderlyingType`. Some unnamed/underlying ValueTuple symbols do not. Normalization now retains the original symbol when that property is null.

Added regressions cover unnamed tuple versus named tuple identity, explicit `System.ValueTuple<int,int>` identity, and unsupported tuple patterns producing AM0019 while a sibling mapping still generates. Tuple pattern support remains deferred.

Release and Debug solution tests after the fix: **209 passed, 0 failed, 0 skipped** in each configuration. Release pack and actual Roslyn 4.8, 4.14, 5.0 and SDK/Roslyn 5.9 package-selection/runtime checks also pass. Existing pattern semantics and generated-source snapshots remain unchanged.

## Follow-up priorities

1. Preserve the current fallback, null, capture and suffix behavior; do not copy upstream discrepancies for parity.
2. Tuple positional patterns are a useful contained coverage gap: verified ValueTuple field access can be added without approximating custom Deconstruct behavior.
3. Consider dedicated NaN lowering separately, with explicit provider-translation tests.
4. Widen custom indexable/deconstruction support only when the selected bound operations can be preserved and verified.
Loading
Loading