scriban icon indicating copy to clipboard operation
scriban copied to clipboard

array.sort only works with simple members (can't use nested members)

Open thomaslevesque opened this issue 5 years ago • 3 comments

For instance, if I have a data structure like this:

order --(N)--> line --(1)--> product

It's not possible to do this

{{ for line in order.lines | array.sort "product.name" }}

because array.sort treats "product.name" as a direct member of the "line" object (IObjectAccessor.TryGetValue is invoked with "product.name" as the member parameter). (the code above doesn't throw an error but the lines aren't sorted)

If I want to display the products in order, I can do this:

{{ for product in order.lines | array.map "product" | array.sort "name" }}

But then I don't have access to the "line" object.

I think array.sort should access the "name" member of the "product" member of the "line", rather than accessing the "product.name" member of the "line" (which doesn't exist).

thomaslevesque avatar Aug 07 '19 13:08 thomaslevesque

Current workaround: replace the current implementation of array.sort with this:

    class CustomBuiltinFunctions : BuiltinFunctions
    {
        public CustomBuiltinFunctions()
        {
            var arrayFunctions = (ArrayFunctions)this["array"];
            arrayFunctions.Import("sort", new Func<TemplateContext, SourceSpan, object, string, IEnumerable>(Sort));
        }

        private static IEnumerable Sort(TemplateContext context, SourceSpan span, object list, string member = null)
        {
            if (list == null)
            {
                return Enumerable.Empty<object>();
            }

            var enumerable = list as IEnumerable;
            if (enumerable == null)
            {
                return new ScriptArray(1) { list };
            }

            var realList = enumerable.Cast<object>().ToList();
            if (realList.Count == 0)
                return realList;

            if (string.IsNullOrEmpty(member))
            {
                realList.Sort();
            }
            else
            {
                var pathSegments = member.Split('.');
                realList.Sort((a, b) =>
                {
                    object leftValue = null;
                    object rightValue = null;
                    TryGetValueForPath(context, span, a, pathSegments, out leftValue);
                    TryGetValueForPath(context, span, b, pathSegments, out rightValue);
                    return Comparer<object>.Default.Compare(leftValue, rightValue);
                });
            }

            return realList;
        }

        private static bool TryGetValueForPath(TemplateContext context, SourceSpan span, object obj, string[] pathSegments, out object value)
        {
            value = obj;
            foreach (var member in pathSegments)
            {
                object memberValue;
                var accessor = context.GetMemberAccessor(obj);
                if (accessor.TryGetValue(context, span, obj, member, out memberValue))
                {
                    obj = memberValue;
                }
                else if (context.TryGetMember(context, span, obj, member, out memberValue))
                {
                    obj = memberValue;
                }
                else
                {
                    return false;
                }
            }

            value = obj;
            return true;
        }
}

thomaslevesque avatar Aug 07 '19 13:08 thomaslevesque

I think this would be a breaking change for some people.

Since a "member name" is really just a dictionary key, and dictionary keys can contain periods, we cannot assume that foo.bar means "the bar property of the member foo." It may mean "The member foo.bar."

I think the best we could do, without a breaking change, is first check to see if there's a member foo.bar present. If that member is not present (note, this is not the same as checking to see if the result of that evaluation is null), then and only then can we check property bar on member foo.

Example:

Template text:

{{ for line in lines | array.sort "product.name" }}
Department: {{ line.department }}
Product Name: {{ line.product.name }}
Aisle: {{ line.product.aisle }}
{{ end }}

Code:

var input = new ScriptObject
{
    {
        "lines",
        new[]
        {
            new ScriptObject
            {
                {"product.name", "celery"},
                { "department", "food" },
                {
                    "product", new
                    {
                        Name = "sedano",
                        Aisle = 10
                    }
                }
            },
            new ScriptObject
            {
                { "product.name", "dog" },
                { "department", "pets" },
                {
                    "product",
                    new
                    {
                        Name = "cane",
                        Aisle = 5
                    }
                }
            }
        }
    }
};
Console.WriteLine(template.Render(input));

Currently results in the following:

Department: food
Product Name: sedano
Aisle: 10

Department: pets
Product Name: cane
Aisle: 5

With your changes, it results in the following:

Department: pets
Product Name: cane
Aisle: 5

Department: food
Product Name: sedano
Aisle: 10

binarycow avatar May 15 '21 13:05 binarycow

Current workaround: replace the current implementation of array.sort with this:

    class CustomBuiltinFunctions : BuiltinFunctions
    {
        public CustomBuiltinFunctions()
        {
            var arrayFunctions = (ArrayFunctions)this["array"];
            arrayFunctions.Import("sort", new Func<TemplateContext, SourceSpan, object, string, IEnumerable>(Sort));
        }

        private static IEnumerable Sort(TemplateContext context, SourceSpan span, object list, string member = null)
        {
            if (list == null)
            {
                return Enumerable.Empty<object>();
            }

            var enumerable = list as IEnumerable;
            if (enumerable == null)
            {
                return new ScriptArray(1) { list };
            }

            var realList = enumerable.Cast<object>().ToList();
            if (realList.Count == 0)
                return realList;

            if (string.IsNullOrEmpty(member))
            {
                realList.Sort();
            }
            else
            {
                var pathSegments = member.Split('.');
                realList.Sort((a, b) =>
                {
                    object leftValue = null;
                    object rightValue = null;
                    TryGetValueForPath(context, span, a, pathSegments, out leftValue);
                    TryGetValueForPath(context, span, b, pathSegments, out rightValue);
                    return Comparer<object>.Default.Compare(leftValue, rightValue);
                });
            }

            return realList;
        }

        private static bool TryGetValueForPath(TemplateContext context, SourceSpan span, object obj, string[] pathSegments, out object value)
        {
            value = obj;
            foreach (var member in pathSegments)
            {
                object memberValue;
                var accessor = context.GetMemberAccessor(obj);
                if (accessor.TryGetValue(context, span, obj, member, out memberValue))
                {
                    obj = memberValue;
                }
                else if (context.TryGetMember(context, span, obj, member, out memberValue))
                {
                    obj = memberValue;
                }
                else
                {
                    return false;
                }
            }

            value = obj;
            return true;
        }
}

thanks for this, it works perfectly but I had to add array.Remove("sort"); to make it work

blahetal avatar Nov 13 '23 09:11 blahetal