Browse Source

Merge pull request #6750 from cakephp/3.1-not-matching

Implemented Query::notMatching()
Mark Story 10 years ago
parent
commit
d2dcaa5336

+ 24 - 1
src/ORM/Association.php

@@ -471,6 +471,8 @@ abstract class Association
      *   properties to be followed from the passed query main entity to this
      *   association
      * - joinType: The SQL join type to use in the query.
+     * - negateMatch: Will append a condition to the passed query for excluding matches.
+     *   with this association.
      *
      * @param Query $query the query to be altered to include the target table data
      * @param array $options Any extra options or overrides to be taken in account
@@ -518,11 +520,32 @@ abstract class Association
 
         $joinOptions = ['table' => 1, 'conditions' => 1, 'type' => 1];
         $options['conditions'] = $dummy->clause('where');
-        $query->join([$target->alias() => array_intersect_key($options, $joinOptions)]);
+        $query->join([$this->_name => array_intersect_key($options, $joinOptions)]);
 
         $this->_appendFields($query, $dummy, $options);
         $this->_formatAssociationResults($query, $dummy, $options);
         $this->_bindNewAssociations($query, $dummy, $options);
+        $this->_appendNotMatching($query, $options);
+    }
+
+    /**
+     * Conditionally adds a condition to the passed Query that will make it find
+     * records where there is no match with this association.
+     *
+     * @param \Cake\Database\Query $query The query to modify
+     * @param array $options Options array containing the `negateMatch` key.
+     * @return void
+     */
+    protected function _appendNotMatching($query, $options)
+    {
+        $target = $this->_targetTable;
+        if (!empty($options['negateMatch'])) {
+            $primaryKey = $query->aliasFields((array)$target->primaryKey(), $this->_name);
+            $query->andWhere(function ($exp) use ($primaryKey) {
+                array_map([$exp, 'isNull'], $primaryKey);
+                return $exp;
+            });
+        }
     }
 
     /**

+ 15 - 0
src/ORM/Association/BelongsToMany.php

@@ -265,6 +265,21 @@ class BelongsToMany extends Association
     /**
      * {@inheritDoc}
      */
