Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
<None Include="build\EntityFrameworkCore.Projectables.Abstractions.props" Pack="true" PackagePath="buildTransitive\" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project>
<PropertyGroup>
<!-- Global defaults for [Projectable] options. Override these in your project to set app-wide defaults.
Per-member attribute settings always take precedence over these values. -->
<Projectables_NullConditionalRewriteSupport
Condition="'$(Projectables_NullConditionalRewriteSupport)' == ''" />
<Projectables_ExpandEnumMethods
Condition="'$(Projectables_ExpandEnumMethods)' == ''" />
<Projectables_AllowBlockBody
Condition="'$(Projectables_AllowBlockBody)' == ''" />
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Include="Projectables_NullConditionalRewriteSupport" />
<CompilerVisibleProperty Include="Projectables_ExpandEnumMethods" />
<CompilerVisibleProperty Include="Projectables_AllowBlockBody" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
using System.Runtime.CompilerServices;
using System.Runtime.CompilerServices;
using EntityFrameworkCore.Projectables.Generator.Models;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace EntityFrameworkCore.Projectables.Generator.Comparers;

/// <summary>
/// Equality comparer for tuples of (MemberDeclarationSyntax, ProjectableAttributeData) and Compilation,
/// Equality comparer for tuples of (MemberDeclarationSyntax, ProjectableAttributeData, ProjectableGlobalOptions) and Compilation,
/// used as keys in the registry to determine if a member's projectable status has changed across incremental generation steps.
/// </summary>
internal class MemberDeclarationSyntaxAndCompilationEqualityComparer
: IEqualityComparer<((MemberDeclarationSyntax Member, ProjectableAttributeData Attribute), Compilation)>
: IEqualityComparer<((MemberDeclarationSyntax Member, ProjectableAttributeData Attribute, ProjectableGlobalOptions GlobalOptions), Compilation)>
{
private readonly static MemberDeclarationSyntaxEqualityComparer _memberComparer = new();

public bool Equals(
((MemberDeclarationSyntax Member, ProjectableAttributeData Attribute), Compilation) x,
((MemberDeclarationSyntax Member, ProjectableAttributeData Attribute), Compilation) y)
((MemberDeclarationSyntax Member, ProjectableAttributeData Attribute, ProjectableGlobalOptions GlobalOptions), Compilation) x,
((MemberDeclarationSyntax Member, ProjectableAttributeData Attribute, ProjectableGlobalOptions GlobalOptions), Compilation) y)
{
var (xLeft, xCompilation) = x;
var (yLeft, yCompilation) = y;

// 1. Fast reference equality short-circuit
if (ReferenceEquals(xLeft.Member, yLeft.Member) &&
ReferenceEquals(xCompilation, yCompilation))
ReferenceEquals(xCompilation, yCompilation) &&
xLeft.GlobalOptions == yLeft.GlobalOptions)
{
return true;
}
Expand All @@ -43,17 +44,23 @@ public bool Equals(
return false;
}

// 4. Member text — string allocation, only reached when the SyntaxTree is shared
// 4. Global options (primitive record struct) — cheap value comparison
if (xLeft.GlobalOptions != yLeft.GlobalOptions)
{
return false;
}

// 5. Member text — string allocation, only reached when the SyntaxTree is shared
if (!_memberComparer.Equals(xLeft.Member, yLeft.Member))
{
return false;
}

// 5. Assembly-level references — most expensive (ImmutableArray enumeration)
// 6. Assembly-level references — most expensive (ImmutableArray enumeration)
return xCompilation.ExternalReferences.SequenceEqual(yCompilation.ExternalReferences);
}

