SQL : obtenir une séquence d'entiers


3 juillet 2011

J'aimerai obtenir une séquence d'entiers compris entre 15, inclus, et 100, exclus, avec la requête suivante :

select value from sequence where value>=15 and value<100;

Pour cela il faut définir, offline, les deux views suivantes et c'est bon :

create view digits as select 0 n union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9;
create view sequence as select u.n+t.n*10+h.n*100 as value from digits as u cross join digits as t cross join digits as h;

Le vue sequence permet donc d'avoir des nombres compris entre 0 et 1000.

Les différents JOINs du standard SQL démystifiés


15 mai 2009

Introduction

Les jointures SQL, représentées par la clause JOIN, sont une façon standardisée de combiner les éléments de deux ou plusieurs tables en leur demandant de suivre une condition. Ces jointures, ces combinaisons, peuvent parfois s'appliquer sur la même table plusieurs fois (par exemple pour les classements sportifs). Grossièrement, il existe deux types de jointures : les internes, représentées par la clause INNER JOIN, et les externes, par OUTER JOIN.

Ces notes sont tirées du chapitre 9 de SQL Bible, A. Kriegel & B. Trukhnov, Wiley 2003 et ne représentent certainement pas un tutorial mais un pense-bête personnel.

Les jointures internes

Les clauses INNER JOIN combinent donc deux tables, ou plus, et renvoient les lignes pour lesquelles la condition de jointure se vérifie, à l'exclusion de toutes les autres.

...FROM <table1>
    [INNER | NATURAL | CROSS] JOIN <table2>
    [ON <condition> | USING <col_name>, ...]...
Syntaxe d'un INNER JOIN

Les jointures internes naturelles et libres

L'option NATURAL est appliquée quand les tables à joindre possède des noms de colonnes en commun et que l'on veut que la jointure les suivent. L'intérêt est que la requête est simple à écrire (mais pas forcément à comprendre) mais ceci impose que les colonnes liées aient les même noms.

Si NATURAL ne peut pas être utilisé (par exemple si les colonnes liées n'ont pas le même nom ou si on ne veut pas qu'elles soient TOUTES concernées par la jointure), on va utiliser les options INNER (le plus souvent) ou CROSS (dans des cas rares).

Ces deux options s'utilisent avec des conditions de jointures : les clauses USING et ON. USING est suivi d'une série de noms de colonnes qui doivent être les mêmes dans les deux tables, cette clause permettant de réaliser une jointure interne comme NATURAL mais sur un sous-ensemble de colonnes communes. La clause ON quand à elle permet de spécifier une série de conditions comme WHERE peut le faire, on est donc cette fois totalement libre de définir la jointure. Par contre la requête se complexifie d'autant (on n'a rien sans rien).

NATURAL : toutes les colonnes de même noms
INNER/CROSS avec USING : les colonnes de même noms que l'on choisit
INNER/CROSS avec ON : jointure que l'on peut définir librement
simplicité d'écriture vs liberté de jointure

Donc pour résumer : on utilisera soit NATURAL sans conditions de jointures, soit INNER/CROSS avec des conditions de jointures.

Le CROSS JOIN

Un CROSS JOIN est le produit cartésien des tenseurs en algèbre : pour chacune des lignes de la table A on associe toutes les lignes de la table B.

On peut se servir de ce type de jointure pour l'élaboration de données de test car on a immédiatement un grand nombre d'éléments après son application.

Les jointures externes

OUTER JOIN : les jointures externes car les INNER JOIN ignorent par définition les colonnes contenant des NULLs dans une seule des deux tables et que ces lignes peuvent tout de même nous intéresser. Une jointure externe renvoie TOUTES les entrées de la table A (à la clause WHERE près évidemment) et celles de la table B qui leur correspondent SI elles existent.

...FROM <table1>
((LEFT | RIGHT | FULL) [OUTER] | UNION) JOIN <table2>
[ON <condition>] | [USING <col_name>, ...], ...
Syntaxe d'une jointure externe

Les jointures externes gauche et droite

Les jointures externes complètes

Un FULL OUTER JOIN renverra la combinaison des deux A LEFT OUTER JOIN B et A RIGHT OUTER JOIN B.

Les UNION JOINs

Les UNION JOINs représentent l'intersection des deux JOINs suivants : (A FULL OUTER JOIN B) et (A INNER JOIN B) On extrait donc les lignes jointées qui correspondent entre les deux tables ou celles qui ont un NULL parmi les champs considérés, on extrait d'autre part les lignes dont les éléments correspondent entre les deux tables et on prend l'intersection de ces deux résultats. On obtient donc toutes les lignes jointées qui ont un NULL dans une des deux tables.

Ex : on doit donc pouvoir avoir les matches non joués de toutes les équipes.

Les jointures à l'ancienne, i.e. sans JOIN explicites mais avec une clause WHERE

On peut effectivement facilement trouver une correspondance entre les INNER JOINs et la "l'ancienne notation" qui n'utilise que la clause WHERE. Mais qu'en est-il des OUTER JOINs, ça a l'air bien plus compliqué de trouver une équivalence de ce type ? Ceci sans utiliser les notations batardes d'Oracle ou MS...

/* Ex : les matches de foot joués par une équipe. Un INNER JOIN ne renverra pas les matches non joués, i.e. ceux qui ont NULL dans les scores. Ex : on peut prendre comme exemple les matches de foot que j'ai déjà fait pour expliquer les LEFT et RIGHT OUTER JOINs. On aura tous les matches d'une équipe, joués ou pas. Pour les FULL OUTER JOINs >On peut peut-être séparer la notion de score à domicile et à l'extérieur pour illustrer les LEFT, RIGHT et FULL OUTER JOINs ! */ //OUTER JOINs simplification : http://dev.mysql.com/doc/refman/5.1/en/outer-join-simplification.html //LEFT and RIGHT JOINs optimization : http://dev.mysql.com/doc/refman/5.1/en/left-join-optimization.html //JOINs syntax : http://dev.mysql.com/doc/refman/5.1/en/join.html

Un Wiki stand-alone en PHP (le source)


1 décembre 2007

Composant Wiki en PHP

Voici de manière brute mon composant Wiki écrit en PHP que je décris sur une autre page.

<?
//###TODO : les antislash à la fin d'une ligne c'est facile, et c'est en préprocess

global  $Markers;
$Markers = array(
    "=="=>array("end"=>"\n", "replace"=>"<h2>$0</h2>", "separator"=>false, "recursive"=>true, "TOCLevel"=>1),
    "==="=>array("end"=>"\n", "replace"=>"<h3>$0</h3>", "separator"=>false, "recursive"=>true, "TOCLevel"=>2),
    "===="=>array("end"=>"\n", "replace"=>"<h4>$0</h4>", "separator"=>false, "recursive"=>true, "TOCLevel"=>3),
    "====="=>array("end"=>"\n", "replace"=>"<h5>$0</h5>", "separator"=>false, "recursive"=>true, "TOCLevel"=>4),
    "http://"=>array("end"=>array(" ", "\n"), "replace"=>"<a href='http://$0'>$0</a>", "separator"=>false, "recursive"=>false, "replaceEndMarker"=>false),
    "//"=>array("end"=>"//", "replace"=>"<i>$0</i>", "separator"=>false, "recursive"=>true),
    "**"=>array("end"=>"**", "replace"=>"<b>$0</b>", "separator"=>false, "recursive"=>true),
    "["=>array("end"=>"]", "replace"=>"<a href='$0'>$1+</a>", "separator"=>" ", "recursive"=>false),
    "[{"=>array("end"=>"}]", "replace"=>"<img src='$0' title='$1+' border='0'/>", "separator"=>" ", "recursive"=>false),
    "----"=>array("end"=>false, "replace"=>"<hr/>", "separator"=>false, "recursive"=>false),
    "\n\n"=>array("end"=>false, "replace"=>"<p>", "separator"=>false, "recursive"=>false),
    "\n"=>array("end"=>false, "replace"=>"<br/>", "separator"=>false, "recursive"=>false),
    "`"=>array("end"=>"`", "replace"=>"$0", "separator"=>false, "recursive"=>false),
    "> "=>array("end"=>"\n", "replace"=>"$0<br/>\n", "separator"=>false, "recursive"=>true, "groupBy"=>"<blockquote>$0</blockquote>"),
    "- "=>array("end"=>"\n", "replace"=>"<li>$0</li>", "separator"=>false, "recursive"=>true, "groupBy"=>"<ul>$0</ul>"),
    "# "=>array("end"=>"\n", "replace"=>"<li>$0</li>", "separator"=>false, "recursive"=>true, "groupBy"=>"<ol>$0</ol>"),
    //
    "[[refs]]"=>array("end"=>false, "replacePlugin"=>"WikiPlug_displayRefs"),
    "[[toc]]"=>array("end"=>false, "replacePlugin"=>"WikiPlug_displayTOC", "deferred"=>true),
    "<math>"=>array("end"=>"</math>", "replacePlugin"=>"WikiPlug_displayFormula", "recursive"=>false),
);

global  $WikiTOC;   $WikiTOC = array();
global  $Refs;      $Refs = array();
global  $Debug;     $Debug = false;
global  $Deferred;  $Deferred = array();

function    WikiPlug_displayRefs($src)
{
global  $Refs;
    if (!count($Refs))      return "";
    $Output = "Notes et références :<ul>";
    $RefIndex = 1;
    foreach($Refs as $Ref)      $Output .= "<li>Référence ".$RefIndex++." : <a href='$Ref'>$Ref</a></li>\n";
    $Output .= "</ul>\n";
    return $Output;
}

