Une CTE, c’est quoi ?
Une CTE, ou Common Table Expression est un moyen en SQL d’isoler et de nommer une requête afin de la réutiliser immédiatement dans une autre requête.
Voyons par exemple cette CTE :
WITH very_simple_cte AS (
SELECT 0
)
SELECT * FROM very_simple_cte;
C’est l’exact équivalent de cela :
SELECT * FROM (SELECT 0) AS very_simple_cte;
Les CTE permettent donc, dans le cas des requêtes imbriquées complexes, d’en simplifier l’écriture et la lecture.
Mais il n’y a pas que ça ! En effet, il est aussi possible d’avoir des CTE récursives !
Et maintenant, qu’est-ce qu’une CTE récursive ?
Imaginons une CTE assez simple, retournant 2 lignes. Nous pouvons d’abord l’envisager comme suit, avec un UNION
en son sein :
WITH simple_cte(x) AS (
SELECT 0
UNION ALL
SELECT 0
)
SELECT * FROM simple_cte;
Notez le (x)
. Cela permet de nommer les champs retournés par la CTE.
Nous pouvons aussi faire 2 CTEs et les appeler ensuite en externalisant l’UNION
:
WITH simple_cte_bis(x) AS (
SELECT 0
)
SELECT * FROM simple_cte_bis
UNION ALL
SELECT * FROM simple_cte_bis
;
Bien. Ça peut vite devenir lourd si on a un nombre élevé de lignes devant respecter un critère dans leur succession… Imaginons que nous souhaitons générer n lignes…
C’est là qu’entre en jeux la récursivité des CTEs !
Pour cela, il suffit d’introduire, lors de la déclaration de la CTE, le mot clé
RECURSIVE
, mais aussi un garde fou, sinon on tourne en boucle sans fin. Disons
que nous arrêterons la suite au bout de 10 itérations.
WITH RECURSIVE recursive_cte(x) AS (
SELECT 0
UNION ALL
SELECT x
FROM recursive_cte
LIMIT 10
)
SELECT * FROM recursive_cte;
- La première partie de l’
UNION
définit le point de départ, la première ligne. - La deuxième partie fait un
SELECT
simple mais… en appelant la CTE elle-même, et en sélectionnant le champx
déclaré au début.
Ce petit bout de code SQL nous donne en sortie 10 lignes contenant un champ ayant zéro comme valeur.
Voilà, sans le savoir, vous venez de créer votre premier générateur. Basique, soit, mais un générateur tout de même, puisque parti de rien vous avez obtenu 10 lignes en sortie.
OK ! Donc, une CTE récursive, c’est un générateur ?
Oui, une CTE récursive est un moyen de créer un générateur.
Pratiquement, les générateurs peuvent servir à générer des suites de nombres, des distributions temporelles (mois, trimestres), ou des alphabets et même d’autres cas encore.
Voyons déjà le cas des générateurs de nombres avec quelques cas simples.
Créons des générateurs de nombres !
Un premier cas simple est tout simplement l’incrémentation de nombres par 1.
Si nous voulons récupérer les nombres de 0 à 15, nous pouvons faire ainsi :
WITH RECURSIVE generator(x) AS (
SELECT 0
UNION ALL
SELECT x + 1
FROM generator
WHERE x < 15
)
SELECT * FROM generator;
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Mais nous pouvons également faire comme cela :
WITH RECURSIVE generator(x) AS (
SELECT 0
UNION ALL
SELECT x + 1
FROM generator
LIMIT 16
)
SELECT * FROM generator;
Dans chaque cas, la deuxième partie de l’UNION
reprend la valeur précédente
et y ajoute 1.
Le garde-fou diffère : soit nous considérons une limitation par rapport à une valeur du champs calculé, soit nous considérons une limite par rapport au nombre de résultats retournés.
L’utilisation de tel ou tel garde-fou dépendra de chaque cas.
De ce principe d’incrémentation simple, nous pouvons en déduire la conception de nombres avec un pas spécifique.
Mettons que nous souhaitons générer des nombres espacés de 5 à chaque fois, en ne dépassant pas la valeur de 100 :
WITH RECURSIVE generator_step(x) AS (
SELECT 0
UNION ALL
SELECT x + 5
FROM generator_step
WHERE x < 100
)
SELECT * FROM generator_step;
0
5
10
15
20
25
30
35
40
45
50
55
60
65
70
75
80
85
90
95
100
Juste pour le fun, on double à chaque fois…
WITH RECURSIVE generator_double(x) AS (
SELECT 1
UNION ALL
SELECT x * 2
FROM generator_double
LIMIT 30
)
SELECT * FROM generator_double;
1
2
4
8
16
32
64
128
256
512
1024
2048
4096
8192
16384
32768
65536
131072
262144
524288
1048576
2097152
4194304
8388608
16777216
33554432
67108864
134217728
268435456
536870912
Je pense que vous avez compris le principe, beaucoup de suites mathématiques sont réalisables ainsi. Voyez par exemple la suite de Fibonacci :
WITH RECURSIVE generator_fibo (p, n) AS
(
SELECT 0,
1
UNION ALL
SELECT n,
p + n
FROM generator_fibo
WHERE n < 100
)
SELECT p
FROM generator_fibo;
0
1
1
2
3
5
8
13
21
34
55
89
Nous pouvons faire mumuse avec les nombres grâce aux générateurs. Faisons maintenant mumuse avec l’alphabet…
Quid des générateurs d’alphabet ?
Vu que les lettres peuvent être codées à partir de code ASCII
, alors un générateur d’alphabet est tout aussi possible.
Voyons cela avec l’alphabet latin en générant des lettres majuscules :
WITH RECURSIVE alphabet(code) AS (
SELECT 65
UNION ALL
SELECT code + 1
FROM alphabet
WHERE code < (65 + 25)
)
SELECT CHAR(code) FROM alphabet;
Le code ASCII 65
est celui codant pour la lettre A
. Les autres suivent dans
l’ordre, donc un processus incrémental de un en un suffit. Au final on applique
la fonction CHAR()
de SQLite pour avoir la lettre à partir du code.
Cool ! On peut faire plus compliqué ? Avec les dates par exemple…
Oui ! Voyons deux cas pratiques : générer la liste des mois dans une année et la liste des trimestres, avec les bornes de début et de fin.
Commençons par la base : afficher les mois de l’année 1995 (pourquoi pas…).
WITH RECURSIVE by_month(d) AS (
SELECT '1995-01-01'
UNION ALL
SELECT DATE(d, '+1 MONTHS')
FROM by_month
WHERE d < '1995-12-01'
)
SELECT * FROM by_month;
1995-01-01
1995-02-01
1995-03-01
1995-04-01
1995-05-01
1995-06-01
1995-07-01
1995-08-01
1995-09-01
1995-10-01
1995-11-01
1995-12-01
Jusqu’ici, rien de bien compliqué. Notez l’utilisation de la fonction SQLite DATE()
,
qui permet de faire des incréments de durées, ici, 1 mois ajouté à la date à chaque fois.
Bien.
Maintenant, vous allez me dire : l’année en dur, c’est bête !
En effet, prenons donc en compte l’année courante :
WITH RECURSIVE by_month(d) AS (
SELECT strftime('%Y', 'now')||'-01-01'
UNION ALL
SELECT DATE(d, '+1 MONTHS')
FROM by_month
WHERE d < (strftime('%Y', 'now')||'-12-01')
)
SELECT * FROM by_month;
La fonction SQLite STRFTIME()
permet de formater la date courante ('now'
).
Ici, on ne récupère que l’année sur 4 chiffres.
C’est bien, mais on peut faire mieux en ne répétant pas le strftime('%Y', 'now')
.
Pour cela, utilisons le fait qu’il est possible d’avoir plusieurs CTE enchaînées
en définissant cet appel de fonction dans une CTE :
WITH RECURSIVE const AS (
SELECT strftime('%Y', 'now') AS y
),
by_month(d) AS (
SELECT const.y||'-01-01'
FROM const
UNION ALL
SELECT DATE(d, '+1 MONTHS')
FROM by_month, const
WHERE d < (const.y||'-12-01')
)
SELECT * FROM by_month;
2021-01-01
2021-02-01
2021-03-01
2021-04-01
2021-05-01
2021-06-01
2021-07-01
2021-08-01
2021-09-01
2021-10-01
2021-11-01
2021-12-01
Voilà, en ne se répétant pas, on évite les erreurs stupides.
La CTE const
ne sort qu’une seule ligne contenant le champ y
stockant
l’année en cours sur 4 chiffres.
La CTE by_month
utilise la CTE const
pour avoir l’année qui est nécessaire
à la construction des dates.
Ajoutons-y les bornes définissant les débuts et les fins de mois, avec encore
notre super fonction SQLite DATE()
:
WITH RECURSIVE const AS (
SELECT strftime('%Y', 'now') AS y
),by_month(d) AS (
SELECT const.y||'-01-01'
FROM const
UNION ALL
SELECT DATE(d, '+1 MONTHS')
FROM by_month, const
WHERE d < (const.y||'-12-01')
)
SELECT d AS date_start,
date(
d,
'start of month',
'+1 month',
'-1 day'
) AS date_end
FROM by_month;
2021-01-01|2021-01-31
2021-02-01|2021-02-28
2021-03-01|2021-03-31
2021-04-01|2021-04-30
2021-05-01|2021-05-31
2021-06-01|2021-06-30
2021-07-01|2021-07-31
2021-08-01|2021-08-31
2021-09-01|2021-09-30
2021-10-01|2021-10-31
2021-11-01|2021-11-30
2021-12-01|2021-12-31
Voilà ! Nous avons un moyen de générer une liste de chaque mois de l’année courante avec les dates de début et de fin de chaque mois.
De la même façon, la réalisation par trimestre en découle (encore merci la fonction DATE()
!) :
WITH RECURSIVE const AS (
SELECT strftime('%Y', 'now') AS y
), by_trimester(d) AS (
SELECT const.y||'-01-01'
FROM const
UNION ALL
SELECT DATE(d, '+3 MONTHS')
FROM by_trimester
LIMIT 4
)
SELECT d AS date_start,
date(
d,
'start of month',
'+3 month',
'-1 day'
) AS date_end
FROM by_trimester;
2021-01-01|2021-03-31
2021-04-01|2021-06-30
2021-07-01|2021-09-30
2021-10-01|2021-12-31
Voilà ! Nos trimestres sont bien générés avec leurs bornes respectives.
Notre table servant d’exemple
Avoir des générateurs, c’est bien, les utiliser dans quelques cas concrets, c’est mieux.
Notre exemple se basera sur une table comportant des commentaires laissés par des internautes sur un site.
Un commentaire peut avoir un commentaire parent (dans ce cas, c’est une réponse) sans limitation dans la profondeur de la hiérarchie.
Le login de l’utilisateur, comportant que des caractères ASCII et commençant obligatoirement par une lettre, est enregistré.
La date de création du commentaire est enregistrée.
Voici la table en question :
CREATE TABLE comments (
id INTEGER PRIMARY KEY UNIQUE,
parent_id INTEGER REFERENCES comments(id),
author TEXT NOT NULL
CHECK(author <> '' AND LENGTH(TRIM(author)) > 0),
content TEXT NOT NULL
DEFAULT '',
created_at DATETIME NOT NULL
DEFAULT (datetime('now'))
);
On y injecte tout un tas de données, que vous retrouverez dans le fichier source.
Lister le nombre de commentaires par trimestre
Au premier abord, vous pouvez me dire : « Réalisons juste des GROUP BY
et basta ! ».
Oui mais non. L’idée est de faire comme un rapport, donc s’il n’y a rien pour
une période donnée, le GROUP BY
ne le montrera pas. Par contre, si on utilise en plus les CTE vues précédemment pour les mois, alors là ça devient intéressant : il est possible de voir, si, pour un mois, un trimestre, il n’y a rien eu.
Pour notre exemple, nous allons lister le nombre de commentaires laissés pour chaque trimestre. Je vous livre l’exemple tel quel :
WITH RECURSIVE by_trimester(d) AS (
SELECT '2021-01-01'
UNION ALL
SELECT DATE(d, '+3 MONTHS')
FROM by_trimester
LIMIT 4
),
comments_by_date AS (
SELECT created_at
FROM comments
),
borned_trimester AS (
SELECT d AS date_start,
date(
d,
'start of month',
'+3 month',
'-1 day'
) AS date_end
FROM by_trimester
)
SELECT t.date_start,
t.date_end,
COUNT(1) AS amount
FROM comments_by_date AS c,
borned_trimester AS t
WHERE strftime('%Y-%m-%d', c.created_at) >= t.date_start
AND
strftime('%Y-%m-%d', c.created_at) <= t.date_end
GROUP BY t.date_start
;
2021-01-01|2021-03-31|17
2021-04-01|2021-06-30|4
2021-07-01|2021-09-30|4
2021-10-01|2021-12-31|10
Vous reconnaissez les différentes parties ? La CTE des trimestres est prise telle
quelle, ainsi que sa partie des bornes, maintenant dans sa propre CTE (orned_trimester
).
La CTE comments_by_date
récupère juste les dates de création des commentaires.
La requête finale utilisant ces trois CTE est assez simple :
- Elle reprend les date de début et de fin de chaque trimestre pour les deux premiers champs.
- Elle compte le nombre d’éléments pour chaque trimestre.
- Le partie
WHERE
détermine les dates des commentaires devant se trouver dans tel ou tel trimestre. - Le
GROUP BY
compacte tout par trimestre, et permet ainsi le calcul duCOUNT
dans leSELECT
pour avoir le nombre de commentaires.
Lister le nombre d’identifiants par ordre alphabétique
Imaginons que, pour des besoins en interne, nous devons lister, pour chaque lettre de l’alphabet, le nombre de commentaires correspondant au login commençant par cette lettre.
Sans surprise, nous allons reprendre notre CTE alphabet
vue plus haut.
À celle-ci, nous ajoutons un autre CTE, qui va récupérer la première lettre de
chaque login (via la fonction SUBSTR()
) et la passer en majucule (via la
fonctionUPPER()
), et, dans un autre champ, y placer le
nombre de messages qui y correspondent :
SELECT UPPER(SUBSTR(author, 1,1)) AS letter,
COUNT(*) AS amount
FROM comments
GROUP BY UPPER(SUBSTR(author, 1,1));
Et enfin, une requête exploite ces deux CTE pour afficher l’alphabet avec pour chaque lettre le nombre de commentaires.
WITH RECURSIVE alphabet(code) AS (
SELECT 65
UNION ALL
SELECT code + 1
FROM alphabet
WHERE code < (65 + 25)
),
by_authors (letter, amount) AS (
SELECT UPPER(SUBSTR(author, 1,1)) AS letter,
COUNT(*) AS amount
FROM comments
GROUP BY UPPER(SUBSTR(author, 1,1))
)
SELECT CHAR(code) AS letter,
COALESCE(amount, 0) AS comments
FROM alphabet AS a
LEFT JOIN by_authors AS b ON b.letter = CHAR(a.code);
A|0
B|5
C|7
D|0
E|0
F|0
G|0
H|0
I|0
J|0
K|0
L|6
M|0
N|0
O|0
P|0
Q|6
R|1
S|9
T|0
U|0
V|1
W|0
X|0
Y|0
Z|0
Ainsi, on peut voir que plein de logins ne déposent jamais de message…
Arbre des réponses
Et si, pour chaque ID
de commentaire, on afficher son cheminement jusqu’à lui
avec le contenu des commentaires parents ?
Pour cela, nous allons nous inspirer de l’article « Chemin d’un nœud dans un arbre hiérarchique de données avec PostgreSQL ».
Nous définissons donc une CTE parents
contenant l’ID
, le nombre de parents en cours et enfin le contenu.
Un premier jet peut être ceci :
WITH RECURSIVE parents AS (
SELECT id AS id,
0 AS ancestors,
content AS tree
FROM comments
WHERE parent_id IS NULL
UNION
SELECT child.id AS id,
c.ancestors + 1 AS ancestors,
(
c.tree
|| CHAR(10)
|| child.content
) AS tree
FROM comments as child
INNER JOIN parents c ON c.id = child.parent_id
)
SELECT id, tree FROM parents;
Ce qui donne une sortie… pas très sexy.
Ce qui serait bien, c’est :
- mettre le nom de l’auteur en première ligne, en majuscules
- mettre le commentaire, tronqué.
- décaler à chaque réponse d’un fil de discussion.
- d’en faire une vue SQL :-)
Allons-y ! Sortons l’arcenal de fonctions disponibles dans SQLite en natif :
UPPER()
pour mettre les auteurs en majusculeCHAR(10)
pour avoir un retour à la ligneSUBSTR()
pour tronquer les chaînes de caractèresPRINTF()
pour créer une indentation du texte croissante
Voici le code :
CREATE VIEW comments_tree AS
WITH RECURSIVE parents AS (
SELECT id AS id,
0 AS ancestors,
UPPER(author)||':'||CHAR(10)||substr(content, 1,50)||'…' AS tree
FROM comments
WHERE parent_id IS NULL
UNION
SELECT child.id AS id,
c.ancestors + 1 AS ancestors,
(
char(10) || c.tree
|| char(10)
|| printf("%"||(2 * (c.ancestors + 1)) || "s", ' ')
|| UPPER(child.author) || ':'
|| char(10)
|| printf("%"||(2 * (c.ancestors + 1)) || "s", ' ')
|| substr(child.content, 1, (50 - (2 * (c.ancestors + 1))))
||'…'
) AS tree
FROM comments as child
INNER JOIN parents c ON c.id = child.parent_id
)
SELECT id, tree FROM parents;
Et voyons, par exemple pour l’ID
29, le fil de discussion :
SELECT tree FROM comments_tree WHERE id = 29;
QUIDAM:
Quo vadis?…
CAESAR:
Ego vadam Romam…
SPQR:
Quid??!!!…
SPQR:
Ut a sem ornare tellus faucibus tincidunt a …
Conclusion
Les CTE, c’est pratique, il faut les utiliser sans modération :
- Elles améliorent la lisibilité des requêtes complexes.
- Elles permettent de réaliser des générateurs de données !
- La possibilité d’avoir des CTE récursives, outre le fait de créer des générateurs, permet d’avoir une souplesse et un contrôle supplémentaire sur les jeux de données.
- La syntaxe est largement répendue dans les bases de données modernes.
Attention toutefois : je vous ai exposé ici le cas de SQLite, pour d’autres bases de données, il peut y avoir des moyens plus simples encore que les CTE pour générer des données.
Par exemple, sous PostgreSQL, la fonction generate_series()
permet de générer du contenu.
L’ensemble des codes sources de cet article sont disponibles dans ce fichier SQL.