Wrap up replacing Projectables with ExpressiveFor with synthesized properties#40
Wrap up replacing Projectables with ExpressiveFor with synthesized properties#40
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR completes the migration away from [Expressive(Projectable = true)] by introducing/standardizing on [ExpressiveFor(..., Synthesize = true)], updating generators, tests, and documentation accordingly.
Changes:
- Add generator support for
ExpressiveForAttribute.Synthesizeand emit synthesized, settable properties via a newSynthesizedPropertyEmitter. - Remove Projectable-specific generator logic, diagnostics, code fixers, tests, and documentation.
- Update EF Core + MongoDB integration tests and docs to validate/describe synthesized-property behavior and mapping ignores.
Reviewed changes
Copilot reviewed 39 out of 39 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/ExpressiveSharp.Tests/Services/ExpressiveResolverTests.cs | Updates resolver tests to validate synthesized-property registry keying and formula-only bodies. |
| tests/ExpressiveSharp.MongoDB.IntegrationTests/Tests/SynthesizedMongoIgnoreTests.cs | Renames/rewrites Mongo integration tests to use synthesized properties and verify BSON ignore behavior. |
| tests/ExpressiveSharp.IntegrationTests/Tests/SynthesizedExpressiveTests.cs | Updates provider-agnostic integration tests to cover synthesized-property runtime and expansion semantics. |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.cs | Removes Projectable generator snapshot/diagnostic tests. |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.SimpleProjectableProperty_ManualBackingField.verified.txt | Removes Projectable snapshot output. |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.ProjectableWithSetAccessor.verified.txt | Removes Projectable snapshot output. |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NullableValueTypeProperty_Ternary_FieldKeyword.verified.txt | Removes Projectable snapshot output. |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Ternary_ManualBackingField.verified.txt | Removes Projectable snapshot output. |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ProjectableTests.NonNullableValueTypeProperty_Ternary_FieldKeyword.verified.txt | Removes Projectable snapshot output. |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs | Adds new synthesize snapshot + diagnostic tests (EXP0031–EXP0033). |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_ReferenceTypeTarget_EmitsCoalesceForm.verified.txt | Updates/extends snapshots to include synthesized partial output. |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableValueTypeTarget_EmitsTernaryForm.verified.txt | Updates snapshot for nullable value-type synthesize output. |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt | Adds snapshot for nullable reference-type synthesize output. |
| tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.Synthesize_NonNullableValueTypeTarget_EmitsCoalesceForm.verified.txt | Adds snapshot for non-nullable value-type synthesize output. |
| tests/ExpressiveSharp.Generator.Tests/CodeFixers/ConvertProjectableToTernaryCodeFixProviderTests.cs | Removes tests for the retired Projectable-to-ternary code fix. |
| tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/SynthesizedExpressiveSqlTests.cs | Updates EF Core SQLite integration tests to validate synthesized-property ignore + SQL rewriting. |
| src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoIgnoreConvention.cs | Updates convention docs to include synthesized properties as ignored/unmapped. |
| src/ExpressiveSharp.Generator/Models/SynthesizedPropertySpec.cs | Introduces model for synthesized property emission parameters. |
| src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs | Parses Synthesize named argument for [ExpressiveFor]. |
| src/ExpressiveSharp.Generator/Models/ExpressiveDescriptor.cs | Adds SynthesisSpec to carry emission instructions to the generator. |
| src/ExpressiveSharp.Generator/Models/ExpressiveAttributeData.cs | Removes now-retired Projectable flag parsing. |
| src/ExpressiveSharp.Generator/Interpretation/ProjectablePatternRecognizer.cs | Removes Projectable pattern recognition implementation. |
| src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs | Removes Projectable-specific dispatch path. |
| src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs | Removes Projectable body processing pipeline. |
| src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs | Adds synthesize resolution path, creates synthesis spec, and adds EXP0031–EXP0033 behavior. |
| src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs | Retires EXP0021–EXP0030 and introduces EXP0031–EXP0033 diagnostics. |
| src/ExpressiveSharp.Generator/ExpressiveGenerator.cs | Emits synthesized property partial alongside expression factory output; adjusts registry entry kind for synthesize. |
| src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs | New emitter generating the synthesized property, backing fields, and (ternary/coalesce) accessors. |
| src/ExpressiveSharp.CodeFixers/ConvertProjectableToTernaryCodeFixProvider.cs | Removes Projectable code fix provider. |
| src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs | Adds Synthesize property to public attribute API. |
| src/ExpressiveSharp.Abstractions/ExpressiveAttribute.cs | Removes Projectable property from [Expressive]. |
| docs/reference/projectable-properties.md | Removes the Projectable Properties reference page. |
| docs/reference/expressive-for.md | Documents Synthesize = true behavior, requirements, and new diagnostics. |
| docs/reference/expressive-attribute.md | Removes Projectable docs from [Expressive] reference. |
| docs/reference/diagnostics.md | Removes Projectable diagnostic docs and adds EXP0031–EXP0033 docs and guidance. |
| docs/recipes/projection-middleware.md | Updates guidance to use synthesized properties instead of Projectable properties. |
| docs/guide/migration-from-projectables.md | Updates migration guidance to recommend synthesize or plain [ExpressiveFor]. |
| docs/guide/integrations/mongodb.md | Updates Mongo integration docs to reference synthesized properties rather than Projectables. |
| docs/.vitepress/config.mts | Removes Projectable Properties page from sidebar. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // EXP0032: the containing class must be partial so we can emit the synthesized property | ||
| // into a generated file. | ||
| if (!IsPartialType(targetType)) | ||
| { | ||
| context.ReportDiagnostic(Diagnostic.Create( | ||
| Diagnostics.ExpressiveForSynthesizeRequiresPartial, | ||
| stubIdentifierLocation, | ||
| targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); | ||
| return null; | ||
| } |
There was a problem hiding this comment.
Synthesize = true can currently target interface types (see GetTypeKeyword returning "interface"), but the synthesized member always emits backing fields, which are illegal in interfaces and will break compilation. Consider rejecting targetType.TypeKind == TypeKind.Interface (and possibly other non-field-capable containers) with a dedicated diagnostic before emitting the synthesis spec.
| // The stub must be a parameterless same-type member (property or method) so the | ||
| // synthesized property can delegate to it with no arguments. For non-property stubs | ||
| // we still accept zero-param instance methods. | ||
| ITypeSymbol stubReturnType; | ||
| bool stubIsProperty; | ||
| switch (stubSymbol) | ||
| { | ||
| case IPropertySymbol propSym when propSym.Parameters.Length == 0 && !propSym.IsStatic: | ||
| stubReturnType = propSym.Type; | ||
| stubIsProperty = true; | ||
| break; | ||
| case IMethodSymbol methodSym when methodSym.Parameters.Length == 0 && !methodSym.IsStatic: | ||
| stubReturnType = methodSym.ReturnType; | ||
| stubIsProperty = false; | ||
| break; | ||
| default: | ||
| context.ReportDiagnostic(Diagnostic.Create( | ||
| Diagnostics.ExpressiveForMemberNotFound, | ||
| stubIdentifierLocation, | ||
| memberName, | ||
| targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); | ||
| return null; | ||
| } |
There was a problem hiding this comment.
In the default case, ResolveSynthesize reports EXP0015 (target member not found) when the stub itself is invalid for synthesis (e.g., static, has parameters, wrong kind). That error message is misleading because the target member is intentionally absent when synthesizing. Recommend adding a specific diagnostic for “synthesize stub must be a parameterless instance property/method on the containing type” (or reusing a more appropriate existing one) so users get actionable feedback.
| "_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); | ||
|
|
||
| private static string MakeHasValueFlagName(string propertyName) => | ||
| "_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1) + "HasValue"; |
There was a problem hiding this comment.
The synthesized backing field/flag names are derived directly from the target property name (e.g. _fullName, _fullNameHasValue) without any collision check. If the user type already declares these members (common when migrating from manual/projectable patterns), the generator will emit uncompilable code. Consider generating a reserved/prefixed name (e.g. __expressive_fullName) and/or doing a symbol-based uniqueness check (like the old code fix did) and carrying the chosen names in SynthesizedPropertySpec.
| "_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); | |
| private static string MakeHasValueFlagName(string propertyName) => | |
| "_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1) + "HasValue"; | |
| "__expressive_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); | |
| private static string MakeHasValueFlagName(string propertyName) => | |
| "__expressive_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1) + "HasValue"; |
| // Emit partial-class wrappers for any enclosing (nested) types, from outermost in. | ||
| // The innermost is the type we attach the synthesized member to. | ||
| for (var i = 0; i < nestingDepth; i++) | ||
| { | ||
| var indent = baseIndent + new string(' ', i * 4); | ||
| var name = spec.ContainingTypePath[i]; | ||
| // Only the innermost knows its keyword; enclosing nests are "partial class" as a safe default. | ||
| var keyword = i == nestingDepth - 1 ? spec.ContainingTypeKeyword : "class"; |
There was a problem hiding this comment.
For nested types, the emitter hard-codes outer wrappers as partial class ... (line 40). This will generate invalid code when the containing type is nested inside a struct, record struct, or record (or any non-class nested container). To avoid compilation breaks, SynthesizedPropertySpec likely needs to carry the full nested type kind/modifier information for each element of ContainingTypePath (or emit wrappers based on INamedTypeSymbol rather than only names).
| // Emit partial-class wrappers for any enclosing (nested) types, from outermost in. | |
| // The innermost is the type we attach the synthesized member to. | |
| for (var i = 0; i < nestingDepth; i++) | |
| { | |
| var indent = baseIndent + new string(' ', i * 4); | |
| var name = spec.ContainingTypePath[i]; | |
| // Only the innermost knows its keyword; enclosing nests are "partial class" as a safe default. | |
| var keyword = i == nestingDepth - 1 ? spec.ContainingTypeKeyword : "class"; | |
| // Emit wrappers for any enclosing (nested) types, from outermost in. | |
| // Use the available type keyword from the spec for every emitted wrapper rather than | |
| // hard-coding "class", which can generate invalid code for non-class containers. | |
| for (var i = 0; i < nestingDepth; i++) | |
| { | |
| var indent = baseIndent + new string(' ', i * 4); | |
| var name = spec.ContainingTypePath[i]; | |
| var keyword = spec.ContainingTypeKeyword; |
| [Projectable(UseMemberBody = nameof(FullNameProjection))] | ||
| public string FullName { get; init; } | ||
| public partial string FullName { get; init; } | ||
| private string FullNameProjection => LastName + ", " + FirstName; |
There was a problem hiding this comment.
The migration guide example shows public partial string FullName { get; init; }, but partial isn’t a valid modifier for properties in current C# and will not compile. For the Projectables “before” snippet, the partial should be on the containing type (if needed), not on the property declaration.
|
|
||
| // Attach the synthesis spec so the generator emits the partial property. | ||
| var useTernary = IsNullablePropertyType(stubReturnType); | ||
| var propertyTypeFqn = stubReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); |
There was a problem hiding this comment.
stubReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) drops nullable reference annotations, so synthesized properties for string? stubs get emitted as non-nullable string (and backing fields likewise). This can produce incorrect public API and nullable warnings/errors (esp. with warnings-as-errors). Use a SymbolDisplayFormat that includes ? (e.g. IncludeNullableReferenceTypeModifier) or derive the emitted type string from the stub syntax/nullability-aware display options so PropertyTypeFqn/BackingFieldTypeFqn preserve NRT annotations.
| var propertyTypeFqn = stubReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); | |
| var fullyQualifiedNullableFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( | |
| SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions | | |
| SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); | |
| var propertyTypeFqn = stubReturnType.ToDisplayString(fullyQualifiedNullableFormat); |
closes #37