Exercice 1

Requêtes SQL sur la base "chess"

Description des tables :

CREATE TABLE joueurs (
	id INTEGER,
	nom VARCHAR(32) UNIQUE NOT NULL, 
	prénom VARCHAR(32) NOT NULL, 
	-- Année de naissance.
	"année" INTEGER NOT NULL,
	PRIMARY KEY (id)
);

-- Lors d'un tournoi chaque participant a au moins une fois
-- les blancs, et au moins une fois les noirs.
CREATE TABLE tournois (
	id INTEGER,
	-- Souvent le nom de la ville dans laquelle le tournoi a lieu.
	nom VARCHAR(32) NOT NULL, 
	"année" INTEGER NOT NULL, 
	UNIQUE (nom, "année"),
	PRIMARY KEY (id)
);

CREATE TABLE parties (
	id_tournoi INTEGER,
	-- Un tournoi est divisé en rondes. Pendant une ronde,
	-- chaque participant joue au plus une partie.
	ronde INTEGER NOT NULL,
	-- "blancs" et "noirs" désignent les identifiants des joueurs
	-- s'affrontant (respectivement avec les pièces blanches et noires).
	blancs INTEGER NOT NULL, 
	noirs INTEGER NOT NULL, 
	-- "elo_b" (resp. "elo_n") ci-dessous désigne le classement
	-- du joueur des blancs (resp. des noirs).
	elo_b INTEGER NOT NULL,
	elo_n INTEGER NOT NULL,
	-- Le résultat peut prendre trois valeurs :
	--   1-0 = les blancs gagnent,
	--   0-1 = les noirs gagnent,
	--   1/2 = match nul.
	"résultat" VARCHAR NOT NULL,
	PRIMARY KEY (id_tournoi, ronde, blancs, noirs),
	FOREIGN KEY (id_tournoi) REFERENCES tournois(id),
	FOREIGN KEY (blancs) REFERENCES joueurs(id),
	FOREIGN KEY (noirs) REFERENCES joueurs(id)
);
  1. Tournois (nom, année) dont le nom n'apparaît qu'une seule fois dans la table.
  2. Liste des participants (prénom, nom) au tournoi de Dortmund en 2005.
  3. Pourcentages de gains blancs, parties nulles et gains noirs sur l'ensemble des parties.
  4. Moyenne des âges des participants aux tournois de (nom =) 'Linares' – toutes années confondues –, relativement à la date du tournoi.
  5. Meilleure performance (joueur A gagnant contre B, avec A moins fort que B) réalisée au tournoi de 'WijkaanZee' en 2001. Renvoyez les noms des joueurs, leurs classements et le résultat.
  6. Nombre de points obtenus par Alexander Morozevich lors du tournoi de Sarajevo en 1999. On compte
    • 1 pour les victoires,
    • 1/2 pour les parties nulles,
    • 0 pour les défaites.
  7. Joueurs (prénom, nom) ayant battu Garry Kasparov.
  8. On définit le "nombre de Kasparov" comme suit :
    • 0 pour Garry Kasparov lui-même,
    • 1 pour les joueurs l'ayant battu,
    • 2 pour les gens ayant battu quelqu'un qui a battu Kasparov (mais n'ayant pas réalisé cette performance eux-mêmes),
    • ... etc,
    • $+\infty$ si aucun entier n'est valide.

    Écrivez une requête qui calcule le nombre de Kasparov.

Solution

Note : vous pouvez constater que les classements Elo ne dépendent que du tournoi et du joueur. En effet les fluctuations de l'Elo ne sont pas prise en compte sur la durée d'un tournoi. La table parties est donc peu normalisée (même pas en 2NF).

  1. SELECT nom, max("année") AS "année"
    FROM tournois
    GROUP BY nom
    HAVING count(*) = 1;
  2. Requête en trois temps :
    1. recherche de l'identifiant ID du tournoi,
    2. recherche des parties où id_tournoi = ID,
    3. recherche des joueurs dont l'identifiant apparaît dans la liste de ces parties.
    SELECT "prénom", nom
    FROM joueurs 
    WHERE id IN 
      (SELECT blancs 
      FROM parties 
      WHERE id_tournoi IN 
        (SELECT id 
        FROM tournois 
        WHERE nom='Dortmund' AND "année"=2005));

    Note : on peut aussi effectuer une jointure.

  3. WITH total AS 
      (SELECT count(*) AS nbParties 
      FROM parties) 
    SELECT "résultat", round(100.*count(*) / nbParties, 1)
           || '%' AS pourcentage 
    -- Le produit cartésien ici ne coûte rien car la table intermédiaire
    -- "total" est réduite à un singleton.
    FROM parties, total 
    -- nbParties doit apparaître dans le GROUP BY pour pouvoir être
    -- utilisé dans le SELECT. C'est toutefois un peu artificiel.
    GROUP BY "résultat", nbParties;

    Autre possibilité : quelques redondances dans l'écriture, mais on y gagne assez nettement en lisibilité, et évite une sous-requête.

    SELECT round(sum(CASE WHEN "résultat" = '1-0' THEN 100. ELSE 0. END)
        / count(*), 1) || '%' AS gains_blancs,
      round(sum(CASE WHEN "résultat" = '1/2' THEN 100. ELSE 0. END)
        / count(*), 1) || '%' AS parties_nulles,
      round(sum(CASE WHEN "résultat" = '0-1' THEN 100. ELSE 0. END)
        / count(*), 1) || '%' AS gains_noirs
    FROM parties;
  4. Attention : une moyenne directe après jointure des trois tables renvoie un résultat potentiellement erroné, car les participants ne jouent pas forcément toutes les rondes : ils peuvent être malades ou abandonner avant la fin. De plus on peut envisager que le nombre de rondes varie. C'est le cas du tournoi de Linares, qui comptait par exemple 6 participants pour 10 rondes en 2001 et 8 participants pour 14 rondes en 2007.

    Il faut donc calculer la moyenne avec exactement une ligne par joueur et par année : cela rend la requête également plus robuste aux données manquantes.

    SELECT avg("annéetournoi" - "annéejoueur")
    FROM (
      SELECT DISTINCT j.id AS idjoueur,
                      j."année" AS "annéejoueur",
                      t."année" AS "annéetournoi"
      FROM joueurs j
      JOIN parties p
        ON j.id = p.blancs
      JOIN tournois t
        ON p.id_tournoi = t.id
      WHERE t.nom = 'Linares') x;
  5. -- Sous-requête renvoyant les parties à performances potentielles ("pp").
    WITH pp AS (
      SELECT blancs, noirs, "résultat", elo_b, elo_n
      FROM parties p
      JOIN tournois t
        ON t.id = p.id_tournoi
      WHERE t.nom = 'WijkaanZee' 
        AND t."année" = 2001 
        AND ((p.elo_b > p.elo_n AND p."résultat" = '0-1') 
          OR (p.elo_b < p.elo_n AND p."résultat" = '1-0')))
    SELECT jb.nom AS blancs, pp.elo_b, 
           jn.nom AS noirs, pp.elo_n, 
           pp."résultat"
    FROM pp
    JOIN joueurs jb
      ON jb.id = pp.blancs
    JOIN joueurs jn
      ON jn.id = pp.noirs
    WHERE abs(pp.elo_b - pp.elo_n) = 
      (select max(abs(pp.elo_b - pp.elo_n)) from pp);
  6. -- La première ligne évite d'avoir à re-trouver l'identifiant du joueur plusieurs fois
    WITH Moro AS (SELECT id FROM joueurs WHERE nom = 'Morozevich')
    SELECT sum((blancs = Moro.id and "résultat"='1-0') or (noirs = Moro.id and "résultat"='0-1'))
           + 0.5 * sum(Moro.id IN (blancs, noirs) and "résultat"='1/2')
    FROM Moro CROSS JOIN parties p
    JOIN tournois t ON p.id_tournoi = t.id
    WHERE t.nom = 'Sarajevo' and t."année" = 1999;
  7. -- Comme précédemment, une première requête récupérant l'identifiant du joueur cible
    WITH Kaspy AS (SELECT id FROM joueurs WHERE nom = 'Kasparov'),
    -- Et une seconde identifiant les parties qu'il a perdu
    kperd as (
      SELECT Kaspy.id as kid, blancs, noirs
      FROM parties p, Kaspy k
      WHERE (p.blancs = k.id AND p."résultat" = '0-1') OR (p.noirs = k.id AND p."résultat" = '1-0'))
    SELECT DISTINCT "prénom", nom
    FROM joueurs j JOIN kperd p on (p.blancs = j.id or p.noirs = j.id)
    WHERE j.id != kid; --nécessaire
  8. WITH RECURSIVE est nécessaire : on considère les joueurs comme des noeuds dans un graphe orienté, un arc de J1 à J2 signifiant "J1 a battu J2".

    Les attributs de la requête se lisent comme suit :
    • prof = profondeur courante du graphe
    • idjoueur = identifiant d'un joueur battu au niveau nb
    • path = chemin courant, J1 -> J2 -> J3 signifiant "J1 bat J2 bat J3"
    WITH RECURSIVE
    winners AS (
      SELECT DISTINCT blancs AS gagnant, noirs AS perdant
      FROM parties
      WHERE "résultat" = '1-0'
        UNION
      SELECT DISTINCT noirs AS gagnant, blancs AS perdant
      FROM parties
      WHERE "résultat" = '0-1' ),
    nbKasparov(prof, idjoueur, path) AS (
      SELECT 0, id, ' '||id||' '
      FROM joueurs
      WHERE nom = 'Kasparov' --a des homonymes, mais seul dans la base
        UNION
      SELECT prof+1, gagnant, path||'<- '||gagnant||' '
      FROM nbKasparov
      JOIN winners
        ON idjoueur=perdant
      WHERE prof <= 2 AND path NOT LIKE '% '||gagnant||' %'
    ),
    target AS (SELECT 2 AS id_cible), --Anand, par exemple
    paths AS (
      SELECT prof, path
      FROM nbKasparov, target
      WHERE path LIKE '% '||id_cible||' %' )
    SELECT prof AS nb_kasparov, path
    FROM paths
    WHERE prof in (SELECT min(prof) FROM paths)
    ORDER BY random()
    LIMIT 1;
    Quelques notes au sujet de cette requête :
    • WITH RECURSIVE remplace le WITH usuel pour indiquer que certaines requêtes énumérées peuvent être récursives.
    • La profondeur est limitée à 3 (chemins de longueur 4) sur cet exemple, car sinon le graphe entier est exploré via tous ses chemins. La requête se terminerait grâce à la condition path NOT LIKE '% '||gagnant||' %' qui évite les cycles, mais ce serait très long... Ce genre de requête est donc à utiliser dans un contexte approprié ; par exemple :
      • requête "friend of friend" dans un réseau social,
      • graphe très sparse, ou de taille modeste.
    • Un simple GROUP BY peut éventuellement se substituer aux dernières lignes, mais on verra alors toujours le même chemin affiché.

Exercice 2

Quelques fonctions trigger

  1. Les tables ci-dessous enregistrent les compositions et résultats de matchs de tennis en simple ou double, lors d'un tournoi inter-clubs.
    CREATE TABLE joueurs (
    	id INTEGER,
    	nom VARCHAR, --en principe NOT NULL (mais facilite les tests)
    	club VARCHAR NOT NULL,
    	PRIMARY KEY (id)
    );
    CREATE TABLE matchs (
    	id INTEGER,
    	horaire DATETIME,
    	type VARCHAR NOT NULL, --"simple" ou "double"
    	"résultat" VARCHAR,
    	PRIMARY KEY (id)
    );
    CREATE TABLE participer (
    	joueur INTEGER,
    	match INTEGER,
    	PRIMARY KEY (joueur, match),
    	FOREIGN KEY (joueur) REFERENCES joueurs(id),
    	FOREIGN KEY (match) REFERENCES matchs(id),
    	UNIQUE (joueur, match)
    );

    Écrire une fonction trigger vérifiant que l'ajout d'un joueur (table "participer") est licite : c'est-à-dire s'il s'agit d'un match simple comportant 0 ou 1 joueur, ou d'un double avec au plus 3 joueurs. Il faut aussi s'assurer que les clubs des joueurs sont cohérents (exactement deux représentés, de manière égale).

  2. Les tables ci-dessous enregistrent les "amitiés" dans un réseau social.
    CREATE TABLE users (
    	id INTEGER,
    	name VARCHAR NOT NULL,
    	UNIQUE (name),
    	PRIMARY KEY (id)
    );
    CREATE TABLE friend_with (
    	user1 INTEGER,
    	user2 INTEGER,
    	PRIMARY KEY (user1, user2)
    );
    Plus précisément, l'enregistrement (user1, user2) existe ssi. user1 a envoyé une demande d'amitié à user2. La ligne réciproque (user2, user1) existe si user2 a accepté cette demande.

    Écrire alors une fonction trigger agissant à la suppression d'une ligne, et supprimant la ligne réciproque si elle est trouvée.

  3. On considère les tables suivantes. etudiants contient au plus une ligne par étudiant, identifié par son numéro "id". L'attribut note correspond à sa note finale dans une certaine matière, et info représente diverses informations auxquelles on ne s'intéressera pas ici (date de naissance, nom, adresse ...).

    La table statistiques maintient des comptes concernant les notes : moyenne de l'ensemble de la promotion, proportion d'étudiants reçus (par exemple note >= 10), et nombre d'étudiant ayant été notés (donc présents à l'examen).

    CREATE TABLE etudiants (
    	id INTEGER PRIMARY KEY,
    	note REAL,
    	info TEXT -- devrait être NOT NULL en réalité
    );
    CREATE TABLE statistiques (
    	moyenne_generale REAL,
    	proportion_recus REAL,
    	nb_presents INTEGER
    );

    Placez un déclencheur sur la table etudiants dont le rôle sera de mettre à jour la table statistiques dès que la note d'un étudiant est entrée. Question/remarque : pourquoi n'a-t-on pas précisé de clé primaire sur la table statistiques ?

    Étendre la modélisation dans le cas – plus réaliste – où un étudiant a des notes dans plusieurs matières. Une troisième table/association contenant les notes sera nécessaire (pourquoi ?)

Solution

  1. Note : à réécrire sans clause WITH, non supportée sur les triggers SQLite.

    -- Suppose le match déjà créé (sinon soucis de clés étrangères)
    CREATE TRIGGER check_coherence BEFORE INSERT ON participer
    BEGIN
      WITH type_match AS (select type from match where id = new.match),
      joueurs_dans_match AS (
        SELECT club
        FROM joueurs
        JOIN participer
          ON joueurs.id = joueur
        WHERE match = new.match
          UNION
        SELECT club
        FROM joueurs
        WHERE id = new.joueur ),
      stats AS (
        SELECT
          count(*) as nb_players,
          count(club) as nb_clubs,
          max(count(*) FROM joueurs_dans_match GROUP BY club) as max_in_club
        FROM joueurs_dans_match )
      SELECT
        CASE
          WHEN (type = 'simple' AND nb_players > 2) OR (type = 'double' AND nb_players > 4) THEN
            RAISE (ABORT, 'Match déjà complet')
          WHEN type = 'simple' AND nb_players = 2 AND nb_clubs = 1 THEN
            RAISE (ABORT, 'Deux joueurs du même club ne s''affrontent pas en simple')
          WHEN type = 'double' THEN
            WHEN nb_clubs > 2 THEN
              RAISE (ABORT, 'Trop de clubs (max 2)')
            WHEN max_in_club > 2 THEN
              RAISE (ABORT, 'Club trop représenté (max 2)')
          END
        END
      FROM joueurs_dans_match, type_match, stats
    END;
  2. -- Il suffit d'effectuer la suppression "inverse" (au pire il ne se passe rien)
    CREATE TRIGGER cascade_delete AFTER DELETE ON friend_with
    BEGIN
      DELETE FROM friend_with
      WHERE user1 = old.user2 AND user2 = old.user1
    END;
  3. -- La version suivante recalcule tout à chaque modification :
    -- c'est assez coûteux, mais beaucoup plus facile à écrire et relire
    -- (...qu'une version écrite en PL/pgSQL, non reproductible en SQLite).
    CREATE TRIGGER t_maj_stats AFTER INSERT ON etudiants
    BEGIN
      WITH total_etu AS (SELECT count(*) AS total FROM etudiants)
      UPDATE statistiques
      SET (moyenne_generale, proportion_recus, nb_presents) =
        ((SELECT AVG(note) FROM etudiants),
        (CAST((SELECT COUNT(*) FROM etudiants WHERE note >= 10) AS real)
          / (SELECT total FROM total_etu)),
      (SELECT total FROM total_etu));
    END;
    -- Note : la fonction suppose que la table statistiques est initialement non vide
    INSERT INTO statistiques VALUES (0.0, 0.0, 0);
    -- Exemple de test :
    INSERT INTO etudiants (note) VALUES (12);
    SELECT * FROM statistiques;
    INSERT INTO etudiants (note) VALUES (7),(18),(5);
    SELECT * FROM statistiques;
    Pas besoin de clé primaire sur la table statistiques, celle-ci étant une "table singleton" qui ne contient qu'une seule rangée à ce stade de la question. On pourrait d'ailleurs s'assurer qu'aucune autre ligne n'est ajoutée via un trigger, mais la situation étant peu réaliste on ne l'a pas fait.
    Suite :
    -- Commençons par ajouter une colonne "matière" à la table statistiques
    -- (qui contiendra donc autant de lignes que de matières).
    DELETE FROM statistiques; -- pour éviter une erreur à la ligne suivante (pourquoi ? laquelle ?)
    ALTER TABLE statistiques ADD COLUMN matiere VARCHAR PRIMARY KEY;
    
    -- On crée la table "notes", pour éviter d'avoir à manipuler un tableau (associatif) de notes pour 
    -- chaque étudiant. C'est plus pratique, et permet aussi de rester en première forme normale.
    CREATE TABLE notes (
    	id_etudiant INTEGER NOT NULL,
    	matiere VARCHAR(20) NOT NULL,
    	note REAL,
    	PRIMARY KEY (id_etudiant, matiere)
    );
    
    -- Pour compléter la mise à jour, on supprime l'attribut note dans etudiants :
    ALTER TABLE etudiants RENAME TO etudiants_old;
    CREATE TABLE etudiants (
    	id INTEGER PRIMARY KEY,
    	info TEXT
    );
    DROP TABLE etudiants_old;
    -- L'attribut note de la table etudiants ayant glissé dans la table notes, la table etudiants ne nous 
    -- est plus utile (il ne reste que le champ info). Le déclencheur modifie donc la table 
    -- statistiques lors d'une action sur notes. Le code ressemble beaucoup à ce que l'on a déjà écrit.
    
    -- Enfin, on supprime l'ancien trigger
    DROP TRIGGER t_maj_stats;
    CREATE TRIGGER t_maj_stats AFTER INSERT ON statistiques
    BEGIN
      WITH total_etu AS (SELECT count(*) AS total FROM notes WHERE matiere = NEW.matiere)
      UPDATE statistiques 
      SET (moyenne_generale, proportion_recus, nb_presents) = 
          ((SELECT AVG(note) FROM notes WHERE matiere = m),
          (CAST((SELECT COUNT(note >= 10 AND matiere = m) FROM notes) AS real) 
            / (SELECT total FROM total_etu)),
          (SELECT total FROM total_etu))
      WHERE matiere = NEW.matiere;
      RETURN NULL;
    END;
    -- Note : la fonction suppose que chaque matière a une ligne dans la table statistiques ;
    -- on peut le faire manuellement ou utiliser un autre trigger, ou ajouter un test...
    INSERT INTO statistiques VALUES (0.0, 0.0, 0, 'maths');
    INSERT INTO statistiques VALUES (0.0, 0.0, 0, 'sport');
    
    -- Exemple de test :
    INSERT INTO notes VALUES (1,'maths',6);
    SELECT * FROM statistiques;
    INSERT INTO notes VALUES (1,'sport',15);
    SELECT * FROM statistiques;
    INSERT INTO notes VALUES (2,'maths',16),(3,'maths',10);
    INSERT INTO notes VALUES (2,'sport',8),(3,'sport',5);
    SELECT * FROM statistiques;
    -- Affichage final attendu :
     moyenne_generale | proportion_recus | nb_presents | matiere 
    ------------------+------------------+-------------+---------
              10.6667 |         0.666667 |           3 | maths
              9.33333 |         0.333333 |           3 | sport

Exercice 3

I have a database with columns looking like:

session | order | atype | amt
--------+-------+-------+-----
1       |  0    | ADD   | 10
1       |  1    | ADD   | 20
1       |  2    | SET   | 35
1       |  3    | ADD   | 10
2       |  0    | SET   | 30
2       |  1    | ADD   | 20
2       |  2    | SET   | 55

It represents actions happening.

  • Each session starts at 0.
  • ADD adds an amount, while SET sets it.

I want a function to return the end value of a session, e.g.

SELECT session_val(1); --returns 45
SELECT session_val(2); --returns 55

Is it possible to write such a function/query?

-- Reproduction de l'exemple :
CREATE TABLE command(session INTEGER,"order" INTEGER,atype VARCHAR,amt REAL);
INSERT INTO command VALUES (1,0,'ADD',10),(1,1,'ADD',20),(1,2,'SET',35),
	(1,3,'ADD',10),(2,0,'SET',30),(2,1,'ADD',20),(2,2,'SET',55);

Solution

Oui : ci-dessous une sorte de complément au cours expliquant comment...

Fonctions de fenêtrage

Principe : appliquer une fonction d'agrégat (MIN, MAX, AVG, ...), mais faire apparaître toutes les lignes dans le résultat (avec autant de répétitions qu'il y a de groupes).

-- Syntaxe PostgreSQL
function_name ([expression [, expression ... ]]) 
  OVER window_name
function_name ([expression [, expression ... ]]) 
  OVER ( window_definition )
function_name ( * ) OVER window_name
function_name ( * ) OVER ( window_definition )

-- avec pour window_definition :
[ existing_window_name ]
[ PARTITION BY expression [, ...] ]
[ ORDER BY expression [ ASC | DESC | USING operator ] 
  [ NULLS { FIRST | LAST } ] [, ...] ]
[ frame_clause ]
-- Exemple
SELECT sum(salaire) OVER w, avg(salaire) OVER w
FROM salaireemp
WINDOW w AS (PARTITION BY nomdep ORDER BY salaire DESC);
Fonctions de fenêtrage - exemple 1

Moyennes des salaires par catégories professionnelles.

SELECT nomdep, noemp, salaire, 
  avg(salaire) OVER (PARTITION BY nomdep) 
FROM salaireemp;
  nomdep   | noemp | salaire |          avg
-----------+-------+---------+-----------------------
 develop   |    11 |   5200  | 5020.0000000000000000
 develop   |     7 |   4200  | 5020.0000000000000000
 develop   |     9 |   4500  | 5020.0000000000000000
 develop   |     8 |   6000  | 5020.0000000000000000
 develop   |    10 |   5200  | 5020.0000000000000000
 personnel |     5 |   3500  | 3700.0000000000000000
 personnel |     2 |   3900  | 3700.0000000000000000
 ventes    |     3 |   4800  | 4866.6666666666666667
 ventes    |     1 |   5000  | 4866.6666666666666667
 ventes    |     4 |   4800  | 4866.6666666666666667
Fonctions de fenêtrage - exemple 2

Somme cumulée des salaires rangés par ordre croissant.

SELECT salaire, sum(salaire) 
OVER (ORDER BY salaire) 
FROM salaireemp;
 salaire|  sum  
--------+-------
   3500 |  3500
   3900 |  7400
   4200 | 11600
   4500 | 16100
   4800 | 25700
   4800 | 25700
   5000 | 30700
   5200 | 41100
   5200 | 41100
   6000 | 47100

Réponse 1 : SQL
SELECT SUM(amt) AS session_val
FROM (
	SELECT segment, amt,
	  MAX(segment) OVER() AS max_segment,
	FROM (
	  SELECT SUM(CASE WHEN atype = 'SET' THEN 1 ELSE 0 END)
	  OVER(ORDER BY "order") AS segment, amt
	  FROM command
	  WHERE session = 1 --ou 2
	) x
) x
WHERE segment = max_segment;
Test :
 v1 | v2 
----+----
 45 | 55
Décryptage
SELECT SUM(CASE WHEN atype = 'SET' THEN 1 ELSE 0 END)
OVER(ORDER BY "order") AS segment, amt
FROM command WHERE session = 1;
 segment | amt 
---------+-----
       0 |  10
       0 |  20
       1 |  35
       1 |  10
SELECT segment, amt,
  MAX(segment) OVER() AS max_segment,
FROM << resultat de la requete precedente >>
 segment | max_segment | amt
---------+-------------+-----
       0 |           1 |  10
       0 |           1 |  20
       1 |           1 |  35
       1 |           1 |  10
Réponse 2 : SQL - nettement plus simple
-- avec session=1 ou 2
SELECT SUM(amt)
FROM command
WHERE session=1 AND "order" >= (
  SELECT COALESCE(MAX("order"),0)
  FROM command
  WHERE atype='SET' AND session=1 );