sebastienros/fluid

Customize Range Expression?

Opened this issue · 12 comments

Is it possible to customize a Range Expression (so as to manipulate the start/end values)?
For now I've created a 'custom function' to deal with it - e.g.
{% for r in range(start,end) %}

But I'd like to know if there's a better way to do it.

The FluidParser class uses Range inside Primary. So you could add a custom pattern like Primary = Primary.Or(somethingelse) to add custom ranges. It would have to return a RangeExpression like the original one. Mind sharing what you are trying to do?

If adding more "Primary" expressions is a common thing we could change the way it's done, the same way we can already add custom operators and tags for instance, with an explicit collection of elements.

What I'm trying to do is related to the other issues/comments that I've posted previously. I.e., How to handle heterogenous type comparisons. My use case / app is that I allow users to create "templates" that contain Liquid language syntax, and they also can create an associated "Form" that contains the variables (using HTML elements). The issue is that when the Form is submitted to my Controller Action method, the parameter is of type IFormCollection. I easily convert the FormCollection into a Dictionary which is the 'model' used as the context passed to RenderAsync. The Dictionary works well, because Fluid resolves the variables (Key). However, the Value of the KeyValuePair is of type Microsoft.Extensions.Primitives.StringValues, which is essentially an array of string. The IFormCollection uses StringValues because HTML elements can contain an 'array' of values - e.g., multiple checkboxes ( ) with the same name, or a select having the "multiple" attribute (<select ... multiple> ). I can't "convert" the data types prior to the Render, because I don't know the "context" of how the variables are used in the liquid tags/expressions. I.e., I don't know if the 'string array of length one' will be used in a {% for x in variable %}, or will it be used in a comparison to a literal number {%if variable == 1 %}. So, I've had to override all the equality related operators (==, !=, <, >, ...) so that I can "normalize" the data types of the operands - e.g. "if both operands look like numbers, via TryParse, then convert them to NumberValue". Now, I've run into the same problem with the "operands" of the ".." range "operator", but being that it's a RangeExpression, and not a Operator, I could only come up with using the 'custom function' as mentioned above.

You can provide custom value converters that will transform any c# type to a custom IFluidValue or existing fluid value type (array, ...). This way all the comparisons and filters will work as expected.

I think you need to reread my

I can't "convert" the data types prior to the Render, because I don't know the "context" of how the variables are used in the liquid tags/expressions.

I cannot "transform any c# type to a ... fluid value type ", because the 'source' type will always be StringValues, yet the 'target' type depends on the context of the liquid expression.

Do you mind sharing some minimal code+template+result that you would expect to work? I might need that to understand what is blocking you.

Sure...
Run this as-is, and it works fine. Then, delete the line {%- assign n = 3 -%}, and then 'n' will come from the model, and it won't work correctly (because n is a string).

using Fluid;
using Fluid.Ast;
using Fluid.Values;
using System.Threading.Tasks;
using Microsoft.Extensions.Primitives;

string feature = @"
{%- assign n = 3 -%}

n = {{n}}

{% if n == 3 %}
  Yes, n is 3
{% endif %}

{% for i in (1..n) %}
 i={{i}}
{% endfor %}
";

var model = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
    {"n", new StringValues("3") },
};
//model.Dump("model");

var parser = new FluidParser();

if (parser.TryParse(feature, out var template, out var error))
{
    var context = new TemplateContext(model);
    var result = template.Render(context);

    Console.WriteLine(result);
}
else
{
     Console.WriteLine($"Error: {error}");
}

Here is an example that does something:

var options = new TemplateOptions();
options.ValueConverters.Add(o => o is StringValues s ? int.Parse(s[0]) : null);
    
if (parser.TryParse(feature, out var template, out var error))
{
	var context = new TemplateContext(model, options);
    var result = template.Render(context);

    Console.WriteLine(result);
}

I configured it to use the first value and convert it to integer.

If you don't convert it to integer then the range will still work but not the equality test (which is type sensitive in Liquid). If you also want to check the value as integer then you need to convert the string to integer in Liquid using the documented way (not a Fluid thing, standard Liquid behavior)

{% assign n = n | plus: 0 -%}

This was just an example where the value was an int. However, as mentioned before, I cannot always convert StringValues to int.Parse(s[0]), because I don't know the "context" of how the end users have written their Liquid. I.e., where/how they use the variable. They might write {% if n == "3" %} with 3 in quotes - i.e. a string, or set up their (html) "Form" such that the element is a checkbox group and thus the variable is an array:

{% for a in arr %}
 a={{a}}
{% endfor %}
...
var model = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
    {"arr", new StringValues(new string[] {"1","2","3"}) },
};

or set up their (html) "Form" such that the element is a checkbox group

So the values passed to the template are completely dynamic/unknown?

They might write {% if n == "3" %} with 3 in quotes

If the same users write the Form and the Template they should be consistent, or expect problems.

I don't know what kind of magic you expect to do the "correct" thing whatever the input (int, string, array of string, array of int, ...)? And whatever the correct thing is for one person, it might not be it for another one.

Yes, completely dynamic.
Users can't be "consistent", because they write the fluid 'naturally', eg. n == 3, or n =< 100 using ints, and yet StringValues always contains strings. I can't expect that they'd always write their expressions using strings, and even if they did, the < and > operators won't work - e.g. 3 < 100, but "3" is not < "100", so with variables, e.g. {% if a < b %} , it won't work.

The "magic" is what I mentioned above -

I can "normalize" the data types of the operands - e.g. "if both operands look like numbers, via TryParse, then convert them to NumberValue".

That worked fine for the operators, by registering (overriding) them:

parser.RegisteredOperators["=="] = (a, b) => new EqualBinaryExpression(a, b);
parser.RegisteredOperators["!="] = (a, b) => new NotEqualBinaryExpression(a, b);
parser.RegisteredOperators[">"] = (a, b) => new GreaterThanBinaryExpression(a, b, true);
parser.RegisteredOperators["<"] = (a, b) => new LowerThanBinaryExpression(a, b, true);
parser.RegisteredOperators[">="] = (a, b) => new GreaterThanBinaryExpression(a, b, false);
parser.RegisteredOperators["<="] = (a, b) => new LowerThanBinaryExpression(a, b, false);
(and of course, the classes for implementation ;)

But, the "range" is not an operator. So, I had to create a "range" function, where I can normalize the arguments:

{% for r in range(first,last) %}

context.SetValue("range", new FunctionValue(Range));
...
static ValueTask<FluidValue> Range(FunctionArguments args, TemplateContext context)
{...

I think it would be nice if this 'normalization' was built-in :)

To make it extensible we could give a way to provide the expression to execute when a range is parsed: https://github.com/sebastienros/fluid/blob/main/Fluid/FluidParser.cs#LL118C50-L118C50

This code would then take this expression instead of returning a new RangeExpression instance.

Another option is to use a better Visitor model (I have it in another branch) that would let you change the abstract syntax tree and replace any RangeExpression by what you want.