Challenge #4 – Elementary, my dear Watson

4 December 2018 Solved Twig Intermediate

All the ele­ments in a Craft site have con­trived a clev­er plan to hide a secret from you. One of the ele­ments in the site has stored the secret in its title field. The oth­er ele­ments have spawned mil­lions of ele­ments as detours to pre­vent you from find­ing out the secret. They have left a trail though, which you must fol­low to the end to reveal the secret.

Every ele­ment (entry, cat­egory, user, etc.) in a Craft site stores a value in a cus­tom field called nextElement, which con­tains an expres­sion which, when eval­u­ated, reveals the ID of the next ele­ment. This cre­ates a trail that you can fol­low until you find the ele­ment that con­tains the secret in its title. You can determ­ine when you have reached this ele­ment because it will have a nextElement value of null.

For the pur­poses of this chal­lenge, we will work with objects rather than Craft ele­ments. Below is an example that should help illus­trate 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."},
} %}

Start­ing with an ele­ment 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 start­ing point of the trail is of course the key to being able to reveal the secret. You have inside inform­a­tion that the start­ing point is the ele­ment with an ID equal to 3 (the value of the cur­rently installed ver­sion of Craft).

Chal­lenge

The chal­lenge is to write a macro called revealSecret” that accepts 2 para­met­ers: startId (the ID to start with) and elements (a col­lec­tion of objects indexed by their IDs). The macro should out­put the trail it fol­lows by out­put­ting the IDs of the ele­ments it encoun­ters, fol­lowed by the secret.

So for example calling:

{% set startId = 3 %}

{{ revealSecret(startId, elements) }}

Giv­en the input above will output:

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

The value of nextElement may con­tain an expres­sion rather than an integer, that when eval­u­ated reveals the ID of the next ele­ment. 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 math­em­at­ic­al expres­sion that uses one and only one of the oper­at­ors +, -, *, /.

Rules

The macro must out­put the trail and the secret giv­en the 2 para­met­ers as described above. It should not rely on any plu­gins and the code will be eval­u­ated based on the fol­low­ing cri­ter­ia in order of priority:

  1. Read­ab­il­ity
  2. Brev­ity
  3. Per­form­ance

There­fore the code should be read­able and easy to under­stand. It should be con­cise and non-repet­at­ive, but not at the expense of read­ab­il­ity. It should be per­form­ant, but not at the expense of read­ab­il­ity or brevity.

Tips

Begin by solv­ing the chal­lenge with the assump­tion that the value of nextElement is always an integer. Then solve it for expres­sions that can con­tain one of the oper­at­ors, 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 ele­ment. So all we need to do is start with the ele­ment with an ID 3 and loop over each sub­sequent ele­ment until we reach an ele­ment with a nextElement of null. We do this as long as the secret has not been revealed by adding a con­di­tion to the for loop.

Since twig does not allow us to break out of a loop how­ever, how do we know how many times we need to iter­ate over the loop? Well, we know that the trail will end at some stage and that it can­not be longer than the total num­ber of ele­ments in the set, so we will use elements|length as the limit.

For each iter­a­tion, we out­put the cur­rent startId and set the new startId to the value of the cur­rent element’s nextElement. If it is null then we set secret to the title of the ele­ment which will pre­vent the loop from being entered again. After the for loop we simply out­put 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 con­dense this down to less code if we wanted to (though per­haps 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 con­sists almost entirely of a loop, using recur­sion could be a more eleg­ant solu­tion to this prob­lem. Recur­sion is an approach in which a func­tion (or macro in this case) solves one step of the prob­lem and then calls itself to solve the next step. 

To illus­trate fur­ther, we use an iter­at­ive approach above to loop n times (the length of the ele­ments) over the code, regard­less of when the solu­tion is found. This could be seen as waste­ful since the trail to the solu­tion could be a single iter­a­tion and the for loop would nev­er­the­less con­tin­ue iter­at­ing until n is reached. 

Iteration

A recurs­ive approach would run only until the solu­tion is revealed and would then imme­di­ately stop by simply not call­ing 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 chal­lenge is to determ­ine the value of nextElement if it is a math­em­at­ic­al expres­sion. There is no obvi­ous func­tion in twig to eval­u­ate a string (although there is a work­around), but Craft gives us a few possibilities.

In Chal­lenge #3’s solu­tion, we made it pos­sible to define an email noti­fic­a­tion tem­plate which we rendered using a vari­ation of the fol­low­ing PHP code:

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

The render­Tem­plate meth­od allowed us to pass in the name of a tem­plate to render as well as some vari­ables. There is also a render­String meth­od which will parse a tem­plate as a string for us. Since the view is avail­able as a glob­al vari­able to us in twig tem­plates, we can make it work for our math­em­at­ic­al expres­sion as follows:

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

We could also use string inter­pol­a­tion to eval­u­ate the string and can also make it work using some of Craft’s oth­er render meth­ods on the View class.

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

Sim­il­ar solu­tions: Andrew Welch.


Craft’s View class extends Yii’s View class, which con­tains a meth­od called eval­u­ate­Dy­nam­ic­Con­tent that will eval­u­ate PHP state­ments (beware!), so the fol­low­ing also works:

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

Sim­il­ar solu­tions: Spenser Han­non.


Even twig provides a template_​from_​string func­tion which can be used. This requires that the Twig_Extension_StringLoader exten­sion is added, which it is by default in Craft.

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

Sim­il­ar solu­tions: Rias Van der Vek­en, Ber­lin Craft Meetup, Chris­ti­an Seel­bach.


We could of course also eval­u­ate the string by pars­ing it using twig alone. There are vari­ous ways we could do this, but for simplicity’s sake we will show a series of if/else state­ments. Here we assume that one and only one oper­at­or 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 %}

Sim­il­ar solu­tions: Paul Ver­heul, Matt Stein, Cole Hen­ley, John Wells, Alex Rop­er, Mark Smits.


Or if none of the above solu­tions take your fancy then you could fol­low Patrick Har­ring­tons example and out­source the prob­lem by using Craft’s API ser­vice to call an extern­al 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