Partial Template Reduction (1)

The Partial Template Reduction feature was discussed in article FreshMarker Partial Template Reduction. The corresponding implementation is presented in this article.

With Partial Template Reduction, the template is filled with a fraction of the necessary data and thus simplified as far as possible. Primarily, interpolations are replaced by constant fragments and conditional directives are reduced to the actual alternative.

In FreshMarker, an internal representation of the template is created in the form of a fragment tree. The output is generated when running through the tree, in which each fragment generates its part of the output in its process method.

The idea behind Partial Template Reduction is to implement a suitable reduce method for each fragment, returning a new fragment if a reduction is possible. If a reduction is not possible, the method returns the current fragment itself. The starting point of the implementation is therefore the Fragment interface that all fragments implement.

public interface Fragment {

    void process(ProcessContext context);

    default Fragment reduce(ReduceContext context) {
        return this;
    }

    default int getSize() {
        return 1;
    }
}

The reduce method is a default method and implements the return of the fragment if no reduction is possible. Instead of the ProcessContext, the reduce method receives a ReduceContext as a parameter. Why this is the case and how the contexts differ is shown below.

The getSize method has also been added for the number of fragments at this position in the fragment tree. For all fragments without a special implementation, the return value is 1. The getSize method has been added to easily check the reduce effect. If a tree contains fewer fragments than the original as a result of the reduce method, then the reduction was successful.

The reduce method of the root of the fragment tree is called within the method of the same name in the Template. Like the process method in the Template, the reduce method is also passed a model in the form of a Map. However, this Map only contains part of the complete model when it is called. Of course, it could also contain the entire model, but that doesn’t really make sense.

public Template reduce(Map<String, Object> dataModel, ReductionStatus status) {
  status.total().set(rootFragment.getSize());
  ProcessContext context = configuration.createContext(dataModel, new StringWriter());
  context.setEnvironment(getWrapperEnvironment(context));
  try {
    BlockFragment reducedFragment = rootFragment.reduce(new ReduceContext(context, status));
    status.deleted().set(rootFragment.getSize() - reducedFragment.getSize());
    return new Template(configuration, templateLoader, path, reducedFragment);
  } catch (RuntimeException e) {
    throw new ReduceException("cannot reduce: " + e.getMessage(), e);
  }
}

The second parameter status contains a few counters to check the reduction more precisely. The attributes total and deleted are filled with the number of fragments in the original template and the difference to the number of fragments in the newly created tree.

The ReduceContext inherits from ProcessContext and is initialized with a ProcessContext and the ReductionStatus instances.

public class ReduceContext extends ProcessContext {
  private final ReductionStatus status;

  public ReduceContext(ProcessContext context, ReductionStatus status) {
    super(context.getEnvironment(), context.builtIns, context.outputs);
    this.status = status;
  }

  public ReductionStatus getStatus() {
    return status;
  }
}

This means that a fully-fledged ProcessContext is available for further processing and fragments can modify the changed attribute in the ReductionStatus. Only the change in the number of fragments says nothing about the size of the reduction, as the replacement of InterpolationFragment instances with ConstantFragment instances cannot be measured in this way.

The Partial Template Reduction feature can already be tested with these changes. Of course, no reduction takes place because only the fragment root of the old Template is inserted into a new Template. In order for something to change, the implementation of Fragment must be overridden. To see the first effects, we will adapt in this article the InterpolationFragment and the BlockFragment. The InterpolationFragment produces an output from an interpolation and the model. If data is missing, processing is aborted with a ProcessException.

public class InterpolationFragment implements Fragment {

  // ...

  @Override
  public Fragment reduce(ReduceContext context) {
    try {
      TemplateString templateObject = expression.evaluate(context, TemplateString.class);
      context.getStatus().changed().incrementAndGet();
      return new ConstantFragment(templateObject.getValue());
    } catch (UnsupportedBuiltInException e) {
      throw new UnsupportedBuiltInException(e.getMessage(), ftl, e);
    } catch (WrongTypeException e) {
      throw new WrongTypeException(e.getMessage(), ftl, e);
    } catch (ProcessException e) {
      return this;
    }
  }
}

The new reduce method works in a similar way. If a new output could be generated, then the reduce method produces a ConstantFragment with this output. In the negative case, the InterpolationFragment itself is returned. In addition, the reduce method increments the number of changed fragments in the ReduceContext so that we can see what happened during the reduction.

As the root fragment in the Template is a BlockFragment, the base implementation must also be overridden here. A BlockFragment is a container that contains a List of Fragment instances. The new BlockFragment must therefore contain a List of Fragment instances that were created using the reduce method.

public class BlockFragment implements Fragment {

  // ...

  @Override
  public int getSize() {
    return fragments.stream().mapToInt(Fragment::getSize).sum() + 1;
  }

  @Override
  public BlockFragment reduce(ReduceContext context) {
    return new BlockFragment(fragments.stream().map(f -> f.reduce(context)).toList());
  }
}

In the reduce method, the reduce method is called on all list elements and the result is collected in a new list. In addition, the getSize method has been overridden, as the number must also include the sizes of the list elements.

Simple templates can already be reduced with these changes.

Map<String, Object> model = Map.of("company", "schegge.de");
Template original = configuration.getTemplate("test", "${company}: ${name}");
Template reduced = origial.reduce(model, reductionStatus);
assertNotNull(reduced);
assertEquals("schegge.de: Jens Kaiser", reduced.process(Map.of("name", "Jens Kaiser")));
assertEquals(5, reductionStatus.total().get());
assertEquals(0, reductionStatus.deleted().get());
assertEquals(1, reductionStatus.changed().get());

In this test, the original Template is created from ${company}: ${name} and then reduced with the model Map.of("company", "schegge.de"). The new reduced template has just as many Fragment instances as the original Template, but the fragment ${company} has been reduced to schegge.de.

The next article deals with the implementations for the List-, Switch- and If-Directives.

2 thoughts on “Partial Template Reduction (1)”

Leave a Comment