Challenge #4 – Elementary, my dear Watson

4 December 2018 Solved Twig Intermediate

All the elements in a Craft site have contrived a clever plan to hide a secret from you. One of the elements in the site has stored the secret in its title field. The other elements have spawned millions of elements as detours to prevent you from finding out the secret. They have left a trail though, which you must follow to the end to reveal the secret.

Every element (entry, category, user, etc.) in a Craft site stores a value in a custom field called nextElement, which contains an expression which, when evaluated, reveals the ID of the next element. This creates a trail that you can follow until you find the element that contains the secret in its title. You can determine when you have reached this element because it will have a nextElement value of null.

For the purposes of this challenge, we will work with objects rather than Craft elements. Below is an example that should help illustrate the problem.

{% set elements = {
    1: {nextElement: 4, title: "I used to think I was indecisive, but now I'm not too sure."},
    2: {nextElement: 2, title: "Doing nothing is hard, you never know when you're done."},
    3: {nextElement: 5, title: "If two wrongs don't make a right, try three."},
    4: {nextElement: 1, title: "I am not lazy, I am on energy saving mode."},
    5: {nextElement: 7, title: "Life is short, smile while you still have teeth."},
    6: {nextElement: null, title: "Sorry for the mean, awful, accurate things I said."},
    7: {nextElement: 9, title: "People say nothing is impossible, but I do nothing every day."},
    8: {nextElement: 6, title: "I’m sorry but if you were right, I’d agree with you."},
    9: {nextElement: null, title: "The answer to the ultimate question of life is 42."},
} %}

Starting with an element ID of 3, the trail is as follows:

3 > 5 > 7 > 9

And the secret is therefore:

The answer to the ultimate question of life is 42.

The starting point of the trail is of course the key to being able to reveal the secret. You have inside information that the starting point is the element with an ID equal to 3 (the value of the currently installed version of Craft).

Challenge

The challenge is to write a macro called "revealSecret" that accepts 2 parameters: startId (the ID to start with) and elements (a collection of objects indexed by their IDs). The macro should output the trail it follows by outputting the IDs of the elements it encounters, followed by the secret.

So for example calling:

{% set startId = 3 %}

{{ revealSecret(startId, elements) }}

Given the input above will output:

3 > 5 > 7 > 9 > The answer to the ultimate question of life is 42.

The value of nextElement may contain an expression rather than an integer, that when evaluated reveals the ID of the next element. So for example, the input may be as follows.

{% set elements = {
    1: {nextElement: "1+2", title: "I used to think I was indecisive, but now I'm not too sure."},
    2: {nextElement: 2, title: "Doing nothing is hard, you never know when you're done."},
    3: {nextElement: "2+3", title: "If two wrongs don't make a right, try three."},
    4: {nextElement: "4/4", title: "I am not lazy, I am on energy saving mode."},
    5: {nextElement: "21/3", title: "Life is short, smile while you still have teeth."},
    6: {nextElement: null, title: "Sorry for the mean, awful, accurate things I said."},
    7: {nextElement: "3*3", title: "People say nothing is impossible, but I do nothing every day."},
    8: {nextElement: "12/2", title: "I’m sorry but if you were right, I’d agree with you."},
    9: {nextElement: null, title: "The answer to the ultimate question of life is 42."},
} %}

The value of nextElement will always be either an integer or a mathematical expression that uses one and only one of the operators +, -, *, /.

Rules

The macro must output the trail and the secret given the 2 parameters as described above. It should not rely on any plugins and the code will be evaluated based on the following criteria in order of priority:

  1. Readability
  2. Brevity
  3. Performance

Therefore the code should be readable and easy to understand. It should be concise and non-repetative, but not at the expense of readability. It should be performant, but not at the expense of readability or brevity.

Tips

Begin by solving the challenge with the assumption that the value of nextElement is always an integer. Then solve it for expressions that can contain one of the operators, for example 2+3.

Solution

To begin with, we will assume that the value of nextElement is always an integer that is equal to the ID of the next element. So all we need to do is start with the element with an ID 3 and loop over each subsequent element until we reach an element with a nextElement of null. We do this as long as the secret has not been revealed by adding a condition to the for loop.

Since twig does not allow us to break out of a loop however, how do we know how many times we need to iterate over the loop? Well, we know that the trail will end at some stage and that it cannot be longer than the total number of elements in the set, so we will use elements|length as the limit.

For each iteration, we output the current startId and set the new startId to the value of the current element's nextElement. If it is null then we set secret to the title of the element which will prevent the loop from being entered again. After the for loop we simply output the secret.

