diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 29de835e..2cc9e245 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -63,22 +63,32 @@ public void Initialize(IncrementalGeneratorInitializationContext context) transform: static (ctx, _) => GetDurableFunction(ctx)) .Where(static func => func != null)!; + // Get the project type configuration from MSBuild properties + IncrementalValueProvider projectTypeProvider = context.AnalyzerConfigOptionsProvider + .Select(static (provider, _) => + { + provider.GlobalOptions.TryGetValue("build_property.DurableTaskGeneratorProjectType", out string? projectType); + return projectType; + }); + // Collect all results and check if Durable Functions is referenced - IncrementalValueProvider<(Compilation, ImmutableArray, ImmutableArray, ImmutableArray)> compilationAndTasks = + IncrementalValueProvider<(Compilation, ImmutableArray, ImmutableArray, ImmutableArray, string?)> compilationAndTasks = durableTaskAttributes.Collect() .Combine(durableEventAttributes.Collect()) .Combine(durableFunctions.Collect()) .Combine(context.CompilationProvider) + .Combine(projectTypeProvider) // Roslyn's IncrementalValueProvider.Combine creates nested tuple pairs: ((Left, Right), Right) // After multiple .Combine() calls, we unpack the nested structure: - // x.Right = Compilation - // x.Left.Left.Left = DurableTaskAttributes (orchestrators, activities, entities) - // x.Left.Left.Right = DurableEventAttributes (events) - // x.Left.Right = DurableFunctions (Azure Functions metadata) - .Select((x, _) => (x.Right, x.Left.Left.Left, x.Left.Left.Right, x.Left.Right)); + // x.Right = projectType (string?) + // x.Left.Right = Compilation + // x.Left.Left.Left.Left = DurableTaskAttributes (orchestrators, activities, entities) + // x.Left.Left.Left.Right = DurableEventAttributes (events) + // x.Left.Left.Right = DurableFunctions (Azure Functions metadata) + .Select((x, _) => (x.Left.Right, x.Left.Left.Left.Left, x.Left.Left.Left.Right, x.Left.Left.Right, x.Right)); // Generate the source - context.RegisterSourceOutput(compilationAndTasks, static (spc, source) => Execute(spc, source.Item1, source.Item2, source.Item3, source.Item4)); + context.RegisterSourceOutput(compilationAndTasks, static (spc, source) => Execute(spc, source.Item1, source.Item2, source.Item3, source.Item4, source.Item5)); } static DurableTaskTypeInfo? GetDurableTaskTypeInfo(GeneratorSyntaxContext context) @@ -235,17 +245,16 @@ static void Execute( Compilation compilation, ImmutableArray allTasks, ImmutableArray allEvents, - ImmutableArray allFunctions) + ImmutableArray allFunctions, + string? projectType) { if (allTasks.IsDefaultOrEmpty && allEvents.IsDefaultOrEmpty && allFunctions.IsDefaultOrEmpty) { return; } - // This generator also supports Durable Functions for .NET isolated, but we only generate Functions-specific - // code if we find the Durable Functions extension listed in the set of referenced assembly names. - bool isDurableFunctions = compilation.ReferencedAssemblyNames.Any( - assembly => assembly.Name.Equals("Microsoft.Azure.Functions.Worker.Extensions.DurableTask", StringComparison.OrdinalIgnoreCase)); + // Determine if we should generate Durable Functions specific code + bool isDurableFunctions = DetermineIsDurableFunctions(compilation, allFunctions, projectType); // Separate tasks into orchestrators, activities, and entities List orchestrators = new(); @@ -381,6 +390,49 @@ public static class GeneratedDurableTaskExtensions context.AddSource("GeneratedDurableTaskExtensions.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); } + /// + /// Determines whether the current project should be treated as an Azure Functions-based Durable Functions project. + /// + /// The Roslyn compilation for the project, used to inspect referenced assemblies. + /// The collection of discovered Durable Functions triggers in the project. + /// + /// An optional project type hint. When set to "Functions" or "Standalone", this value takes precedence + /// over automatic detection. Any other value (including "Auto") falls back to auto-detection. + /// + /// + /// true if the project is determined to be a Durable Functions (Azure Functions) project; otherwise, false. + /// + static bool DetermineIsDurableFunctions(Compilation compilation, ImmutableArray allFunctions, string? projectType) + { + // Check if the user has explicitly configured the project type + if (!string.IsNullOrWhiteSpace(projectType)) + { + // Explicit configuration takes precedence + if (projectType!.Equals("Functions", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + else if (projectType.Equals("Standalone", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + // If "Auto" or unrecognized value, fall through to auto-detection + } + + // Auto-detect based on the presence of Azure Functions trigger attributes + // If we found any methods with OrchestrationTrigger, ActivityTrigger, or EntityTrigger attributes, + // then this is a Durable Functions project + if (!allFunctions.IsDefaultOrEmpty) + { + return true; + } + + // Fallback: check if Durable Functions assembly is referenced + // This handles edge cases where the project references the assembly but hasn't defined triggers yet + return compilation.ReferencedAssemblyNames.Any( + assembly => assembly.Name.Equals("Microsoft.Azure.Functions.Worker.Extensions.DurableTask", StringComparison.OrdinalIgnoreCase)); + } + static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator) { sourceBuilder.AppendLine($@" diff --git a/src/Generators/README.md b/src/Generators/README.md index 5c7f145b..9c253760 100644 --- a/src/Generators/README.md +++ b/src/Generators/README.md @@ -1,3 +1,63 @@ Source generators for `Microsoft.DurableTask` +## Overview + +The `Microsoft.DurableTask.Generators` package provides source generators that automatically generate type-safe extension methods for orchestrators and activities. The generator automatically detects whether you're using Azure Functions or the Durable Task Scheduler and generates appropriate code for your environment. + +## Configuration + +### Project Type Detection + +The generator uses intelligent automatic detection to determine the project type: + +1. **Primary Detection**: Checks for Azure Functions trigger attributes (`OrchestrationTrigger`, `ActivityTrigger`, `EntityTrigger`) in your code + - If any methods use these trigger attributes, it generates Azure Functions-specific code + - Otherwise, it generates standalone Durable Task Worker code + +2. **Fallback Detection**: If no trigger attributes are found, checks if `Microsoft.Azure.Functions.Worker.Extensions.DurableTask` is referenced + - This handles projects that reference the Functions package but haven't defined triggers yet + +This automatic detection solves the common issue where transitive dependencies on Functions packages would incorrectly trigger Functions mode even when not using Azure Functions. + +### Explicit Project Type Configuration (Optional) + +In rare scenarios where you need to override the automatic detection, you can explicitly configure the project type using the `DurableTaskGeneratorProjectType` MSBuild property in your `.csproj` file: + +```xml + + Standalone + +``` + +#### Supported Values + +- `Auto` (default): Automatically detects project type using the intelligent detection described above +- `Functions`: Forces generation of Azure Functions-specific code +- `Standalone`: Forces generation of standalone Durable Task Worker code (includes `AddAllGeneratedTasks` method) + +#### Example: Force Standalone Mode + +```xml + + + net8.0 + Standalone + + + + + + + +``` + +With standalone mode, the generator produces the `AddAllGeneratedTasks` extension method for worker registration: + +```csharp +builder.Services.AddDurableTaskWorker(builder => +{ + builder.AddTasks(r => r.AddAllGeneratedTasks()); +}); +``` + For more information, see https://github.com/microsoft/durabletask-dotnet \ No newline at end of file diff --git a/test/Generators.Tests/ProjectTypeConfigurationTests.cs b/test/Generators.Tests/ProjectTypeConfigurationTests.cs new file mode 100644 index 00000000..bcac0a3d --- /dev/null +++ b/test/Generators.Tests/ProjectTypeConfigurationTests.cs @@ -0,0 +1,514 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Generators.Tests.Utils; + +namespace Microsoft.DurableTask.Generators.Tests; + +public class ProjectTypeConfigurationTests +{ + const string GeneratedClassName = "GeneratedDurableTaskExtensions"; + const string GeneratedFileName = $"{GeneratedClassName}.cs"; + + [Fact] + public Task ExplicitStandaloneMode_WithFunctionsReference_GeneratesStandaloneCode() + { + // Test that explicit "Standalone" configuration overrides the Functions reference + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(nameof(MyActivity))] +class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + // Even though we have Functions references, we should get Standalone code (AddAllGeneratedTasks) + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""MyActivity"", input, options); +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddActivity(); + return builder; +}", + isDurableFunctions: false); + + // Pass isDurableFunctions: true to add Functions references, but projectType: "Standalone" to override + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true, + projectType: "Standalone"); + } + + [Fact] + public Task ExplicitStandaloneMode_WithFunctionsReference_OrchestratorTest() + { + // Test that explicit "Standalone" configuration overrides the Functions reference + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(nameof(MyOrchestrator))] +class MyOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext ctx, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewMyOrchestratorInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallMyOrchestratorAsync( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""MyOrchestrator"", input, options); +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + return builder; +}", + isDurableFunctions: false); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true, + projectType: "Standalone"); + } + + [Fact] + public Task ExplicitFunctionsMode_WithoutFunctionsReference_GeneratesFunctionsCode() + { + // Test that explicit "Functions" configuration generates Functions code + // even without Functions references + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(nameof(MyActivity))] +class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + // With explicit "Functions", we should get Functions code (Activity trigger function) + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""MyActivity"", input, options); +} + +[Function(nameof(MyActivity))] +public static async Task MyActivity([ActivityTrigger] int input, string instanceId, FunctionContext executionContext) +{ + ITaskActivity activity = ActivatorUtilities.GetServiceOrCreateInstance(executionContext.InstanceServices); + TaskActivityContext context = new GeneratedActivityContext(""MyActivity"", instanceId); + object? result = await activity.RunAsync(context, input); + return (string)result!; +} + +sealed class GeneratedActivityContext : TaskActivityContext +{ + public GeneratedActivityContext(TaskName name, string instanceId) + { + this.Name = name; + this.InstanceId = instanceId; + } + + public override TaskName Name { get; } + + public override string InstanceId { get; } +}", + isDurableFunctions: true); + + // Pass isDurableFunctions: true for expected output, but don't add references + // Instead rely on projectType: "Functions" to force Functions mode + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true, + projectType: "Functions"); + } + + [Fact] + public Task ExplicitFunctionsMode_OrchestratorTest() + { + // Test that "Functions" mode generates orchestrator Functions code + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(nameof(MyOrchestrator))] +class MyOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext ctx, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +static readonly ITaskOrchestrator singletonMyOrchestrator = new MyOrchestrator(); + +[Function(nameof(MyOrchestrator))] +public static Task MyOrchestrator([OrchestrationTrigger] TaskOrchestrationContext context) +{ + return singletonMyOrchestrator.RunAsync(context, context.GetInput()) + .ContinueWith(t => (string)(t.Result ?? default(string)!), TaskContinuationOptions.ExecuteSynchronously); +} + +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewMyOrchestratorInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallMyOrchestratorAsync( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""MyOrchestrator"", input, options); +}", + isDurableFunctions: true); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true, + projectType: "Functions"); + } + + [Fact] + public Task AutoMode_WithFunctionsReference_GeneratesFunctionsCode() + { + // Test that "Auto" mode falls back to auto-detection + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(nameof(MyActivity))] +class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""MyActivity"", input, options); +} + +[Function(nameof(MyActivity))] +public static async Task MyActivity([ActivityTrigger] int input, string instanceId, FunctionContext executionContext) +{ + ITaskActivity activity = ActivatorUtilities.GetServiceOrCreateInstance(executionContext.InstanceServices); + TaskActivityContext context = new GeneratedActivityContext(""MyActivity"", instanceId); + object? result = await activity.RunAsync(context, input); + return (string)result!; +} + +sealed class GeneratedActivityContext : TaskActivityContext +{ + public GeneratedActivityContext(TaskName name, string instanceId) + { + this.Name = name; + this.InstanceId = instanceId; + } + + public override TaskName Name { get; } + + public override string InstanceId { get; } +}", + isDurableFunctions: true); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true, + projectType: "Auto"); + } + + [Fact] + public Task AutoMode_WithoutFunctionsReference_GeneratesStandaloneCode() + { + // Test that "Auto" mode falls back to auto-detection + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(nameof(MyActivity))] +class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""MyActivity"", input, options); +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddActivity(); + return builder; +}", + isDurableFunctions: false); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false, + projectType: "Auto"); + } + + [Fact] + public Task UnrecognizedMode_WithFunctionsReference_FallsBackToAutoDetection() + { + // Test that unrecognized values fall back to auto-detection + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(nameof(MyActivity))] +class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""MyActivity"", input, options); +} + +[Function(nameof(MyActivity))] +public static async Task MyActivity([ActivityTrigger] int input, string instanceId, FunctionContext executionContext) +{ + ITaskActivity activity = ActivatorUtilities.GetServiceOrCreateInstance(executionContext.InstanceServices); + TaskActivityContext context = new GeneratedActivityContext(""MyActivity"", instanceId); + object? result = await activity.RunAsync(context, input); + return (string)result!; +} + +sealed class GeneratedActivityContext : TaskActivityContext +{ + public GeneratedActivityContext(TaskName name, string instanceId) + { + this.Name = name; + this.InstanceId = instanceId; + } + + public override TaskName Name { get; } + + public override string InstanceId { get; } +}", + isDurableFunctions: true); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true, + projectType: "UnrecognizedValue"); + } + + [Fact] + public Task NullProjectType_WithoutFunctionsReference_GeneratesStandaloneCode() + { + // Test that null projectType (default) falls back to auto-detection + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(nameof(MyActivity))] +class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""MyActivity"", input, options); +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddActivity(); + return builder; +}", + isDurableFunctions: false); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false, + projectType: null); + } + + [Fact] + public Task NullProjectType_WithFunctionsReference_GeneratesFunctionsCode() + { + // Test that null projectType (default) with Functions reference falls back to auto-detection + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(nameof(MyActivity))] +class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""MyActivity"", input, options); +} + +[Function(nameof(MyActivity))] +public static async Task MyActivity([ActivityTrigger] int input, string instanceId, FunctionContext executionContext) +{ + ITaskActivity activity = ActivatorUtilities.GetServiceOrCreateInstance(executionContext.InstanceServices); + TaskActivityContext context = new GeneratedActivityContext(""MyActivity"", instanceId); + object? result = await activity.RunAsync(context, input); + return (string)result!; +} + +sealed class GeneratedActivityContext : TaskActivityContext +{ + public GeneratedActivityContext(TaskName name, string instanceId) + { + this.Name = name; + this.InstanceId = instanceId; + } + + public override TaskName Name { get; } + + public override string InstanceId { get; } +}", + isDurableFunctions: true); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true, + projectType: null); + } + + [Fact] + public Task AutoDetect_WithTriggerAttributes_GeneratesFunctionsCode() + { + // Test that presence of Azure Functions trigger attributes auto-detects as Functions project + // This validates the allFunctions.IsDefaultOrEmpty check in DetermineIsDurableFunctions + string code = @" +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; + +public class MyFunctions +{ + [Function(nameof(MyActivity))] + public int MyActivity([ActivityTrigger] int input) => input; +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""MyActivity"", input, options); +}", + isDurableFunctions: true); + + // No explicit projectType - should auto-detect based on [ActivityTrigger] attribute + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true, + projectType: null); + } +} diff --git a/test/Generators.Tests/Utils/TestHelpers.cs b/test/Generators.Tests/Utils/TestHelpers.cs index 960a525f..92f3db86 100644 --- a/test/Generators.Tests/Utils/TestHelpers.cs +++ b/test/Generators.Tests/Utils/TestHelpers.cs @@ -17,6 +17,21 @@ public static Task RunTestAsync( string inputSource, string expectedOutputSource, bool isDurableFunctions) where TSourceGenerator : IIncrementalGenerator, new() + { + return RunTestAsync( + expectedFileName, + inputSource, + expectedOutputSource, + isDurableFunctions, + projectType: null); + } + + public static Task RunTestAsync( + string expectedFileName, + string inputSource, + string expectedOutputSource, + bool isDurableFunctions, + string? projectType) where TSourceGenerator : IIncrementalGenerator, new() { CSharpSourceGeneratorVerifier.Test test = new() { @@ -53,6 +68,16 @@ public static Task RunTestAsync( test.TestState.AdditionalReferences.Add(dependencyInjection); } + // Set the project type configuration if specified + if (projectType != null) + { + test.TestState.AnalyzerConfigFiles.Add( + ("/.globalconfig", $""" + is_global = true + build_property.DurableTaskGeneratorProjectType = {projectType} + """)); + } + return test.RunAsync(); }