The Limbo World Championships have called you in to optimise their scoreboard for performance.
On inspecting the homepage, you notice that it is suboptimal, executing 62
database queries and populating 28
elements, when you can plainly see that only 18
unique elements are actually displayed on the scoreboard. Also, as the number of competitors increases, so do the number of queries and elements, meaning that the code has a complexity of O(n) (also known as the N+1 problem).
There’s no time to lose. Your task is to bring the complexity of the queries under control and reduce the number of database queries executed and elements loaded as much as humanly possible. There have been murmurs in the limbo community of a rising star that has managed to achieve the coveted 35
(database queries), but this is yet to be confirmed.
This GitHub repo contains the Craft CMS site that you can spin up with a single command, either locally or in the browser using a GitHub codespace (see the readme file in the repo). Once you are set up, log into the CP as the admin
user, visit the front-end URL, open the debug toolbar and then get to work!
Your solution should consist of an optimised version of the templates/index.twig
template, along with mentioning the number of database queries executed and elements loaded.
templates/index.twig
template. body
block, but doing so shouldn’t be necessary or even helpful.admin
user with the debug toolbar enabled.Eager-load all the things and leverage Twig filters to help avoid queries.
Since reducing the number of database queries executed is the primary goal of this challenge, the obvious place to start is with adding eager-loading to the element query. This involves telling Craft which elements to load up-front (in the initial database query), saving us having to fetch those elements later (in subsequent database queries).
Craft lazy-loads elements by default, as not doing so could lead to huge amounts of data being unnecessarily fetched via database queries. Imagine, for example, that we wanted to list only the titles of all entries in an entry type that has several relation fields (assets, entries, etc.). If Craft eager-loaded every relation field by default then the query (and the returned data) would be much more bloated and much less performant than loading only the simple (non-relational fields). Therefore, if we want to list the titles along with related asset image then it is up to us to instruct Craft to eager-load those assets in the element query.
Starting with the competitors, we can see that for each entry we’ll need the title, score, image (related asset) and country flag (a related asset on a related entry). The title and score are non-relational fields, so they will always be loaded automatically. The image and country are relational fields and the flag is a related asset on the country, so we’ll eager-load these using the with
parameter on the query.
{% set competitors = craft.entries
.section('competitors')
.with([
'image',
'country.flag',
])
.orderBy('score asc')
.all()
%}
Similar solutions: Alex Roper, Dominik Krulak, Gaël Patron, Liam Rella, Marco, Marko Gajić, Philippe Perusse, Piotr Pogorzelski, Robert Baker, Rodrigo Passos.
This change alone brings the number of database queries from 62
down to 35
!!
One reason for this is that competitor.country.one()
was previously an element query execution within a for loop, meaning that it suffered from O(n) complexity.
{% for competitor in competitors %}
{% set country = competitor.country.one() %}
{# ... #}
{% endfor %}
When we eager-loaded the country’s flag using country.flag
, this also eager-loaded the country (by necessity). So whereas competitor.country
previously represented an element query, it now represents an eager-loaded Laravel Collection of countries. Collections in Craft have a one()
macro that calls first()
internally, so calling competitor.country.one()
simply returns the first item in the collection of countries – no DB calls required.
Interestingly, though, while the number of database queries has decreased, the number of elements loaded has increased from 28
to 38
! This is because we are now fetching competitors along with eager-loaded elements for every single entry in the competitors
section. Limiting this number to the number of competitors we want to output should solve the issue.
{% set topCompetitors = craft.entries
.section('competitors')
.with([
'country.flag',
'image',
])
.orderBy('score asc')
.limit(5)
.all()
%}
Similar solutions: Harald Atteneder.
This indeed does bring the number of loaded elements down to 18
, but it also presents a “gotcha”, which we’ll come back to later.
With the limit
parameter in place, there is now no need to call competitors|slice(0, 5)
since our query can return at most 5 entries. We can also clean up the for
loop used to fetch the top 3 unique countries using two approaches: Twig filters and Collection methods.
Twig filters will likely be more familiar to most people, since they have been available in Craft since forever. There are multiple approaches using filters that can be taken to reduce the countries to a unique set of 3. Here is my preferred approach.
{% set topCountries = topCompetitors
|map(competitor => competitor.country.one())
|unique
|slice(0, 3)
%}
Similar solutions: Andrew Welch, Austin Siewert.
Executing element queries with .collect()
instead of .all()
returns a Collection of elements, which makes it is possible to use the many methods that Collections provide. The only caveat is that Twig only allows arrow functions to be used in the map
, filter
and reduce
filters. This makes some Collection methods less useful (or completely unusable), unless you install the Craft Closure package, in which case you can use arrow function closures everywhere!
In the following approach we pluck the country
out of each of the topCompetitors
, collapse the result into a flat collection of countries, reduce the result to unique items based on the country id
, and finally take just the first 3 items. Note how chaining methods is possible because each method returns a new Collection of modified results.
{% set topCountries = topCompetitors
.pluck('country')
.collapse()
.unique('id')
.take(3)
%}
Similar solutions: Amber Waltens, Chris Violette, Dave Spengeler, Johannes Ahrndt.
The reason that calling .collapse()
is necessary above is that the country
field is itself a Collection, since it was eager-loaded in the element query. Therefore .pluck('country')
returns a Collection of Collections, which we must collapse into a Collection of entries before calling the unique
method.
If Craft Closure was installed then we would be able to refactor the code to resemble the Twig filter approach more closely using the map method, which is more explicit and somewhat more representative of what is going on behind the scenes.
{% set topCountries = topCompetitors
.map(competitor => competitor.country.one())
.unique('id')
.take(3)
%}
We mentioned a “gotcha” and it is this: What happens if the top 5 competitors come from only 1 or 2 unique countries? If that’s the case then the top countries will display fewer than 3 countries. There are two approaches to solving this edge-case: fetching all competitors upfront or conditionally fetching more competitors.
Fetching all competitors upfront will result in more elements being loaded but the same number of database queries being executed – it is, in fact, the first solution presented above. Conditionally fetching more competitors, however, would only actually happen in the scenario in which the top 5 competitors come from just 1 or 2 unique countries. In other words, the second approach will be more performant most of the time.
There are various ways we could go about this, but here’s a simple approach that is only executed if fewer than 3 countries exist in topCountries
.
{% set topCountries = topCompetitors
|map(competitor => competitor.country.one())
|unique
|slice(0, 3)
%}
{% if topCountries|length < 3 %}
{% set allCompetitors = craft.entries
.section('competitors')
.with('country.flag')
.orderBy('score asc')
.all()
%}
{% set topCountries = allCompetitors
|map(competitor => competitor.country.one())
|unique
|slice(0, 3)
%}
{% endif %}
Similar solutions: Eddie Dale.
The following approach results in fewer elements being loaded by offsetting the competitors previously fetched and merging the remaining countries with the existing top countries.
{% set topCountries = topCompetitors
|map(competitor => competitor.country.one())
|unique
|slice(0, 3)
%}
{% if topCountries|length < 3 %}
{% set remainingCompetitors = craft.entries
.section('competitors')
.with('country.flag')
.orderBy('score asc')
.offset(5)
.all()
%}
{% set remainingCountries = remainingCompetitors
|map(competitor => competitor.country.one())
%}
{% set topCountries = topCountries
|merge(remainingCountries)
|unique
|slice(0, 3)
%}
{% endif %}
Similar solutions: Lukas Jansen.
Since ChatGPT is all the hype these days, I decided to see how it would do, given the challenge (I promise this will be the only time I ever try this!). The bot struggled, always coming back to executing one query for the competitors and another for the countries (and insisted that this was the most performant solution). It took over an hour of coaching, but I finally got it to arrive at a presentable solution.
There are ways of reducing the number of database queries even further, but they quickly get complex, fragile and difficult to maintain. Nevertheless, I’ll leave you with this solution by Steve Comrie that requires only 19 database queries through bypassing element queries completely and using Craft’s Query class to manually join tables instead (not recommended but interesting to see!).
Solution submitted by Marco on 15 March 2023.
{% extends '_layout.twig' %}
{% set competitors = craft.entries
.section('competitors')
.orderBy('score asc')
.with(['country','country.flag','image'])
.collect()
%}
{% set topCompetitors = competitors|slice(0,5) %}
{% set topCountries = competitors|group('country')|slice(0,3)|map((competitorsByCountry) => {
title: competitorsByCountry[0].country.one().title,
flag: competitorsByCountry[0].country.one().flag,
})
%}
Solution submitted by Andrew Welch on 15 March 2023.
{% extends '_layout.twig' %}
{% set topCompetitors = craft.entries.section('competitors')
.with(['country.flag', 'image'])
.orderBy('score ASC')
.limit(5)
.all()
%}
{% set topCountries = topCompetitors|reduce(
(countries, competitors) =>
competitors.country.one() in countries or countries|length == 3
? countries
: countries|merge([competitors.country.one()])
, []) %}
Solution submitted by Robert Baker on 15 March 2023.
{% extends '_layout.twig' %}
{% set competitors = craft.entries
.section('competitors')
.orderBy('score asc')
.with([
'country.flag',
'image'
])
.all()
%}
{% set countries = [] %}
{% for competitor in competitors %}
{% set country = competitor.country.one() %}
{% if country.id not in countries|map(country => country.id) %}
{% set countries = countries|push(country) %}
{% endif %}
{% endfor %}
{% set topCompetitors = competitors|slice(0, 5) %}
{% set topCountries = countries|slice(0, 3) %}
Solution submitted by Chris Violette on 15 March 2023.
{% extends '_layout.twig' %}
{% set topCompetitors = craft.entries
.section('competitors')
.with([
'image',
'country.flag',
])
.orderBy('score asc')
.limit(5)
.collect()
%}
{% set topCountries = topCompetitors.pluck('country').collapse().unique('id').take(3) %}
{# The downside of this approach is that we don't *know* if 4 or more of the first 5 competitors have the same country. If that's the case, we wouldn't get 3 countries. This solution just works with this specific set of data #}
Solution submitted by Austin Siewert on 16 March 2023.
{% set competitors = craft.entries
.section('competitors')
.with(['image','country', 'country.flag'])
.orderBy('score asc')
.limit(5)
.all()
%}
{% set countries = competitors|group(e => e.country[0])|map(e => e[0]) %}
{% set topCompetitors = competitors %}
{% set topCountries = countries|map(e => e.country[0])|slice(0, 3) %}
Solution submitted by Lukas Jansen on 18 March 2023.
{% extends '_layout.twig' %}
{% set competitorsQuery = craft.entries
.section('competitors')
.orderBy('score asc')
%}
{% set topCompetitors = competitorsQuery
.with([
'country',
'country.flag',
'image',
])
.limit(5)
.collect()
%}
{% set topCountries = topCompetitors.pluck('country').collapse().unique('id').take(3) %}
{# scenario when the top 5 competitors are not from 3 different countries #}
{# get the other competitors that are not in the topCompetitors and merge them in the topCountries and get the unique countries #}
{# did not limit this query as it still could be that the next competitors are from the same countries #}
{% if topCountries.count < 3 %}
{% set competitors = competitorsQuery
.id(['not']|merge(topCompetitors.ids()))
.with([
'country',
'country.flag',
])
.orderBy('score asc')
.collect()
%}
{% set topCountries = topCountries|merge(competitors.pluck('country').collapse().unique('id')) %}
{% set topCountries = collect(topCountries).unique('id').take(3) %}
{% endif %}
Solution submitted by Marko Gajić on 18 March 2023.
{% extends '_layout.twig' %}
{% set competitors = craft.entries
.section('competitors')
.with([
'image',
'country.flag'
])
.orderBy('score asc')
.all()
%}
{% set countries = [] %}
{% for competitor in competitors %}
{% set country = competitor.country.one() %}
{% if country.id not in countries|map(country => country.id) %}
{% set countries = countries|push(country) %}
{% endif %}
{% endfor %}
{% set topCompetitors = competitors|slice(0, 5) %}
{% set topCountries = countries|slice(0, 3) %}
Solution submitted by Steve Comrie on 20 March 2023.
{% extends '_layout.twig' %}
{% set entries = craft.query()
.select([
'elements.id',
'elements.enabled',
'elements.archived',
'elements_sites.id AS sitesettingsid',
'elements_sites.enabled AS enabledforsite',
'entries.sectionid',
'entries.postdate',
'entries.expirydate',
'content.id AS contentid',
'content.title',
'content.field_score_zsrmwbmz AS score',
'countries.targetId AS countryId',
'flags.targetId AS flagAssetId',
'images.targetId AS imageAssetId'
])
.from('elements')
.innerJoin( "elements_sites", "elements_sites.elementId = elements.id" )
.innerJoin( "entries", "entries.id = elements.id" )
.innerJoin( "content", "content.id = elements.id" )
.leftJoin( "relations countries", "countries.fieldId=1 AND countries.sourceId = elements.id" )
.leftJoin( "relations flags", "flags.fieldId =4 AND flags.sourceId = elements.id" )
.leftJoin( "relations images", "images.fieldId =2 AND images.sourceId = elements.id" )
.where( "elements.archived=false" )
.andWhere( "entries.sectionid IN (1,2)" )
.andWhere( "elements.enabled=true" )
.andWhere( "elements_sites.enabled=true" )
.andWhere( "elements_sites.siteId=1" )
.andWhere( "entries.postdate<=NOW()" )
.andWhere( "entries.expirydate IS NULL OR entries.expirydate>=NOW()" )
.andWhere( "elements.datedeleted IS NULL" )
.andWhere( "elements.draftid IS NULL" )
.andWhere( "elements.revisionid IS NULL" )
.orderBy( 'content.field_score_zsrmwbmz ASC' )
%}
{% set allCountries = {} %}
{% set topCountries = [] %}
{% set competitors = [] %}
{% set assetLookup = [] %}
{% for entry in entries.all() %}
{% if entry.sectionid == 1 %}
{% set allCountries = allCountries|merge( { ('c#'~entry.id) : entry } ) %}
{% endif %}
{% if entry.sectionid == 2 %}
{% if competitors|length < 5 or topCountries|length < 3 %}
{% set competitor = {
'title' : entry.title,
'score' : entry.score,
'headId' : entry.imageAssetId,
'country' : {
'id' : entry.countryId,
'title' : allCountries['c#'~entry.countryId].title,
'flagId' : allCountries['c#'~entry.countryId].flagAssetId
}
} %}
{% set competitors = competitors|push(competitor) %}
{% set assetLookup = assetLookup|push(competitor.headId) %}
{% set assetLookup = assetLookup|push(competitor.country.flagId) %}
{% if topCountries|length < 3 %}
{% set topCountries = topCountries|push(competitor.country.id)|unique %}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
{% set assets = craft.query()
.select([
'assets.id',
'assets.filename',
'volumes.fs',
'volumefolders.path'
])
.from('assets')
.innerJoin( "volumes", "volumes.id = assets.volumeId" )
.innerJoin( "volumefolders", "volumefolders.id = assets.folderId" )
.where(["in", "assets.id", assetLookup|unique ])
.indexBy('id')
.all()
%}
{% macro imagePath(asset, alt = "") %}
{% set imagePath = [ asset.fs, asset.path, asset.filename ]|filter %}
<img src="/assets/{{ imagePath|join('/') }}" alt="{{ alt }}" class="w-24 mr-5">
{% endmacro %}
{% block body %}
<div class="flex max-w-4xl mx-auto mb-20">
<div class="w-1/3">
<h2 class="text-3xl font-bold underline my-8">
Top 3 Countries
</h2>
{% for countryId in topCountries %}
{% set country = allCountries['c#'~countryId] %}
<div class="flex">
<h3 class="text-2xl font-bold my-8 w-1/2">
{{ loop.index }}. {{ country.title }}
</h3>
{{ _self.imagePath( assets[country.flagAssetId], country.title )}}
</div>
{% endfor %}
</div>
<div class="w-2/3">
<h2 class="text-3xl font-bold underline my-8">
Top 5 Competitors
</h2>
{% for competitor in competitors|slice(0, 5) %}
<div class="flex">
<h3 class="text-2xl font-bold my-8 w-1/2">
{{ loop.index }}. {{ competitor.title }}:
{{ competitor.score }}
</h3>
{{ _self.imagePath( assets[competitor.headId], competitor.title )}}
{{ _self.imagePath( assets[competitor.country.flagId], competitor.country.title )}}
</div>
{% endfor %}
</div>
</div>
{% endblock %}
Solution submitted by Rodrigo Passos on 21 March 2023.
{% extends '_layout.twig' %}
{% set competitors = craft.entries
.section('competitors')
.orderBy('score asc')
.with(['country','country.flag','image'])
.collect()
%}
{% set countries = competitors|map( c => c.country|first )|unique %}
{% set topCompetitors = competitors|slice(0, 5) %}
{% set topCountries = countries|slice(0, 3) %}
Solution submitted by Johannes Ahrndt on 21 March 2023.
{% extends '_layout.twig' %}
{% set competitors = craft.entries
.section('competitors')
.with([
'country.flag',
'image',
])
.orderBy('score asc')
.collect()
%}
{% set topCompetitors = competitors|slice(0, 5) %}
{# Is the unique() method missing on the ElementCollection? #}
{% set topCountries = competitors.pluck('country').flatten().toArray()|unique|slice(0,3) %}
Solution submitted by Philippe Perusse on 22 March 2023.
{% extends '_layout.twig' %}
{% set competitors = craft.entries
.with(['country.flag', 'image'])
.section('competitors')
.orderBy('score asc')
.all()
%}
{% set countries = [] %}
{% for competitor in competitors %}
{% set country = competitor.country.one() %}
{% if country.id not in countries|map(country => country.id) %}
{% set countries = countries|push(country) %}
{% endif %}
{% endfor %}
{% set topCompetitors = competitors|slice(0, 5) %}
{% set topCountries = countries|slice(0, 3) %}
Solution submitted by Amber Waltens on 28 March 2023.
{% extends '_layout.twig' %}
{% set topCompetitors = craft.entries
.section('competitors')
.with([
'country.flag',
'image'
])
.orderBy('score asc')
.limit(5)
.collect()
%}
{% set topCountries = topCompetitors
.pluck('country')
.collapse()
.unique('id')
.take(3)
%}
Solution submitted by Eddie Dale on 29 March 2023.
{% extends '_layout.twig' %}
{% set topCompetitors = craft.entries
.section('competitors')
.orderBy('score asc')
.limit(5)
.with([
['image'],
['country.flag']
])
.collect()
%}
{% set topCountriesQuery = topCompetitors
.pluck('country')
.collapse()
.toArray()
| unique
%}
{# Check if we have 3 unique top countries already. Otherwise an extra query is needed #}
{% if topCountriesQuery|length >= 3 %}
{% set topCountries = topCountriesQuery|slice(0,3) %}
{% else %}
{% set topCountries = craft.entries
.section('competitors')
.orderBy('score asc')
.with(['country.flag'])
.limit(30)
.collect()
.pluck('country')
.collapse()
.toArray()
| unique
| slice(0,3)
%}
{% endif %}
Solution submitted by Dominik Krulak on 6 April 2023.
{% extends '_layout.twig' %}
{% set competitors = craft.entries
.section('competitors')
.with([
['country.flag'],
['image']
])
.orderBy('score asc')
.all()
%}
{% set countries = [] %}
{% for competitor in competitors %}
{% set country = competitor.country.one() %}
{% if country.id not in countries|map(country => country.id) %}
{% set countries = countries|push(country) %}
{% endif %}
{% endfor %}
{% set topCompetitors = competitors|slice(0, 5) %}
{% set topCountries = countries|slice(0, 3) %}
Solution submitted by Harald Atteneder on 6 April 2023.
{% extends '_layout.twig' %}
{% set competitors = craft.entries
.section('competitors')
.orderBy('score asc')
.limit(5)
.with(['image', 'country', 'country.flag'])
.all()
%}
{% set countries = [] %}
{% for competitor in competitors %}
{% set country = competitor.country[0] %}
{% if country.id not in countries|map(country => country.id) %}
{% set countries = countries|push(country) %}
{% endif %}
{% endfor %}
{% set topCompetitors = competitors|slice(0, 5) %}
{% set topCountries = countries|slice(0, 3) %}
Solution submitted by Liam Rella on 13 April 2023.
{% extends "_layout.twig" %}
{% set competitors = craft.entries
.section("competitors")
.orderBy("score desc")
.with(["country", "country.flag", "image"])
.all()
%}
{# Create a new object that contains unique countries tallying scores by each competitor #}
{% set countries = competitors|reduce((carry, entry) => (
carry|merge({
(entry.country[0].slug): {
country: entry.country[0],
score: entry.score + (carry[entry.country[0].slug].score ?? 0)
}
})
), {}) %}
{# Sort countries by score #}
{% set countries = countries|sort((a, b) => a.score <=> b.score)|reverse %}
{# Restructure countries to fit existing _layout.twig structure #}
{% set countries = countries|map((item) => item.country) %}
{# Set variables required by _layout.twig #}
{% set topCompetitors = competitors|slice(0, 5) %}
{% set topCountries = countries|slice(0, 3) %}
Solution submitted by Dave Spengeler on 13 April 2023.
{% extends '_layout.twig' %}
{% set topCompetitors = craft.entries
.section('competitors')
.orderBy('score asc')
.with(['image','country.flag'])
.limit(5)
.collect()
%}
{% set topCountries = topCompetitors.pluck('country').collapse().unique('id').splice(0,3).all() %}
Solution submitted by Gaël Patron on 14 April 2023.
{% set competitors = craft.entries
.section('competitors')
.orderBy('score asc')
.with(['image', 'country.flag'])
.all()
%}
Solution submitted by Piotr Pogorzelski on 16 April 2023.
{% extends '_layout.twig' %}
{# We need to add eager loading. We use nested eager loading, because we need to get every flag of every country of every competitor. We also change all() to collect() for our convenience. #}
{% set competitors = craft.entries
.section('competitors')
.orderBy('score asc')
.with(['country.flag', 'image'])
.collect()
%}
{% set countries = [] %}
{% for competitor in competitors %}
{# Since we use collections, we can change ugly numeric index [0] to pretty .collect().first() #}
{% set country = competitor.country.collect().first() %}
{% if country.id not in countries|map(country => country.id) %}
{% set countries = countries|push(country) %}
{% endif %}
{% endfor %}
{% set topCompetitors = competitors|slice(0, 5) %}
{% set topCountries = countries|slice(0, 3) %}
Solution submitted by Alex Roper on 18 April 2023.
{% extends 'layout.twig' %}
{% set competitors = craft.entries
.section('competitors')
.orderBy('score asc')
.with([
'image',
'country.flag'
])
.all()
%}
{% set countries = [] %}
{% for competitor in competitors %}
{% set country = competitor.country.one() %}
{% if country.id not in countries|map(country => country.id) %}
{% set countries = countries|push(country) %}
{% endif %}
{% endfor %}
{% set topCompetitors = competitors|slice(0,5) %}
{% set topCountries = countries|slice(0,3) %}