+    protected function _appendNotMatching($query, $options)
+    {
+        $target = $junction = $this->junction();
+        if (!empty($options['negateMatch'])) {
+            $primaryKey = $query->aliasFields((array)$target->primaryKey(), $target->alias());
+            $query->andWhere(function ($exp) use ($primaryKey) {
+                array_map([$exp, 'isNull'], $primaryKey);
+                return $exp;
+            });
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
     public function transformRow($row, $nestKey, $joined)
     {
         $alias = $this->junction()->alias();

+ 5 - 2
src/ORM/EagerLoader.php

@@ -64,7 +64,8 @@ class EagerLoader
         'queryBuilder' => 1,
         'finder' => 1,
         'joinType' => 1,
-        'strategy' => 1
+        'strategy' => 1,
+        'negateMatch' => 1
     ];
 
     /**
@@ -190,9 +191,11 @@ class EagerLoader
         $containments = [];
         $pointer =& $containments;
         $options += ['joinType' => 'INNER'];
+        $opts = ['matching' => true] + $options;
+        unset($opts['negateMatch']);
 
         foreach ($assocs as $name) {
-            $pointer[$name] = ['matching' => true] + $options;
+            $pointer[$name] = $opts;
             $pointer =& $pointer[$name];
         }
 

+ 61 - 0
src/ORM/Query.php

@@ -456,6 +456,67 @@ class Query extends DatabaseQuery implements JsonSerializable
     }
 
     /**
+     * Adds filtering conditions to this query to only bring rows that have no match
+     * to another from an associated table, based on conditions in the associated table.
+     *
+     * This function will add entries in the `contain` graph.
+     *
+     * ### Example:
+     *
+     * ```
+     *  // Bring only articles that were not tagged with 'cake'
+     *  $query->notMatching('Tags', function ($q) {
+     *      return $q->where(['name' => 'cake']);
+     *  );
+     * ```
+     *
+     * It is possible to filter by deep associations by using dot notation:
+     *
+     * ### Example:
+     *
+     * ```
+     *  // Bring only articles that weren't commented by 'markstory'
+     *  $query->notMatching('Comments.Users', function ($q) {
+     *      return $q->where(['username' => 'markstory']);
+     *  );
+     * ```
+     *
+     * As this function will create a `LEFT JOIN`, you might want to consider
+     * calling `distinct` on this query as you might get duplicate rows if
+     * your conditions don't filter them already. This might be the case, for example,
+     * of the same article having multiple comments.
+     *
+     * ### Example:
+     *
+     * ```
+     *  // Bring unique articles that were commented by 'markstory'
+     *  $query->distinct(['Articles.id'])
+     *  ->notMatching('Comments.Users', function ($q) {
+     *      return $q->where(['username' => 'markstory']);
+     *  );
+     * ```
+     *
+     * Please note that the query passed to the closure will only accept calling
+     * `select`, `where`, `andWhere` and `orWhere` on it. If you wish to
+     * add more complex clauses you can do it directly in the main query.
+     *
+     * @param string $assoc The association to filter by
+     * @param callable $builder a function that will receive a pre-made query object
+     * that can be used to add custom conditions or selecting some fields
+     * @return $this
+     */
+    public function notMatching($assoc, callable $builder = null)
+    {
+        $this->eagerLoader()->matching($assoc, $builder, [
+            'joinType' => 'LEFT',
+            'fields' => false,
+            'negateMatch' => true
+        ]);
+        $this->_dirty();
+        return $this;
+    }
+
+    /**
      * Returns a key => value array representing a single aliased field
      * that can be passed directly to the select() method.
      * The key will contain the alias and the value the actual field name.

+ 139 - 0
tests/TestCase/ORM/QueryTest.php

@@ -2843,4 +2843,143 @@ class QueryTest extends TestCase
             ->toArray();
         $this->assertEquals($expected, $results);
     }
+
+    /**
+     * Tests notMatching() with and without conditions
+     *
+     * @return void
+     */
+    public function testNotMatching()
+    {
+        $table = TableRegistry::get('authors');
+        $table->hasMany('articles');
+
+        $results = $table->find()
+            ->hydrate(false)
+            ->notMatching('articles')
+            ->toArray();
+
+        $expected = [
+            ['id' => 2, 'name' => 'nate'],
+            ['id' => 4, 'name' => 'garrett'],
+        ];
+        $this->assertEquals($expected, $results);
+
+        $results = $table->find()
+            ->hydrate(false)
+            ->notMatching('articles', function ($q) {
+                return $q->where(['articles.author_id' => 1]);
+            })
+            ->toArray();
+        $expected = [
+            ['id' => 2, 'name' => 'nate'],
+            ['id' => 3, 'name' => 'larry'],
+            ['id' => 4, 'name' => 'garrett'],
+        ];
+        $this->assertEquals($expected, $results);
+    }
+
+    /**
+     * Tests notMatching() with a belongsToMany association
+     *
+     * @return void
+     */
+    public function testNotMatchingBelongsToMany()
+    {
+        $table = TableRegistry::get('articles');
+        $table->belongsToMany('tags');
+
+        $results = $table->find()
+            ->hydrate(false)
+            ->notMatching('tags', function ($q) {
+                return $q->where(['tags.name' => 'tag2']);
+            })
+            ->toArray();
+
+        $expected = [
+            [
+                'id' => 2,
+                'author_id' => 3,
+                'title' => 'Second Article',
+                'body' => 'Second Article Body',
+                'published' => 'Y'
+            ],
+            [
+                'id' => 3,
+                'author_id' => 1,
+                'title' => 'Third Article',
+                'body' => 'Third Article Body',
+                'published' => 'Y'
+            ]
+        ];
+        $this->assertEquals($expected, $results);
+    }
+
+    /**
+     * Tests notMatching() with a deeply nested belongsToMany association.
+     *
+     * @return void
+     */
+    public function testNotMatchingDeep()
+    {
+        $table = TableRegistry::get('authors');
+        $articles = $table->hasMany('articles');
+        $articles->belongsToMany('tags');
+
+        $results = $table->find()
+            ->hydrate(false)
+            ->notMatching('articles.tags', function ($q) {
+                return $q->where(['tags.name' => 'tag3']);
+            })
+            ->distinct(['authors.id']);
+
+        $this->assertEquals([1, 2, 4], $results->extract('id')->toList());
+
+        $results = $table->find()
+            ->hydrate(false)
+            ->notMatching('articles.tags', function ($q) {
+                return $q->where(['tags.name' => 'tag3']);
+            })
+            ->matching('articles')
+            ->distinct(['authors.id']);
+
+        $this->assertEquals([1], $results->extract('id')->toList());
+    }
+
+    /**
+     * Tests that it is possible to nest a notMatching call inside another
+     * eagerloader function.
+     *
+     * @return void
+     */
+    public function testNotMatchingNested()
+    {
+        $table = TableRegistry::get('authors');
+        $articles = $table->hasMany('articles');
+        $articles->belongsToMany('tags');
+
+        $results = $table->find()
+            ->hydrate(false)
+            ->matching('articles', function ($q) {
+                return $q->notMatching('tags', function ($q) {
+                    return $q->where(['tags.name' => 'tag3']);
+                });
+            })
+            ->order(['authors.id' => 'ASC', 'articles.id' => 'ASC']);
+
+        $expected = [
+            'id' => 1,
+            'name' => 'mariano',
+            '_matchingData' => [
+                'articles' => [
+                    'id' => 1,
+                    'author_id' => 1,
+                    'title' => 'First Article',
+                    'body' => 'First Article Body',
+                    'published' => 'Y'
+                ]
+            ]
+        ];
+        $this->assertEquals($expected, $results->first());
+    }
 }