Skip to content

Wrap up replacing Projectables with ExpressiveFor with synthesized properties#40

Open
koenbeuk wants to merge 1 commit intomainfrom
feat/expressive-for-synthesize
Open

Wrap up replacing Projectables with ExpressiveFor with synthesized properties#40
koenbeuk wants to merge 1 commit intomainfrom
feat/expressive-for-synthesize

Conversation

@koenbeuk
Copy link
Copy Markdown
Collaborator

@koenbeuk koenbeuk commented Apr 20, 2026

closes #37

Copilot AI review requested due to automatic review settings April 20, 2026 01:22
Comment on lines +287 to +294
foreach (var reference in type.DeclaringSyntaxReferences)
{
if (reference.GetSyntax() is TypeDeclarationSyntax typeDecl
&& typeDecl.Modifiers.Any(SyntaxKind.PartialKeyword))
{
return true;
}
}
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.Synthesize and emit synthesized, settable properties via a new SynthesizedPropertyEmitter.
  • 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.

Comment on lines +166 to +175
// 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;
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +212
// 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;
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +103
"_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1);

private static string MakeHasValueFlagName(string propertyName) =>
"_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1) + "HasValue";
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

The 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.

Suggested change
"_" + 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";

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +40
// 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";
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
// 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;

Copilot uses AI. Check for mistakes.
Comment on lines 145 to 147
[Projectable(UseMemberBody = nameof(FullNameProjection))]
public string FullName { get; init; }
public partial string FullName { get; init; }
private string FullNameProjection => LastName + ", " + FirstName;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.

// Attach the synthesis spec so the generator emits the partial property.
var useTernary = IsNullablePropertyType(stubReturnType);
var propertyTypeFqn = stubReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
var propertyTypeFqn = stubReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var fullyQualifiedNullableFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions(
SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions |
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
var propertyTypeFqn = stubReturnType.ToDisplayString(fullyQualifiedNullableFormat);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Suggestion] Non-nullable value type projectable verbosity

2 participants