function    WikiPlug_displayTOC($src)
{
global  $WikiTOC;
    $Output = "";
    foreach($WikiTOC as $Row)       {
        $Output .= "<div style='padding-left:".($Row["level"]*2+1)."em;'><a href='#".$Row["name"]."'>".$Row["title"]."</a></div>";
    }
    return "<div style='padding:0 1em 1em 1em;'><table style='border:1px solid black;'><tr><td><h3>Table des matières</h3>\n$Output</td></tr></table></div>";
}

//Ce script nécessite d'installer le package texgd et la présence de plusieurs répertoires ayant les privilèges d'écriture règlés pour le serveur web (l'utilisateur sous lequel tourne ce script).
function    WikiPlug_displayFormula($exp)
{
    $Formula = str_replace("\\\\", "\\", rawurldecode($exp));
    $PicName = md5($Formula).".png";
    $CacheDir = "mathcache/";
    $PicFilePath = "$CacheDir$PicName";
    $TmpPath = $CacheDir."tmp/";
    //On ne crée l'image que si elle n'existe pas déjà dans le cache
    if (!file_exists($PicFilePath))     {
        $TexGDCommand = "export texgd_src='$Formula'; export texgd_tmpdir=$TmpPath; export texgd_fontdir=".$CacheDir."mathfonts; export texgd_outfile=$PicFilePath; export texgd_texheader=".$CacheDir."header.tex; export texgd_style='\$\$'; export texgd_density=10; export texgd_compressratio='3'; texgd";
        $Ret = exec($TexGDCommand, $Output);
    }
    //On envoie un tag HTML contenant la référence de l'image générée.
    return "<img style='vertical-align:middle;' src='$PicFilePath' border='0' title='$Formula'/>";
}

function    WikiCallPlugin($plugIn, $src)
{
    if (!function_exists($plugIn))      die("Le plug-in de remplacement <b>$plugIn</b> n'existe pas !");
    return call_user_func_array($plugIn, array($src));
}

function    Wiki_flushPreviousBlock($previousMarker, $curBlock, &$output)
{
global  $Markers;
    if (!isset($Markers[$previousMarker]["groupBy"]))       return;
    $BlockPattern = $Markers[$previousMarker]["groupBy"];
    $BlockPattern = str_replace("\$0", $curBlock, $BlockPattern);
    $output .= $BlockPattern;
}