{% macro revealSecret(startId, elements) %}

    {% set secret = '' %}

    {% for i in 1..elements|length if not secret %}
        {{ startId }} >
        {% if elements[startId].nextElement is null %}
            {% set secret = elements[startId].title %}
        {% else %}
            {% set startId = elements[startId].nextElement %}
        {% endif %}
    {% endfor %}

    {{ secret }}

{% endmacro %}

We could condense this down to less code if we wanted to (though perhaps less readable).

{% macro revealSecret(startId, elements) %}

    {% for i in 1..elements|length if elements[startId].nextElement is not null %}
        {{ startId }} >
        {% set startId = elements[startId].nextElement %}
    {% endfor %}

    {{ startId }} > {{ elements[startId].title }}

{% endmacro %}

Since our macro consists almost entirely of a loop, using recursion could be a more elegant solution to this problem. Recursion is an approach in which a function (or macro in this case) solves one step of the problem and then calls itself to solve the next step.

To illustrate further, we use an iterative approach above to loop n times (the length of the elements) over the code, regardless of when the solution is found. This could be seen as wasteful since the trail to the solution could be a single iteration and the for loop would nevertheless continue iterating until n is reached.

Iteration

A recursive approach would run only until the solution is revealed and would then immediately stop by simply not calling itself any more.

Recursion

Note that in order for a macro to call itself, it must first import itself.

{% macro revealSecret(startId, elements) %}

    {{ startId }} >

    {% if elements[startId].nextElement is null %}
        {{ elements[startId].title }}
    {% else %}
        {% from _self import revealSecret %}
        {{ revealSecret(elements[startId].nextElement, elements) }}
    {% endif %}

{% endmacro %}

The next challenge is to determine the value of nextElement if it is a mathematical expression. There is no obvious function in twig to evaluate a string (although there is a workaround), but Craft gives us a few possibilities.

In Challenge #3's solution, we made it possible to define an email notification template which we rendered using a variation of the following PHP code:

Craft::$app->getView()->renderTemplate($templateName, $variables);

The renderTemplate method allowed us to pass in the name of a template to render as well as some variables. There is also a renderString method which will parse a template as a string for us. Since the view is available as a global variable to us in twig templates, we can make it work for our mathematical expression as follows:

{% set startId = view.renderString("{{" ~ elements[startId].nextElement ~ "}}") %}

We could also use string interpolation to evaluate the string and can also make it work using some of Craft's other render methods on the View class.

{% set startId = view. renderObjectTemplate("{{ #{elements[startId].nextElement} }}", {}) %}

Similar solutions: Andrew Welch.


Craft's View class extends Yii's View class, which contains a method called evaluateDynamicContent that will evaluate PHP statements (beware!), so the following also works:

{% set startId = view.evaluateDynamicContent("return " ~ elements[id].nextElement ~ ";") %}

Similar solutions: Spenser Hannon.


Even twig provides a template_from_string function which can be used. This requires that the Twig_Extension_StringLoader extension is added, which it is by default in Craft.

{% set startId = include(template_from_string("{{" ~ elements[startId].nextElement ~ "}}")) %}

Similar solutions: Rias Van der Veken, Berlin Craft Meetup, Christian Seelbach.


We could of course also evaluate the string by parsing it using twig alone. There are various ways we could do this, but for simplicity's sake we will show a series of if/else statements. Here we assume that one and only one operator can be used.

{% set nextElement = elements[startId].nextElement %}

{% if '+' in nextElement %}
    {% set numbers = nextElement|split('+') %}
    {% set startId = numbers[0] + numbers[1] %}
{% elseif '-' in nextElement %}
    {% set numbers = nextElement|split('-') %}
    {% set startId = numbers[0] - numbers[1] %}
{% elseif '*' in nextElement %}
    {% set numbers = nextElement|split('*') %}
    {% set startId = numbers[0] * numbers[1] %}
{% elseif '/' in nextElement %}
    {% set numbers = nextElement|split('/') %}
    {% set startId = numbers[0] / numbers[1] %}
{% endif %}

Similar solutions: Paul Verheul, Matt Stein, Cole Henley, John Wells, Alex Roper, Mark Smits.


Or if none of the above solutions take your fancy then you could follow Patrick Harrington's example and outsource the problem by using Craft's API service to call an external API to solve it for you so you can get on with the rest of your life!!

Sleep

Submitted Solutions

  • Andrew Welch
  • Paul Verheul
  • Rias Van der Veken
  • Matt Stein
  • Cole Henley
  • Patrick Harrington
  • John Wells
  • Berlin Craft Meetup
  • Christian Seelbach
  • Spenser Hannon
  • Alex Roper
  • Mark Smits