Opérateur $and dans MongoDB avec Doctrine ODM

Voilà quelques temps que j’utilise MongoDB en PHP par l’intermédiaire de Doctrine ODM.

Je passerais ici les multiples avantages de ces technologies pour me concentrer sur une limitation de la version actuelle de MongoDB (1.8) : l’absence de l’opérateur $and et la façon de le contourner.

Le problème est simple : Je dispose d’objets de type Element qui sont associés à un ou plusieurs Tags.
Je souhaite pouvoir effectuer une requête simple : Trouver tous les documents associés aux tags (1 ou 2) ET (3 ou 4). En l’état, MongoDB ne le permet pas.

Voici les objets concernés.

/**
 * @Document
 */
class Element
{
    /**
     * @Id
     */
    private $id;
    
    /**
     * @String
     */
    protected $titre;

    /**
     * @Collection
     * @var Doctrine\Common\Collections\ArrayCollection
     */
    protected $tags = array();
    
    public function __construct ($pTitre)
    {
        $this->titre = $pTitre;
    }
    
    public function addTag ($pTag)
    {
        $this->tags[] = $pTag;
    }
    
    public function __toString()
    {
        return '["'.
               $this->titre.
               '" : ('.
               implode(', ', $this->tags).
               ')]';
    }
}

Dans Mongo, il est bien sûr de faire des requêtes de type
1) Trouver les documents qui disposent du tag « A »

   {"tags" : {$in : ["A"]}}

2) Trouver les documents qui disposent du tag « A » ou « B »

   {"tags" : {$in : ["A", "B"]}}

3) Trouver les documents qui disposent du tag « A » et « B »

   {"tags" : {$all : ["A", "B"]}}

4) Trouver les documents qui disposent du tag (« A » et « B ») OU (« C » et « D »)

{
 $or : [{"tags" : {$all : ['A', 'B']}},
        {"tags" : {$all : ['C', 'D']}}]
}

Ces requêtes, avec doctrine ODM, ressemblent à :

        echo 'Selection des elements qui contiennent A <br />';
        $qb = $dm->createQueryBuilder('Element');        
        $qb->field('tags')->in(array('A'));
        foreach ($qb->getQuery()->execute() as $result){
            echo $result, '<br />';
        }
        
        echo 'Selection des elements qui contiennent A ou B<br />';
        $qb = $dm->createQueryBuilder('Element');
        $qb->field('tags')->in(array('A', 'B'));
        foreach ($qb->getQuery()->execute() as $result){
            echo $result, '<br />';
        }
        
        echo 'Selection des elements qui contiennent A ET B<br />';
        $qb = $dm->createQueryBuilder('Element');
        $qb->field('tags')->all(array('A', 'B'));
        foreach ($qb->getQuery()->execute() as $result){
            echo $result, '<br />';
        }
        
        echo 'Selection des elements qui contiennent (A ET B) OU (C ET D) <br />';
        $qb = $dm->createQueryBuilder('Element');
        $qb->addOr($qb->expr()->field('tags')->all(array('A', 'B')));
        $qb->addOr($qb->expr()->field('tags')->all(array('C', 'D')));
        foreach ($qb->getQuery()->execute() as $result){
            echo $result, '<br />';
        }

Arrive le problème du $and

Nous voulons maintenant sélectionner les éléments qui disposent des tags (A ou B) ET (C ou D).

Si l’opérateur existait, nous ferions quelque chose du style :

{$and : [{"tags" : {$in : ["A", "B"]}},
            {"tags" : {$in : ["C", "D"]}}]
   

Mais ce n’est pas le cas….

Nous pourrions tenter d’écrire :

{"tags" : {$in : ["A", "B"]}
 "tags" : {$in : ["C", "D"]}}

Mais cela écrase simplement la clef « tags » et a pour effet d’effectuer la même requête que si nous écrivions :

{"tags" : {$in : ["C", "D"]}}

Ainsi, il nous faut transformer ces ET sur groupes de OU en OU sur des groupes de ET.

C’est à dire, transformer

(A OU B) ET (C OU D)
en
(A et C) OU (A et D) OU (B et C) OU (B et D)

Proposition de solution

Construisez le tableau « simple » en langage (A OU B) ET (C OU D).

Par exemple :

$groupes = array();
$groupes['premierGroupeDeOU'] = array('A', 'B');
$groupes['deuxiemeGroupeDeOU'] = array('C', 'D');

et utilisez la fonction ci – dessous pour le transformer en OU sur groupes de ET :

//exemple d'appel de la fonction
echo 'Selection des elements qui contiennent (A OU B) ET (C OU D) <br />';
$qb = $dm->createQueryBuilder('Element');
foreach (transform_and_group_of_or_to_or_group_of_and($groupes) as $groupe){
    $qb->addOr($qb->expr()->field('tags')->all($groupe));            
}
foreach ($qb->getQuery()->execute() as $result){
    echo $result, '<br />';
}

//les fonctions concernées
function transform_and_group_of_or_to_or_group_of_and ($pArray)
{
    //réinitialise le tableau des groupes pour avoir des indices numériques qui se suivent
    $pArray = array_values($pArray);

    //Calcul des indices maximum
    $maxIndices = array();
    foreach ($pArray as $key=>&$values){
        //s'assure de clefs numériques séquentielles
        $values = array_values($values);

        $maxIndices[$key] = count($values)-1;//précalcul
        $arIndices[$key] = 0;//première valeur, tableau de compte
    }

    $groupCount = count($pArray);
    $groupOfAnd = array();

    do {
        $newGroup = array();
        for ($i=0; $i<$groupCount; $i++){
            $indice = $arIndices[$i];
            $sousTab = $pArray[$i];
            $newGroup[] = $sousTab[$indice];
        }
        $groupOfAnd[] = $newGroup;
    } while(increment_numbers($arIndices, $maxIndices));
    
    return $groupOfAnd;
}

//"Compte" sur un système numérique arbitraire décrit par $maxIndices
//eg avec maxIndices = array(2, 3) :
//[0,0] [0,1] [0,2] [0,3]
//[1,0] [1,1] [1,2] [1,3]
//[2,0] [2,1] [2,2] [2,3] 
//FIN
function increment_numbers(& $arIndices, $maxIndices){
    //Augmente le dernier indice
    $arIndices[count($arIndices)-1]++;

    $check = true;
    for ($i=count($arIndices)-1; (($i>=0) && ($check === true)); $i--){
        if ($arIndices[$i] > $maxIndices[$i]){
            if ($i > 0){
               $arIndices[$i-1]++;//increment the upper element
            } else {
                return 0;
            }
            $arIndices[$i] = 0;
            $check=true;
        }else{
            $check = false;
        }
    }
    return true;
}

Ce n’est peut être pas le plus élégant, mais voilà qui fonctionne parfaitement 🙂

Si ce billet économise ne serait-ce qu’une petite demi heure à un collègue qui rencontrera la problématique, j’en serais heureux !

Conclusion ?

  • Si vous avez rencontré le même problème et que vous avez une autre solution plus simple, je suis bien sûr preneur !
  • Si vous connaissez une fonction PHP permettant de réaliser increment_numbers, je suis également preneur (en gros un genre de base_convert en plus paramétrable)

Liens

3 réflexions sur « Opérateur $and dans MongoDB avec Doctrine ODM »

  1. As tu essayé avec la négation ?
    (A || B) && (C || D) ! ( !(A && B) || !(C && D) )

    genre :
    {$nor: [
    {tags: {$nin: [‘A’, ‘B’]}},
    {tags: {$nin:[‘C’,’D’]}}
    ]});

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *