Skip to content

Commit

Permalink
Fix support for nested function expressions
Browse files Browse the repository at this point in the history
This commit modifies the TemplateData handler to correctly process nested function expressions. Unit tests for various function call scenarios have been added to ensure reliability and correctness. While this technically just was not supported prior, it is likely to be expected by end users, given nested functions, technically, work by chance; making this more of a fix than a new feature.
  • Loading branch information
X39 committed Nov 3, 2024
1 parent 3955f88 commit 97b2e3c
Show file tree
Hide file tree
Showing 4 changed files with 329 additions and 17 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ jobs:
- name: Pack
# CHANGE PACKAGE VERSION - The retarded way
# Change the /p:VERSION=X.X.X part to change the actual package version.
run: dotnet pack --configuration Release /p:VERSION=5.1.1.${{ github.run_number }} --version-suffix ${{ github.sha }}
run: dotnet pack --configuration Release /p:VERSION=5.1.2.${{ github.run_number }} --version-suffix ${{ github.sha }}
- name: Upload to NuGet
run: dotnet nuget push ./source/X39.Solutions.PdfTemplate/bin/Release/X39.Solutions.PdfTemplate.*.nupkg -s https://api.nuget.org/v3/index.json -k ${{ secrets.NUGET_API_KEY }}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
public class FunctionNotFoundDuringEvaluationException : EvaluationException
{
internal FunctionNotFoundDuringEvaluationException(string functionName)
: base ($"Function '{functionName}' not found.")
: base($"Function '{functionName}' not found.")
{
FunctionName = functionName;
}
Expand All @@ -15,4 +15,35 @@ internal FunctionNotFoundDuringEvaluationException(string functionName)
/// The name of the function that was not found.
/// </summary>
public string FunctionName { get; set; }
}

/// <summary>
/// Thrown when a function expression has additional data at the end.
/// </summary>
public class FunctionExpressionNotFullyHandledException : EvaluationException
{
internal FunctionExpressionNotFullyHandledException(string functionName, string expression, string unhandledData)
: base(
$"Expression '{expression}' was properly matched to function {functionName} but has an invalid format, leaving '{unhandledData}' unhandled."
)
{
FunctionName = functionName;
Expression = expression;
UnhandledData = unhandledData;
}

/// <summary>
/// The name of the function that was found.
/// </summary>
public string FunctionName { get; set; }

/// <summary>
/// The full function expression, including the function name and the args.
/// </summary>
public string Expression { get; }

/// <summary>
/// The data passed into the function that could not be handled.
/// </summary>
public string UnhandledData { get; }
}
51 changes: 36 additions & 15 deletions source/X39.Solutions.PdfTemplate/TemplateData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,29 +129,50 @@ private static bool IsNumericExpression(char firstChar)
}

private async ValueTask<object?> HandleFunctionExpressionAsync(
CultureInfo cultureInfo,
string expression,
CultureInfo cultureInfo,
string expression,
CancellationToken cancellationToken
)
{
var functionName = expression[..expression.IndexOf('(')];
var function = GetFunction(functionName);
var argsStartIndex = expression.IndexOf('(');
var functionName = expression[..argsStartIndex];
var function = GetFunction(functionName);
if (function is null)
throw new FunctionNotFoundDuringEvaluationException(functionName);
var argumentEnd = expression.LastIndexOf(')');
var argumentsExpression = expression[(functionName.Length + 1)..(argumentEnd)].Trim();
if (argumentsExpression.IsNullOrEmpty())
return await function.ExecuteAsync(cultureInfo, Array.Empty<object?>(), cancellationToken)
.ConfigureAwait(false);
var splatted = argumentsExpression.Split(',', StringSplitOptions.TrimEntries);
var arguments = new object?[splatted.Length];
for (var i = 0; i < arguments.Length; i++)

var args = new List<object?>();
var braceCount = 1;
var currentStart = argsStartIndex + 1;
var i = argsStartIndex + 1;
for (; i < expression.Length && braceCount > 0; i++)
{
var result = await EvaluateAsync(cultureInfo, splatted[i], cancellationToken).ConfigureAwait(false);
arguments[i] = result;
var c = expression[i];
switch (c)
{
case '(':
braceCount++;
break;
case ')' when braceCount > 1:
braceCount--;
break;
case ',' when braceCount == 1:
case ')':
{
var nestedExpression = expression[currentStart..i].Trim();
if (nestedExpression.Length is 0)
break;
var value = await EvaluateAsync(cultureInfo, nestedExpression, cancellationToken);
args.Add(value);
currentStart = i + 1;
break;
}
}
}
if (i != expression.Length)
throw new FunctionExpressionNotFullyHandledException(functionName, expression, expression[i..]);

return await function.ExecuteAsync(cultureInfo, arguments, cancellationToken).ConfigureAwait(false);
return await function.ExecuteAsync(cultureInfo, args.ToArray(), cancellationToken)
.ConfigureAwait(false);
}

private static string HandleStringExpression(string expression)
Expand Down
260 changes: 260 additions & 0 deletions test/X39.Solutions.PdfTemplate.Test/Xml/FunctionCallTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
using System.Globalization;
using System.Text;
using System.Xml;
using X39.Solutions.PdfTemplate.Test.ExpressionTests;
using X39.Solutions.PdfTemplate.Transformers;
using X39.Solutions.PdfTemplate.Xml;

namespace X39.Solutions.PdfTemplate.Test.Xml;

public class FunctionCallTests
{
[Fact]
public async Task CanCallEmptyFunction()
{
// Setup
const string ns = Constants.ControlsNamespace;
const string template = $$"""
<?xml version="1.0" encoding="utf-8"?>
<styleMustBeEmptyTagTest xmlns="{{ns}}" someAttribute="asd">
<text>@myFunc()</text>
</styleMustBeEmptyTagTest>
""";
var templateData = new TemplateData();
templateData.RegisterFunction(new DummyValueFunction("myFunc", "someValue", []));

// Act
var templateReader = new XmlTemplateReader(CultureInfo.InvariantCulture, templateData, []);
using var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(template));
using var xmlReader = XmlReader.Create(xmlStream);
var nodeInformation = await templateReader.ReadAsync(xmlReader);

// Assert
var xmlNodeInformation = Assert.Single(nodeInformation.Children);
Assert.Equal("someValue", xmlNodeInformation.TextContent);
}

[Fact]
public async Task CanCallSingleArgumentFunction()
{
// Setup
const string ns = Constants.ControlsNamespace;
const string template = $$"""
<?xml version="1.0" encoding="utf-8"?>
<styleMustBeEmptyTagTest xmlns="{{ns}}" someAttribute="asd">
<text>@myFunc(1)</text>
</styleMustBeEmptyTagTest>
""";
var templateData = new TemplateData();
templateData.RegisterFunction(new DummyValueFunction("myFunc", "someValue", [typeof(int)]));

// Act
var templateReader = new XmlTemplateReader(CultureInfo.InvariantCulture, templateData, []);
using var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(template));
using var xmlReader = XmlReader.Create(xmlStream);
var nodeInformation = await templateReader.ReadAsync(xmlReader);

// Assert
var xmlNodeInformation = Assert.Single(nodeInformation.Children);
Assert.Equal("someValue", xmlNodeInformation.TextContent);
}

[Fact]
public async Task CanCallDoubleArgumentFunction()
{
// Setup
const string ns = Constants.ControlsNamespace;
const string template = $$"""
<?xml version="1.0" encoding="utf-8"?>
<styleMustBeEmptyTagTest xmlns="{{ns}}" someAttribute="asd">
<text>@myFunc(1,2)</text>
</styleMustBeEmptyTagTest>
""";
var templateData = new TemplateData();
templateData.RegisterFunction(new DummyValueFunction("myFunc", "someValue", [typeof(int), typeof(int)]));

// Act
var templateReader = new XmlTemplateReader(CultureInfo.InvariantCulture, templateData, []);
using var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(template));
using var xmlReader = XmlReader.Create(xmlStream);
var nodeInformation = await templateReader.ReadAsync(xmlReader);

// Assert
var xmlNodeInformation = Assert.Single(nodeInformation.Children);
Assert.Equal("someValue", xmlNodeInformation.TextContent);
}

[Fact]
public async Task CanCallTripleArgumentFunction()
{
// Setup
const string ns = Constants.ControlsNamespace;
const string template = $$"""
<?xml version="1.0" encoding="utf-8"?>
<styleMustBeEmptyTagTest xmlns="{{ns}}" someAttribute="asd">
<text>@myFunc(1,2,3)</text>
</styleMustBeEmptyTagTest>
""";
var templateData = new TemplateData();
templateData.RegisterFunction(
new DummyValueFunction("myFunc", "someValue", [typeof(int), typeof(int), typeof(int)])
);

// Act
var templateReader = new XmlTemplateReader(CultureInfo.InvariantCulture, templateData, []);
using var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(template));
using var xmlReader = XmlReader.Create(xmlStream);
var nodeInformation = await templateReader.ReadAsync(xmlReader);

// Assert
var xmlNodeInformation = Assert.Single(nodeInformation.Children);
Assert.Equal("someValue", xmlNodeInformation.TextContent);
}

[Theory]
[InlineData("myFunc( 1 , 2 , 3 )", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc(1 , 2 , 3 )", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc(1, 2 , 3 )", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc(1,2 , 3 )", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc(1,2, 3 )", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc(1,2,3 )", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc( 1 , 2 , 3)", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc( 1 , 2 ,3)", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc( 1 , 2,3)", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc( 1 ,2,3)", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc( 1,2,3)", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc(1,2,3)", new[] { typeof(int), typeof(int), typeof(int) })]
[InlineData("myFunc(\"someString\")", new[] { typeof(string) })]
[InlineData("myFunc(\"someString\", \"someString\")", new[] { typeof(string), typeof(string) })]
[InlineData("myFunc(true)", new[] { typeof(bool) })]
[InlineData("myFunc(false)", new[] { typeof(bool) })]
[InlineData("myFunc(1.1)", new[] { typeof(double) })]
[InlineData("myFunc(1, true, \"string\")", new[] { typeof(int), typeof(bool), typeof(string) })]
public async Task CanCallNonNestedFunction(string call, Type[] args)
{
// Setup
const string ns = Constants.ControlsNamespace;
string template = $$"""
<?xml version="1.0" encoding="utf-8"?>
<styleMustBeEmptyTagTest xmlns="{{ns}}" someAttribute="asd">
<text>@{{call}}</text>
</styleMustBeEmptyTagTest>
""";
var templateData = new TemplateData();
templateData.RegisterFunction(new DummyValueFunction("myFunc", "someValue", args));

// Act
var templateReader = new XmlTemplateReader(CultureInfo.InvariantCulture, templateData, []);
using var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(template));
using var xmlReader = XmlReader.Create(xmlStream);
var nodeInformation = await templateReader.ReadAsync(xmlReader);

// Assert
var xmlNodeInformation = Assert.Single(nodeInformation.Children);
Assert.Equal("someValue", xmlNodeInformation.TextContent);
}

[Fact]
public async Task CanCallNestedFunctionWithNoArgs()
{
// Setup
const string ns = Constants.ControlsNamespace;
const string template = $$"""
<?xml version="1.0" encoding="utf-8"?>
<styleMustBeEmptyTagTest xmlns="{{ns}}" someAttribute="asd">
<text>@myFunc(nestedFunc())</text>
</styleMustBeEmptyTagTest>
""";
var templateData = new TemplateData();
templateData.RegisterFunction(new DummyValueFunction("myFunc", "myValue", [typeof(string)]));
templateData.RegisterFunction(new DummyValueFunction("nestedFunc", "someValue", []));

// Act
var templateReader = new XmlTemplateReader(CultureInfo.InvariantCulture, templateData, []);
using var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(template));
using var xmlReader = XmlReader.Create(xmlStream);
var nodeInformation = await templateReader.ReadAsync(xmlReader);

// Assert
var xmlNodeInformation = Assert.Single(nodeInformation.Children);
Assert.Equal("myValue", xmlNodeInformation.TextContent);
}

