fix(expressions): keep C# variable accessor lookup keys as real names (#8160)

* fix(expressions): keep C# variable accessor lookup keys as real names

Pascalize only the generated C# property identifier. Get/Set still use
the original variable.Name so camelCase names like orderId resolve the
same way as JS, Liquid, and Python.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(expressions): disambiguate Options.Create in accessor tests

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Sipke Schoorstra 2026-09-14 20:00:31 +02:00 committed by GitHub
parent 6503c0cb92
commit 7e22a12081
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 94 additions and 2 deletions

View file

@ -38,7 +38,8 @@ public class GenerateWorkflowVariableAccessors(IOptions<CSharpOptions> options)
{
foreach (var variable in variables.Where(x => x.Name.IsValidVariableName()))
{
var variableName = variable.Name.Pascalize();
var propertyName = variable.Name.Pascalize();
var variableName = variable.Name;
var variableType = variable.GetVariableType();
// Check if the variable type is ExpandoObject
@ -48,7 +49,7 @@ public class GenerateWorkflowVariableAccessors(IOptions<CSharpOptions> options)
var displayTypeName = isExpandoObject ? "dynamic" : variableType.GetFriendlyTypeName(Brackets.Angle);
var retrieveTypeName = isExpandoObject ? "ExpandoObject" : displayTypeName;
sb.AppendLine($"\tpublic {displayTypeName} {variableName}");
sb.AppendLine($"\tpublic {displayTypeName} {propertyName}");
sb.AppendLine("\t{");
sb.AppendLine($"\t\tget => Get<{retrieveTypeName}>(\"{variableName}\");");
sb.AppendLine($"\t\tset => Set(\"{variableName}\", value);");

View file

@ -0,0 +1,91 @@
using Elsa.Expressions.CSharp.Handlers;
using Elsa.Expressions.CSharp.Notifications;
using Elsa.Expressions.CSharp.Options;
using Elsa.Expressions.CSharp.Services;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Mediator.Contracts;
using Elsa.Workflows.Memory;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Expressions.UnitTests.CSharp;
public class GenerateWorkflowVariableAccessorsTests
{
[Fact]
public async Task PascalizedProperty_ReadsCamelCaseVariable_ByRealName()
{
var context = CreateContext(new Variable<string>("orderId", "ORD-1"));
var evaluator = CreateEvaluator();
var result = await evaluator.EvaluateAsync("Variables.OrderId", typeof(string), context, new ExpressionEvaluatorOptions());
Assert.Equal("ORD-1", result);
}
[Fact]
public async Task PascalizedProperty_WritesCamelCaseVariable_WithoutCreatingPascalizedShadow()
{
var context = CreateContext(new Variable<string>("orderId", "ORD-1"));
var evaluator = CreateEvaluator();
var result = await evaluator.EvaluateAsync(
"""
Variables.OrderId = "ORD-2";
Variables.OrderId
""",
typeof(string),
context,
new ExpressionEvaluatorOptions());
Assert.Equal("ORD-2", result);
Assert.Equal("ORD-2", context.GetVariable<string>("orderId"));
Assert.Null(context.GetVariable<string>("OrderId"));
}
[Fact]
public async Task PascalizedProperty_ReadsAlreadyPascalCaseVariable()
{
var context = CreateContext(new Variable<string>("OrderId", "ORD-3"));
var evaluator = CreateEvaluator();
var result = await evaluator.EvaluateAsync("Variables.OrderId", typeof(string), context, new ExpressionEvaluatorOptions());
Assert.Equal("ORD-3", result);
}
private static ExpressionExecutionContext CreateContext(Variable variable)
{
var memoryRegister = new MemoryRegister();
memoryRegister.Declare(variable);
return new ExpressionExecutionContext(new ServiceCollection().BuildServiceProvider(), memoryRegister);
}
private static CSharpEvaluator CreateEvaluator()
{
var csharpOptions = new CSharpOptions
{
AllowHostCodeExecution = true
};
var options = Microsoft.Extensions.Options.Options.Create(csharpOptions);
var accessors = new GenerateWorkflowVariableAccessors(options);
var assemblies = new AddAssembliesAndReferencesFromOptions(options);
var notificationSender = Substitute.For<INotificationSender>();
notificationSender
.SendAsync(Arg.Any<INotification>(), Arg.Any<CancellationToken>())
.Returns(async callInfo =>
{
if (callInfo.Arg<INotification>() is not EvaluatingCSharp evaluating)
return;
var cancellationToken = callInfo.Arg<CancellationToken>();
await assemblies.HandleAsync(evaluating, cancellationToken);
await accessors.HandleAsync(evaluating, cancellationToken);
});
return new CSharpEvaluator(notificationSender, options, new MemoryCache(new MemoryCacheOptions()));
}
}