diff --git a/docs/guide/migration-from-projectables.md b/docs/guide/migration-from-projectables.md index ea277374..cc6c4959 100644 --- a/docs/guide/migration-from-projectables.md +++ b/docs/guide/migration-from-projectables.md @@ -161,10 +161,12 @@ public string FullName 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): +**Option B -- `[ExpressiveFor]`** (separate stub, also supports cross-type mapping): **Scenario 1: Same-type member with an alternative body** +Use the co-located form: a property stub on the same class combined with the single-argument attribute. `this` is the receiver naturally -- the migration reads almost identically to `UseMemberBody`. + ```csharp // Before (Projectables) public string FullName => $"{FirstName} {LastName}".Trim().ToUpper(); @@ -178,8 +180,8 @@ using ExpressiveSharp.Mapping; public string FullName => $"{FirstName} {LastName}".Trim().ToUpper(); -[ExpressiveFor(typeof(MyEntity), nameof(MyEntity.FullName))] -static string FullNameExpr(MyEntity e) => e.FirstName + " " + e.LastName; +[ExpressiveFor(nameof(FullName))] +private string FullNameExpression => FirstName + " " + LastName; ``` **Scenario 2: External/third-party type methods** @@ -212,9 +214,9 @@ static OrderDto CreateDto(int id, string name) | | `UseMemberBody` (Projectables) | `[ExpressiveFor]` (ExpressiveSharp) | |---|---|---| -| Scope | Same type only | Any type (including external/third-party) | +| Scope | Same type only | Same type **or** any accessible type (including external/third-party) | | Syntax | Property on `[Projectable]` | Separate attribute on a stub method | -| Target member | Must be in the same class | Any accessible type | +| Target member | Must be in the same class | Co-located (single-arg form, `this` is receiver) or cross-type (two-arg form) | | Namespace | `EntityFrameworkCore.Projectables` | `ExpressiveSharp.Mapping` | | Constructors | Not supported | `[ExpressiveForConstructor]` | diff --git a/docs/recipes/external-member-mapping.md b/docs/recipes/external-member-mapping.md index 82d14f20..cac46c4a 100644 --- a/docs/recipes/external-member-mapping.md +++ b/docs/recipes/external-member-mapping.md @@ -191,7 +191,6 @@ static class DateTimeMappings |------|-------------| | EXP0014 | `[ExpressiveFor]` target type not found | | EXP0015 | `[ExpressiveFor]` target member not found on the specified type | -| EXP0016 | `[ExpressiveFor]` stub method must be `static` | | EXP0017 | Return type of stub does not match target member's return type | | EXP0019 | Target member already has `[Expressive]` -- use `[Expressive]` directly instead | | EXP0020 | Duplicate `[ExpressiveFor]` mapping for the same target member | @@ -199,7 +198,32 @@ static class DateTimeMappings ## Tips ::: tip Match the signature exactly -For static methods, the stub parameters must match the target method signature. For instance members, add the receiver as the first parameter (e.g., `static string FullName(Person p)`). +For static methods, the stub parameters must match the target method signature. For instance members you have three options: write a `static` method stub whose first parameter is the receiver (e.g. `static string FullName(Person p)`), write an `instance` method stub on the target type itself where the stub's `this` is the receiver, or write a **property stub** on the target type (parameterless; `this` is the receiver) -- property stubs can only target other properties. +::: + +::: tip Co-locate when possible +When the target is on the same type as the stub, you can drop `typeof(...)` and use the single-argument form -- it targets a member on the stub's containing type. Combined with an **instance property stub**, this is the ergonomic shape for the case where a property has its own backing storage (a plain settable auto-property used for DTO shape, serialization, or in-memory assignment in tests) but you want queries to compute it from other columns: + +```csharp +public class Person +{ + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + // Regular auto-property — can be assigned directly (for DTOs, tests, deserialization). + public string FullName { get; set; } = ""; + + // When used in a LINQ expression tree, FullName is rewritten to this body, + // so EF Core projects it from the underlying columns instead of trying to + // map it to a column of its own. `this` is the receiver automatically. + [ExpressiveFor(nameof(FullName))] + private string FullNameExpression => FirstName + " " + LastName; +} +``` + +A method stub (`string FullNameExpression() => ...`) works the same way and is appropriate when the target is a method or you need a block body. + +If the property has no backing storage and the same body works at both runtime and query time, put `[Expressive]` directly on the property and delete the stub. ::: ::: tip Consider [Expressive] first @@ -207,7 +231,7 @@ Many `[ExpressiveFor]` use cases exist because of syntax limitations in other li ::: ::: warning Placement -`[ExpressiveFor]` stubs must be in a `static` class. The class can be in any namespace -- it is discovered at compile time by the source generator. +`[ExpressiveFor]` stubs can live either in a `static` helper class (with a `static` method stub) or on the target type itself as an instance method or property. Either way, they are discovered at compile time by the source generator. ::: ## See Also diff --git a/docs/reference/diagnostics.md b/docs/reference/diagnostics.md index 74b1915e..431b0585 100644 --- a/docs/reference/diagnostics.md +++ b/docs/reference/diagnostics.md @@ -6,6 +6,10 @@ The ExpressiveSharp source generator and companion analyzers emit diagnostics du See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find the error message or behavior you see and get step-by-step resolution. ::: +::: info Retired diagnostics +`EXP0016` ("`[ExpressiveFor]` stub must be static") has been retired. Instance stubs on the target type are now permitted; constructor-stub and unrelated-type mismatches surface as `EXP0015` (member not found) instead. +::: + ## Overview | ID | Severity | Title | Code Fix | @@ -25,7 +29,6 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t | [EXP0013](#exp0013) | Warning | Member could benefit from `[Expressive]` | [Add `[Expressive]`](#exp0013-fix) | | [EXP0014](#exp0014) | Error | `[ExpressiveFor]` target type not found | -- | | [EXP0015](#exp0015) | Error | `[ExpressiveFor]` target member not found | -- | -| [EXP0016](#exp0016) | Error | `[ExpressiveFor]` stub must be static | -- | | [EXP0017](#exp0017) | Error | `[ExpressiveFor]` return type mismatch | -- | | [EXP0019](#exp0019) | Error | `[ExpressiveFor]` conflicts with `[Expressive]` | -- | | [EXP0020](#exp0020) | Error | Duplicate `[ExpressiveFor]` mapping | -- | @@ -417,32 +420,6 @@ static double Clamp(double value, double min, double max) --- -### EXP0016 -- Stub must be static {#exp0016} - -**Severity:** Error -**Category:** Design - -**Message:** -``` -[ExpressiveFor] stub method '{0}' must be static -``` - -**Cause:** The stub method is not declared `static`. - -**Fix:** Add the `static` modifier: - -```csharp -// Error: not static -[ExpressiveFor(typeof(Math), nameof(Math.Clamp))] -double Clamp(double value, double min, double max) => /* ... */; - -// Fixed -[ExpressiveFor(typeof(Math), nameof(Math.Clamp))] -static double Clamp(double value, double min, double max) => /* ... */; -``` - ---- - ### EXP0017 -- Return type mismatch {#exp0017} **Severity:** Error diff --git a/docs/reference/expressive-for.md b/docs/reference/expressive-for.md index 41d4b1a0..2c5fa277 100644 --- a/docs/reference/expressive-for.md +++ b/docs/reference/expressive-for.md @@ -10,15 +10,21 @@ using ExpressiveSharp.Mapping; ## How It Works -You write a static stub method whose body defines the expression-tree replacement. The `[ExpressiveFor]` attribute tells the generator which external member this stub maps to. At runtime, the replacer substitutes calls to the target member with the stub's expression tree -- call sites remain unchanged. +You write a stub member -- a method **or** a property -- whose body defines the expression-tree replacement. The `[ExpressiveFor]` attribute tells the generator which external member this stub maps to. At runtime, the replacer substitutes calls to the target member with the stub's expression tree -- call sites remain unchanged. ## Mapping Rules -- The stub method **must be `static`** (EXP0016 if not). -- For **static methods**, the stub's parameters must match the target method's parameters exactly. -- For **instance methods**, the first parameter of the stub is the receiver (`this`), followed by the target method's parameters. -- For **instance properties**, the stub takes a single parameter: the receiver. -- The return type must match (EXP0017 if not). +- The stub can be a **method** (receiver supplied as the first parameter for instance targets, or `this` for instance stubs on the target type) **or** a **property** (parameterless; `this` is the receiver for instance stubs). +- The single-argument form `[ExpressiveFor(nameof(X))]` is shorthand for `[ExpressiveFor(typeof(ContainingType), nameof(X))]` -- use it when the target member is on the same type as the stub. +- For **static methods** (and static stubs over static members), the stub's parameters must match the target method's parameters exactly. +- For **instance methods** with a `static` stub, the first parameter of the stub is the receiver (`this`), followed by the target method's parameters. +- For **instance methods** with an `instance` stub on the target type, `this` is the receiver; remaining parameters match the target's exactly. +- For **instance properties** with a `static` method stub, the stub takes a single parameter: the receiver. +- For **instance properties** with an `instance` method or property stub on the target type, the stub is parameterless. +- For **static properties**, the stub is parameterless. +- Property stubs can only target other properties (no parameters to carry method arguments). +- The return type / property type must match (EXP0017 if not). +- Constructor stubs (`[ExpressiveForConstructor]`) must still be `static` methods; instance or property ctor stubs have no coherent meaning. ## Static Method Mapping @@ -63,10 +69,55 @@ static class EntityMappings } ``` +## Co-located Form (Instance Stub + Single-argument Attribute) + +When the target is on the same type as the stub, the most ergonomic form combines an **instance stub** with the **single-argument** attribute. `this` is the receiver automatically. Use this form when a property has its own backing storage -- e.g. a plain settable auto-property used for DTO shape, serialization, or in-memory assignment in tests -- but queries should still compute it from other columns. + +A **property stub** is often the cleanest choice for this (no parentheses, reads like the target it replaces): + +```csharp +public class Person +{ + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + + // Regular auto-property — assignable directly (for DTOs, tests, deserialization). + public string FullName { get; set; } = ""; + + // When FullName appears in a LINQ expression tree, it is rewritten to this body, + // so EF Core projects it from FirstName/LastName instead of mapping it to its own column. + [ExpressiveFor(nameof(FullName))] + private string FullNameExpression => FirstName + " " + LastName; +} +``` + +A **method stub** is equivalent in behaviour and appropriate when the target is a method or when you need a block body: + +```csharp +[ExpressiveFor(nameof(FullName))] +private string FullNameExpression() => FirstName + " " + LastName; +``` + +Both forms are equivalent to the verbose `[ExpressiveFor(typeof(Person), nameof(Person.FullName))] static string FullName(Person obj) => obj.FirstName + " " + obj.LastName;` but reuse `this` instead of threading a receiver parameter. When the EF Core integration is enabled, both the target property **and** the stub property itself are automatically excluded from the model (no `[NotMapped]` needed -- see [Automatic NotMapped for `[ExpressiveFor]` targets](#automatic-notmapped-for-expressivefor-targets)). + +::: warning When to prefer `[Expressive]` instead +If the property has no backing storage and the same body works at both runtime and query time, put `[Expressive]` directly on it (`[Expressive] public string FullName => FirstName + " " + LastName;`) and skip the stub. `[ExpressiveFor]` is for the dual-body case; `[Expressive]` is for the single-body case. +::: + ::: tip The stub can use any C# syntax that `[Expressive]` supports -- switch expressions, pattern matching, null-conditional operators, and more. ::: +## Automatic NotMapped for `[ExpressiveFor]` targets + +When `UseExpressives()` is active, EF Core's model builder automatically ignores properties that are: + +1. Decorated with `[Expressive]`, +2. Decorated with `[ExpressiveFor]` (a property stub itself), **or** +3. The target of an `[ExpressiveFor]` stub anywhere in the loaded assemblies. + +You do not need to add `[NotMapped]` to a property you are expressing externally or using as a property stub -- the `ExpressivePropertiesNotMappedConvention` detects these cases via attribute metadata and the generated registry and calls `Ignore()` for you. + ## Constructor Mapping with `[ExpressiveForConstructor]` Use `[ExpressiveForConstructor]` to provide an expression-tree body for a constructor on a type you do not own: @@ -114,7 +165,6 @@ The following diagnostics are specific to `[ExpressiveFor]` and `[ExpressiveForC |------|----------|-------------| | [EXP0014](./diagnostics#exp0014) | Error | Target type specified in `[ExpressiveFor]` could not be resolved | | [EXP0015](./diagnostics#exp0015) | Error | No member with the given name found on the target type matching the stub's parameter signature | -| [EXP0016](./diagnostics#exp0016) | Error | The stub method must be `static` | | [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 | diff --git a/docs/reference/projectable-properties.md b/docs/reference/projectable-properties.md index b6629ac7..45e512a2 100644 --- a/docs/reference/projectable-properties.md +++ b/docs/reference/projectable-properties.md @@ -198,7 +198,7 @@ No `[NotMapped]` annotation or manual `modelBuilder.Ignore(...)` call is require ## Comparison with `[ExpressiveFor]` -`[ExpressiveFor]` is the verbose alternative -- the formula lives in a separate static stub instead of on the property: +`[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 diff --git a/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs b/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs index 1522051f..b58ee5f8 100644 --- a/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs +++ b/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs @@ -12,13 +12,14 @@ namespace ExpressiveSharp.Mapping; /// For instance properties, the stub takes a single parameter (the receiver) and returns the property type. /// For static properties, the stub is parameterless and returns the property type. /// -[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)] +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = false)] public sealed class ExpressiveForAttribute : Attribute { /// - /// The type that declares the target member. + /// The type that declares the target member, or null when the single-argument constructor + /// is used (in which case the target defaults to the stub's containing type at generator time). /// - public Type TargetType { get; } + public Type? TargetType { get; } /// /// The name of the target member on . @@ -43,4 +44,14 @@ public ExpressiveForAttribute(Type targetType, string memberName) TargetType = targetType; MemberName = memberName; } + + /// + /// Shorthand for [ExpressiveFor(typeof(ContainingType), memberName)] — + /// use when the target member is on the same type as the stub. + /// + public ExpressiveForAttribute(string memberName) + { + TargetType = null; + MemberName = memberName; + } } diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs index 7121d080..b4fc8840 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs @@ -43,6 +43,10 @@ public ExpressiveOptionsExtension(IReadOnlyList plugins, bool [SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required to decorate query compiler")] public void ApplyServices(IServiceCollection services) { + // The expressive resolver is stateless at the instance level (all caches are process-static), + // so a singleton lifetime is appropriate and cheap to share across scopes. + services.TryAddSingleton(); + // Register conventions services.AddScoped(); services.AddScoped(); diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConvention.cs b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConvention.cs index 438b82fa..08057220 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConvention.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConvention.cs @@ -1,15 +1,29 @@ using System.Reflection; +using ExpressiveSharp.Mapping; +using ExpressiveSharp.Services; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Conventions; namespace ExpressiveSharp.EntityFrameworkCore.Infrastructure.Internal; /// -/// Convention that marks properties decorated with -/// as unmapped in the EF Core model, since they have no backing database column. +/// Convention that marks properties as unmapped in the EF Core model when they have no backing +/// database column: +/// +/// decorated with , +/// decorated with (the property is a stub itself), or +/// the target of an stub elsewhere in the solution. +/// /// public class ExpressivePropertiesNotMappedConvention : IEntityTypeAddedConvention { + private readonly IExpressiveResolver _resolver; + + public ExpressivePropertiesNotMappedConvention(IExpressiveResolver resolver) + { + _resolver = resolver; + } + public void ProcessEntityTypeAdded( IConventionEntityTypeBuilder entityTypeBuilder, IConventionContext context) @@ -19,7 +33,9 @@ public void ProcessEntityTypeAdded( foreach (var property in entityTypeBuilder.Metadata.ClrType.GetRuntimeProperties()) { - if (property.GetCustomAttribute() is not null) + if (property.GetCustomAttribute() is not null + || property.GetCustomAttribute() is not null + || _resolver.FindExternalExpression(property) is not null) { entityTypeBuilder.Ignore(property.Name); } diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConventionPlugin.cs b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConventionPlugin.cs index d001ca82..cd3609b3 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConventionPlugin.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConventionPlugin.cs @@ -1,3 +1,4 @@ +using ExpressiveSharp.Services; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; @@ -5,9 +6,16 @@ namespace ExpressiveSharp.EntityFrameworkCore.Infrastructure.Internal; public class ExpressivePropertiesNotMappedConventionPlugin : IConventionSetPlugin { + private readonly IExpressiveResolver _resolver; + + public ExpressivePropertiesNotMappedConventionPlugin(IExpressiveResolver resolver) + { + _resolver = resolver; + } + public ConventionSet ModifyConventions(ConventionSet conventionSet) { - conventionSet.EntityTypeAddedConventions.Add(new ExpressivePropertiesNotMappedConvention()); + conventionSet.EntityTypeAddedConventions.Add(new ExpressivePropertiesNotMappedConvention(_resolver)); return conventionSet; } } diff --git a/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs b/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs index 2f2d6467..e00e9e0e 100644 --- a/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs +++ b/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs @@ -8,27 +8,28 @@ namespace ExpressiveSharp.Generator.Comparers; /// /// Equality comparer for [ExpressiveFor] pipeline tuples, /// mirroring for the standard pipeline. +/// The Member field is a to cover both method and property stubs. /// internal class ExpressiveForMemberCompilationEqualityComparer - : IEqualityComparer<((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)> + : IEqualityComparer<((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)> { private readonly static MemberDeclarationSyntaxEqualityComparer _memberComparer = new(); public bool Equals( - ((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) x, - ((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) y) + ((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) x, + ((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) y) { var (xLeft, xCompilation) = x; var (yLeft, yCompilation) = y; - if (ReferenceEquals(xLeft.Method, yLeft.Method) && + if (ReferenceEquals(xLeft.Member, yLeft.Member) && ReferenceEquals(xCompilation, yCompilation) && xLeft.GlobalOptions == yLeft.GlobalOptions) { return true; } - if (!ReferenceEquals(xLeft.Method.SyntaxTree, yLeft.Method.SyntaxTree)) + if (!ReferenceEquals(xLeft.Member.SyntaxTree, yLeft.Member.SyntaxTree)) { return false; } @@ -43,7 +44,7 @@ public bool Equals( return false; } - if (!_memberComparer.Equals(xLeft.Method, yLeft.Method)) + if (!_memberComparer.Equals(xLeft.Member, yLeft.Member)) { return false; } @@ -51,14 +52,14 @@ public bool Equals( return xCompilation.ExternalReferences.SequenceEqual(yCompilation.ExternalReferences); } - public int GetHashCode(((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) obj) + public int GetHashCode(((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) obj) { var (left, compilation) = obj; unchecked { var hash = 17; - hash = hash * 31 + _memberComparer.GetHashCode(left.Method); - hash = hash * 31 + RuntimeHelpers.GetHashCode(left.Method.SyntaxTree); + hash = hash * 31 + _memberComparer.GetHashCode(left.Member); + hash = hash * 31 + RuntimeHelpers.GetHashCode(left.Member.SyntaxTree); hash = hash * 31 + left.Attribute.GetHashCode(); hash = hash * 31 + left.GlobalOptions.GetHashCode(); diff --git a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs index bf48a5ac..b061fffa 100644 --- a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs +++ b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs @@ -125,7 +125,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) /// Discovers, interprets, emits expression factory source, and returns the pipeline /// for registry entry extraction. /// - private static IncrementalValuesProvider<((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)> + private static IncrementalValuesProvider<((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)> CreateExpressiveForPipeline( IncrementalGeneratorInitializationContext context, IncrementalValueProvider globalOptions, @@ -135,16 +135,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var declarations = context.SyntaxProvider .ForAttributeWithMetadataName( attributeFullName, - predicate: static (s, _) => s is MethodDeclarationSyntax, + predicate: static (s, _) => s is MethodDeclarationSyntax or PropertyDeclarationSyntax, transform: (c, _) => ( - Method: (MethodDeclarationSyntax)c.TargetNode, + Member: (MemberDeclarationSyntax)c.TargetNode, Attribute: new ExpressiveForAttributeData(c.Attributes[0], memberKind) )); var declarationsWithGlobalOptions = declarations .Combine(globalOptions) .Select(static (pair, _) => ( - Method: pair.Left.Method, + Member: pair.Left.Member, Attribute: pair.Left.Attribute, GlobalOptions: pair.Right )); @@ -163,14 +163,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) foreach (var source in items) { - var ((method, attribute, globalOptions), compilation) = source; - var semanticModel = compilation.GetSemanticModel(method.SyntaxTree); - var stubSymbol = semanticModel.GetDeclaredSymbol(method) as IMethodSymbol; + var ((member, attribute, globalOptions), compilation) = source; + var semanticModel = compilation.GetSemanticModel(member.SyntaxTree); + var stubSymbol = semanticModel.GetDeclaredSymbol(member); - if (stubSymbol is null) + if (stubSymbol is not (IMethodSymbol or IPropertySymbol)) continue; - ExecuteFor(method, semanticModel, stubSymbol, attribute, globalOptions, + ExecuteFor(member, semanticModel, stubSymbol, attribute, globalOptions, compilation, spc, emittedFileNames); } }); @@ -411,9 +411,9 @@ private static void EmitExpressionTreeSource( /// validates the stub, and emits the expression tree factory source file. /// private static void ExecuteFor( - MethodDeclarationSyntax stubMethod, + MemberDeclarationSyntax stubMember, SemanticModel semanticModel, - IMethodSymbol stubSymbol, + ISymbol stubSymbol, ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, Compilation compilation, @@ -421,7 +421,7 @@ private static void ExecuteFor( HashSet? emittedFileNames = null) { var descriptor = ExpressiveForInterpreter.GetDescriptor( - semanticModel, stubMethod, stubSymbol, attributeData, globalOptions, context, compilation); + semanticModel, stubMember, stubSymbol, attributeData, globalOptions, context, compilation); if (descriptor is null) return; @@ -442,7 +442,7 @@ private static void ExecuteFor( if (descriptor.ExpressionTreeEmission is null) throw new InvalidOperationException("ExpressionTreeEmission must be set"); - EmitExpressionTreeSource(descriptor, generatedClassName, methodSuffix, generatedFileName, stubMethod, compilation, context); + EmitExpressionTreeSource(descriptor, generatedClassName, methodSuffix, generatedFileName, stubMember, compilation, context); } /// @@ -450,19 +450,30 @@ private static void ExecuteFor( /// The entry points to the external target member, not the stub itself. /// private static ExpressionRegistryEntry? ExtractRegistryEntryForExternal( - ((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) source) + ((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) source) { - var ((method, attribute, globalOptions), compilation) = source; - var semanticModel = compilation.GetSemanticModel(method.SyntaxTree); - var stubSymbol = semanticModel.GetDeclaredSymbol(method) as IMethodSymbol; + var ((member, attribute, _), compilation) = source; + var semanticModel = compilation.GetSemanticModel(member.SyntaxTree); + var rawStubSymbol = semanticModel.GetDeclaredSymbol(member); - if (stubSymbol is null) + if (rawStubSymbol is not (IMethodSymbol or IPropertySymbol)) return null; - // Resolve target type + var stubIsProperty = rawStubSymbol is IPropertySymbol; + var stubIsStatic = rawStubSymbol.IsStatic; + var stubContainingType = rawStubSymbol.ContainingType; + var stubMethodSymbol = rawStubSymbol as IMethodSymbol; + + // Property stubs cannot map to constructors (no parameter list). + if (stubIsProperty && attribute.MemberKind == ExpressiveForMemberKind.Constructor) + return null; + + // Resolve target type. Two cases (mirrors ExpressiveForInterpreter): + // - Two-arg form: resolve from metadata name. + // - Single-arg form: default to the stub's containing type. var targetType = attribute.TargetTypeMetadataName is not null ? compilation.GetTypeByMetadataName(attribute.TargetTypeMetadataName) - : null; + : stubContainingType; if (targetType is null) return null; @@ -482,9 +493,9 @@ private static void ExecuteFor( memberKind = ExpressionRegistryMemberType.Constructor; memberLookupName = "_ctor"; - // Constructor params match stub params directly + // Constructor params match stub params directly (method stubs only — guarded above). parameterTypeNames = [ - ..stubSymbol.Parameters.Select(p => p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + ..stubMethodSymbol!.Parameters.Select(p => p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) ]; } else @@ -493,8 +504,10 @@ private static void ExecuteFor( if (memberName is null) return null; - // Determine if this maps to a property or method - var isProperty = targetType.GetMembers(memberName).OfType().Any(); + // Property stubs can only target properties; method stubs may target either. + var isProperty = stubIsProperty + || targetType.GetMembers(memberName).OfType().Any(); + if (isProperty) { memberKind = ExpressionRegistryMemberType.Property; @@ -506,29 +519,13 @@ private static void ExecuteFor( memberKind = ExpressionRegistryMemberType.Method; memberLookupName = memberName; - // Find the matching target method to get its parameter types (not the stub's) + // Signature matching via the shared matcher so the registry entry can never + // disagree with what ExpressiveForInterpreter accepted. + var stubParams = stubMethodSymbol!.Parameters; var targetMethod = targetType.GetMembers(memberName).OfType() .Where(m => m.MethodKind is not (MethodKind.PropertyGet or MethodKind.PropertySet)) - .FirstOrDefault(m => - { - var expectedParamCount = m.IsStatic ? m.Parameters.Length : m.Parameters.Length + 1; - if (stubSymbol.Parameters.Length != expectedParamCount) - return false; - - // For instance methods, validate that the stub's first parameter matches the target type - if (!m.IsStatic && - !SymbolEqualityComparer.Default.Equals(stubSymbol.Parameters[0].Type, targetType)) - return false; - - var offset = m.IsStatic ? 0 : 1; - for (var i = 0; i < m.Parameters.Length; i++) - { - if (!SymbolEqualityComparer.Default.Equals( - m.Parameters[i].Type, stubSymbol.Parameters[i + offset].Type)) - return false; - } - return true; - }); + .FirstOrDefault(m => Interpretation.ExpressiveForSignatureMatcher + .MatchesMethodSignature(m, targetType, stubIsStatic, stubContainingType, stubParams)); if (targetMethod is null) return null; @@ -557,7 +554,12 @@ private static void ExecuteFor( var expressionMethodName = methodSuffix + "_Expression"; // Capture stub location for duplicate detection diagnostics - var stubLocation = method.Identifier.GetLocation(); + var stubLocation = member switch + { + MethodDeclarationSyntax m => m.Identifier.GetLocation(), + PropertyDeclarationSyntax p => p.Identifier.GetLocation(), + _ => member.GetLocation() + }; var stubLineSpan = stubLocation.GetLineSpan(); return new ExpressionRegistryEntry( diff --git a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs index 77dad02a..d51ec6d7 100644 --- a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs +++ b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs @@ -127,13 +127,9 @@ static internal class Diagnostics DiagnosticSeverity.Error, isEnabledByDefault: true); - public readonly static DiagnosticDescriptor ExpressiveForStubMustBeStatic = new DiagnosticDescriptor( - id: "EXP0016", - title: "[ExpressiveFor] stub must be static", - messageFormat: "[ExpressiveFor] stub method '{0}' must be static", - category: "Design", - DiagnosticSeverity.Error, - isEnabledByDefault: true); + // NOTE: EXP0016 (ExpressiveForStubMustBeStatic) is retired. Instance stubs are now permitted + // on the target type itself. Constructor stubs remain static-only; signature mismatches fall + // back to EXP0015 (ExpressiveForMemberNotFound). public readonly static DiagnosticDescriptor ExpressiveForReturnTypeMismatch = new DiagnosticDescriptor( id: "EXP0017", diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs index 97ef2b82..e20057ad 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs @@ -17,45 +17,66 @@ static internal class ExpressiveForInterpreter { public static ExpressiveDescriptor? GetDescriptor( SemanticModel semanticModel, - MethodDeclarationSyntax stubMethod, - IMethodSymbol stubSymbol, + MemberDeclarationSyntax stubMember, + ISymbol stubSymbol, ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, SourceProductionContext context, Compilation compilation) { - // Validate: stub must be static - if (!stubSymbol.IsStatic) + var stubIdentifierLocation = stubMember switch { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.ExpressiveForStubMustBeStatic, - stubMethod.Identifier.GetLocation(), - stubSymbol.Name)); - return null; - } + MethodDeclarationSyntax m => m.Identifier.GetLocation(), + PropertyDeclarationSyntax p => p.Identifier.GetLocation(), + _ => stubMember.GetLocation() + }; - // Resolve target type - var targetType = attributeData.TargetTypeMetadataName is not null - ? compilation.GetTypeByMetadataName(attributeData.TargetTypeMetadataName) - : null; + // Resolve target type. Two cases: + // - Two-arg form: [ExpressiveFor(typeof(T), "Name")] — resolve T from metadata name. + // - Single-arg form: [ExpressiveFor("Name")] — default to the stub's containing type. + INamedTypeSymbol? targetType; + if (attributeData.TargetTypeMetadataName is not null) + { + targetType = compilation.GetTypeByMetadataName(attributeData.TargetTypeMetadataName); + if (targetType is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressiveForTargetTypeNotFound, + stubIdentifierLocation, + attributeData.TargetTypeFullName)); + return null; + } + } + else + { + targetType = stubSymbol.ContainingType; + } - if (targetType is null) + // 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. + if (stubMember is PropertyDeclarationSyntax stubProperty && stubSymbol is IPropertySymbol stubPropertySymbol) { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.ExpressiveForTargetTypeNotFound, - stubMethod.Identifier.GetLocation(), - attributeData.TargetTypeFullName)); - return null; + if (attributeData.MemberKind == ExpressiveForMemberKind.Constructor) + return null; + + return ResolvePropertyStub(semanticModel, stubProperty, stubPropertySymbol, attributeData, + globalOptions, context, compilation, targetType, stubIdentifierLocation); } - return attributeData.MemberKind switch + if (stubMember is MethodDeclarationSyntax stubMethod && stubSymbol is IMethodSymbol stubMethodSymbol) { - ExpressiveForMemberKind.MethodOrProperty => - ResolveMethodOrProperty(semanticModel, stubMethod, stubSymbol, attributeData, globalOptions, context, compilation, targetType), - ExpressiveForMemberKind.Constructor => - ResolveConstructor(semanticModel, stubMethod, stubSymbol, attributeData, globalOptions, context, compilation, targetType), - _ => null - }; + return attributeData.MemberKind switch + { + ExpressiveForMemberKind.MethodOrProperty => + ResolveMethodOrProperty(semanticModel, stubMethod, stubMethodSymbol, attributeData, globalOptions, context, compilation, targetType), + ExpressiveForMemberKind.Constructor => + ResolveConstructor(semanticModel, stubMethod, stubMethodSymbol, attributeData, globalOptions, context, compilation, targetType), + _ => null + }; + } + + return null; } private static ExpressiveDescriptor? ResolveMethodOrProperty( @@ -158,76 +179,87 @@ static internal class ExpressiveForInterpreter ctor.Parameters, isInstanceMember: false); } - private static IPropertySymbol? FindTargetProperty( - INamedTypeSymbol targetType, string memberName, IMethodSymbol stubSymbol) + private static ExpressiveDescriptor? ResolvePropertyStub( + SemanticModel semanticModel, + PropertyDeclarationSyntax stubProperty, + IPropertySymbol stubSymbol, + ExpressiveForAttributeData attributeData, + ExpressiveGlobalOptions globalOptions, + SourceProductionContext context, + Compilation compilation, + INamedTypeSymbol targetType, + Location stubIdentifierLocation) { - var members = targetType.GetMembers(memberName); - foreach (var member in members) - { - if (member is not IPropertySymbol property) - continue; - - // Exclude indexers (they have parameters) - if (property.Parameters.Length > 0) - continue; + var memberName = attributeData.MemberName; + if (memberName is null) + return null; - // Instance property: stub should have 1 param (the instance) whose type matches targetType - // Static property: stub should have 0 params - if (property.IsStatic && stubSymbol.Parameters.Length == 0) - return property; - if (!property.IsStatic && stubSymbol.Parameters.Length == 1 && - SymbolEqualityComparer.Default.Equals(stubSymbol.Parameters[0].Type, targetType)) - return property; + var target = FindTargetPropertyForPropertyStub(targetType, memberName, stubSymbol); + if (target is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressiveForMemberNotFound, + stubIdentifierLocation, + memberName, + targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + return null; } - return null; - } - private static IMethodSymbol? FindTargetMethod( - INamedTypeSymbol targetType, string memberName, IMethodSymbol stubSymbol) - { - var members = targetType.GetMembers(memberName); - foreach (var member in members) + if (HasExpressiveAttribute(target, compilation)) { - if (member is not IMethodSymbol method || method.MethodKind is MethodKind.PropertyGet or MethodKind.PropertySet) - continue; + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressiveForConflictsWithExpressive, + stubIdentifierLocation, + memberName, + targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + return null; + } - // For instance methods: first stub param = this, rest = method params - // For static methods: all stub params = method params - var expectedStubParamCount = method.IsStatic - ? method.Parameters.Length - : method.Parameters.Length + 1; + if (!SymbolEqualityComparer.Default.Equals(stubSymbol.Type, target.Type)) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.ExpressiveForReturnTypeMismatch, + stubProperty.Type.GetLocation(), + target.Name, + target.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), + stubSymbol.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + return null; + } - if (stubSymbol.Parameters.Length != expectedStubParamCount) - continue; + return BuildDescriptorFromPropertyStub(semanticModel, stubProperty, stubSymbol, attributeData, + globalOptions, context, targetType, target.Name); + } - // For instance methods, validate that the stub's first parameter matches the target type - if (!method.IsStatic && - !SymbolEqualityComparer.Default.Equals(stubSymbol.Parameters[0].Type, targetType)) - continue; + private static IPropertySymbol? FindTargetPropertyForPropertyStub( + INamedTypeSymbol targetType, string memberName, IPropertySymbol stubSymbol) + => targetType.GetMembers(memberName) + .OfType() + .FirstOrDefault(property => ExpressiveForSignatureMatcher.MatchesPropertyFromPropertyStub( + property, targetType, stubSymbol.IsStatic, stubSymbol.ContainingType)); - // Check parameter types match - var offset = method.IsStatic ? 0 : 1; - var match = true; - for (var i = 0; i < method.Parameters.Length; i++) - { - var targetParamType = method.Parameters[i].Type; - var stubParamType = stubSymbol.Parameters[i + offset].Type; - if (!SymbolEqualityComparer.Default.Equals(targetParamType, stubParamType)) - { - match = false; - break; - } - } + private static IPropertySymbol? FindTargetProperty( + INamedTypeSymbol targetType, string memberName, IMethodSymbol stubSymbol) + => targetType.GetMembers(memberName) + .OfType() + .FirstOrDefault(property => ExpressiveForSignatureMatcher.MatchesPropertyFromMethodStub( + property, targetType, stubSymbol.IsStatic, stubSymbol.ContainingType, stubSymbol.Parameters)); - if (match) - return method; - } - return null; - } + private static IMethodSymbol? FindTargetMethod( + INamedTypeSymbol targetType, string memberName, IMethodSymbol stubSymbol) + => targetType.GetMembers(memberName) + .OfType() + .Where(m => m.MethodKind is not (MethodKind.PropertyGet or MethodKind.PropertySet)) + .FirstOrDefault(method => ExpressiveForSignatureMatcher.MatchesMethodSignature( + method, targetType, stubSymbol.IsStatic, stubSymbol.ContainingType, stubSymbol.Parameters)); private static IMethodSymbol? FindTargetConstructor( INamedTypeSymbol targetType, IMethodSymbol stubSymbol) { + // Constructor stubs remain static-only: an instance stub producing a new instance of its + // own containing type has no natural `this` semantics and would produce incoherent code. + if (!stubSymbol.IsStatic) + return null; + foreach (var ctor in targetType.Constructors) { if (ctor.IsStatic) @@ -310,7 +342,7 @@ static internal class ExpressiveForInterpreter } /// - /// Builds the from the stub method's body, + /// Builds the from a method stub's body, /// using the target type's namespace/class path for generated class naming. /// private static ExpressiveDescriptor? BuildDescriptorFromStub( @@ -325,49 +357,10 @@ static internal class ExpressiveForInterpreter System.Collections.Immutable.ImmutableArray targetParameters, bool isInstanceMember) { - var declarationSyntaxRewriter = new DeclarationSyntaxRewriter(semanticModel); - - // Use target type's namespace/class path for naming — this is what the registry will use - var targetClassNamespace = targetType.ContainingNamespace.IsGlobalNamespace - ? null - : targetType.ContainingNamespace.ToDisplayString(); - - var descriptor = new ExpressiveDescriptor - { - UsingDirectives = stubMethod.SyntaxTree.GetRoot().DescendantNodes().OfType(), - ClassName = targetType.Name, - ClassNamespace = targetClassNamespace, - MemberName = targetMemberName, - NestedInClassNames = GetNestedInClassPath(targetType), - TargetClassNamespace = targetClassNamespace, - TargetNestedInClassNames = GetNestedInClassPath(targetType), - ParametersList = SyntaxFactory.ParameterList() - }; - - // Populate declared transformers from attribute - foreach (var typeName in attributeData.TransformerTypeNames) - descriptor.DeclaredTransformerTypeNames.Add(typeName); - - // Collect parameter type names for registry disambiguation - // Use the TARGET member's parameter types (not the stub's) - if (!targetParameters.IsEmpty) - { - descriptor.ParameterTypeNames = targetParameters - .Select(p => p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) - .ToList(); - } - - // Build the stub's parameter list on the descriptor - // (this is what the expression factory method will use) - var rewrittenParamList = (ParameterListSyntax)declarationSyntaxRewriter.Visit(stubMethod.ParameterList); - foreach (var p in rewrittenParamList.Parameters) - { - descriptor.ParametersList = descriptor.ParametersList.AddParameters(p); - } - - // Extract and emit the body + var rewriter = new DeclarationSyntaxRewriter(semanticModel); var allowBlockBody = attributeData.AllowBlockBody ?? globalOptions.AllowBlockBody; + // Extract body from the stub method. SyntaxNode bodySyntax; if (stubMethod.ExpressionBody is not null) { @@ -394,26 +387,163 @@ static internal class ExpressiveForInterpreter return null; } - var returnTypeSyntax = declarationSyntaxRewriter.Visit(stubMethod.ReturnType); - descriptor.ReturnTypeName = returnTypeSyntax.ToString(); + var returnTypeName = rewriter.Visit(stubMethod.ReturnType).ToString(); + + // Explicit stub params (after syntax rewriting for default values / modifiers). + var rewrittenParamList = (ParameterListSyntax)rewriter.Visit(stubMethod.ParameterList); + var explicitSyntax = rewrittenParamList.Parameters.ToList(); + var explicitEmitterParams = stubSymbol.Parameters + .Select(p => new EmitterParameter( + p.Name, + p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + symbol: p)) + .ToList(); + + return BuildDescriptorCore(semanticModel, context, stubMethod.SyntaxTree, stubSymbol, + attributeData, targetType, targetMemberName, targetParameters, + explicitSyntax, explicitEmitterParams, returnTypeName, bodySyntax); + } + + /// + /// Builds the from a property stub's body. + /// Property stubs are always parameterless; an instance stub's this becomes a synthetic + /// receiver on the generated factory method. + /// + private static ExpressiveDescriptor? BuildDescriptorFromPropertyStub( + SemanticModel semanticModel, + PropertyDeclarationSyntax stubProperty, + IPropertySymbol stubSymbol, + ExpressiveForAttributeData attributeData, + ExpressiveGlobalOptions globalOptions, + SourceProductionContext context, + INamedTypeSymbol targetType, + string targetMemberName) + { + var rewriter = new DeclarationSyntaxRewriter(semanticModel); + var allowBlockBody = attributeData.AllowBlockBody ?? globalOptions.AllowBlockBody; + + // Extract body: prefer the expression-bodied form, otherwise fall back to the get accessor. + SyntaxNode? bodySyntax = null; + if (stubProperty.ExpressionBody is not null) + { + bodySyntax = stubProperty.ExpressionBody.Expression; + } + else if (stubProperty.AccessorList is not null) + { + var getter = stubProperty.AccessorList.Accessors + .FirstOrDefault(a => a.Kind() == SyntaxKind.GetAccessorDeclaration); + + if (getter is not null) + { + if (getter.ExpressionBody is not null) + { + bodySyntax = getter.ExpressionBody.Expression; + } + else if (getter.Body is not null) + { + if (!allowBlockBody) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.BlockBodyRequiresOptIn, + stubProperty.Identifier.GetLocation(), + stubSymbol.Name)); + return null; + } + bodySyntax = getter.Body; + } + } + } + + if (bodySyntax is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.RequiresBodyDefinition, + stubProperty.GetLocation(), + stubSymbol.Name)); + return null; + } + + var returnTypeName = rewriter.Visit(stubProperty.Type).ToString(); + + return BuildDescriptorCore(semanticModel, context, stubProperty.SyntaxTree, stubSymbol, + attributeData, targetType, targetMemberName, + targetParameters: System.Collections.Immutable.ImmutableArray.Empty, + explicitStubParamSyntax: [], + explicitStubEmitterParams: [], + returnTypeName, bodySyntax); + } + + /// + /// Shared scaffolding for both method-stub and property-stub descriptor construction. + /// Builds the descriptor shell, synthesizes @this for instance stubs, appends the + /// caller-supplied explicit stub parameters, assembles the delegate type, and emits the body. + /// + private static ExpressiveDescriptor BuildDescriptorCore( + SemanticModel semanticModel, + SourceProductionContext context, + SyntaxTree stubSyntaxTree, + ISymbol stubSymbol, + ExpressiveForAttributeData attributeData, + INamedTypeSymbol targetType, + string targetMemberName, + System.Collections.Immutable.ImmutableArray targetParameters, + IReadOnlyList explicitStubParamSyntax, + IReadOnlyList explicitStubEmitterParams, + string returnTypeName, + SyntaxNode bodySyntax) + { + var targetClassNamespace = targetType.ContainingNamespace.IsGlobalNamespace + ? null + : targetType.ContainingNamespace.ToDisplayString(); + + var descriptor = new ExpressiveDescriptor + { + UsingDirectives = stubSyntaxTree.GetRoot().DescendantNodes().OfType(), + ClassName = targetType.Name, + ClassNamespace = targetClassNamespace, + MemberName = targetMemberName, + NestedInClassNames = GetNestedInClassPath(targetType), + TargetClassNamespace = targetClassNamespace, + TargetNestedInClassNames = GetNestedInClassPath(targetType), + ParametersList = SyntaxFactory.ParameterList(), + ReturnTypeName = returnTypeName, + }; + + foreach (var typeName in attributeData.TransformerTypeNames) + descriptor.DeclaredTransformerTypeNames.Add(typeName); + + // Target member's parameter types disambiguate method overloads in the registry; + // properties and parameterless targets keep ParameterTypeNames null. + if (!targetParameters.IsEmpty) + { + descriptor.ParameterTypeNames = targetParameters + .Select(p => p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .ToList(); + } - // Build emitter parameters from the stub's parameters - var emitter = new ExpressionTreeEmitter(semanticModel, context); var emitterParams = new List(); - foreach (var param in stubSymbol.Parameters) + + // For instance stubs, prepend `@this` so IInstanceReferenceOperation in the body binds to it. + if (!stubSymbol.IsStatic) { - emitterParams.Add(new EmitterParameter( - param.Name, - param.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - symbol: param)); + var thisTypeFqn = stubSymbol.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + descriptor.ParametersList = descriptor.ParametersList.AddParameters( + SyntaxFactory.Parameter(SyntaxFactory.Identifier("@this")) + .WithType(SyntaxFactory.ParseTypeName(thisTypeFqn))); + emitterParams.Add(new EmitterParameter("@this", thisTypeFqn, isThis: true)); } + foreach (var p in explicitStubParamSyntax) + descriptor.ParametersList = descriptor.ParametersList.AddParameters(p); + emitterParams.AddRange(explicitStubEmitterParams); + var allTypeArgs = emitterParams.Select(p => p.TypeFqn).ToList(); - allTypeArgs.Add(descriptor.ReturnTypeName); + allTypeArgs.Add(returnTypeName); var delegateTypeFqn = $"global::System.Func<{string.Join(", ", allTypeArgs)}>"; + var emitter = new ExpressionTreeEmitter(semanticModel, context); descriptor.ExpressionTreeEmission = emitter.Emit(bodySyntax, emitterParams, - descriptor.ReturnTypeName, delegateTypeFqn); + returnTypeName, delegateTypeFqn); return descriptor; } diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForSignatureMatcher.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForSignatureMatcher.cs new file mode 100644 index 00000000..66189b28 --- /dev/null +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForSignatureMatcher.cs @@ -0,0 +1,123 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace ExpressiveSharp.Generator.Interpretation; + +/// +/// Shared signature-matching rules for [ExpressiveFor] stubs. +/// Kept central so the interpreter (which validates + emits) and the registry extractor +/// (which builds the runtime lookup entry) can never disagree about which target member +/// a stub maps to — a divergence here produces either silently-missing bodies or +/// orphaned registry entries. +/// +static internal class ExpressiveForSignatureMatcher +{ + /// + /// Four-quadrant matrix for method-target ↔ method-stub matching: + /// + /// static target + static stub: stub params = target params + /// instance target + static stub: stub params = [receiver] + target params (receiver type = targetType) + /// instance target + instance stub on targetType: stub params = target params (this is receiver) + /// static target + instance stub: never matches (no way to receive a non-null instance) + /// + /// + public static bool MatchesMethodSignature( + IMethodSymbol target, + INamedTypeSymbol targetType, + bool stubIsStatic, + INamedTypeSymbol stubContainingType, + ImmutableArray stubParameters) + { + if (target.IsStatic && !stubIsStatic) + return false; + + int expectedStubParamCount; + int offset; + if (!target.IsStatic && stubIsStatic) + { + // Static stub over instance target: first stub param is the explicit receiver. + expectedStubParamCount = target.Parameters.Length + 1; + offset = 1; + } + else + { + // Either both static, or both instance (receiver implicit via `this`). + expectedStubParamCount = target.Parameters.Length; + offset = 0; + } + + if (stubParameters.Length != expectedStubParamCount) + return false; + + if (!target.IsStatic && stubIsStatic && + !SymbolEqualityComparer.Default.Equals(stubParameters[0].Type, targetType)) + return false; + + if (!target.IsStatic && !stubIsStatic && + !SymbolEqualityComparer.Default.Equals(stubContainingType, targetType)) + return false; + + for (var i = 0; i < target.Parameters.Length; i++) + { + if (!SymbolEqualityComparer.Default.Equals( + target.Parameters[i].Type, stubParameters[i + offset].Type)) + return false; + } + + return true; + } + + /// + /// Matching rules for property-target ↔ method-stub (the parameter-count encoding is the + /// same matrix but restricted to zero target parameters): + /// + /// static property + static stub (0 params): match. + /// instance property + static stub (1 param of targetType): match. + /// instance property + instance stub on targetType (0 params): match (this is receiver). + /// static property + instance stub: never matches. + /// + /// + public static bool MatchesPropertyFromMethodStub( + IPropertySymbol target, + INamedTypeSymbol targetType, + bool stubIsStatic, + INamedTypeSymbol stubContainingType, + ImmutableArray stubParameters) + { + // Indexers have parameters and cannot be expressive targets. + if (target.Parameters.Length > 0) + return false; + + if (stubIsStatic) + { + if (target.IsStatic && stubParameters.Length == 0) + return true; + if (!target.IsStatic && stubParameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(stubParameters[0].Type, targetType)) + return true; + return false; + } + + return !target.IsStatic && stubParameters.Length == 0 && + SymbolEqualityComparer.Default.Equals(stubContainingType, targetType); + } + + /// + /// Matching rules for property-target ↔ property-stub (parameterless stub, this as receiver). + /// + public static bool MatchesPropertyFromPropertyStub( + IPropertySymbol target, + INamedTypeSymbol targetType, + bool stubIsStatic, + INamedTypeSymbol stubContainingType) + { + if (target.Parameters.Length > 0) + return false; + + if (stubIsStatic && target.IsStatic) + return true; + + return !stubIsStatic && !target.IsStatic && + SymbolEqualityComparer.Default.Equals(stubContainingType, targetType); + } +} diff --git a/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs b/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs index 15002ab4..20202e47 100644 --- a/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs +++ b/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs @@ -38,7 +38,9 @@ public ExpressiveForAttributeData(AttributeData attribute, ExpressiveForMemberKi bool? allowBlockBody = null; var transformerTypeNames = new List(); - // Extract target type from first constructor argument + // Extract target type from first constructor argument. + // ExpressiveFor has two constructors: (Type, string) and (string). + // ExpressiveForConstructor has a single (Type) constructor. if (attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is INamedTypeSymbol targetTypeSymbol) { @@ -51,12 +53,20 @@ public ExpressiveForAttributeData(AttributeData attribute, ExpressiveForMemberKi TargetTypeMetadataName = null; } - // Extract member name from second constructor argument (only for ExpressiveFor, not ExpressiveForConstructor) - if (memberKind != ExpressiveForMemberKind.Constructor && - attribute.ConstructorArguments.Length > 1 && - attribute.ConstructorArguments[1].Value is string memberName) + if (memberKind != ExpressiveForMemberKind.Constructor) { - MemberName = memberName; + if (attribute.ConstructorArguments.Length > 1 && + attribute.ConstructorArguments[1].Value is string memberNameTwoArg) + { + // Two-argument form: [ExpressiveFor(typeof(T), "Name")] + MemberName = memberNameTwoArg; + } + else if (attribute.ConstructorArguments.Length == 1 && + attribute.ConstructorArguments[0].Value is string memberNameOneArg) + { + // Single-argument form: [ExpressiveFor("Name")] — target defaults to stub's containing type + MemberName = memberNameOneArg; + } } // Extract named arguments diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ExpressivePropertiesNotMappedConventionTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ExpressivePropertiesNotMappedConventionTests.cs new file mode 100644 index 00000000..3b6bf272 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ExpressivePropertiesNotMappedConventionTests.cs @@ -0,0 +1,151 @@ +using ExpressiveSharp.Mapping; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Tests.Sqlite; + +/// +/// Verifies the ignores properties that +/// would otherwise be mapped to columns when they have no backing database value — +/// either via [Expressive] directly, or as the target of an [ExpressiveFor] stub. +/// +[TestClass] +public class ExpressivePropertiesNotMappedConventionTests +{ + private SqliteConnection _connection = null!; + + [TestInitialize] + public void Setup() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + } + + [TestCleanup] + public void Cleanup() => _connection.Dispose(); + + private DbContextOptions CreateOptions() where TContext : DbContext + => new DbContextOptionsBuilder() + .UseSqlite(_connection) + .UseExpressives() + .Options; + + [TestMethod] + public void PropertyWithExpressiveAttribute_IsIgnored() + { + using var ctx = new NotMappedTestContext(CreateOptions()); + + var entity = ctx.Model.FindEntityType(typeof(NotMappedItem))!; + + // The [Expressive] property must not appear as a mapped property. + Assert.IsNull(entity.FindProperty(nameof(NotMappedItem.DoubledValue)), + "[Expressive] properties should be ignored by the convention."); + + // Baseline: regular scalar properties should still be mapped. + Assert.IsNotNull(entity.FindProperty(nameof(NotMappedItem.Value))); + } + + [TestMethod] + public void PropertyTargetedByExpressiveFor_IsIgnored() + { + using var ctx = new NotMappedTestContext(CreateOptions()); + + var entity = ctx.Model.FindEntityType(typeof(NotMappedItem))!; + + // The property `DescribedValue` is targeted by an [ExpressiveFor] stub + // declared on the same class (single-arg + instance-stub form). + Assert.IsNull(entity.FindProperty(nameof(NotMappedItem.DescribedValue)), + "Properties targeted by [ExpressiveFor] should be ignored by the convention."); + } + + [TestMethod] + public void PropertyStubIsIgnored() + { + using var ctx = new NotMappedTestContext(CreateOptions()); + + var entity = ctx.Model.FindEntityType(typeof(NotMappedItem))!; + + // A property decorated with [ExpressiveFor] is itself a stub — it must not be mapped + // as a column (otherwise EF would try to materialize/persist its value). + Assert.IsNull(entity.FindProperty(nameof(NotMappedItem.DescribedValueExpression)), + "Property stubs carrying [ExpressiveFor] should be ignored by the convention."); + } + + [TestMethod] + public void PropertyTargetedByExternalExpressiveFor_IsIgnored() + { + using var ctx = new NotMappedTestContext(CreateOptions()); + + var entity = ctx.Model.FindEntityType(typeof(NotMappedItem))!; + + // `ExternalDescribedValue` is targeted by a stub in an unrelated static class — + // the cross-assembly-style mapping that was already supported for [Expressive]. + Assert.IsNull(entity.FindProperty(nameof(NotMappedItem.ExternalDescribedValue)), + "Properties targeted by [ExpressiveFor] stubs in other classes should also be ignored."); + } + + [TestMethod] + public async Task QueryReferencingIgnoredProperty_UsesExpressiveFormulaNotColumn() + { + using var ctx = new NotMappedTestContext(CreateOptions()); + ctx.Database.EnsureCreated(); + + ctx.Items.Add(new NotMappedItem { Id = 1, Value = 5 }); + ctx.Items.Add(new NotMappedItem { Id = 2, Value = 7 }); + await ctx.SaveChangesAsync(); + + // The formula for DoubledValue comes from the [Expressive] getter; no DB column. + var results = await ctx.Items + .OrderBy(x => x.Id) + .Select(x => new { x.Id, x.DoubledValue }) + .ToListAsync(); + + Assert.AreEqual(2, results.Count); + Assert.AreEqual(10, results[0].DoubledValue); + Assert.AreEqual(14, results[1].DoubledValue); + } + + // ── Test-local models ──────────────────────────────────────────────── + + public class NotMappedItem + { + public int Id { get; set; } + public int Value { get; set; } + + /// Computed by [Expressive] — existing convention path. + [Expressive] + public int DoubledValue => Value * 2; + + /// Targeted by a co-located property stub (new single-arg form). + public string DescribedValue { get; set; } = ""; + + [ExpressiveFor(nameof(DescribedValue))] + public string DescribedValueExpression => "value=" + Value; + + /// Targeted by a stub declared in an external static class. + public string ExternalDescribedValue { get; set; } = ""; + } + + private static class NotMappedItemMappings + { + [ExpressiveFor(typeof(NotMappedItem), nameof(NotMappedItem.ExternalDescribedValue))] + static string ExternalDescribedValue(NotMappedItem item) => "ext=" + item.Value; + } + + public class NotMappedTestContext : DbContext + { + public DbSet Items => Set(); + + public NotMappedTestContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedNever(); + }); + } + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.InstanceStub_OnInstanceProperty_SameType.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.InstanceStub_OnInstanceProperty_SameType.verified.txt new file mode 100644 index 00000000..acdb1770 --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.InstanceStub_OnInstanceProperty_SameType.verified.txt @@ -0,0 +1,24 @@ +// +#nullable disable + +using ExpressiveSharp.Mapping; +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_MyType + { + // [ExpressiveFor(typeof(MyType), "FullName")] + // string FullNameExpr() => FirstName + " " + LastName; + static global::System.Linq.Expressions.Expression> FullName_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.MyType), "@this"); + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("FirstName")); // FirstName + 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.MyType).GetProperty("LastName")); // LastName + 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/ExpressiveForTests.PropertyStub_InstanceProperty_SameType.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.PropertyStub_InstanceProperty_SameType.verified.txt new file mode 100644 index 00000000..41835078 --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.PropertyStub_InstanceProperty_SameType.verified.txt @@ -0,0 +1,24 @@ +// +#nullable disable + +using ExpressiveSharp.Mapping; +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_MyType + { + // [ExpressiveFor(nameof(FullName))] + // private string FullNameExpression => FirstName + " " + LastName; + static global::System.Linq.Expressions.Expression> FullName_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.MyType), "@this"); + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("FirstName")); // FirstName + 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.MyType).GetProperty("LastName")); // LastName + 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/ExpressiveForTests.SingleArgForm_DefaultsToContainingType.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.SingleArgForm_DefaultsToContainingType.verified.txt new file mode 100644 index 00000000..bcb109f1 --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.SingleArgForm_DefaultsToContainingType.verified.txt @@ -0,0 +1,24 @@ +// +#nullable disable + +using ExpressiveSharp.Mapping; +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_MyType + { + // [ExpressiveFor(nameof(FullName))] + // string FullNameExpr() => FirstName + " " + LastName; + static global::System.Linq.Expressions.Expression> FullName_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.MyType), "@this"); + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("FirstName")); // FirstName + 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.MyType).GetProperty("LastName")); // LastName + 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/ExpressiveForTests.SingleArgForm_InstanceMethodTarget.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.SingleArgForm_InstanceMethodTarget.verified.txt new file mode 100644 index 00000000..9bfaefcd --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.SingleArgForm_InstanceMethodTarget.verified.txt @@ -0,0 +1,24 @@ +// +#nullable disable + +using ExpressiveSharp.Mapping; +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_MyType + { + // [ExpressiveFor(nameof(AddAndDouble))] + // int AddAndDoubleExpr(int x) => (Base + x) * 2; + static global::System.Linq.Expressions.Expression> AddAndDouble_P0_int_Expression() + { + var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.MyType), "@this"); + var p_x = global::System.Linq.Expressions.Expression.Parameter(typeof(int), "x"); + var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("Base")); // Base + var expr_1 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Add, expr_2, p_x); + var expr_3 = global::System.Linq.Expressions.Expression.Constant(2, typeof(int)); // 2 + var expr_0 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Multiply, expr_1, expr_3); + return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this, p_x); + } + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.StaticStub_StaticPropertyTarget_Match.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.StaticStub_StaticPropertyTarget_Match.verified.txt new file mode 100644 index 00000000..9a8cb77a --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.StaticStub_StaticPropertyTarget_Match.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable + +using ExpressiveSharp.Mapping; +using Foo; + +namespace ExpressiveSharp.Generated +{ + static partial class Foo_MyType + { + // [ExpressiveFor(typeof(MyType), nameof(MyType.DefaultValue))] + // static int DefaultValue() => 99; + static global::System.Linq.Expressions.Expression> DefaultValue_Expression() + { + var expr_0 = global::System.Linq.Expressions.Expression.Constant(99, typeof(int)); // 99 + return global::System.Linq.Expressions.Expression.Lambda>(expr_0, global::System.Array.Empty()); + } + } +} diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs index 26081b24..0d3f936e 100644 --- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs @@ -195,8 +195,10 @@ static class Mappings { } [TestMethod] - public void StubNotStatic_EXP0016() + public void InstanceStubOnUnrelatedType_Rejected_EXP0015() { + // Instance stub targeting System.Math.Abs — stub's containing type is `Mappings`, + // which does not match `System.Math`, so no member should be found. var compilation = CreateCompilation( """ using ExpressiveSharp.Mapping; @@ -211,7 +213,113 @@ class Mappings { var result = RunExpressiveGenerator(compilation); Assert.AreEqual(1, result.Diagnostics.Length); - Assert.AreEqual("EXP0016", result.Diagnostics[0].Id); + Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); + } + + [TestMethod] + public Task InstanceStub_OnInstanceProperty_SameType() + { + // Instance stub on the target type — `this` is the receiver. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public string FirstName { get; set; } + public string LastName { get; set; } + public string FullName => FirstName + " " + LastName; + + [ExpressiveFor(typeof(MyType), "FullName")] + string FullNameExpr() => FirstName + " " + LastName; + } + } + """); + 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 SingleArgForm_DefaultsToContainingType() + { + // [ExpressiveFor(nameof(X))] without typeof — target defaults to the stub's containing type. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public string FirstName { get; set; } + public string LastName { get; set; } + public string FullName => FirstName + " " + LastName; + + [ExpressiveFor(nameof(FullName))] + string FullNameExpr() => FirstName + " " + LastName; + } + } + """); + 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 PropertyStub_InstanceProperty_SameType() + { + // [ExpressiveFor] on an expression-bodied PROPERTY — cleanest same-type form. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public string FirstName { get; set; } + public string LastName { get; set; } + public string FullName { get; set; } + + [ExpressiveFor(nameof(FullName))] + private string FullNameExpression => FirstName + " " + LastName; + } + } + """); + 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 SingleArgForm_InstanceMethodTarget() + { + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public int Base { get; set; } + public int AddAndDouble(int x) => (Base + x) * 2; + + [ExpressiveFor(nameof(AddAndDouble))] + int AddAndDoubleExpr(int x) => (Base + x) * 2; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(1, result.GeneratedTrees.Length); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); } [TestMethod] @@ -287,4 +395,233 @@ static class Mappings2 { var exp0020 = result.Diagnostics.Where(d => d.Id == "EXP0020").ToArray(); Assert.AreEqual(2, exp0020.Length); } + + // ── Signature-matrix coverage ────────────────────────────────────────── + // + // Each test below exercises one specific branch of + // ExpressiveForSignatureMatcher (method/property, stub kind, static/instance). + // Happy-path acceptance is covered by the snapshot tests above; this block + // focuses on the rejection branches (param-count, receiver-type, containing-type, + // param-type mismatches) so every matrix cell has a test. + + [TestMethod] + public Task StaticStub_StaticPropertyTarget_Match() + { + // Method stub → static property, both parameterless. Exercises + // MatchesPropertyFromMethodStub static/static branch. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public static int DefaultValue => 42; + } + + static class Mappings { + [ExpressiveFor(typeof(MyType), nameof(MyType.DefaultValue))] + static int DefaultValue() => 99; + } + } + """); + 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 InstanceStub_StaticPropertyTarget_Rejected_EXP0015() + { + // Static property + instance property stub → never matches (no way to supply receiver). + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public static int DefaultValue => 42; + + [ExpressiveFor(nameof(DefaultValue))] + int DefaultValueExpression => 99; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Length); + Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); + } + + [TestMethod] + public void PropertyStub_TargetingMethod_Rejected_EXP0015() + { + // Property stubs can only target properties — even if a matching method exists. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public int Value { get; set; } + public int Compute() => Value * 2; + + [ExpressiveFor(nameof(Compute))] + int ComputeExpression => Value * 2; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Length); + Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); + } + + [TestMethod] + public void StaticStub_InstanceMethod_WrongReceiverType_Rejected_EXP0015() + { + // Static stub over instance method, but the explicit receiver param is the wrong type. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public int Value { get; set; } + public int Add(int x) => Value + x; + } + + static class Mappings { + [ExpressiveFor(typeof(MyType), nameof(MyType.Add))] + static int Add(string wrongType, int x) => x; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Length); + Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); + } + + [TestMethod] + public void StaticStub_MethodTarget_WrongParamType_Rejected_EXP0015() + { + // Param count matches but a param type differs — hits the matcher's per-param loop. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + static class Mappings { + [ExpressiveFor(typeof(System.Math), nameof(System.Math.Abs))] + static int Abs(string s) => 0; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Length); + Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); + } + + [TestMethod] + public void StaticStub_InstanceMethod_ParamCountMismatch_Rejected_EXP0015() + { + // Instance method has 1 param; static stub provides [receiver] only (missing the arg). + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public int Value { get; set; } + public int Add(int x) => Value + x; + } + + static class Mappings { + [ExpressiveFor(typeof(MyType), nameof(MyType.Add))] + static int Add(MyType receiver) => receiver.Value; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Length); + Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); + } + + [TestMethod] + public void PropertyStub_WithExplicitTargetType_WrongContainingType_Rejected_EXP0015() + { + // Property stub must be on the target type; [ExpressiveFor(typeof(Other))] on a stub + // whose containing type is not Other cannot supply a receiver from `this`. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class Other { + public string Name { get; set; } + } + + class MyType { + [ExpressiveFor(typeof(Other), nameof(Other.Name))] + string NameExpression => "hardcoded"; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Length); + Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); + } + + [TestMethod] + public void InstanceStub_InstanceMethod_ParamCountMismatch_Rejected_EXP0015() + { + // Instance stub on target type, but arg count doesn't match the target method. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public int Value { get; set; } + public int Add(int x, int y) => Value + x + y; + + [ExpressiveFor(nameof(Add))] + int AddExpression(int x) => Value + x; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Length); + Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); + } + + [TestMethod] + public void SingleArgForm_UnknownMember_Rejected_EXP0015() + { + // Single-arg form with a name that doesn't exist on the stub's containing type. + var compilation = CreateCompilation( + """ + using ExpressiveSharp.Mapping; + + namespace Foo { + class MyType { + public int Value { get; set; } + + [ExpressiveFor("NoSuchMember")] + int NoSuchMemberExpression => Value; + } + } + """); + var result = RunExpressiveGenerator(compilation); + + Assert.AreEqual(1, result.Diagnostics.Length); + Assert.AreEqual("EXP0015", result.Diagnostics[0].Id); + } } diff --git a/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/DisplayFormatter.cs b/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/DisplayFormatter.cs index 24a13df7..27e3fd09 100644 --- a/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/DisplayFormatter.cs +++ b/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/DisplayFormatter.cs @@ -4,10 +4,15 @@ namespace ExpressiveSharp.IntegrationTests.Scenarios.Store.Models; /// /// Represents an external (e.g. third-party) class with instance -/// methods and properties that test code cannot mark with -/// [Expressive] directly. -/// provides [ExpressiveFor] bodies so its members can be used inside -/// LINQ expression trees. +/// methods and properties. Demonstrates two [ExpressiveFor] styles in one model: +/// +/// The Wrap mapping uses the ergonomic co-located form — an instance stub +/// with the single-argument attribute, where the stub's this is the receiver. +/// The Label mapping uses the original external form — a static stub in +/// with the explicit receiver parameter. +/// +/// Integration tests assert both paths produce identical runtime behavior, so regressions +/// in either the new or the legacy form are caught end-to-end. /// public class DisplayFormatter { @@ -25,18 +30,20 @@ public DisplayFormatter(string prefix, string suffix) /// Label that combines the prefix/suffix — instance property. public string Label => "[" + Prefix + "/" + Suffix + "]"; + + // Single-arg + instance-stub form: the target is Wrap on this type, and the stub's `this` + // is the receiver. Equivalent to [ExpressiveFor(typeof(DisplayFormatter), nameof(Wrap))] + // with a static stub taking `(DisplayFormatter, string)`. + [ExpressiveFor(nameof(Wrap))] + string WrapExpr(string value) => Prefix + value + Suffix; } /// -/// [ExpressiveFor] mappings for . -/// Each mapping takes the target instance as its first parameter. +/// [ExpressiveFor] mapping for using the original +/// external-class form (static stub with explicit receiver parameter). /// static class DisplayFormatterMappings { - [ExpressiveFor(typeof(DisplayFormatter), nameof(DisplayFormatter.Wrap))] - static string Wrap(DisplayFormatter formatter, string value) - => formatter.Prefix + value + formatter.Suffix; - [ExpressiveFor(typeof(DisplayFormatter), nameof(DisplayFormatter.Label))] static string Label(DisplayFormatter formatter) => "[" + formatter.Prefix + "/" + formatter.Suffix + "]";