[Fact]
public async Task CanCallNestedVariable()
{
// Setup
const string ns = Constants.ControlsNamespace;
const string template = $$"""
<?xml version="1.0" encoding="utf-8"?>
<styleMustBeEmptyTagTest xmlns="{{ns}}" someAttribute="asd">
<text>@myFunc(fancyVar)</text>
</styleMustBeEmptyTagTest>
""";
var templateData = new TemplateData();
templateData.RegisterFunction(new DummyValueFunction("myFunc", "myValue", [typeof(string)]));
templateData.SetVariable("fancyVar", "string");

// Act
var templateReader = new XmlTemplateReader(CultureInfo.InvariantCulture, templateData, []);
using var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(template));
using var xmlReader = XmlReader.Create(xmlStream);
var nodeInformation = await templateReader.ReadAsync(xmlReader);

// Assert
var xmlNodeInformation = Assert.Single(nodeInformation.Children);
Assert.Equal("myValue", xmlNodeInformation.TextContent);
}

[Fact]
public async Task FunctionWithThreeArgsCanCallNestedFunctionWithThreeArgs()
{
// Setup
const string ns = Constants.ControlsNamespace;
const string template = $$"""
<?xml version="1.0" encoding="utf-8"?>
<styleMustBeEmptyTagTest xmlns="{{ns}}" someAttribute="asd">
<text>@myFunc(nestedFunc("string", "string", "string"),nestedFunc("string","string","string"), nestedFunc( "string" , "string" , "string" ) )</text>
</styleMustBeEmptyTagTest>
""";
var templateData = new TemplateData();
templateData.RegisterFunction(new DummyValueFunction("myFunc", "myValue", [typeof(string),typeof(string),typeof(string)]));
templateData.RegisterFunction(new DummyValueFunction("nestedFunc", "someValue", [typeof(string),typeof(string),typeof(string)]));

// Act
var templateReader = new XmlTemplateReader(CultureInfo.InvariantCulture, templateData, []);
using var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(template));
using var xmlReader = XmlReader.Create(xmlStream);
var nodeInformation = await templateReader.ReadAsync(xmlReader);

// Assert
var xmlNodeInformation = Assert.Single(nodeInformation.Children);
Assert.Equal("myValue", xmlNodeInformation.TextContent);
}

[Fact]
public async Task FunctionWithSingleNestedFunctionWithTwoArgs()
{
// Setup
const string ns = Constants.ControlsNamespace;
const string template = $$"""
<?xml version="1.0" encoding="utf-8"?>
<styleMustBeEmptyTagTest xmlns="{{ns}}" someAttribute="asd">
<text>@myFunc(nestedFunc("string", "string"))</text>
</styleMustBeEmptyTagTest>
""";
var templateData = new TemplateData();
templateData.RegisterFunction(new DummyValueFunction("myFunc", "myValue", [typeof(string)]));
templateData.RegisterFunction(new DummyValueFunction("nestedFunc", "someValue", [typeof(string),typeof(string)]));

// Act
var templateReader = new XmlTemplateReader(CultureInfo.InvariantCulture, templateData, []);
using var xmlStream = new MemoryStream(Encoding.UTF8.GetBytes(template));
using var xmlReader = XmlReader.Create(xmlStream);
var nodeInformation = await templateReader.ReadAsync(xmlReader);

// Assert
var xmlNodeInformation = Assert.Single(nodeInformation.Children);
Assert.Equal("myValue", xmlNodeInformation.TextContent);
}
}

0 comments on commit 97b2e3c

Please sign in to comment.