diff --git a/src/EntityFrameworkCore.Projectables.Abstractions/EntityFrameworkCore.Projectables.Abstractions.csproj b/src/EntityFrameworkCore.Projectables.Abstractions/EntityFrameworkCore.Projectables.Abstractions.csproj
index b7feab3..6264b30 100644
--- a/src/EntityFrameworkCore.Projectables.Abstractions/EntityFrameworkCore.Projectables.Abstractions.csproj
+++ b/src/EntityFrameworkCore.Projectables.Abstractions/EntityFrameworkCore.Projectables.Abstractions.csproj
@@ -26,6 +26,7 @@
+
diff --git a/src/EntityFrameworkCore.Projectables.Abstractions/build/EntityFrameworkCore.Projectables.Abstractions.props b/src/EntityFrameworkCore.Projectables.Abstractions/build/EntityFrameworkCore.Projectables.Abstractions.props
new file mode 100644
index 0000000..39ed2b6
--- /dev/null
+++ b/src/EntityFrameworkCore.Projectables.Abstractions/build/EntityFrameworkCore.Projectables.Abstractions.props
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EntityFrameworkCore.Projectables.Generator/Comparers/MemberDeclarationSyntaxAndCompilationEqualityComparer.cs b/src/EntityFrameworkCore.Projectables.Generator/Comparers/MemberDeclarationSyntaxAndCompilationEqualityComparer.cs
index 38ca7de..5395d68 100644
--- a/src/EntityFrameworkCore.Projectables.Generator/Comparers/MemberDeclarationSyntaxAndCompilationEqualityComparer.cs
+++ b/src/EntityFrameworkCore.Projectables.Generator/Comparers/MemberDeclarationSyntaxAndCompilationEqualityComparer.cs
@@ -1,4 +1,4 @@
-using System.Runtime.CompilerServices;
+using System.Runtime.CompilerServices;
using EntityFrameworkCore.Projectables.Generator.Models;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
@@ -6,24 +6,25 @@
namespace EntityFrameworkCore.Projectables.Generator.Comparers;
///
-/// 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.
///
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;
}
@@ -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
@@ -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;
@@ -72,7 +80,7 @@ public int GetHashCode(((MemberDeclarationSyntax Member, ProjectableAttributeDat
referencesHash = referencesHash * 31 + RuntimeHelpers.GetHashCode(reference);
}
hash = hash * 31 + referencesHash;
-
+
return hash;
}
}
diff --git a/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs
index ba8f327..d450d70 100644
--- a/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs
+++ b/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs
@@ -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);
diff --git a/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableAttributeData.cs b/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableAttributeData.cs
index 597f40e..9541dec 100644
--- a/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableAttributeData.cs
+++ b/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableAttributeData.cs
@@ -4,21 +4,23 @@ namespace EntityFrameworkCore.Projectables.Generator.Models;
///
/// Plain-data snapshot of the [Projectable] attribute arguments.
+/// Nullable option fields are null when the named argument was absent from the attribute,
+/// meaning the global MSBuild default (or hard-coded fallback) should be used instead.
///
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;
@@ -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;
}
-}
\ No newline at end of file
+}
diff --git a/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableGlobalOptions.cs b/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableGlobalOptions.cs
new file mode 100644
index 0000000..28e6649
--- /dev/null
+++ b/src/EntityFrameworkCore.Projectables.Generator/Models/ProjectableGlobalOptions.cs
@@ -0,0 +1,37 @@
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace EntityFrameworkCore.Projectables.Generator.Models;
+
+///
+/// Plain-data snapshot of the MSBuild global defaults for [Projectable] options.
+/// Read from build_property.* entries in AnalyzerConfigOptions.GlobalOptions.
+/// null means the property was not set (no global override).
+///
+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(nullConditionalStr, ignoreCase: true, out var nullConditional))
+ {
+ 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;
+ }
+ }
+}
diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs
index 38821b1..04cedae 100644
--- a/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs
+++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs
@@ -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.
@@ -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);
@@ -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);
@@ -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)
{
diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/GlobalOptionsTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/GlobalOptionsTests.cs
new file mode 100644
index 0000000..874593a
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/GlobalOptionsTests.cs
@@ -0,0 +1,290 @@
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+using Xunit;
+
+namespace EntityFrameworkCore.Projectables.Generator.Tests;
+
+///
+/// Tests that MSBuild global properties (CompilerVisibleProperty) are respected as defaults
+/// for [Projectable] options, and that per-attribute settings override them.
+///
+public class GlobalOptionsTests : ProjectionExpressionGeneratorTestsBase
+{
+ public GlobalOptionsTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { }
+
+ // -------------------------------------------------------------------------
+ // AllowBlockBody
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void GlobalAllowBlockBody_True_EnablesBlockBodyWithoutAttributeFlag()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class C {
+ public int Value { get; set; }
+ [Projectable]
+ public int GetDouble()
+ {
+ return Value * 2;
+ }
+ }
+}
+");
+ var result = RunGenerator(compilation, new Dictionary
+ {
+ ["build_property.Projectables_AllowBlockBody"] = "true"
+ });
+
+ Assert.Empty(result.Diagnostics);
+ Assert.Single(result.GeneratedTrees);
+ }
+
+ [Fact]
+ public void GlobalAllowBlockBody_False_StillEmitsWarningForBlockBody()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class C {
+ public int Value { get; set; }
+ [Projectable]
+ public int GetDouble()
+ {
+ return Value * 2;
+ }
+ }
+}
+");
+ var result = RunGenerator(compilation, new Dictionary
+ {
+ ["build_property.Projectables_AllowBlockBody"] = "false"
+ });
+
+ var diagnostic = Assert.Single(result.Diagnostics);
+ Assert.Equal("EFP0001", diagnostic.Id);
+ }
+
+ [Fact]
+ public void AttributeAllowBlockBody_False_OverridesGlobalTrue()
+ {
+ // Global says true, but per-attribute explicitly opts out → warning expected.
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class C {
+ public int Value { get; set; }
+ [Projectable(AllowBlockBody = false)]
+ public int GetDouble()
+ {
+ return Value * 2;
+ }
+ }
+}
+");
+ var result = RunGenerator(compilation, new Dictionary
+ {
+ ["build_property.Projectables_AllowBlockBody"] = "true"
+ });
+
+ var diagnostic = Assert.Single(result.Diagnostics);
+ Assert.Equal("EFP0001", diagnostic.Id);
+ }
+
+ [Fact]
+ public void AttributeAllowBlockBody_True_OverridesGlobalFalse()
+ {
+ // Global says false (or not set), but per-attribute explicitly opts in → no warning.
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class C {
+ public int Value { get; set; }
+ [Projectable(AllowBlockBody = true)]
+ public int GetDouble()
+ {
+ return Value * 2;
+ }
+ }
+}
+");
+ var result = RunGenerator(compilation, new Dictionary
+ {
+ ["build_property.Projectables_AllowBlockBody"] = "false"
+ });
+
+ Assert.Empty(result.Diagnostics);
+ Assert.Single(result.GeneratedTrees);
+ }
+
+ // -------------------------------------------------------------------------
+ // ExpandEnumMethods
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void GlobalExpandEnumMethods_True_ExpandsEnumMethodsWithoutAttributeFlag()
+ {
+ var compilation = CreateCompilation(@"
+using System;
+using System.ComponentModel.DataAnnotations;
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ public enum MyEnum { A, B }
+ public static class MyEnumExtensions {
+ public static string GetName(this MyEnum value) => value.ToString();
+ }
+ public record Entity {
+ public MyEnum Status { get; set; }
+ [Projectable]
+ public string StatusName => Status.GetName();
+ }
+}
+");
+ var result = RunGenerator(compilation, new Dictionary
+ {
+ ["build_property.Projectables_ExpandEnumMethods"] = "true"
+ });
+
+ Assert.Empty(result.Diagnostics);
+ Assert.Single(result.GeneratedTrees);
+ // The generated tree should contain ternary expansion for each enum value.
+ var generated = result.GeneratedTrees[0].ToString();
+ Assert.Contains("MyEnum.A", generated);
+ Assert.Contains("MyEnum.B", generated);
+ }
+
+ [Fact]
+ public void AttributeExpandEnumMethods_False_OverridesGlobalTrue()
+ {
+ // Global sets true but attribute explicitly opts out → no expansion.
+ var compilation = CreateCompilation(@"
+using System;
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ public enum MyEnum { A, B }
+ public static class MyEnumExtensions {
+ public static string GetName(this MyEnum value) => value.ToString();
+ }
+ public record Entity {
+ public MyEnum Status { get; set; }
+ [Projectable(ExpandEnumMethods = false)]
+ public string StatusName => Status.GetName();
+ }
+}
+");
+ var result = RunGenerator(compilation, new Dictionary
+ {
+ ["build_property.Projectables_ExpandEnumMethods"] = "true"
+ });
+
+ Assert.Empty(result.Diagnostics);
+ Assert.Single(result.GeneratedTrees);
+ // Without expansion the ternary chain should NOT appear.
+ var generated = result.GeneratedTrees[0].ToString();
+ Assert.DoesNotContain("MyEnum.A ==", generated);
+ }
+
+ // -------------------------------------------------------------------------
+ // NullConditionalRewriteSupport
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void GlobalNullConditionalRewriteSupport_Rewrite_AllowsNullConditionals()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class Inner { public int Value { get; set; } }
+ class C {
+ public Inner? Inner { get; set; }
+ [Projectable]
+ public int? InnerValue => Inner?.Value;
+ }
+}
+");
+ var result = RunGenerator(compilation, new Dictionary
+ {
+ ["build_property.Projectables_NullConditionalRewriteSupport"] = "Rewrite"
+ });
+
+ Assert.Empty(result.Diagnostics);
+ Assert.Single(result.GeneratedTrees);
+ }
+
+ [Fact]
+ public void AttributeNullConditionalRewriteSupport_None_OverridesGlobalRewrite()
+ {
+ // Global says Rewrite, but attribute explicitly sets None → diagnostic expected.
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class Inner { public int Value { get; set; } }
+ class C {
+ public Inner? Inner { get; set; }
+ [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.None)]
+ public int? InnerValue => Inner?.Value;
+ }
+}
+");
+ var result = RunGenerator(compilation, new Dictionary
+ {
+ ["build_property.Projectables_NullConditionalRewriteSupport"] = "Rewrite"
+ });
+
+ Assert.NotEmpty(result.Diagnostics);
+ }
+
+ // -------------------------------------------------------------------------
+ // No global option set — hard-coded defaults apply (regression guard)
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void NoGlobalOptions_HardCodedDefaultsApply_BlockBodyStillWarns()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class C {
+ public int Value { get; set; }
+ [Projectable]
+ public int GetDouble()
+ {
+ return Value * 2;
+ }
+ }
+}
+");
+ // No global options passed — same as the existing test without options.
+ var result = RunGenerator(compilation, new Dictionary());
+
+ var diagnostic = Assert.Single(result.Diagnostics);
+ Assert.Equal("EFP0001", diagnostic.Id);
+ }
+
+ [Fact]
+ public void MalformedGlobalOption_IsTreatedAsNotSet()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class C {
+ public int Value { get; set; }
+ [Projectable]
+ public int GetDouble()
+ {
+ return Value * 2;
+ }
+ }
+}
+");
+ // Malformed value — should be silently ignored, falling back to hard-coded default.
+ var result = RunGenerator(compilation, new Dictionary
+ {
+ ["build_property.Projectables_AllowBlockBody"] = "not-a-bool"
+ });
+
+ var diagnostic = Assert.Single(result.Diagnostics);
+ Assert.Equal("EFP0001", diagnostic.Id);
+ }
+}
diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTestsBase.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTestsBase.cs
index bf6bd43..08912e5 100644
--- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTestsBase.cs
+++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTestsBase.cs
@@ -1,5 +1,6 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
@@ -158,6 +159,57 @@ protected TestGeneratorRunResult RunGenerator(Compilation compilation)
return result;
}
+ ///
+ /// Runs the generator with the supplied MSBuild global properties (simulating
+ /// CompilerVisibleProperty values read from the project file).
+ ///
+ protected TestGeneratorRunResult RunGenerator(Compilation compilation, IReadOnlyDictionary globalProperties)
+ {
+ _testOutputHelper.WriteLine("Running generator with global properties and updating compilation...");
+
+ var subject = new ProjectionExpressionGenerator();
+ var optionsProvider = new DictionaryAnalyzerConfigOptionsProvider(globalProperties);
+ GeneratorDriver driver = CSharpGeneratorDriver
+ .Create(subject)
+ .WithUpdatedAnalyzerConfigOptions(optionsProvider);
+ driver = driver
+ .RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _);
+
+ var rawResult = driver.GetRunResult();
+ var result = new TestGeneratorRunResult(rawResult);
+
+ LogGeneratorResult(result, outputCompilation);
+
+ return result;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers for injecting MSBuild global properties in tests
+ // ---------------------------------------------------------------------------
+
+ private sealed class DictionaryAnalyzerConfigOptions : AnalyzerConfigOptions
+ {
+ private readonly IReadOnlyDictionary _dict;
+ public DictionaryAnalyzerConfigOptions(IReadOnlyDictionary dict) => _dict = dict;
+ public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) =>
+ _dict.TryGetValue(key, out value);
+ }
+
+ private sealed class DictionaryAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider
+ {
+ private static readonly AnalyzerConfigOptions _empty =
+ new DictionaryAnalyzerConfigOptions(ImmutableDictionary.Empty);
+
+ public DictionaryAnalyzerConfigOptionsProvider(IReadOnlyDictionary globalOptions)
+ {
+ GlobalOptions = new DictionaryAnalyzerConfigOptions(globalOptions);
+ }
+
+ public override AnalyzerConfigOptions GlobalOptions { get; }
+ public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => _empty;
+ public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => _empty;
+ }
+
///
/// Creates a new generator driver and runs the generator on the given compilation,
/// returning both the driver and the run result. The driver can be passed to subsequent