//$level positif indique qu'on est en train de processer le texte Wiki
//$level nul indique qu'on a terminé mais qu'on fait la passe des éléments mis en attente (la TOC par exemple)
function    WikiProcess(&$wikiText, &$output, $level=1)
{
global  $Markers, $Debug, $Refs;
global  $WikiTOC, $Deferred;
if ($Debug)     echo "Entrée dans WikiProcess, level = $level, avec le texte :<blockquote style='border:1px solid red'>".str_replace("\n", "<br/>", $wikiText)."</blockquote>\n";
    //
    $Cursor = 0;
    $StartSegment = 0;
    $WikiTextLn = strlen($wikiText);
    $Marker = "";
    $PreviousMarker = "";
    $MarkerBlock = "";
    while ($Cursor < $WikiTextLn)       {
        //Tout d'abord on détermine le marqueur concerné, si c'en est un
        $MarkerFound = null;
        do  {
            $Char = $wikiText[$Cursor++];
if ($Debug)     echo "on rencontre <b>$Char</b> alors que le marqueur était $Marker.<br/>\n";
            $Marker .= $Char;
            $MarkerLn = strlen($Marker);
            $MarkersFound = array();
            foreach($Markers as $Key=>$Params)      {
                $SubKey = substr($Key, 0, $MarkerLn);
                if ($SubKey == $Marker)     array_push($MarkersFound, $Key);
                if ($Key == $Marker)        $MarkerFound = $Key;
            }
if ($Debug)     {   print_r($MarkersFound); echo "<br/>\n"; }
        }   while($Cursor < $WikiTextLn && count($MarkersFound));
if ($Debug)     if ($Cursor == $WikiTextLn)         echo "Le marqueur ? $MarkerFound, compteur ? ".count($MarkersFound)."<br/>\n";
        if ($MarkerFound)       {       //On revient au marqueur précédent
            if ($Cursor < $WikiTextLn)      $Cursor--;      //On recule d'un caractère
            $SegmentLn = $Cursor-strlen($MarkerFound)-$StartSegment;
            if ($Cursor == $WikiTextLn)     $SegmentLn--;
            $Segment = $SegmentLn > 0 ? substr($wikiText, $StartSegment, $SegmentLn) : "";
if ($Debug)     echo "On a trouvé un marqueur (Taille = ".strlen($MarkerFound).", Cursor = $Cursor, StartSegment = $StartSegment), on émet donc le segment précédent : ".(strlen($Segment) ? htmlentities($Segment) : "<i>Segment vide</i>")."<br/>\n";
            $output .= $Segment;
            //
if ($Debug)     echo "On a trouvé le marqueur <b>".str_replace("\n", "\\n", $MarkerFound)."</b> (Taille : ".strlen($MarkerFound).").<br/>\n";
            $Target =& $Markers[$MarkerFound];
            $EndMarker = $Target["end"];
            if ($EndMarker !== false)       {
                //Cas d'un marqueur qui définit un début et une fin (via un marqueur de fin)
                if (is_array($EndMarker))       {
                    $EndPos = false;
                    foreach($EndMarker as $CurEndMarker)        {
                        $CurEndPos = strpos($wikiText, $CurEndMarker, $Cursor);
                        if ($EndPos === false || $EndPos > $CurEndPos)      {
                            $EndPos = $CurEndPos;
                            $RealEndMarker = $CurEndMarker;
                        }
                    }
                    $EndMarker = $RealEndMarker;
                }   else    $EndPos = strpos($wikiText, $EndMarker, $Cursor);
if ($Debug)     echo "On a trouvé le marqueur de fin (<b>".$Target["end"]."</b>) à la position ".($EndPos === false ? "<i>false</i>" : $EndPos).".<br/>\n";
                $TagContent = substr($wikiText, $Cursor, $EndPos-$Cursor);
                if ($Target["recursive"])   {       //On traite ce marqueur de manière récursive (le contenu marqué peut lui-même être marqué)
                    $TagProcessed = "";
                    WikiProcess($TagContent, $TagProcessed, $level+1);
                }   else    $TagProcessed = $TagContent;
                $Sep = isset($Target["separator"]) ? $Target["separator"] : false;
if ($Debug)     echo "Le séparateur de <b>$MarkerFound</b> est ".($Sep === false ? "<i>false</i>" : $Sep)."<br/>\n";
                if ($Sep === false)     {
if ($Debug)     echo "On va remplacer \$0 par $TagProcessed dans ".htmlentities($Target["replace"])."<br/>\n";
                    if (isset($Target["replacePlugin"]))
                        $Pattern = WikiCallPlugin($Target["replacePlugin"], $TagProcessed);
                    else
                        $Pattern = str_replace("$0", $TagProcessed, $Target["replace"]);
if ($Debug)     echo "<blockquote>$output</blockquote>\n";
                }   else    {
                    $TagProcessed = trim($TagProcessed);
                    $TagItems = explode($Sep, $TagProcessed);
                    $Pattern = $Target["replace"];
                    $Base = $TagItems[0];
                    $Pattern = str_replace('$0', $Base, $Pattern);
                    $Label = "";
                    for($i=1; $i<count($TagItems); $i++)        $Label .= (strlen($Label) ? " " : "").$TagItems[$i];
                    if (!strlen($Label))        {
                        $Refs[] = $Base;
                        $Label = "<sup><span style='font-size:20%;'>[".count($Refs)."]</span></sup>";
                    }
                    $Pattern = str_replace('$1+', $Label, $Pattern);
                }
                //
                if (isset($Target["TOCLevel"]))     {
                    $TOCLevel = $Target["TOCLevel"];
                    $TOCIndex = count($WikiTOC);
                    $SectionName = "section-".md5($TOCIndex);
                    array_push($WikiTOC, array("name"=>$SectionName, "level"=>$TOCLevel, "title"=>$TagProcessed));
                    $Pattern = "<a name='$SectionName'>$Pattern</a>";
                }
                //
                if ($PreviousMarker != $MarkerFound)        {
                    Wiki_flushPreviousBlock($PreviousMarker, $MarkerBlock, $output);
                    $MarkerBlock = "";
                    $BlockPattern = "";
                }
                if (isset($Target["groupBy"]))      $MarkerBlock .= $Pattern;
                else    {
                    $MarkerBlock = "";
                    $output .= $Pattern;
                }
                //
                $Cursor += strlen($TagContent);
                $Cursor += strlen($EndMarker);
                //
                $PreviousMarker = $MarkerFound;
            }   else    {
                //Cas d'un marqueur ponctuel, remplacé simplement
if ($Debug)     echo "On remplace un marqueur ponctuel<br/>\n";
                if (isset($Target["deferred"]) && $Target["deferred"] === true && $level)       {
                    $DeferredIndex = md5(date("c").($level + $Cursor));
                    $Deferred[] = $DeferredIndex;
                    $output .= "{{"."$DeferredIndex:".strlen($MarkerFound).":$MarkerFound"."}}";
                }   else    {
                    if (isset($Target["replacePlugin"]))        $output .= WikiCallPlugin($Target["replacePlugin"], null);
                    else    {
                        $output .= $Target["replace"];
if ($Debug)     echo "Contexte (Cursor = $Cursor) : ".str_replace("\n", "\\n", substr($wikiText, $Cursor, 4))."<br/>\n";
                    }
                }
                $Marker = "";
            }
            if (isset($Target["replaceEndMarker"]) && $Target["replaceEndMarker"] === false)
                $Cursor -= strlen($RealEndMarker);
            $StartSegment = $Cursor;
            $Marker = "";
        }   else    if (!count($MarkersFound))          $Marker = "";
    }
    //
    Wiki_flushPreviousBlock($PreviousMarker, $MarkerBlock, $output);
    $output .= substr($wikiText, $StartSegment);

if ($Debug)     echo "<h3>On est arrivé au bout et le résultat est...</h3><blockquote style='border:1px solid black;'>".htmlentities($output)."</blockquote>\n";
    //On termine le niveau 1, le premier de la récursion, on peut donc passer au niveau 0, celui qui concrétise les éléments en attente.
    if ($level == 1 && count($Deferred))        {
        foreach($Deferred as $Key=>$Index)          {
            $Marker = "{{".$Index.":";
            $EndMarker = "}}";
            $MarkPos = strpos($output, $Marker, 0);
            $ElementPos = strpos($output, ":", $MarkPos+strlen($Marker)) + 1;
            $LnPos = $MarkPos+strlen($Marker);
            $Ln = substr($output, $LnPos, $ElementPos-$LnPos-1);
            $DeferredTxt = substr($output, $ElementPos, $Ln);
            $DeferredProcessed = "";
            WikiProcess($DeferredTxt, $DeferredProcessed, 0);
            $output = substr($output, 0, $MarkPos) . $DeferredProcessed . substr($output, $ElementPos+$Ln+strlen($EndMarker));
        }
    }
}

