From d5eb0e8ea508c1248bf985d6cd0e5668d1db64c5 Mon Sep 17 00:00:00 2001 From: wangbill Date: Sat, 14 Mar 2026 22:52:12 -0700 Subject: [PATCH 1/4] Generate extension methods in task namespace instead of Microsoft.DurableTask Fixes #428, Fixes #659 Extension methods generated by the DurableTask source generator are now placed in the same namespace as the orchestrator/activity/entity class, instead of always being in Microsoft.DurableTask. This fixes IDE clutter and resolves the 'multiple GeneratedDurableTaskExtensions' conflict when multiple projects reference each other. Changes: - DurableTaskSourceGenerator groups tasks/events by namespace and generates a separate GeneratedDurableTaskExtensions class for each namespace - Types in the same namespace use simplified names (e.g., MyClass instead of MyNS.MyClass) in generated see-cref and method signatures - The AddAllGeneratedTasks() registration method stays in Microsoft.DurableTask - Tasks in the global namespace fall back to Microsoft.DurableTask for backward compatibility - Added multi-namespace test and NamespaceGenerationSample - All 72 generator tests pass --- Microsoft.DurableTask.sln | 28 ++ .../NamespaceGenerationSample.csproj | 26 ++ samples/NamespaceGenerationSample/Program.cs | 33 ++ samples/NamespaceGenerationSample/README.md | 67 +++ samples/NamespaceGenerationSample/Tasks.cs | 51 +++ src/Generators/DurableTaskSourceGenerator.cs | 384 +++++++++++++----- test/Generators.Tests/AzureFunctionsTests.cs | 36 +- .../Generators.Tests/ClassBasedSyntaxTests.cs | 103 ++++- test/Generators.Tests/Utils/TestHelpers.cs | 53 ++- 9 files changed, 661 insertions(+), 120 deletions(-) create mode 100644 samples/NamespaceGenerationSample/NamespaceGenerationSample.csproj create mode 100644 samples/NamespaceGenerationSample/Program.cs create mode 100644 samples/NamespaceGenerationSample/README.md create mode 100644 samples/NamespaceGenerationSample/Tasks.cs diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index ea7d17973..cfb416387 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -111,18 +111,33 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExportHistory.Tests", "test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DistributedTracingSample", "samples\DistributedTracingSample\DistributedTracingSample.csproj", "{4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NamespaceGenerationSample", "samples\NamespaceGenerationSample\NamespaceGenerationSample.csproj", "{5A69FD28-D814-490E-A76B-B0A5F88C25B2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x64.ActiveCfg = Debug|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x64.Build.0 = Debug|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x86.ActiveCfg = Debug|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x86.Build.0 = Debug|Any CPU {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|Any CPU.Build.0 = Release|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x64.ActiveCfg = Release|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x64.Build.0 = Release|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x86.ActiveCfg = Release|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x86.Build.0 = Release|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x64.ActiveCfg = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x64.Build.0 = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x86.ActiveCfg = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x86.Build.0 = Debug|Any CPU @@ -660,6 +675,18 @@ Global {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8}.Release|x64.Build.0 = Release|Any CPU {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8}.Release|x86.ActiveCfg = Release|Any CPU {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8}.Release|x86.Build.0 = Release|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Debug|x64.ActiveCfg = Debug|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Debug|x64.Build.0 = Debug|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Debug|x86.ActiveCfg = Debug|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Debug|x86.Build.0 = Debug|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Release|Any CPU.Build.0 = Release|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Release|x64.ActiveCfg = Release|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Release|x64.Build.0 = Release|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Release|x86.ActiveCfg = Release|Any CPU + {5A69FD28-D814-490E-A76B-B0A5F88C25B2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -715,6 +742,7 @@ Global {354CE69B-78DB-9B29-C67E-0DBB862C7A65} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B} {05C9EBA6-7221-D458-47D6-DA457C2F893B} = {E5637F81-2FB9-4CD7-900D-455363B142A7} {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {5A69FD28-D814-490E-A76B-B0A5F88C25B2} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/samples/NamespaceGenerationSample/NamespaceGenerationSample.csproj b/samples/NamespaceGenerationSample/NamespaceGenerationSample.csproj new file mode 100644 index 000000000..8a1c8798f --- /dev/null +++ b/samples/NamespaceGenerationSample/NamespaceGenerationSample.csproj @@ -0,0 +1,26 @@ + + + + Exe + net8.0;net10.0 + enable + + + + + + + + + + + + + + + + + + diff --git a/samples/NamespaceGenerationSample/Program.cs b/samples/NamespaceGenerationSample/Program.cs new file mode 100644 index 000000000..30bb3095d --- /dev/null +++ b/samples/NamespaceGenerationSample/Program.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates how the source generator places extension methods into the same +// namespace as the orchestrator/activity classes, keeping IDE suggestions clean and scoped. +// Tasks in different namespaces get their own GeneratedDurableTaskExtensions class. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Testing; + +// The generated AddAllGeneratedTasks() method is always in Microsoft.DurableTask namespace. +// Extension methods like ScheduleNewApprovalOrchestratorInstanceAsync() are in the +// NamespaceGenerationSample.Approvals namespace, and CallRegistrationActivityAsync() is in +// NamespaceGenerationSample.Registrations namespace. +using NamespaceGenerationSample.Approvals; + +// Start the in-process test host (no external services needed) +// The generated AddAllGeneratedTasks() registers all orchestrators and activities +await using DurableTaskTestHost testHost = await DurableTaskTestHost.StartAsync( + registry => registry.AddAllGeneratedTasks()); + +DurableTaskClient client = testHost.Client; + +// Use the generated typed extension method (in the Approvals namespace) +string instanceId = await client.ScheduleNewApprovalOrchestratorInstanceAsync("request-123"); +Console.WriteLine($"Started approval orchestration: {instanceId}"); + +// Wait for completion +OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true); +Console.WriteLine($"Orchestration completed with status: {result?.RuntimeStatus}"); +Console.WriteLine($"Output: {result?.ReadOutputAs()}"); diff --git a/samples/NamespaceGenerationSample/README.md b/samples/NamespaceGenerationSample/README.md new file mode 100644 index 000000000..2e469c8e2 --- /dev/null +++ b/samples/NamespaceGenerationSample/README.md @@ -0,0 +1,67 @@ +# Namespace Generation Sample + +This sample demonstrates how the DurableTask source generator places extension methods into the same namespace as the orchestrator/activity classes. + +## What it shows + +When using the `[DurableTask]` attribute on classes in different namespaces, the source generator will: + +1. Place extension methods (e.g., `ScheduleNewApprovalOrchestratorInstanceAsync()`, `CallRegistrationActivityAsync()`) into the **same namespace** as the task class +2. Keep the `AddAllGeneratedTasks()` registration method in the `Microsoft.DurableTask` namespace +3. Simplify type names within the same namespace (e.g., `MyClass` instead of `MyNS.MyClass`) + +This results in cleaner IDE suggestions — you only see extension methods for tasks that are imported via `using` statements. + +## Project structure + +- `Tasks.cs` - Defines an orchestrator in `NamespaceGenerationSample.Approvals` and an activity in `NamespaceGenerationSample.Registrations` +- `Program.cs` - Shows how to use the generated extension methods with explicit `using` statements + +## How to run + +1. Start the DTS emulator: + ```bash + docker run --name durabletask-emulator -d -p 8080:8080 mcr.microsoft.com/durabletask/emulator:latest + ``` + +2. Run the sample: + ```bash + dotnet run + ``` + +## Generated code + +The source generator produces code like this: + +```csharp +// Extension methods in the task's namespace +namespace NamespaceGenerationSample.Approvals +{ + public static class GeneratedDurableTaskExtensions + { + public static Task ScheduleNewApprovalOrchestratorInstanceAsync( + this IOrchestrationSubmitter client, string input, StartOrchestrationOptions? options = null) { ... } + + public static Task CallApprovalOrchestratorAsync( + this TaskOrchestrationContext context, string input, TaskOptions? options = null) { ... } + } +} + +namespace NamespaceGenerationSample.Registrations +{ + public static class GeneratedDurableTaskExtensions + { + public static Task CallRegistrationActivityAsync( + this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) { ... } + } +} + +// Registration method stays in Microsoft.DurableTask +namespace Microsoft.DurableTask +{ + public static class GeneratedDurableTaskExtensions + { + internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) { ... } + } +} +``` diff --git a/samples/NamespaceGenerationSample/Tasks.cs b/samples/NamespaceGenerationSample/Tasks.cs new file mode 100644 index 000000000..7ca435991 --- /dev/null +++ b/samples/NamespaceGenerationSample/Tasks.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Tasks are organized into separate namespaces. The source generator will place +// each task's extension methods into its own namespace instead of Microsoft.DurableTask. + +using Microsoft.DurableTask; +using NamespaceGenerationSample.Registrations; + +// Approval-related tasks live in their own namespace. +// The generated CallRegistrationActivityAsync() and ScheduleNewApprovalOrchestratorInstanceAsync() +// extension methods will be generated in this namespace. +namespace NamespaceGenerationSample.Approvals +{ + /// + /// An orchestrator that runs an approval workflow. + /// The generated extension method ScheduleNewApprovalOrchestratorInstanceAsync() + /// will be in the NamespaceGenerationSample.Approvals namespace. + /// + [DurableTask(nameof(ApprovalOrchestrator))] + public class ApprovalOrchestrator : TaskOrchestrator + { + public override async Task RunAsync(TaskOrchestrationContext context, string requestId) + { + // Use the generated typed extension method (in the Registrations namespace) + // By importing the Registrations namespace, we get access to CallRegistrationActivityAsync(). + string registrationResult = await context.CallRegistrationActivityAsync(42); + + return $"Approved request '{requestId}' with registration: {registrationResult}"; + } + } +} + +// Registration-related tasks in a separate namespace. +// The generated CallRegistrationActivityAsync() extension method will be in this namespace. +namespace NamespaceGenerationSample.Registrations +{ + /// + /// An activity that performs registration. + /// The generated extension method CallRegistrationActivityAsync() + /// will be in the NamespaceGenerationSample.Registrations namespace. + /// + [DurableTask(nameof(RegistrationActivity))] + public class RegistrationActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, int registrationId) + { + return Task.FromResult($"Registration-{registrationId} completed"); + } + } +} diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 0116e568c..3b0ba3d10 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -145,6 +145,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } string className = classType.ToDisplayString(); + string classNamespace = classType.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : classType.ContainingNamespace.ToDisplayString(); INamedTypeSymbol? taskType = null; DurableTaskKind kind = DurableTaskKind.Orchestrator; @@ -211,7 +214,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) taskNameLocation = expression.GetLocation(); } - return new DurableTaskTypeInfo(className, taskName, inputType, outputType, kind, taskNameLocation); + return new DurableTaskTypeInfo(className, classNamespace, taskName, inputType, outputType, kind, taskNameLocation); } static DurableEventTypeInfo? GetDurableEventTypeInfo(GeneratorSyntaxContext context) @@ -256,7 +259,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } } - return new DurableEventTypeInfo(eventName, eventType, eventNameLocation); + string eventNamespace = eventType.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : eventType.ContainingNamespace.ToDisplayString(); + + return new DurableEventTypeInfo(eventName, eventNamespace, eventType, eventNameLocation); } static DurableFunction? GetDurableFunction(GeneratorSyntaxContext context) @@ -358,6 +365,56 @@ static void Execute( return; } + // Group tasks by namespace. Tasks in the global namespace use "Microsoft.DurableTask" for backward compatibility. + Dictionary> tasksByNamespace = new(); + foreach (DurableTaskTypeInfo task in orchestrators.Concat(activities).Concat(entities)) + { + string targetNamespace = string.IsNullOrEmpty(task.Namespace) ? "Microsoft.DurableTask" : task.Namespace; + if (!tasksByNamespace.TryGetValue(targetNamespace, out List? list)) + { + list = new List(); + tasksByNamespace[targetNamespace] = list; + } + + list.Add(task); + } + + // Group events by namespace. Events in the global namespace use "Microsoft.DurableTask". + Dictionary> eventsByNamespace = new(); + foreach (DurableEventTypeInfo eventInfo in validEvents) + { + string targetNamespace = string.IsNullOrEmpty(eventInfo.Namespace) ? "Microsoft.DurableTask" : eventInfo.Namespace; + if (!eventsByNamespace.TryGetValue(targetNamespace, out List? list)) + { + list = new List(); + eventsByNamespace[targetNamespace] = list; + } + + list.Add(eventInfo); + } + + // Collect all distinct namespaces + HashSet allNamespaces = new(tasksByNamespace.Keys); + foreach (string ns in eventsByNamespace.Keys) + { + allNamespaces.Add(ns); + } + + // Activity function triggers from DurableFunction go into Microsoft.DurableTask namespace + IEnumerable activityTriggers = allFunctions.Where( + df => df.Kind == DurableFunctionKind.Activity); + if (activityTriggers.Any()) + { + allNamespaces.Add("Microsoft.DurableTask"); + } + + // Registration method always goes in Microsoft.DurableTask + bool needsRegistrationMethod = !isDurableFunctions && (orchestrators.Count > 0 || activities.Count > 0 || entities.Count > 0); + if (needsRegistrationMethod) + { + allNamespaces.Add("Microsoft.DurableTask"); + } + StringBuilder sourceBuilder = new(capacity: found * 1024); sourceBuilder.Append(@"// #nullable enable @@ -365,6 +422,7 @@ static void Execute( using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.DurableTask; using Microsoft.DurableTask.Internal;"); if (isDurableFunctions) @@ -374,93 +432,120 @@ static void Execute( using Microsoft.Extensions.DependencyInjection;"); } - sourceBuilder.Append(@" + // Sort namespaces so "Microsoft.DurableTask" comes last for consistent output + List sortedNamespaces = allNamespaces + .OrderBy(ns => ns == "Microsoft.DurableTask" ? 1 : 0) + .ThenBy(ns => ns, StringComparer.Ordinal) + .ToList(); -namespace Microsoft.DurableTask -{ - public static class GeneratedDurableTaskExtensions - {"); - if (isDurableFunctions) + foreach (string targetNamespace in sortedNamespaces) { - // Generate a singleton orchestrator object instance that can be reused for all invocations. - foreach (DurableTaskTypeInfo orchestrator in orchestrators) + tasksByNamespace.TryGetValue(targetNamespace, out List? tasksInNamespace); + eventsByNamespace.TryGetValue(targetNamespace, out List? eventsInNamespace); + + List orchestratorsInNs = tasksInNamespace?.Where(t => t.IsOrchestrator).ToList() ?? new(); + List activitiesInNs = tasksInNamespace?.Where(t => t.IsActivity).ToList() ?? new(); + List entitiesInNs = tasksInNamespace?.Where(t => t.IsEntity).ToList() ?? new(); + bool isMicrosoftDurableTask = targetNamespace == "Microsoft.DurableTask"; + + // Check if there's any content to generate for this namespace + bool hasOrchestratorMethods = orchestratorsInNs.Count > 0; + bool hasActivityMethods = activitiesInNs.Count > 0; + bool hasEntityFunctions = isDurableFunctions && entitiesInNs.Count > 0; + bool hasActivityTriggers = isMicrosoftDurableTask && activityTriggers.Any(); + bool hasEvents = eventsInNamespace != null && eventsInNamespace.Count > 0; + bool hasRegistration = isMicrosoftDurableTask && needsRegistrationMethod; + + if (!hasOrchestratorMethods && !hasActivityMethods && !hasEntityFunctions + && !hasActivityTriggers && !hasEvents && !hasRegistration) { - sourceBuilder.AppendLine($@" - static readonly ITaskOrchestrator singleton{orchestrator.TaskName} = new {orchestrator.TypeName}();"); + continue; } - } - foreach (DurableTaskTypeInfo orchestrator in orchestrators) - { + sourceBuilder.Append($@" + +namespace {targetNamespace} +{{ + public static class GeneratedDurableTaskExtensions + {{"); if (isDurableFunctions) { - // Generate the function definition required to trigger orchestrators in Azure Functions - AddOrchestratorFunctionDeclaration(sourceBuilder, orchestrator); + // Generate a singleton orchestrator object instance that can be reused for all invocations. + foreach (DurableTaskTypeInfo orchestrator in orchestratorsInNs) + { + sourceBuilder.AppendLine($@" + static readonly ITaskOrchestrator singleton{orchestrator.TaskName} = new {SimplifyTypeName(orchestrator.TypeName, targetNamespace)}();"); + } } - AddOrchestratorCallMethod(sourceBuilder, orchestrator); - AddSubOrchestratorCallMethod(sourceBuilder, orchestrator); - } + foreach (DurableTaskTypeInfo orchestrator in orchestratorsInNs) + { + if (isDurableFunctions) + { + AddOrchestratorFunctionDeclaration(sourceBuilder, orchestrator, targetNamespace); + } - foreach (DurableTaskTypeInfo activity in activities) - { - AddActivityCallMethod(sourceBuilder, activity); + AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace); + AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace); + } - if (isDurableFunctions) + foreach (DurableTaskTypeInfo activity in activitiesInNs) { - // Generate the function definition required to trigger activities in Azure Functions - AddActivityFunctionDeclaration(sourceBuilder, activity); + AddActivityCallMethod(sourceBuilder, activity, targetNamespace); + + if (isDurableFunctions) + { + AddActivityFunctionDeclaration(sourceBuilder, activity, targetNamespace); + } } - } - foreach (DurableTaskTypeInfo entity in entities) - { - if (isDurableFunctions) + foreach (DurableTaskTypeInfo entity in entitiesInNs) { - // Generate the function definition required to trigger entities in Azure Functions - AddEntityFunctionDeclaration(sourceBuilder, entity); + if (isDurableFunctions) + { + AddEntityFunctionDeclaration(sourceBuilder, entity, targetNamespace); + } } - } - // Activity function triggers are supported for code-gen (but not orchestration triggers) - IEnumerable activityTriggers = allFunctions.Where( - df => df.Kind == DurableFunctionKind.Activity); - foreach (DurableFunction function in activityTriggers) - { - AddActivityCallMethod(sourceBuilder, function); - } + // Activity function triggers from DurableFunction always go in Microsoft.DurableTask + if (isMicrosoftDurableTask) + { + foreach (DurableFunction function in activityTriggers) + { + AddActivityCallMethod(sourceBuilder, function); + } + } - // Generate WaitFor{EventName}Async methods for each event type - foreach (DurableEventTypeInfo eventInfo in validEvents) - { - AddEventWaitMethod(sourceBuilder, eventInfo); - AddEventSendMethod(sourceBuilder, eventInfo); - } + // Generate WaitFor/Send methods for events in this namespace + if (eventsInNamespace != null) + { + foreach (DurableEventTypeInfo eventInfo in eventsInNamespace) + { + AddEventWaitMethod(sourceBuilder, eventInfo, targetNamespace); + AddEventSendMethod(sourceBuilder, eventInfo, targetNamespace); + } + } - if (isDurableFunctions) - { - if (activities.Count > 0) + if (isDurableFunctions) { - // Functions-specific helper class, which is only needed when - // using the class-based syntax. - AddGeneratedActivityContextClass(sourceBuilder); + if (activitiesInNs.Count > 0) + { + AddGeneratedActivityContextClass(sourceBuilder); + } } - } - else - { - // ASP.NET Core-specific service registration methods - // Only generate if there are actually tasks to register - if (orchestrators.Count > 0 || activities.Count > 0 || entities.Count > 0) + + // Registration method goes in Microsoft.DurableTask namespace only + if (isMicrosoftDurableTask && needsRegistrationMethod) { AddRegistrationMethodForAllTasks( sourceBuilder, orchestrators, activities, - entities); + entities); } - } - sourceBuilder.AppendLine(" }").AppendLine("}"); + sourceBuilder.AppendLine(" }").AppendLine("}"); + } context.AddSource("GeneratedDurableTaskExtensions.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); } @@ -508,55 +593,107 @@ static bool DetermineIsDurableFunctions(Compilation compilation, ImmutableArray< assembly => assembly.Name.Equals("Microsoft.Azure.Functions.Worker.Extensions.DurableTask", StringComparison.OrdinalIgnoreCase)); } - static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator) + /// + /// Simplifies a fully qualified type name relative to a target namespace. + /// Types in the same namespace are returned without the namespace prefix. + /// + static string SimplifyTypeName(string fullyQualifiedTypeName, string targetNamespace) + { + if (string.IsNullOrEmpty(targetNamespace)) + { + return fullyQualifiedTypeName; + } + + if (fullyQualifiedTypeName.StartsWith(targetNamespace + ".", StringComparison.Ordinal)) + { + return fullyQualifiedTypeName.Substring(targetNamespace.Length + 1); + } + + return fullyQualifiedTypeName; + } + + static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) { + string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); + string outputType = orchestrator.GetOutputTypeForNamespace(targetNamespace); + sourceBuilder.AppendLine($@" [Function(nameof({orchestrator.TaskName}))] - public static Task<{orchestrator.OutputType}> {orchestrator.TaskName}([OrchestrationTrigger] TaskOrchestrationContext context) + public static Task<{outputType}> {orchestrator.TaskName}([OrchestrationTrigger] TaskOrchestrationContext context) {{ - return singleton{orchestrator.TaskName}.RunAsync(context, context.GetInput<{orchestrator.InputType}>()) - .ContinueWith(t => ({orchestrator.OutputType})(t.Result ?? default({orchestrator.OutputType})!), TaskContinuationOptions.ExecuteSynchronously); + return singleton{orchestrator.TaskName}.RunAsync(context, context.GetInput<{inputType}>()) + .ContinueWith(t => ({outputType})(t.Result ?? default({outputType})!), TaskContinuationOptions.ExecuteSynchronously); }}"); } - static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator) + static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) { + string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); + string outputType = orchestrator.GetOutputTypeForNamespace(targetNamespace); + string inputParameter = inputType + " input"; + if (inputType.EndsWith("?", StringComparison.Ordinal)) + { + inputParameter += " = default"; + } + + string simplifiedTypeName = SimplifyTypeName(orchestrator.TypeName, targetNamespace); + sourceBuilder.AppendLine($@" /// - /// Schedules a new instance of the orchestrator. + /// Schedules a new instance of the orchestrator. /// /// public static Task ScheduleNew{orchestrator.TaskName}InstanceAsync( - this IOrchestrationSubmitter client, {orchestrator.InputParameter}, StartOrchestrationOptions? options = null) + this IOrchestrationSubmitter client, {inputParameter}, StartOrchestrationOptions? options = null) {{ return client.ScheduleNewOrchestrationInstanceAsync(""{orchestrator.TaskName}"", input, options); }}"); } - static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator) + static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) { + string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); + string outputType = orchestrator.GetOutputTypeForNamespace(targetNamespace); + string inputParameter = inputType + " input"; + if (inputType.EndsWith("?", StringComparison.Ordinal)) + { + inputParameter += " = default"; + } + + string simplifiedTypeName = SimplifyTypeName(orchestrator.TypeName, targetNamespace); + sourceBuilder.AppendLine($@" /// - /// Calls the sub-orchestrator. + /// Calls the sub-orchestrator. /// /// - public static Task<{orchestrator.OutputType}> Call{orchestrator.TaskName}Async( - this TaskOrchestrationContext context, {orchestrator.InputParameter}, TaskOptions? options = null) + public static Task<{outputType}> Call{orchestrator.TaskName}Async( + this TaskOrchestrationContext context, {inputParameter}, TaskOptions? options = null) {{ - return context.CallSubOrchestratorAsync<{orchestrator.OutputType}>(""{orchestrator.TaskName}"", input, options); + return context.CallSubOrchestratorAsync<{outputType}>(""{orchestrator.TaskName}"", input, options); }}"); } - static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity) + static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace) { + string inputType = activity.GetInputTypeForNamespace(targetNamespace); + string outputType = activity.GetOutputTypeForNamespace(targetNamespace); + string inputParameter = inputType + " input"; + if (inputType.EndsWith("?", StringComparison.Ordinal)) + { + inputParameter += " = default"; + } + + string simplifiedTypeName = SimplifyTypeName(activity.TypeName, targetNamespace); + sourceBuilder.AppendLine($@" /// - /// Calls the activity. + /// Calls the activity. /// /// - public static Task<{activity.OutputType}> Call{activity.TaskName}Async(this TaskOrchestrationContext ctx, {activity.InputParameter}, TaskOptions? options = null) + public static Task<{outputType}> Call{activity.TaskName}Async(this TaskOrchestrationContext ctx, {inputParameter}, TaskOptions? options = null) {{ - return ctx.CallActivityAsync<{activity.OutputType}>(""{activity.TaskName}"", input, options); + return ctx.CallActivityAsync<{outputType}>(""{activity.TaskName}"", input, options); }}"); } @@ -588,55 +725,70 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableFunction a } } - static void AddEventWaitMethod(StringBuilder sourceBuilder, DurableEventTypeInfo eventInfo) + static void AddEventWaitMethod(StringBuilder sourceBuilder, DurableEventTypeInfo eventInfo, string targetNamespace) { + string typeName = SimplifyTypeName(eventInfo.TypeName, targetNamespace); + sourceBuilder.AppendLine($@" /// - /// Waits for an external event of type . + /// Waits for an external event of type . /// /// - public static Task<{eventInfo.TypeName}> WaitFor{eventInfo.EventName}Async(this TaskOrchestrationContext context, CancellationToken cancellationToken = default) + public static Task<{typeName}> WaitFor{eventInfo.EventName}Async(this TaskOrchestrationContext context, CancellationToken cancellationToken = default) {{ - return context.WaitForExternalEvent<{eventInfo.TypeName}>(""{eventInfo.EventName}"", cancellationToken); + return context.WaitForExternalEvent<{typeName}>(""{eventInfo.EventName}"", cancellationToken); }}"); } - static void AddEventSendMethod(StringBuilder sourceBuilder, DurableEventTypeInfo eventInfo) + static void AddEventSendMethod(StringBuilder sourceBuilder, DurableEventTypeInfo eventInfo, string targetNamespace) { + string typeName = SimplifyTypeName(eventInfo.TypeName, targetNamespace); + sourceBuilder.AppendLine($@" /// - /// Sends an external event of type to another orchestration instance. + /// Sends an external event of type to another orchestration instance. /// /// - public static void Send{eventInfo.EventName}(this TaskOrchestrationContext context, string instanceId, {eventInfo.TypeName} eventData) + public static void Send{eventInfo.EventName}(this TaskOrchestrationContext context, string instanceId, {typeName} eventData) {{ context.SendEvent(instanceId, ""{eventInfo.EventName}"", eventData); }}"); } - static void AddActivityFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo activity) + static void AddActivityFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace) { + string inputType = activity.GetInputTypeForNamespace(targetNamespace); + string outputType = activity.GetOutputTypeForNamespace(targetNamespace); + string inputParameter = inputType + " input"; + if (inputType.EndsWith("?", StringComparison.Ordinal)) + { + inputParameter += " = default"; + } + + string simplifiedActivityTypeName = SimplifyTypeName(activity.TypeName, targetNamespace); // GeneratedActivityContext is a generated class that we use for each generated activity trigger definition. // Note that the second "instanceId" parameter is populated via the Azure Functions binding context. sourceBuilder.AppendLine($@" [Function(nameof({activity.TaskName}))] - public static async Task<{activity.OutputType}> {activity.TaskName}([ActivityTrigger] {activity.InputParameter}, string instanceId, FunctionContext executionContext) + public static async Task<{outputType}> {activity.TaskName}([ActivityTrigger] {inputParameter}, string instanceId, FunctionContext executionContext) {{ - ITaskActivity activity = ActivatorUtilities.GetServiceOrCreateInstance<{activity.TypeName}>(executionContext.InstanceServices); + ITaskActivity activity = ActivatorUtilities.GetServiceOrCreateInstance<{simplifiedActivityTypeName}>(executionContext.InstanceServices); TaskActivityContext context = new GeneratedActivityContext(""{activity.TaskName}"", instanceId); object? result = await activity.RunAsync(context, input); - return ({activity.OutputType})result!; + return ({outputType})result!; }}"); } - static void AddEntityFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo entity) + static void AddEntityFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo entity, string targetNamespace) { + string simplifiedEntityTypeName = SimplifyTypeName(entity.TypeName, targetNamespace); + // Generate the entity trigger function that dispatches to the entity implementation. sourceBuilder.AppendLine($@" [Function(nameof({entity.TaskName}))] public static Task {entity.TaskName}([EntityTrigger] TaskEntityDispatcher dispatcher) {{ - return dispatcher.DispatchAsync<{entity.TypeName}>(); + return dispatcher.DispatchAsync<{simplifiedEntityTypeName}>(); }}"); } @@ -712,6 +864,7 @@ class DurableTaskTypeInfo { public DurableTaskTypeInfo( string taskType, + string taskNamespace, string taskName, ITypeSymbol? inputType, ITypeSymbol? outputType, @@ -719,9 +872,12 @@ public DurableTaskTypeInfo( Location? taskNameLocation = null) { this.TypeName = taskType; + this.Namespace = taskNamespace; this.TaskName = taskName; this.Kind = kind; this.TaskNameLocation = taskNameLocation; + this.InputTypeSymbol = inputType; + this.OutputTypeSymbol = outputType; // Entities only have a state type parameter, not input/output if (kind == DurableTaskKind.Entity) @@ -744,12 +900,15 @@ public DurableTaskTypeInfo( } public string TypeName { get; } + public string Namespace { get; } public string TaskName { get; } public string InputType { get; } public string InputParameter { get; } public string OutputType { get; } public DurableTaskKind Kind { get; } public Location? TaskNameLocation { get; } + ITypeSymbol? InputTypeSymbol { get; } + ITypeSymbol? OutputTypeSymbol { get; } public bool IsActivity => this.Kind == DurableTaskKind.Activity; @@ -757,6 +916,47 @@ public DurableTaskTypeInfo( public bool IsEntity => this.Kind == DurableTaskKind.Entity; + /// + /// Gets the rendered input type expression relative to the specified namespace. + /// + public string GetInputTypeForNamespace(string targetNamespace) + { + return GetRenderedTypeExpressionForNamespace(this.InputTypeSymbol, targetNamespace); + } + + /// + /// Gets the rendered output type expression relative to the specified namespace. + /// + public string GetOutputTypeForNamespace(string targetNamespace) + { + return GetRenderedTypeExpressionForNamespace(this.OutputTypeSymbol, targetNamespace); + } + + static string GetRenderedTypeExpressionForNamespace(ITypeSymbol? symbol, string targetNamespace) + { + if (symbol == null) + { + return "object"; + } + + string expression = symbol.ToDisplayString(); + + // Simplify System types (e.g., System.String -> String, System.Int32 -> int) + if (expression.StartsWith("System.", StringComparison.Ordinal) + && symbol.ContainingNamespace.ToDisplayString() == "System") + { + expression = expression.Substring("System.".Length); + } + // Simplify types in the same namespace + else if (!string.IsNullOrEmpty(targetNamespace) + && symbol.ContainingNamespace.ToDisplayString() == targetNamespace) + { + expression = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + } + + return expression; + } + static string GetRenderedTypeExpression(ITypeSymbol? symbol) { if (symbol == null) @@ -777,14 +977,16 @@ static string GetRenderedTypeExpression(ITypeSymbol? symbol) class DurableEventTypeInfo { - public DurableEventTypeInfo(string eventName, ITypeSymbol eventType, Location? eventNameLocation = null) + public DurableEventTypeInfo(string eventName, string eventNamespace, ITypeSymbol eventType, Location? eventNameLocation = null) { this.TypeName = GetRenderedTypeExpression(eventType); + this.Namespace = eventNamespace; this.EventName = eventName; this.EventNameLocation = eventNameLocation; } public string TypeName { get; } + public string Namespace { get; } public string EventName { get; } public Location? EventNameLocation { get; } diff --git a/test/Generators.Tests/AzureFunctionsTests.cs b/test/Generators.Tests/AzureFunctionsTests.cs index 3a02eeee2..ac2d81992 100644 --- a/test/Generators.Tests/AzureFunctionsTests.cs +++ b/test/Generators.Tests/AzureFunctionsTests.cs @@ -293,8 +293,9 @@ public class MyOrchestrator : TaskOrchestrator<{inputType}, {outputType}> string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, + "MyNS", methodList: $@" -static readonly ITaskOrchestrator singletonMyOrchestrator = new MyNS.MyOrchestrator(); +static readonly ITaskOrchestrator singletonMyOrchestrator = new MyOrchestrator(); [Function(nameof(MyOrchestrator))] public static Task<{outputType}> MyOrchestrator([OrchestrationTrigger] TaskOrchestrationContext context) @@ -304,7 +305,7 @@ public class MyOrchestrator : TaskOrchestrator<{inputType}, {outputType}> }} /// -/// Schedules a new instance of the orchestrator. +/// Schedules a new instance of the orchestrator. /// /// public static Task ScheduleNewMyOrchestratorInstanceAsync( @@ -314,7 +315,7 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync( }} /// -/// Calls the sub-orchestrator. +/// Calls the sub-orchestrator. /// /// public static Task<{outputType}> CallMyOrchestratorAsync( @@ -376,8 +377,9 @@ public abstract class MyOrchestratorBase : TaskOrchestrator<{inputType}, {output string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, + "MyNS", methodList: $@" -static readonly ITaskOrchestrator singletonMyOrchestrator = new MyNS.MyOrchestrator(); +static readonly ITaskOrchestrator singletonMyOrchestrator = new MyOrchestrator(); [Function(nameof(MyOrchestrator))] public static Task<{outputType}> MyOrchestrator([OrchestrationTrigger] TaskOrchestrationContext context) @@ -387,7 +389,7 @@ public abstract class MyOrchestratorBase : TaskOrchestrator<{inputType}, {output }} /// -/// Schedules a new instance of the orchestrator. +/// Schedules a new instance of the orchestrator. /// /// public static Task ScheduleNewMyOrchestratorInstanceAsync( @@ -397,7 +399,7 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync( }} /// -/// Calls the sub-orchestrator. +/// Calls the sub-orchestrator. /// /// public static Task<{outputType}> CallMyOrchestratorAsync( @@ -441,11 +443,12 @@ public class MyEntity : TaskEntity<{stateType}> string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, + "MyNS", methodList: @" [Function(nameof(MyEntity))] public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher) { - return dispatcher.DispatchAsync(); + return dispatcher.DispatchAsync(); }", isDurableFunctions: true); @@ -488,11 +491,12 @@ public abstract class MyEntityBase : TaskEntity<{stateType}> string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, + "MyNS", methodList: @" [Function(nameof(MyEntity))] public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher) { - return dispatcher.DispatchAsync(); + return dispatcher.DispatchAsync(); }", isDurableFunctions: true); @@ -532,11 +536,12 @@ public class MyEntity : TaskEntity string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, + "MyNS", methodList: @" [Function(nameof(MyEntity))] public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher) { - return dispatcher.DispatchAsync(); + return dispatcher.DispatchAsync(); }", isDurableFunctions: true); @@ -584,8 +589,9 @@ public class MyEntity : TaskEntity string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, + "MyNS", methodList: $@" -static readonly ITaskOrchestrator singletonMyOrchestrator = new MyNS.MyOrchestrator(); +static readonly ITaskOrchestrator singletonMyOrchestrator = new MyOrchestrator(); [Function(nameof(MyOrchestrator))] public static Task MyOrchestrator([OrchestrationTrigger] TaskOrchestrationContext context) @@ -595,7 +601,7 @@ public static Task MyOrchestrator([OrchestrationTrigger] TaskOrchestrati }} /// -/// Schedules a new instance of the orchestrator. +/// Schedules a new instance of the orchestrator. /// /// public static Task ScheduleNewMyOrchestratorInstanceAsync( @@ -605,7 +611,7 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync( }} /// -/// Calls the sub-orchestrator. +/// Calls the sub-orchestrator. /// /// public static Task CallMyOrchestratorAsync( @@ -615,7 +621,7 @@ public static Task CallMyOrchestratorAsync( }} /// -/// Calls the activity. +/// Calls the activity. /// /// public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) @@ -626,7 +632,7 @@ public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx [Function(nameof(MyActivity))] public static async Task MyActivity([ActivityTrigger] int input, string instanceId, FunctionContext executionContext) {{ - ITaskActivity activity = ActivatorUtilities.GetServiceOrCreateInstance(executionContext.InstanceServices); + ITaskActivity activity = ActivatorUtilities.GetServiceOrCreateInstance(executionContext.InstanceServices); TaskActivityContext context = new GeneratedActivityContext(""MyActivity"", instanceId); object? result = await activity.RunAsync(context, input); return (string)result!; @@ -635,7 +641,7 @@ public static async Task MyActivity([ActivityTrigger] int input, string [Function(nameof(MyEntity))] public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher) {{ - return dispatcher.DispatchAsync(); + return dispatcher.DispatchAsync(); }} {TestHelpers.DeIndent(DurableTaskSourceGenerator.GetGeneratedActivityContextCode(), spacesToRemove: 8)}", isDurableFunctions: true); diff --git a/test/Generators.Tests/ClassBasedSyntaxTests.cs b/test/Generators.Tests/ClassBasedSyntaxTests.cs index 638d5a267..b10fdbdf4 100644 --- a/test/Generators.Tests/ClassBasedSyntaxTests.cs +++ b/test/Generators.Tests/ClassBasedSyntaxTests.cs @@ -228,23 +228,25 @@ class MyActivityImpl : TaskActivity public class MyClass { } }"; - string expectedOutput = TestHelpers.WrapAndFormat( + string expectedOutput = TestHelpers.WrapAndFormatMultiNamespace( GeneratedClassName, - methodList: @" + isDurableFunctions: false, + ("MyNS", @" /// -/// Calls the activity. +/// Calls the activity. /// /// -public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, MyNS.MyClass input, TaskOptions? options = null) +public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, MyClass input, TaskOptions? options = null) { - return ctx.CallActivityAsync(""MyActivity"", input, options); -} - + return ctx.CallActivityAsync(""MyActivity"", input, options); +}"), + ("Microsoft.DurableTask", @" internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) { builder.AddActivity(); return builder; -}"); +}") + ); return TestHelpers.RunTestAsync( GeneratedFileName, @@ -608,21 +610,22 @@ public record DataReceivedEvent(int Id, string Data); string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, + "MyNS", methodList: @" /// -/// Waits for an external event of type . +/// Waits for an external event of type . /// /// -public static Task WaitForDataReceivedEventAsync(this TaskOrchestrationContext context, CancellationToken cancellationToken = default) +public static Task WaitForDataReceivedEventAsync(this TaskOrchestrationContext context, CancellationToken cancellationToken = default) { - return context.WaitForExternalEvent(""DataReceivedEvent"", cancellationToken); + return context.WaitForExternalEvent(""DataReceivedEvent"", cancellationToken); } /// -/// Sends an external event of type to another orchestration instance. +/// Sends an external event of type to another orchestration instance. /// /// -public static void SendDataReceivedEvent(this TaskOrchestrationContext context, string instanceId, MyNS.DataReceivedEvent eventData) +public static void SendDataReceivedEvent(this TaskOrchestrationContext context, string instanceId, DataReceivedEvent eventData) { context.SendEvent(instanceId, ""DataReceivedEvent"", eventData); }"); @@ -633,4 +636,78 @@ public static void SendDataReceivedEvent(this TaskOrchestrationContext context, expectedOutput, isDurableFunctions: false); } + + [Fact] + public Task MultiNamespace_OrchestratorAndActivityInDifferentNamespaces() + { + // Verify that tasks in different namespaces generate extension methods in their respective namespaces + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +namespace Approvals +{ + [DurableTask(nameof(ApprovalOrchestrator))] + public class ApprovalOrchestrator : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext ctx, string input) => Task.FromResult(string.Empty); + } +} + +namespace Registrations +{ + [DurableTask(nameof(RegistrationActivity))] + public class RegistrationActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); + } +}"; + + string expectedOutput = TestHelpers.WrapAndFormatMultiNamespace( + GeneratedClassName, + isDurableFunctions: false, + ("Approvals", @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewApprovalOrchestratorInstanceAsync( + this IOrchestrationSubmitter client, string input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""ApprovalOrchestrator"", input, options); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallApprovalOrchestratorAsync( + this TaskOrchestrationContext context, string input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""ApprovalOrchestrator"", input, options); +}"), + ("Registrations", @" +/// +/// Calls the activity. +/// +/// +public static Task CallRegistrationActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""RegistrationActivity"", input, options); +}"), + ("Microsoft.DurableTask", @" +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + builder.AddActivity(); + return builder; +}") + ); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } } diff --git a/test/Generators.Tests/Utils/TestHelpers.cs b/test/Generators.Tests/Utils/TestHelpers.cs index 92f3db86f..786033a76 100644 --- a/test/Generators.Tests/Utils/TestHelpers.cs +++ b/test/Generators.Tests/Utils/TestHelpers.cs @@ -82,12 +82,18 @@ public static Task RunTestAsync( } public static string WrapAndFormat(string generatedClassName, string methodList, bool isDurableFunctions = false) + { + return WrapAndFormat(generatedClassName, "Microsoft.DurableTask", methodList, isDurableFunctions); + } + + public static string WrapAndFormat(string generatedClassName, string targetNamespace, string methodList, bool isDurableFunctions = false) { string formattedMethodList = IndentLines(spaces: 8, methodList); string usings = @" using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.DurableTask; using Microsoft.DurableTask.Internal;"; if (isDurableFunctions) @@ -102,7 +108,7 @@ public static string WrapAndFormat(string generatedClassName, string methodList, #nullable enable {usings} -namespace Microsoft.DurableTask +namespace {targetNamespace} {{ public static class {generatedClassName} {{ @@ -112,6 +118,51 @@ public static class {generatedClassName} ".TrimStart(); } + /// + /// Wraps content in multiple namespace blocks, producing the complete generated file structure. + /// + public static string WrapAndFormatMultiNamespace( + string generatedClassName, + bool isDurableFunctions, + params (string Namespace, string MethodList)[] namespaceBlocks) + { + string usings = @" +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Internal;"; + + if (isDurableFunctions) + { + usings += @" +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.DependencyInjection;"; + } + + StringBuilder sb = new(); + sb.Append($@"// +#nullable enable +{usings}"); + + foreach ((string ns, string methodList) in namespaceBlocks) + { + string formattedMethodList = IndentLines(spaces: 8, methodList); + sb.Append($@" + +namespace {ns} +{{ + public static class {generatedClassName} + {{ + {formattedMethodList.TrimStart()} + }} +}} +"); + } + + return sb.ToString().TrimStart(); + } + static string IndentLines(int spaces, string multilineText) { string indent = new(' ', spaces); From a67299dd8ba561b52697ff113925d02ca144116c Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 16 Mar 2026 18:20:39 -0700 Subject: [PATCH 2/4] Address PR review comments - Remove unused outputType variable in AddOrchestratorCallMethod - Materialize activityTriggers to List to avoid repeated enumeration - Update sample to use AzureManaged + DTS emulator instead of InProcessTestHost - Fix README run instructions to match actual execution model - Fix misleading comment in Tasks.cs about extension method namespaces --- .../NamespaceGenerationSample.csproj | 5 ++- samples/NamespaceGenerationSample/Program.cs | 32 +++++++++++++++---- samples/NamespaceGenerationSample/README.md | 9 ++++-- samples/NamespaceGenerationSample/Tasks.cs | 5 +-- src/Generators/DurableTaskSourceGenerator.cs | 9 +++--- 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/samples/NamespaceGenerationSample/NamespaceGenerationSample.csproj b/samples/NamespaceGenerationSample/NamespaceGenerationSample.csproj index 8a1c8798f..495a9a9fc 100644 --- a/samples/NamespaceGenerationSample/NamespaceGenerationSample.csproj +++ b/samples/NamespaceGenerationSample/NamespaceGenerationSample.csproj @@ -12,9 +12,8 @@ - - - + + diff --git a/samples/NamespaceGenerationSample/Program.cs b/samples/NamespaceGenerationSample/Program.cs index 30bb3095d..1572e6fa9 100644 --- a/samples/NamespaceGenerationSample/Program.cs +++ b/samples/NamespaceGenerationSample/Program.cs @@ -7,7 +7,12 @@ using Microsoft.DurableTask; using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Testing; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; // The generated AddAllGeneratedTasks() method is always in Microsoft.DurableTask namespace. // Extension methods like ScheduleNewApprovalOrchestratorInstanceAsync() are in the @@ -15,12 +20,25 @@ // NamespaceGenerationSample.Registrations namespace. using NamespaceGenerationSample.Approvals; -// Start the in-process test host (no external services needed) -// The generated AddAllGeneratedTasks() registers all orchestrators and activities -await using DurableTaskTestHost testHost = await DurableTaskTestHost.StartAsync( - registry => registry.AddAllGeneratedTasks()); +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); -DurableTaskClient client = testHost.Client; +// Read the DTS connection string from configuration +string schedulerConnectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException("DURABLE_TASK_SCHEDULER_CONNECTION_STRING is not set."); + +builder.Services.AddDurableTaskClient(clientBuilder => clientBuilder.UseDurableTaskScheduler(schedulerConnectionString)); + +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + // Use the generated AddAllGeneratedTasks() to register all orchestrators and activities + workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + workerBuilder.UseDurableTaskScheduler(schedulerConnectionString); +}); + +IHost host = builder.Build(); +await host.StartAsync(); + +await using DurableTaskClient client = host.Services.GetRequiredService(); // Use the generated typed extension method (in the Approvals namespace) string instanceId = await client.ScheduleNewApprovalOrchestratorInstanceAsync("request-123"); @@ -31,3 +49,5 @@ instanceId, getInputsAndOutputs: true); Console.WriteLine($"Orchestration completed with status: {result?.RuntimeStatus}"); Console.WriteLine($"Output: {result?.ReadOutputAs()}"); + +await host.StopAsync(); diff --git a/samples/NamespaceGenerationSample/README.md b/samples/NamespaceGenerationSample/README.md index 2e469c8e2..299ec0e13 100644 --- a/samples/NamespaceGenerationSample/README.md +++ b/samples/NamespaceGenerationSample/README.md @@ -21,10 +21,15 @@ This results in cleaner IDE suggestions — you only see extension methods for t 1. Start the DTS emulator: ```bash - docker run --name durabletask-emulator -d -p 8080:8080 mcr.microsoft.com/durabletask/emulator:latest + docker run --name durabletask-emulator -d -p 8080:8080 -e ASPNETCORE_URLS=http://+:8080 mcr.microsoft.com/dts/dts-emulator:latest ``` -2. Run the sample: +2. Set the connection string environment variable: + ```bash + export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" + ``` + +3. Run the sample: ```bash dotnet run ``` diff --git a/samples/NamespaceGenerationSample/Tasks.cs b/samples/NamespaceGenerationSample/Tasks.cs index 7ca435991..404a81aad 100644 --- a/samples/NamespaceGenerationSample/Tasks.cs +++ b/samples/NamespaceGenerationSample/Tasks.cs @@ -8,8 +8,9 @@ using NamespaceGenerationSample.Registrations; // Approval-related tasks live in their own namespace. -// The generated CallRegistrationActivityAsync() and ScheduleNewApprovalOrchestratorInstanceAsync() -// extension methods will be generated in this namespace. +// The generated ScheduleNewApprovalOrchestratorInstanceAsync() extension method +// will be generated in this namespace. The CallRegistrationActivityAsync() extension +// method is generated in the NamespaceGenerationSample.Registrations namespace. namespace NamespaceGenerationSample.Approvals { /// diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 3b0ba3d10..ddf77cd67 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -401,9 +401,9 @@ static void Execute( } // Activity function triggers from DurableFunction go into Microsoft.DurableTask namespace - IEnumerable activityTriggers = allFunctions.Where( - df => df.Kind == DurableFunctionKind.Activity); - if (activityTriggers.Any()) + List activityTriggers = allFunctions.Where( + df => df.Kind == DurableFunctionKind.Activity).ToList(); + if (activityTriggers.Count > 0) { allNamespaces.Add("Microsoft.DurableTask"); } @@ -452,7 +452,7 @@ static void Execute( bool hasOrchestratorMethods = orchestratorsInNs.Count > 0; bool hasActivityMethods = activitiesInNs.Count > 0; bool hasEntityFunctions = isDurableFunctions && entitiesInNs.Count > 0; - bool hasActivityTriggers = isMicrosoftDurableTask && activityTriggers.Any(); + bool hasActivityTriggers = isMicrosoftDurableTask && activityTriggers.Count > 0; bool hasEvents = eventsInNamespace != null && eventsInNamespace.Count > 0; bool hasRegistration = isMicrosoftDurableTask && needsRegistrationMethod; @@ -629,7 +629,6 @@ static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, Dura static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); - string outputType = orchestrator.GetOutputTypeForNamespace(targetNamespace); string inputParameter = inputType + " input"; if (inputType.EndsWith("?", StringComparison.Ordinal)) { From 1ac7e5248198698ddb932b11afeb98888d4fcdd8 Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 16 Mar 2026 19:14:46 -0700 Subject: [PATCH 3/4] Remove unused InputType/OutputType/InputParameter properties and old GetRenderedTypeExpression method --- src/Generators/DurableTaskSourceGenerator.cs | 39 -------------------- 1 file changed, 39 deletions(-) diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index ddf77cd67..b4b2de97c 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -877,33 +877,11 @@ public DurableTaskTypeInfo( this.TaskNameLocation = taskNameLocation; this.InputTypeSymbol = inputType; this.OutputTypeSymbol = outputType; - - // Entities only have a state type parameter, not input/output - if (kind == DurableTaskKind.Entity) - { - this.InputType = string.Empty; - this.InputParameter = string.Empty; - this.OutputType = string.Empty; - } - else - { - this.InputType = GetRenderedTypeExpression(inputType); - this.InputParameter = this.InputType + " input"; - if (this.InputType[this.InputType.Length - 1] == '?') - { - this.InputParameter += " = default"; - } - - this.OutputType = GetRenderedTypeExpression(outputType); - } } public string TypeName { get; } public string Namespace { get; } public string TaskName { get; } - public string InputType { get; } - public string InputParameter { get; } - public string OutputType { get; } public DurableTaskKind Kind { get; } public Location? TaskNameLocation { get; } ITypeSymbol? InputTypeSymbol { get; } @@ -955,23 +933,6 @@ static string GetRenderedTypeExpressionForNamespace(ITypeSymbol? symbol, string return expression; } - - static string GetRenderedTypeExpression(ITypeSymbol? symbol) - { - if (symbol == null) - { - return "object"; - } - - string expression = symbol.ToString(); - if (expression.StartsWith("System.", StringComparison.Ordinal) - && symbol.ContainingNamespace.Name == "System") - { - expression = expression.Substring("System.".Length); - } - - return expression; - } } class DurableEventTypeInfo From d80c72399a683ca4cffbf56588f680a1c4d6f9a7 Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 16 Mar 2026 19:42:37 -0700 Subject: [PATCH 4/4] Add test for cross-namespace custom type simplification --- .../Generators.Tests/ClassBasedSyntaxTests.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/Generators.Tests/ClassBasedSyntaxTests.cs b/test/Generators.Tests/ClassBasedSyntaxTests.cs index b10fdbdf4..aaed02098 100644 --- a/test/Generators.Tests/ClassBasedSyntaxTests.cs +++ b/test/Generators.Tests/ClassBasedSyntaxTests.cs @@ -710,4 +710,75 @@ internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistr expectedOutput, isDurableFunctions: false); } + + [Fact] + public Task MultiNamespace_CustomTypesSimplifiedPerNamespace() + { + // Verify that custom types are simplified when in the same namespace as the generated code, + // but remain fully qualified when referenced from a different namespace + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +namespace OrderNS +{ + public class OrderInput { } + public class OrderOutput { } + + [DurableTask(nameof(OrderActivity))] + public class OrderActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, OrderInput input) => Task.FromResult(new OrderOutput()); + } +} + +namespace ShippingNS +{ + public class ShippingRequest { } + public class ShippingResult { } + + [DurableTask(nameof(ShippingActivity))] + public class ShippingActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, ShippingRequest input) => Task.FromResult(new ShippingResult()); + } +}"; + + string expectedOutput = TestHelpers.WrapAndFormatMultiNamespace( + GeneratedClassName, + isDurableFunctions: false, + ("OrderNS", @" +/// +/// Calls the activity. +/// +/// +public static Task CallOrderActivityAsync(this TaskOrchestrationContext ctx, OrderInput input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""OrderActivity"", input, options); +}"), + ("ShippingNS", @" +/// +/// Calls the activity. +/// +/// +public static Task CallShippingActivityAsync(this TaskOrchestrationContext ctx, ShippingRequest input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""ShippingActivity"", input, options); +}"), + ("Microsoft.DurableTask", @" +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddActivity(); + builder.AddActivity(); + return builder; +}") + ); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + }