One of the fundamental design decisions for FreshMarker is always the question of whether a feature is actually needed or whether pre-processing the data can solve the problem.
This is always a delicate balancing act, because any unnecessary pre-processing casts a shadow on the attractiveness of the template engine. It’s always a question of whether I can use my internal data model in the template engine or whether I have to implement a complex mapping.
Many classes can be used directly by FreshMarker because Java Beans, Records, Lists, Maps and many basic types are supported. A previously unclosed gap in the direct use of lists is now to be closed.
The problem with lists is demonstrated using the following example. A report for a monitoring system is to be generated using the following two records.
public recod Sensor(UUID id, Sting name, boolean active) { } public record MonitoringSystem(String name, List<Sensor> sensors) { }
A MonitorinSystem
has a name and a list of sensors. These Sensors
all have an ID, a name and an activity flag. A simple table for the sensors could be implemented as follows.
<h1>${system.name}</h1> <table> <#list system.sensors as sensor with loop> <tr style="${loop.item_parity}"> <td>${sensor.id}</td><td>${sensor.name}</td><td>${sensor.active}</td> </tr> </#list> </table>
What does the template look like if inactive sensors should not appear in the table? The previous FreshMarker solution allows the non-active sensors to be hidden via an If Directive.
<h1>${system.name}</h1> <table> <#list system.sensors as sensor with loop> <#if sensor.flag> <tr style="${loop.item_parity}"> <td>${sensor.id}</td><td>${sensor.name}</td><td>${sensor.active}</td> </tr> </#if> </#list> </table>
There is only one small flaw with this solution: the loop variable is incremented. This means that two consecutive table rows can receive the same style via ${loop.item_parity}
. The problem could be avoided by manually controlling the styles, but this would caricature the idea of the looper.
Another solution would be to add another model variable activerSensors
for active sensors to the model map in addition to the model variable system
for MonitorinSystem
.
<h1>${system.name}</h1> <table> <#list activeSensors as sensor with loop> <tr style="${loop.item_parity}"> <td>${sensor.id}</td><td>${sensor.name}</td><td>${sensor.active}</td> </tr> </#list> </table>
Although the output now corresponds to the desired result, this approach is only helpful with a few lists and a flat data model. This approach is unacceptable for many lists that have to be fished out of deeply nested models.
Another solution is to filter the list within the template engine. Either as an additional operation on the sequence data types or as an additional clause of the List Directive. Extensions on the sequence data types are more complex, as lambdas have to be modeled for filtering. The extension of the List Directive is much simpler.
<h1>${system.name}</h1> <table> <#list activeSensors as sensor with loop filter sensor.active> <tr style="${loop.item_parity}"> <td>${sensor.id}</td><td>${sensor.name}</td><td>${sensor.active}</td> </tr> </#list> </table>
In this example, the keyword filter
is followed by a boolean expression sensor.active
, which determines whether the item remains in the sequence (true
) or is filtered out (false
). The semantics of the filter expression therefore correspond to the Java stream method filter
. Perhaps the filter
expression will be replaced in time by the more clearly named expressions take
and drop
.
To implement the filter
feature, the FreshMarker grammar must first be adapted.
List #ListInstruction : (<FTL_DIRECTIVE_OPEN1>|<FTL_DIRECTIVE_OPEN2>) <LIST><BLANK> Expression <AS> <IDENTIFIER> [ [ <SORTED> (<ASCENDING> | <DESCENDING>) ] <COMMA> <IDENTIFIER> ] [ <WITH> <IDENTIFIER> ] [ <FILTER> Expression ] [ <LIMIT> Expression ] <CLOSE_TAG> Block DirectiveEnd("list") ;
An optional filter
and an optional limit
clause are inserted after the with
clause. The limit
clause limits the length of the sequence in the List Directive, but is not discussed further in this article. The existing visit
method for the List Directive evaluates the two new clauses and inserts the expressions into the TemplateSequence
implementation.
The first implementation of the filter feature is kept simple. At the beginning of the List Directive, the list is determined and then the filter expression is applied to every element.
protected List<T> handleFilter(ProcessContext context, List<T> objectList, Integer intLimit) { Environment contextEnvironment = context.getEnvironment(); int counter = Objects.requireNonNullElse(intLimit, objectList.size()); try { FilterVariableEnvironment filterVariableEnvironment = new FilterVariableEnvironment(contextEnvironment, context); context.setEnvironment(filterVariableEnvironment); List<T> newList = new ArrayList<>(); for (int i = 0; i < objectList.size(); i++) { if (i >= counter) { break; } T value = objectList.get(i); addFilterVariable(filterVariableEnvironment, value); if (filter.evaluate(context, TemplateBoolean.class) == TemplateBoolean.TRUE) { newList.add(value); } } return newList; } finally { context.setEnvironment(contextEnvironment); } }
In the handleFilter
method, an environment is created that contains the current value
from the sequence under the variable name from the directive. This allows the current value
of the sequence and other model variables to be accessed in the filter expression. Only the looper variable cannot be used in the filter expression. The value
is only transferred to the new list if the filter expression returns TemplateBoolean.TRUE
.
The List Directive with filter and limit clause can be tried out in the latest version 1.6.6 of FreshMarker. Have fun!