?>

Variantes d'écriture d'une requête SQL simple


28 octobre 2007

Présentation

Pendant la lecture du chapitre 6, pages 126 et 127, du livre de Joe Celko "Sql Programming Style", Elsevier, 2005, j'ai eu envie de vérifier un schéma et une requête qu'il exécute sur un certain type de données.

Les données sont des prêts pour lesquels on stocke des paiements successifs qui peuvent avoir chacun un status (S, U ou F) qui détermine, si on veut, un paiement a échéance, en retard ou envoyé (la signification de ce status n'a pas d'importance). Il cherche, étant donné ce schéma, la liste des prêts dont TOUS les paiements ont le status 'F'.

Simple ? Oui, mais on peut diverger quand à la construction des requêtes possibles pour trouver ce résultat.

Le schéma et la solution de Celko

Son schéma, que je reprend, est le suivant :

CREATE TABLE loans (
        loanId int not null,
        paymentId int not null,
        paymentStatus = ENUM('F', 'U', 'S'),
        primary key (loanId, paymentId)
    )

Sa solution est élégante et rapide (cf ci-dessous) et je voulais savoir si on pouvais faire mieux. Tout d'abord présentons sa solution :

select loanId, count(*) as nbPayments
        FROM loans
        GROUP BY loanId
        HAVING MAX(paymentStatus)='F' and MIN(paymentStatus)='F';

On parcourt donc la totalité de la table et on extrait bien les prêts dont les paiements n'ont pas d'autres statuts que 'F'.

Les alternatives

J'ai deux solutions alternatives. La première utilise deux sous-requêtes, une qui donne pour chaque prêts le nombre de paiements ayant le statuts 'F' et une autre qui donne le nombre de paiements total. Une clause WHERE est présente pour ne garder que les prêts pour lesquels ces décomptes sont égaux.

select GlobalStats.loanId, GlobalStats.nbPayments
        from
        (select loanId, count(*) as nbPayments from loans where paymentStatus='F' group by loanId) as FStats,
        (select loanId, count(*) as nbPayments from loans group by loanId) as GlobalStats
        where FStats.loanId=GlobalStats.loanId and FStats.nbPayments=GlobalStats.nbPayments;

La deuxième solution calcule directement pour chaque prêt le nombre de paiements et le nombre de paiements ayant le statut 'F', en utilisant la somme d'une valeur issue d'un IF.

select loanId, nbPayments
        from (
            select loanId, count(loanId) as nbPayments, sum(if(paymentStatus='F', 1, 0)) as nbFPayments from loans group by loanId
        ) as inter
        where inter.nbPayments=inter.nbFPayments;

Conclusion

Le résultat ? Déprimant ! Mes deux solutions peuvent être plus simples à comprendre au premier coup d'oeil mais sont jusqu'à 30% plus lente à l'exécution, alors que la solution de Celko qui peut surprendre au premier regard, est en fait très simple à comprendre, plus rapide et plus constante dans son temps d'exécution.

Pour tester ça j'ai utilisé un petit script PHP qui initialise la table, la remplit avec des milliers de données, règle certains prêts de manière à ce qu'ils apparaissent dans le résultat des requêtes de cette page et compare les temps d'exécution des trois variantes.

C'est moi ou Joe Celko ressemble drôlement à Anton Lavey ?

SQL adapté au classement sportifs (comprenez Foot)


28 octobre 2007

Présentation

Pas la peine de crier, pensez ce que vous voulez du foot, ça m'amuse de compiler des données là-dessus et d'établir la meilleure structure de données possible afin d'obtenir non seulement des résultats le plus simplement du monde (comprenez une seule requête SQL) et d'extraire des informations d'un amas plutôt brut de résultats de matchs (ex : Juninho à 75% de chance de marquer un but s'il rentre avant la 23ème minute d'un match. Cherchez pas je viens de l'inventer mais on peut calculer ça !).

