diff --git a/all.sln b/all.sln index c5ca7a6ea..aada63d9f 100644 --- a/all.sln +++ b/all.sln @@ -245,6 +245,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Workflow.Versioning.Ru EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkflowVersioning", "examples\Workflow\WorkflowVersioning\WorkflowVersioning.csproj", "{837E02A5-D1C0-4F60-AF93-71117BF3B6DC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows", "test\Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows\Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows.csproj", "{97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -651,6 +653,10 @@ Global {837E02A5-D1C0-4F60-AF93-71117BF3B6DC}.Debug|Any CPU.Build.0 = Debug|Any CPU {837E02A5-D1C0-4F60-AF93-71117BF3B6DC}.Release|Any CPU.ActiveCfg = Release|Any CPU {837E02A5-D1C0-4F60-AF93-71117BF3B6DC}.Release|Any CPU.Build.0 = Release|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -770,6 +776,7 @@ Global {1AD32297-630E-4DFB-B3E4-CAFCE993F27F} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} {4FF7F075-2818-41E4-A88F-743417EA0A99} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} {837E02A5-D1C0-4F60-AF93-71117BF3B6DC} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/examples/Workflow/CrossAppVersioning/CrossAppVersioning.csproj b/examples/Workflow/CrossAppVersioning/CrossAppVersioning.csproj new file mode 100644 index 000000000..cc095b683 --- /dev/null +++ b/examples/Workflow/CrossAppVersioning/CrossAppVersioning.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/examples/Workflow/CrossAppVersioning/Program.cs b/examples/Workflow/CrossAppVersioning/Program.cs new file mode 100644 index 000000000..42eae3c67 --- /dev/null +++ b/examples/Workflow/CrossAppVersioning/Program.cs @@ -0,0 +1,16 @@ +using Dapr.Workflow.Versioning; + +var builder = WebApplication.CreateBuilder(args); + +// Enable workflow versioning and allow the generator to scan referenced assemblies. +builder.Services.AddDaprWorkflowVersioning(); + +var app = builder.Build(); + +app.MapGet("/registry", (IServiceProvider services) => +{ + var registry = GeneratedWorkflowVersionRegistry.GetWorkflowVersionRegistry(services); + return Results.Ok(registry); +}); + +await app.RunAsync(); diff --git a/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs b/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs index d0e40d559..b4219f033 100644 --- a/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs +++ b/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs @@ -32,10 +32,15 @@ public sealed class WorkflowSourceGenerator : IIncrementalGenerator { private const string WorkflowBaseMetadataName = "Dapr.Workflow.Workflow`2"; private const string WorkflowVersionAttributeFullName = "Dapr.Workflow.Versioning.WorkflowVersionAttribute"; + private const string ScanReferencesPropertyName = "build_property.DaprWorkflowVersioningScanReferences"; /// public void Initialize(IncrementalGeneratorInitializationContext context) { + var scanReferences = context.AnalyzerConfigOptionsProvider.Select((options, _) => + options.GlobalOptions.TryGetValue(ScanReferencesPropertyName, out var value) && + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)); + // Cache the attribute symbol var known = context.CompilationProvider.Select((c, _) => new KnownSymbols( @@ -157,8 +162,43 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } }); + var referenced = context.CompilationProvider + .Combine(known) + .Combine(scanReferences) + .Select((input, _) => + { + var ((compilation, ks), scan) = input; + if (!scan) + return ImmutableArray.Empty; + + if (ks.WorkflowBase is null) + return ImmutableArray.Empty; + + var list = new List(); + foreach (var assembly in compilation.SourceModule.ReferencedAssemblySymbols) + { + list.AddRange(DiscoverReferencedWorkflows(assembly, ks, compilation.Assembly)); + } + + return list.ToImmutableArray(); + }); + // Collect and emit - context.RegisterSourceOutput(discovered.Collect(), (spc, items) => + var discoveredAll = discovered.Collect() + .Combine(referenced) + .Select((input, _) => + { + var (current, extra) = input; + if (extra.IsDefaultOrEmpty) + return current; + + var list = new List(current.Length + extra.Length); + list.AddRange(current); + list.AddRange(extra); + return list.ToImmutableArray(); + }); + + context.RegisterSourceOutput(discoveredAll, (spc, items) => { var workflows = items.Where(x => x is not null).ToList(); @@ -282,6 +322,87 @@ private static DiscoveredWorkflow BuildDiscoveredWorkflow( ); } + private static IEnumerable DiscoverReferencedWorkflows( + IAssemblySymbol assemblySymbol, + KnownSymbols knownSymbols, + IAssemblySymbol currentAssembly) + { + foreach (var type in EnumerateTypes(assemblySymbol.GlobalNamespace)) + { + if (!IsAccessibleFromAssembly(type, currentAssembly)) + continue; + + if (!InheritsFromWorkflow(type, knownSymbols.WorkflowBase)) + continue; + + AttributeData? attrData = null; + if (knownSymbols.WorkflowVersionAttribute is not null) + { + attrData = type.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, knownSymbols.WorkflowVersionAttribute)); + } + + attrData ??= type.GetAttributes().FirstOrDefault(a => + string.Equals(a.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + $"global::{WorkflowVersionAttributeFullName}", StringComparison.Ordinal)); + + yield return BuildDiscoveredWorkflow(type, attrData); + } + } + + private static IEnumerable EnumerateTypes(INamespaceSymbol root) + { + foreach (var member in root.GetMembers()) + { + switch (member) + { + case INamespaceSymbol ns: + foreach (var type in EnumerateTypes(ns)) + yield return type; + break; + case INamedTypeSymbol type: + foreach (var nested in EnumerateNestedTypes(type)) + yield return nested; + break; + } + } + } + + private static IEnumerable EnumerateNestedTypes(INamedTypeSymbol type) + { + yield return type; + foreach (var nested in type.GetTypeMembers()) + { + foreach (var child in EnumerateNestedTypes(nested)) + yield return child; + } + } + + private static bool IsAccessibleFromAssembly(INamedTypeSymbol type, IAssemblySymbol currentAssembly) + { + for (var containing = type; containing is not null; containing = containing.ContainingType) + { + if (!IsAccessibleCore(containing, currentAssembly)) + return false; + } + + return true; + } + + private static bool IsAccessibleCore(INamedTypeSymbol type, IAssemblySymbol currentAssembly) + { + switch (type.DeclaredAccessibility) + { + case Accessibility.Public: + return true; + case Accessibility.Internal: + case Accessibility.ProtectedOrInternal: + return type.ContainingAssembly.GivesAccessTo(currentAssembly); + default: + return false; + } + } + /// /// Final source-emission step for the generator. Receives the collected set of discovered /// workflow descriptors and adds the generated registry/registration source to the compilation. diff --git a/src/Dapr.Workflow.Versioning.Runtime/Dapr.Workflow.Versioning.Runtime.csproj b/src/Dapr.Workflow.Versioning.Runtime/Dapr.Workflow.Versioning.Runtime.csproj index 4807ee2d0..97f6bda9c 100644 --- a/src/Dapr.Workflow.Versioning.Runtime/Dapr.Workflow.Versioning.Runtime.csproj +++ b/src/Dapr.Workflow.Versioning.Runtime/Dapr.Workflow.Versioning.Runtime.csproj @@ -14,6 +14,7 @@ + diff --git a/test/Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows/CrossAssemblyWorkflows.cs b/test/Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows/CrossAssemblyWorkflows.cs new file mode 100644 index 000000000..8fab2989c --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows/CrossAssemblyWorkflows.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow; +using Dapr.Workflow.Versioning; + +namespace Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows; + +public static class CrossAssemblyWorkflowConstants +{ + public const string CanonicalName = "CrossAppWorkflow"; +} + +[WorkflowVersion(CanonicalName = CrossAssemblyWorkflowConstants.CanonicalName, Version = "1")] +public sealed class CrossAppWorkflowV1 : Workflow +{ + public override Task RunAsync(WorkflowContext context, string input) + { + return Task.FromResult($"v1:{input}"); + } +} + +[WorkflowVersion(CanonicalName = CrossAssemblyWorkflowConstants.CanonicalName, Version = "2")] +public sealed class CrossAppWorkflowV2 : Workflow +{ + public override Task RunAsync(WorkflowContext context, string input) + { + return Task.FromResult($"v2:{input}"); + } +} diff --git a/test/Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows/Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows.csproj b/test/Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows/Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows.csproj new file mode 100644 index 000000000..be5c325db --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows/Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows.csproj @@ -0,0 +1,14 @@ + + + + enable + enable + false + + + + + + + + diff --git a/test/Dapr.IntegrationTest.Workflow.Versioning/CrossAssemblyScanIntegrationTests.cs b/test/Dapr.IntegrationTest.Workflow.Versioning/CrossAssemblyScanIntegrationTests.cs new file mode 100644 index 000000000..0e731ad98 --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow.Versioning/CrossAssemblyScanIntegrationTests.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.IntegrationTest.Workflow.Versioning.ReferenceWorkflows; +using Dapr.Workflow.Versioning; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Workflow.Versioning; + +public sealed class CrossAssemblyScanIntegrationTests +{ + [Fact] + public void ShouldDiscoverReferencedWorkflowsWhenEnabled() + { + var services = new ServiceCollection(); + services.AddDaprWorkflowVersioning(); + + using var provider = services.BuildServiceProvider(); + var registry = GeneratedWorkflowVersionRegistry.GetWorkflowVersionRegistry(provider); + + Assert.True(registry.TryGetValue(CrossAssemblyWorkflowConstants.CanonicalName, out var versions)); + Assert.NotNull(versions); + Assert.Contains(versions, v => v.EndsWith("CrossAppWorkflowV1", StringComparison.Ordinal)); + Assert.Contains(versions, v => v.EndsWith("CrossAppWorkflowV2", StringComparison.Ordinal)); + + var latest = NormalizeWorkflowTypeName(versions![0]); + Assert.Equal("CrossAppWorkflowV2", latest); + } + + private static string NormalizeWorkflowTypeName(string typeName) + { + var trimmed = typeName; + if (trimmed.StartsWith("global::", StringComparison.Ordinal)) + { + trimmed = trimmed["global::".Length..]; + } + + var lastDot = trimmed.LastIndexOf('.'); + return lastDot >= 0 ? trimmed[(lastDot + 1)..] : trimmed; + } +} diff --git a/test/Dapr.IntegrationTest.Workflow.Versioning/Dapr.IntegrationTest.Workflow.Versioning.csproj b/test/Dapr.IntegrationTest.Workflow.Versioning/Dapr.IntegrationTest.Workflow.Versioning.csproj index 8abb476c4..9d0a2c997 100644 --- a/test/Dapr.IntegrationTest.Workflow.Versioning/Dapr.IntegrationTest.Workflow.Versioning.csproj +++ b/test/Dapr.IntegrationTest.Workflow.Versioning/Dapr.IntegrationTest.Workflow.Versioning.csproj @@ -4,8 +4,13 @@ enable enable false + true + + + + @@ -25,6 +30,7 @@ + - \ No newline at end of file +