diff --git a/samples/AzureFunctionsApp/AzureFunctionsApp.csproj b/samples/AzureFunctionsApp/AzureFunctionsApp.csproj index 8531f38a7..69f3ff811 100644 --- a/samples/AzureFunctionsApp/AzureFunctionsApp.csproj +++ b/samples/AzureFunctionsApp/AzureFunctionsApp.csproj @@ -5,6 +5,9 @@ v4 Exe enable + + false + false @@ -12,7 +15,8 @@ - + + diff --git a/samples/AzureFunctionsApp/Entities/Counter.cs b/samples/AzureFunctionsApp/Entities/Counter.cs index b960d3865..38697da98 100644 --- a/samples/AzureFunctionsApp/Entities/Counter.cs +++ b/samples/AzureFunctionsApp/Entities/Counter.cs @@ -29,7 +29,11 @@ namespace AzureFunctionsApp.Entities; /// Example on how to dispatch to an entity which directly implements TaskEntity. Using TaskEntity gives /// the added benefit of being able to use DI. When using TaskEntity, state is deserialized to the "State" /// property. No other properties on this type will be serialized/deserialized. +/// +/// Source generators are used to generate the [Function] method with [EntityTrigger] binding automatically. +/// The generated function will be named "Counter" (based on the class name or the DurableTask attribute value). /// +[DurableTask(nameof(Counter))] public class Counter : TaskEntity { readonly ILogger logger; @@ -49,19 +53,21 @@ public int Add(int input) public void Reset() => this.State = 0; - [Function("Counter")] - public Task DispatchAsync([EntityTrigger] TaskEntityDispatcher dispatcher) - { - // Can dispatch to a TaskEntity by passing a instance. - return dispatcher.DispatchAsync(this); - } + // Note: The [Function("Counter")] method is now auto-generated by the source generator. + // The generated code will look like: + // [Function(nameof(Counter))] + // public static Task Counter([EntityTrigger] TaskEntityDispatcher dispatcher) + // { + // return dispatcher.DispatchAsync(); + // } [Function("Counter_Alt")] public static Task DispatchStaticAsync([EntityTrigger] TaskEntityDispatcher dispatcher) { - // Can also dispatch to a TaskEntity by using a static method. - // However, this is a different entity ID - "counter_alt" and not "counter". Even though it uses the same - // entity implementation, the function attribute has a different name, which determines the entity ID. + // This is kept as a manual example showing how to create an alternative entity function + // with a different name. This creates a separate entity ID "counter_alt" vs "counter". + // Even though it uses the same entity implementation, the function attribute has a different name, + // which determines the entity ID. return dispatcher.DispatchAsync(); } } diff --git a/samples/AzureFunctionsApp/Entities/Lifetime.cs b/samples/AzureFunctionsApp/Entities/Lifetime.cs index ed6c1b095..5a219bac2 100644 --- a/samples/AzureFunctionsApp/Entities/Lifetime.cs +++ b/samples/AzureFunctionsApp/Entities/Lifetime.cs @@ -4,6 +4,7 @@ using System.Net; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask; using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Client.Entities; using Microsoft.DurableTask.Entities; @@ -16,7 +17,10 @@ namespace AzureFunctionsApp.Entities; /// is considered deleted when is null at the end of an operation. It /// is also possible to design an entity which remains stateless by always returning null from /// and never assigning a non-null state. +/// +/// Source generators are used to generate the [Function] method with [EntityTrigger] binding automatically. /// +[DurableTask(nameof(Lifetime))] public class Lifetime : TaskEntity { readonly ILogger logger; @@ -34,14 +38,13 @@ public Lifetime(ILogger logger) /// protected override bool AllowStateDispatch => base.AllowStateDispatch; - // NOTE: when using TaskEntity, you cannot use "RunAsync" as the entity trigger name as this conflicts - // with the base class method 'RunAsync'. - [Function(nameof(Lifetime))] - public Task DispatchAsync([EntityTrigger] TaskEntityDispatcher dispatcher) - { - this.logger.LogInformation("Dispatching entity"); - return dispatcher.DispatchAsync(this); - } + // Note: The [Function(nameof(Lifetime))] method is now auto-generated by the source generator. + // The generated code will look like: + // [Function(nameof(Lifetime))] + // public static Task Lifetime([EntityTrigger] TaskEntityDispatcher dispatcher) + // { + // return dispatcher.DispatchAsync(); + // } public MyState Get() => this.State; diff --git a/samples/AzureFunctionsApp/Entities/User.cs b/samples/AzureFunctionsApp/Entities/User.cs index c66630a9c..3fb822457 100644 --- a/samples/AzureFunctionsApp/Entities/User.cs +++ b/samples/AzureFunctionsApp/Entities/User.cs @@ -18,7 +18,11 @@ public record UserUpdate(string? Name, int? Age); /// /// This sample demonstrates how to bind to as well as dispatch to orchestrations. +/// +/// Source generators are used to generate the [Function] method with [EntityTrigger] binding automatically. +/// The generated function will be named "User" (based on the class name or the DurableTask attribute value). /// +[DurableTask(nameof(User))] public class UserEntity : TaskEntity { readonly ILogger logger; @@ -68,12 +72,13 @@ public void Greet(TaskEntityContext context, string? message = null) // this.Context.ScheduleNewOrchestration(nameof(Greeting.GreetingOrchestration), input); } - [Function(nameof(User))] - public Task DispatchAsync([EntityTrigger] TaskEntityDispatcher dispatcher) - { - // Can dispatch to a TaskEntity by passing a instance. - return dispatcher.DispatchAsync(this); - } + // Note: The [Function(nameof(User))] method is now auto-generated by the source generator. + // The generated code will look like: + // [Function(nameof(User))] + // public static Task User([EntityTrigger] TaskEntityDispatcher dispatcher) + // { + // return dispatcher.DispatchAsync(); + // } protected override User InitializeState(TaskEntityOperation entityOperation) { diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index c45adca13..986f7ec3e 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -270,6 +270,15 @@ public static class GeneratedDurableTaskExtensions } } + foreach (DurableTaskTypeInfo entity in entities) + { + if (isDurableFunctions) + { + // Generate the function definition required to trigger entities in Azure Functions + AddEntityFunctionDeclaration(sourceBuilder, entity); + } + } + // Activity function triggers are supported for code-gen (but not orchestration triggers) IEnumerable activityTriggers = allFunctions.Where( df => df.Kind == DurableFunctionKind.Activity); @@ -368,6 +377,17 @@ static void AddActivityFunctionDeclaration(StringBuilder sourceBuilder, DurableT }}"); } + static void AddEntityFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo entity) + { + // 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}>(); + }}"); + } + /// /// Adds a custom ITaskActivityContext implementation used by code generated from . /// diff --git a/test/Generators.Tests/AzureFunctionsTests.cs b/test/Generators.Tests/AzureFunctionsTests.cs index 63dd768de..6ae9523d6 100644 --- a/test/Generators.Tests/AzureFunctionsTests.cs +++ b/test/Generators.Tests/AzureFunctionsTests.cs @@ -312,4 +312,227 @@ await TestHelpers.RunTestAsync( expectedOutput, isDurableFunctions: true); } + + /// + /// Verifies that using the class-based syntax for authoring entities generates + /// function triggers for Azure Functions. + /// + /// The entity state type. + [Theory] + [InlineData("int")] + [InlineData("string")] + public async Task Entities_ClassBasedSyntax(string stateType) + { + string code = $@" +#nullable enable +using System.Threading.Tasks; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; + +namespace MyNS +{{ + [DurableTask(nameof(MyEntity))] + public class MyEntity : TaskEntity<{stateType}> + {{ + public {stateType} Get() => this.State; + }} +}}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +[Function(nameof(MyEntity))] +public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher) +{ + return dispatcher.DispatchAsync(); +}", + isDurableFunctions: true); + + await TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true); + } + + /// + /// Verifies that using the class-based syntax for authoring entities with inheritance generates + /// function triggers for Azure Functions. + /// + /// The entity state type. + [Theory] + [InlineData("int")] + [InlineData("string")] + public async Task Entities_ClassBasedSyntax_Inheritance(string stateType) + { + string code = $@" +#nullable enable +using System.Threading.Tasks; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; + +namespace MyNS +{{ + [DurableTask] + public class MyEntity : MyEntityBase + {{ + public override {stateType} Get() => this.State; + }} + + public abstract class MyEntityBase : TaskEntity<{stateType}> + {{ + public abstract {stateType} Get(); + }} +}}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +[Function(nameof(MyEntity))] +public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher) +{ + return dispatcher.DispatchAsync(); +}", + isDurableFunctions: true); + + await TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true); + } + + /// + /// Verifies that using the class-based syntax for authoring entities with custom state types generates + /// function triggers for Azure Functions. + /// + [Fact] + public async Task Entities_ClassBasedSyntax_CustomStateType() + { + string code = @" +#nullable enable +using System.Threading.Tasks; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; + +namespace MyNS +{ + public class MyState + { + public int Value { get; set; } + } + + [DurableTask(nameof(MyEntity))] + public class MyEntity : TaskEntity + { + public MyState Get() => this.State; + } +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +[Function(nameof(MyEntity))] +public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher) +{ + return dispatcher.DispatchAsync(); +}", + isDurableFunctions: true); + + await TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true); + } + + /// + /// Verifies that using the class-based syntax for authoring a mix of orchestrators, activities, + /// and entities generates the appropriate function triggers for Azure Functions. + /// + [Fact] + public async Task Mixed_OrchestratorActivityEntity_ClassBasedSyntax() + { + string code = @" +#nullable enable +using System; +using System.Threading.Tasks; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; + +namespace MyNS +{ + [DurableTask(nameof(MyOrchestrator))] + public class MyOrchestrator : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext ctx, int input) => Task.FromResult(string.Empty); + } + + [DurableTask(nameof(MyActivity))] + public class MyActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); + } + + [DurableTask(nameof(MyEntity))] + public class MyEntity : TaskEntity + { + public int Get() => this.State; + } +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: $@" +static readonly ITaskOrchestrator singletonMyOrchestrator = new MyNS.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); +}} + +/// +public static Task ScheduleNewMyOrchestratorInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{{ + return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options); +}} + +/// +public static Task CallMyOrchestratorAsync( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{{ + return context.CallSubOrchestratorAsync(""MyOrchestrator"", input, options); +}} + +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!; +}} + +[Function(nameof(MyEntity))] +public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher) +{{ + return dispatcher.DispatchAsync(); +}} +{TestHelpers.DeIndent(DurableTaskSourceGenerator.GetGeneratedActivityContextCode(), spacesToRemove: 8)}", + isDurableFunctions: true); + + await TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: true); + } }