L'objectif : le classement du championnat de France

Chaque journée (groupe de N matchs entre les 2N équipes de ligue 1 ou 2 du championnat) permet, avec les journées précédentes, d'établir un classement de ces 2N équipes en fonction d'un barème simple et précis : une victoire = 3 points, un match nul = 1 point et une défaite = aucun point. On passera pour l'instant sur les tris utilisés quand ces scores sont égaux et qui permettent de départager les ex-aequo.

Les données

On va créer une table extrèmement simpl(ist)e pour stocker les matches :

CREATE TABLE `matches` (
  `id` int(10) unsigned NOT NULL auto_increment,
  `home` varchar(100) collate utf8_unicode_ci NOT NULL,
  `visitor` varchar(100) collate utf8_unicode_ci NOT NULL,
  `nbGoalsHome` int(10) default NULL,
  `nbGoalsVisitor` int(10) default NULL,
  `dayIndex` int(11) NOT NULL,
  PRIMARY KEY  (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci COMMENT='Matches de foot';

La plupart des champs sont évident, à l'exception de dayIndex qui est l'indice de la "journée" à laquelle appartient le match. Ceci posé on va la remplir avec les résultats des 8 premières journées du championnat dont je dispose à cette date, ce qui nous fait 80 matches car il y a 20 équipes en lice.

Premier essai : mix-up de PHP et SQL

La version du code qui vient immédiatement consiste à procéder à des requêtes simples et à traiter les résultats de ces requêtes en programmation classique et enfin a afficher le résultat. D'où :

//On détermine les noms des différentes équipes évoluant dans le championnat
    $Result = mysql_query("SELECT home as name from matches group by home;");
    while ($Item = mysql_fetch_array($Result))      $Buckets[$Item["name"]] = 0;

    //Pour chaque équipe on calcule les points issus des ses rencontres jouées à domicile
    $Result = mysql_query("SELECT
        home, nbGoalsHome, sum(if (nbGoalsHome>nbGoalsVisitor,3,if(nbGoalsHome=nbGoalsVisitor,1,0))) as homeScore,
        visitor, nbGoalsVisitor, sum(if (nbGoalsHome<nbGoalsVisitor,3,if(nbGoalsHome=nbGoalsVisitor,1,0))) as visitorScore
        FROM `matches` group by visitor;");
    while ($Item = mysql_fetch_array($Result))      $Buckets[$Item["visitor"]] += $Item["visitorScore"];

    //Comme précédemment mais pour les rencontres jouées à l'extérieur
    $Result = mysql_query("SELECT
        home, nbGoalsHome, sum(if (nbGoalsHome>nbGoalsVisitor,3,if(nbGoalsHome=nbGoalsVisitor,1,0))) as homeScore,
        visitor, nbGoalsVisitor, sum(if (nbGoalsHome<nbGoalsVisitor,3,if(nbGoalsHome=nbGoalsVisitor,1,0))) as visitorScore
        FROM `matches` group by home;");
    while ($Item = mysql_fetch_array($Result))      $Buckets[$Item["home"]] += $Item["homeScore"];

    //Le tri final et l'affichage
    arsort($Buckets);
    foreach($Buckets as $TeamName=>$TeamScore)      echo "$TeamName = $TeamScore<br/>";

Ce qui renvoie :

Nancy = 16
Rennes = 15
Lyon = 15
Bordeaux = 15
Valenciennes = 14
Monaco = 13
Le mans = 13
Lorient = 12
Strasbourg = 12
Paris-SG = 11
Saint-Etienne = 11
Nice = 11
Toulouse = 10
Lille = 9
Marseille = 7
Auxerre = 6
Lens = 5
Caen = 4
Sochaux = 4
Metz = 2

On a ici une première version du classement attendu mais avec quelques petites différences dûes aux ex-aequo qui ne sont pas bien placés du fait de la présence d'un seul tri (sur le score global) qui ne suffit pas.

Note : j'ai fait un tri simple (la ligne du arsort) parce que trier au sens de la LFP comme évoqué plus loin m'ennuie profondément en PHP.

Deuxième essai : une seule requête SQL

Le chemin vers une requête SQL unique plutôt qu'un mélange de deux langages est assez direct :

  • on doit cumuler les points de chaque équipe qu'elle joue à domicile ou à l'extérieur
  • ayant cumulé les points engrangés à chaque match avec une requête on les cumule avec une deuxième qui va l'encapsuler et procéder à des additions (grossièrement)
  • on va trier ces cumuls pour refléter le classement correct au sens de la LFP

Calcul des points par match

Le premier point nous oblige à doubler les matchs contenus dans notre table, car sinon les cumuls vont être impossibles à réaliser en une seule requête. Je m'explique : les points d'une équipe à domicile ne pourront être cumulés à ceux qu'elle a rapportés de ses voyage à l'extérieur, les champs (home et visitor) sont tout simplement différents ! On va donc leurrer le code qui réalise les cumuls en ajoutant pour chaque match son match miroir, ainsi Toulouse-Marseille est l'équivalent de Marseille-Toulouse et les cumuls réalisés sur chaque équipe sont valides. On va faire tout ceci en utilisant un UNION :

select
        t1.id, t1.home, t1.nbGoalsHome, t1.visitor, t1.nbGoalsVisitor
    from matches as t1
UNION ALL
    select
        t2.id, t2.visitor, t2.nbGoalsVisitor, t2.home, t2.nbGoalsHome
    from
        matches as t2
order by id

Cette requête renvoie 180 lignes, deux fois plus de lignes que de matchs, comme suit, du moins pour les 10 premières lignes :

idhomenbGoalsHomevisitornbGoalsVisitor
1Toulouse2Marseille1
1Marseille1Toulouse2
2Lens1Nancy0
2Nancy0Lens1
3Valenciennes0Le mans2
3Le mans2Valenciennes0
4Bordeaux1Lille1
4Lille1Bordeaux1
5Lyon5Metz1
5Metz1Lyon5

On voit clairement les lignes doublées, elles ont la même id mais avec les équipes inversées.

On peut lire ce résultat comme : "je vais traiter les résultats de chaque équipe de chaque match peu importe qu'elle joue à l'extérieur ou à domicile".

Pour calculer le nombre de points engrangés par chaque équipe on doit utiliser une clause IF pour chaque SELECT de l'opération UNION, placé juste après le nom de l'équipe qui apparaît en premier sur chaque ligne :

select
    t1.id, t1.home,
    IF(t1.nbGoalsHome>t1.nbGoalsVisitor, 3, IF(t1.nbGoalsHome=t1.nbGoalsVisitor, 1, 0)) as score,
    t1.nbGoalsHome, t1.visitor, t1.nbGoalsVisitor
from matches as t1

UNION ALL

select
    t2.id, t2.visitor,
    IF(t2.nbGoalsHome<t2.nbGoalsVisitor, 3, IF(t2.nbGoalsHome=t2.nbGoalsVisitor, 1, 0)),
    t2.nbGoalsVisitor, t2.home, t2.nbGoalsHome
from matches as t2

order by id

Ce qui renvoie :

idhomescorenbGoalsHomevisitornbGoalsVisitor
1Marseille01Toulouse2
1Toulouse32Marseille1
2Lens31Nancy0
2Nancy00Lens1
3Le mans32Valenciennes0
3Valenciennes00Le mans2
4Lille11Bordeaux1
4Bordeaux11Lille1
5Metz01Lyon5
5Lyon35Metz1

Ce qui se lit en français "chaque équipe remporte 3, 1 ou zéro points suivant le résultat du match qu'elle a joué à domicile ou à l'extérieur".

Les cumuls des points

Tout ceci est bien beau mais il faut accumuler les points calculés précédemment pour obtenir des totaux nous permettant d'ordonner les équipes entre elles, ce qui est le but initial... Pour ça, on peut transformer la requête précédente en sous-requête d'une requête principale de cumuls :

select nomEquipe, sum(score) as totalScore
    from (sous-requête) as liste
group by nomEquipe
order by totalScore desc

si on remplace la mention sous-requête par celle qu'on a écrit précédemment. Ce qui renvoie :

nomEquipetotalScore
Nancy16
Rennes15
Bordeaux15
Lyon15
Valenciennes14
Le mans13
Monaco13
Strasbourg12
Lorient12
Paris-SG11
Saint-Etienne11
Nice11
Toulouse10
Lille9
Marseille7
Auxerre6
Lens5
Caen4
Sochaux4
Metz2

et qui correspond presque au classement officiel de cette 8ème journée, si ce n'est que quelques équipes à égalité de points sont mal placées et qui nous fait arriver au dernier point.

Les tris officiels

On peut consulter les modes de calculs et de tri des résultats dans les règles du championnat, article 307 éditées par la Ligue de Football Professionnel. Je cite :

En cas dâ??égalité de points, le classement des clubs ex-aequo est déter- miné par la différence entre les buts marqués et les buts concédés par chacun dâ??eux au cours des matches joués pour lâ??ensemble de la divi- sion. En cas de nouvelle égalité, avantage sera donné au club ayant marqué le plus grand nombre de buts.

On va donc transposer ça en mettant bout à bout des tris au sein d'une clause ORDER BY, en considérant que totalScore est le nombre de points d'une équipe, Diff est la différence de buts cumulée et BP est le nombre de buts marqués cumulé :

order by totalScore desc, Diff desc, BP desc

Le résultat final

Voici donc, en utilisant tout ce qui précède, la requête finale à exécuter pour obtenir en quelques millièmes de secondes le classement des équipes jouant le championnat de France :

select
    nomEquipe, sum(score) as totalScore, sum(J) as J, sum(G) as G, sum(N) as N, sum(P) as P, sum(bp) as BP, sum(bc) as BC, sum(diff) as Diff
    from (
        SELECT
            t1.id, t1.home as nomEquipe,
            IF (t1.nbGoalsHome > t1.nbGoalsVisitor, 3, IF (t1.nbGoalsHome = t1.nbGoalsVisitor, 1, 0)) as score,
            if(t1.nbGoalsHome is null, 0, 1) as J,
            if(t1.nbGoalsHome>t1.nbGoalsVisitor, 1, 0) as G, if(t1.nbGoalsHome=t1.nbGoalsVisitor, 1, 0) as N,
            if(t1.nbGoalsHome<t1.nbGoalsVisitor, 1, 0) as P,
            t1.nbGoalsHome as bp, t1.nbGoalsVisitor as bc,
            t1.nbGoalsHome-t1.nbGoalsVisitor as diff
        FROM matches AS t1
        UNION ALL
        SELECT
            t2.id, t2.visitor as nomEquipe,
            IF (t2.nbGoalsVisitor > t2.nbGoalsHome, 3, IF (t2.nbGoalsVisitor = t2.nbGoalsHome, 1, 0)),
            if(t2.nbGoalsVisitor is null, 0, 1),
            if(t2.nbGoalsVisitor>t2.nbGoalsHome, 1, 0),
            if(t2.nbGoalsHome=t2.nbGoalsVisitor, 1, 0),
            if(t2.nbGoalsVisitor<t2.nbGoalsHome, 1, 0),
            t2.nbGoalsVisitor, t2.nbGoalsHome,
            t2.nbGoalsVisitor-t2.nbGoalsHome as diff
        FROM matches AS t2
    ) as resultat
    group by nomEquipe
    order by totalScore desc, Diff desc, BP desc

Cette requête renvoie ce qu'on attend d'elle, à savoir :

nomEquipetotalScoreJGNPBPBCDiff
1Nancy1675111358
2Lyon1575021477
3Bordeaux1584311064
4Rennes158431954
5Valenciennes1484221293
6Monaco1384131385
7Le mans13841312111
8Strasbourg128332853
9Lorient1283321091
10Saint-Etienne118323963
11Paris-SG118251761
12Nice118323770
13Toulouse106312880
14Lille98161880
15Marseille78143710-3
16Auxerre68206415-11
17Lens5612324-2
18Caen4611449-5
19Sochaux48044614-8
20Metz28026213-11

Vous pouvez vérifier que ce classement est correct en vous rendant sur la page officielle de cette 8ème journée 2007/2008 de la LFP.

Dernier arrangement

On voit tout de suite que les deux SELECT que l'on mélange avec le UNION de la requête précédente sont optimisables : il suffit de déplacer les deux listes de champs et de les mélanger avec les sommes qu'exécutent la requête supérieure. �a marche très bien, la requête est plus courte, plus claire et elle s'exécute 20% plus rapidement sur plusieurs installations de MySQL :

ELECT
        nomEquipe,
        sum(IF(nbGoals1 > nbGoals2, 3, IF (nbGoals1 = nbGoals2, 1, 0))) as totalScore,
        sum(if(nbGoals1 is null, 0, 1)) as J,
        sum(if(nbGoals1>nbGoals2, 1, 0)) as G,
        sum(if(nbGoals1=nbGoals2, 1, 0)) as N,
        sum(if(nbGoals1<nbGoals1, 1, 0)) as P,
        sum(nbGoals1) as BP,
        sum(nbGoals2) as BC,
        sum(nbGoals1-nbGoals2) as Diff
    FROM (
            SELECT
                t1.home as nomEquipe, t1.nbGoalsHome as nbGoals1, t1.nbGoalsVisitor as nbGoals2
            FROM matches as t1
        UNION ALL
            SELECT
                t2.visitor, t2.nbGoalsVisitor, t2.nbGoalsHome
            FROM matches as t2
    ) as mList
    GROUP BY nomEquipe
    ORDER BY totalScore desc, Diff desc, BP desc

Fonctionnalités supplémentaires

En ajoutant une clause WHERE très simple, WHERE dayIndex<=X, dans la grosse requête précédente, on obtient le classement après une journée quelconque, X, et plus sur l'ensemble des journées écoulées. En répètant cette requête pour toutes les valeurs de X partant de 1 jusqu'à la dernière journée jouée, on a les classements successifs journées après journées et on peut établir, par exemple, la progression d'une équipe dans ce classement.

//Je n'ai pas pu résister à l'envie de faire un petit graphique regroupant les positions successives des équipes renvoyées par cette nouvelle requête : //<IMAGE>

On pourra ajouter beaucoup de clauses WHERE dans cette requête histoire de gérer un design plus poussé mais ceci est l'objet d'un autre article plus complet. Je voulais ici me concentrer sur le point précis de l'obtention de ce classement et ce qu'il supposait du point de vue SQL.

Dernier point

Il y a une chose étrange : après avoir ajouté cet indice de la journée de championnat qui correspond au match j'ai cru constater un ralentissement de l'exécution de la requête quand ce nombre était déclaré comme INDEX alors que je croyais que cette déclaration allait optimiser mon WHERE... A suivre donc.

Accueil1 2