public int GetHashCode(((MemberDeclarationSyntax Member, ProjectableAttributeData Attribute), Compilation) obj)
public int GetHashCode(((MemberDeclarationSyntax Member, ProjectableAttributeData Attribute, ProjectableGlobalOptions GlobalOptions), Compilation) obj)
{
var (left, compilation) = obj;
unchecked
Expand All @@ -62,7 +69,8 @@ public int GetHashCode(((MemberDeclarationSyntax Member, ProjectableAttributeDat
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();

// Incorporate compilation external references to align with Equals
var references = compilation.ExternalReferences;
var referencesHash = 17;
Expand All @@ -72,7 +80,7 @@ public int GetHashCode(((MemberDeclarationSyntax Member, ProjectableAttributeDat
referencesHash = referencesHash * 31 + RuntimeHelpers.GetHashCode(reference);
}
hash = hash * 31 + referencesHash;

return hash;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ static internal partial class ProjectableInterpreter
MemberDeclarationSyntax member,
ISymbol memberSymbol,
ProjectableAttributeData projectableAttribute,
ProjectableGlobalOptions globalOptions,
SourceProductionContext context,
Compilation? compilation = null)
{
// Read directly from the struct fields
var nullConditionalRewriteSupport = projectableAttribute.NullConditionalRewriteSupport;
// Resolve effective values: per-attribute wins, then global MSBuild default, then hard-coded fallback.
var nullConditionalRewriteSupport =
projectableAttribute.NullConditionalRewriteSupport ?? globalOptions.NullConditionalRewriteSupport ?? default;
var useMemberBody = projectableAttribute.UseMemberBody;
var expandEnumMethods = projectableAttribute.ExpandEnumMethods;
var allowBlockBody = projectableAttribute.AllowBlockBody;
var expandEnumMethods =
projectableAttribute.ExpandEnumMethods ?? globalOptions.ExpandEnumMethods ?? false;
var allowBlockBody =
projectableAttribute.AllowBlockBody ?? globalOptions.AllowBlockBody ?? false;

// 1. Resolve the member body (handles UseMemberBody redirection)
var memberBody = TryResolveMemberBody(member, memberSymbol, useMemberBody, context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@ namespace EntityFrameworkCore.Projectables.Generator.Models;

/// <summary>
/// Plain-data snapshot of the [Projectable] attribute arguments.
/// Nullable option fields are <c>null</c> when the named argument was absent from the attribute,
/// meaning the global MSBuild default (or hard-coded fallback) should be used instead.
/// </summary>
readonly internal record struct ProjectableAttributeData
{
public NullConditionalRewriteSupport NullConditionalRewriteSupport { get; }
public NullConditionalRewriteSupport? NullConditionalRewriteSupport { get; }
public string? UseMemberBody { get; }
public bool ExpandEnumMethods { get; }
public bool AllowBlockBody { get; }
public bool? ExpandEnumMethods { get; }
public bool? AllowBlockBody { get; }

public ProjectableAttributeData(AttributeData attribute)
{
var nullConditionalRewriteSupport = default(NullConditionalRewriteSupport);
NullConditionalRewriteSupport? nullConditionalRewriteSupport = null;
string? useMemberBody = null;
var expandEnumMethods = false;
var allowBlockBody = false;
bool? expandEnumMethods = null;
bool? allowBlockBody = null;

foreach (var namedArgument in attribute.NamedArguments)
{
var key = namedArgument.Key;
Expand All @@ -40,23 +42,23 @@ value.Value is not null &&
}
break;
case nameof(ExpandEnumMethods):
if (value.Value is bool expand && expand)
if (value.Value is bool expand)
{
expandEnumMethods = true;
expandEnumMethods = expand;
}
break;
case nameof(AllowBlockBody):
if (value.Value is bool allow && allow)
if (value.Value is bool allow)
{
allowBlockBody = true;
allowBlockBody = allow;
}
break;
}
}

NullConditionalRewriteSupport = nullConditionalRewriteSupport;
UseMemberBody = useMemberBody;
ExpandEnumMethods = expandEnumMethods;
AllowBlockBody = allowBlockBody;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Microsoft.CodeAnalysis.Diagnostics;

namespace EntityFrameworkCore.Projectables.Generator.Models;

/// <summary>
/// Plain-data snapshot of the MSBuild global defaults for [Projectable] options.
/// Read from <c>build_property.*</c> entries in <c>AnalyzerConfigOptions.GlobalOptions</c>.
/// <c>null</c> means the property was not set (no global override).
/// </summary>
readonly internal record struct ProjectableGlobalOptions
{
public NullConditionalRewriteSupport? NullConditionalRewriteSupport { get; }
public bool? ExpandEnumMethods { get; }
public bool? AllowBlockBody { get; }

public ProjectableGlobalOptions(AnalyzerConfigOptions globalOptions)
{
if (globalOptions.TryGetValue("build_property.Projectables_NullConditionalRewriteSupport", out var nullConditionalStr)
&& !string.IsNullOrEmpty(nullConditionalStr)
&& Enum.TryParse<NullConditionalRewriteSupport>(nullConditionalStr, ignoreCase: true, out var nullConditional))
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

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

Projectables_NullConditionalRewriteSupport is parsed with Enum.TryParse, which will also accept numeric values (and potentially undefined enum values) as valid. That means a malformed MSBuild value like 999 would silently become an effective option rather than being treated as "not set". Consider validating with Enum.IsDefined (or rejecting numeric-only inputs) so malformed values are ignored consistently with the bool options tests.

Suggested change
&& Enum.TryParse<NullConditionalRewriteSupport>(nullConditionalStr, ignoreCase: true, out var nullConditional))
&& Enum.TryParse<NullConditionalRewriteSupport>(nullConditionalStr, ignoreCase: true, out var nullConditional)
&& Enum.IsDefined(typeof(NullConditionalRewriteSupport), nullConditional))

Copilot uses AI. Check for mistakes.
{
NullConditionalRewriteSupport = nullConditional;
}

if (globalOptions.TryGetValue("build_property.Projectables_ExpandEnumMethods", out var expandStr)
&& bool.TryParse(expandStr, out var expand))
{
ExpandEnumMethods = expand;
}

if (globalOptions.TryGetValue("build_property.Projectables_AllowBlockBody", out var allowStr)
&& bool.TryParse(allowStr, out var allow))
{
AllowBlockBody = allow;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public class ProjectionExpressionGenerator : IIncrementalGenerator

public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Snapshot global MSBuild defaults once per generator run.
var globalOptions = context.AnalyzerConfigOptionsProvider
.Select(static (opts, _) => new ProjectableGlobalOptions(opts.GlobalOptions));

// Extract only pure stable data from the attribute in the transform.
// No live Roslyn objects (no AttributeData, SemanticModel, Compilation, ISymbol) —
// those are always new instances and defeat incremental caching entirely.
Expand All @@ -50,14 +54,23 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
Attribute: new ProjectableAttributeData(c.Attributes[0])
));

var compilationAndMemberPairs = memberDeclarations
// Flatten (Member, Attribute) + GlobalOptions into a single named tuple.
var memberDeclarationsWithGlobalOptions = memberDeclarations
.Combine(globalOptions)
.Select(static (pair, _) => (
Member: pair.Left.Member,
Attribute: pair.Left.Attribute,
GlobalOptions: pair.Right
));

var compilationAndMemberPairs = memberDeclarationsWithGlobalOptions
.Combine(context.CompilationProvider)
.WithComparer(new MemberDeclarationSyntaxAndCompilationEqualityComparer());

context.RegisterSourceOutput(compilationAndMemberPairs,
static (spc, source) =>
{
var ((member, attribute), compilation) = source;
var ((member, attribute, globalOptions), compilation) = source;
var semanticModel = compilation.GetSemanticModel(member.SyntaxTree);
var memberSymbol = semanticModel.GetDeclaredSymbol(member);

Expand All @@ -66,13 +79,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return;
}

Execute(member, semanticModel, memberSymbol, attribute, compilation, spc);
Execute(member, semanticModel, memberSymbol, attribute, globalOptions, compilation, spc);
});

// Build the projection registry: collect all entries and emit a single registry file
var registryEntries = compilationAndMemberPairs.Select(
static (source, cancellationToken) => {
var ((member, _), compilation) = source;
var ((member, _, _), compilation) = source;

var semanticModel = compilation.GetSemanticModel(member.SyntaxTree);
var memberSymbol = semanticModel.GetDeclaredSymbol(member, cancellationToken);
Expand Down Expand Up @@ -170,11 +183,12 @@ private static void Execute(
SemanticModel semanticModel,
ISymbol memberSymbol,
ProjectableAttributeData projectableAttribute,
ProjectableGlobalOptions globalOptions,
Compilation? compilation,
SourceProductionContext context)
{
var projectable = ProjectableInterpreter.GetDescriptor(
semanticModel, member, memberSymbol, projectableAttribute, context, compilation);
semanticModel, member, memberSymbol, projectableAttribute, globalOptions, context, compilation);

if (projectable is null)
{
Expand Down
Loading
Loading