Browse Source

Merge pull request #6731 from cakephp/3.1-join-with

Implemented leftJoinWith and innerJoinWith
José Lorenzo Rodríguez 10 years ago
parent
commit
cc4b6fdb9b

+ 9 - 5
src/ORM/Association.php

@@ -464,8 +464,7 @@ abstract class Association
      *   will be merged with any conditions originally configured for this association
      * - fields: a list of fields in the target table to include in the result
      * - type: The type of join to be used (e.g. INNER)
-     * - matching: Indicates whether the query records should be filtered based on
-     *   the records found on this association. This will force a 'INNER JOIN'
+     *   the records found on this association
      * - aliasPath: A dot separated string representing the path of association names
      *   followed from the passed query main table to this association.
      * - propertyPath: A dot separated string representing the path of association
@@ -488,7 +487,7 @@ abstract class Association
             'foreignKey' => $this->foreignKey(),
             'conditions' => [],
             'fields' => [],
-            'type' => empty($options['matching']) ? $joinType : 'INNER',
+            'type' => $joinType,
             'table' => $target->table(),
             'finder' => $this->finder()
         ];
@@ -731,10 +730,15 @@ abstract class Association
             $newContain[$options['aliasPath'] . '.' . $alias] = $value;
         }
 
-        $query->contain($newContain);
+        $eagerLoader = $query->eagerLoader();
+        $eagerLoader->contain($newContain);
 
         foreach ($matching as $alias => $value) {
-            $query->matching($options['aliasPath'] . '.' . $alias, $value['queryBuilder']);
+            $eagerLoader->matching(
+                $options['aliasPath'] . '.' . $alias,
+                $value['queryBuilder'],
+                $value
+            );
         }
     }
 

+ 2 - 1
src/ORM/Association/BelongsToMany.php

@@ -254,10 +254,11 @@ class BelongsToMany extends Association
         }
 
         unset($options['queryBuilder']);
+        $type = array_intersect_key($options, ['joinType' => 1, 'fields' => 1]);
         $options = ['conditions' => [$cond]] + compact('includeFields');
         $options['foreignKey'] = $this->targetForeignKey();
         $assoc = $this->_targetTable->association($junction->alias());
-        $assoc->attachTo($query, $options);
+        $assoc->attachTo($query, $options + $type);
         $query->eagerLoader()->addToJoinsMap($junction->alias(), $assoc, true);
     }
 

+ 6 - 3
src/ORM/EagerLoader.php

@@ -171,9 +171,11 @@ class EagerLoader
      * @param string|null $assoc A single association or a dot separated path of associations.
      * @param callable|null $builder the callback function to be used for setting extra
      * options to the filtering query
+     * @param array $options Extra options for the association matching, such as 'joinType'
+     * and 'fields'
      * @return array The resulting containments array
      */
-    public function matching($assoc = null, callable $builder = null)
+    public function matching($assoc = null, callable $builder = null, $options = [])
     {
         if ($this->_matching === null) {
             $this->_matching = new self();
@@ -187,13 +189,14 @@ class EagerLoader
         $last = array_pop($assocs);
         $containments = [];
         $pointer =& $containments;
+        $options += ['joinType' => 'INNER'];
 
         foreach ($assocs as $name) {
-            $pointer[$name] = ['matching' => true];
+            $pointer[$name] = ['matching' => true] + $options;
             $pointer =& $pointer[$name];
         }
 
-        $pointer[$last] = ['queryBuilder' => $builder, 'matching' => true];
+        $pointer[$last] = ['queryBuilder' => $builder, 'matching' => true] + $options;
         return $this->_matching->contain($containments);
     }
 

+ 118 - 0
src/ORM/Query.php

@@ -338,6 +338,124 @@ class Query extends DatabaseQuery implements JsonSerializable
     }
 
     /**
+     * Creates a LEFT JOIN with the passed association table while preserving
+     * the foreign key matching and the custom conditions that were originally set
+     * for it.
+     *
+     * This function will add entries in the `contain` graph.
+     *
+     * ### Example:
+     *
+     * ```
+     *  // Get the count of articles per user
+     *  $usersQuery
+     *      ->select(['total_articles' => $query->func()->count('Articles.id')])
+     *      ->leftJoinWith('Articles')
+     *      ->group(['Users.id'])
+     *      ->autoFields(true);
+     * ```
+     *
+     * You can also customize the conditions passed to the LEFT JOIN:
+     *
+     * ```
+     *  // Get the count of articles per user with at least 5 votes
+     *  $usersQuery
+     *      ->select(['total_articles' => $query->func()->count('Articles.id')])
+     *      ->leftJoinWith('Articles', function ($q) {
+     *          return $q->where(['Articles.votes >=' => 5]);
+     *      })
+     *      ->group(['Users.id'])
+     *      ->autoFields(true);
+     * ```
+     *
+     * This will create the following SQL:
+     *
+     * ```
+     *  SELECT COUNT(Articles.id) AS total_articles, Users.*
+     *  FROM users Users
+     *  LEFT JOIN articles Articles ON Articles.user_id = Users.id AND Articles.votes >= 5
+     *  GROUP BY USers.id
+     * ```
+     *
+     * It is possible to left join deep associations by using dot notation
+     *
+     * ### Example:
+     *
+     * ```
+     *  // Total comments in articles by 'markstory'
+     *  $query
+     *   ->select(['total_comments' => $query->func()->count('Comments.id')])
+     *   ->leftJoinWith('Comments.Users', function ($q) {
+     *      return $q->where(['username' => 'markstory']);
+     *  )
+     *  ->group(['Users.id']);
+     * ```
+     *
+     * 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 join with
+     * @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 leftJoinWith($assoc, callable $builder = null)
+    {
+        $this->eagerLoader()->matching($assoc, $builder, [
+            'joinType' => 'LEFT',
+            'fields' => false
+        ]);
+        $this->_dirty();
+        return $this;
+    }
+
+    /**
+     * Creates an INNER JOIN with the passed association table while preserving
+     * the foreign key matching and the custom conditions that were originally set
+     * for it.
+     *
+     * This function will add entries in the `contain` graph.
+     *
+     * ### Example:
+     *
+     * ```
+     *  // Bring only articles that were tagged with 'cake'
+     *  $query->innerJoinWith('Tags', function ($q) {
+     *      return $q->where(['name' => 'cake']);
+     *  );
+     * ```
+     *
+     * This will create the following SQL:
+     *
+     * ```
+     *  SELECT Articles.*
+     *  FROM articles Articles
+     *  INNER JOIN tags Tags ON Tags.name = 'cake'
+     *  INNER JOIN articles_tags ArticlesTags ON ArticlesTags.tag_id = Tags.id
+     *    AND ArticlesTags.articles_id = Articles.id
+     * ```
+     *
+     * This function works the same as `matching()` with the difference that it
+     * will select no fields from the association.
+     *
+     * @param string $assoc The association to join with
+     * @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
+     * @see \Cake\ORM\Query::matching()
+     */
+    public function innerJoinWith($assoc, callable $builder = null)
+    {
+        $this->eagerLoader()->matching($assoc, $builder, [
+            'joinType' => 'INNER',
+            'fields' => false
+        ]);
+        $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.

+ 0 - 34
tests/TestCase/ORM/Association/BelongsToTest.php

@@ -204,40 +204,6 @@ class BelongsToTest extends TestCase
     }
 
     /**
-     * Tests that by passing the matching option to `attachTo` the association
-     * is joinned using `INNER`
-     *
-     * @return void
-     */
-    public function testAttachToMatching()
-    {
-        $query = $this->getMock('\Cake\ORM\Query', ['join', 'select'], [null, null]);
-        $config = [
-            'foreignKey' => 'company_id',
-            'sourceTable' => $this->client,
-            'targetTable' => $this->company,
-            'conditions' => ['Companies.is_active' => true]
-        ];
-        $association = new BelongsTo('Companies', $config);
-        $field = new IdentifierExpression('Clients.company_id');
-        $query->expects($this->once())->method('join')->with([
-            'Companies' => [
-                'conditions' => new QueryExpression([
-                    'Companies.is_active' => true,
-                    ['Companies.id' => $field]
-                ], $this->companiesTypeMap),
-                'table' => 'companies',
-                'type' => 'INNER'
-            ]
-        ]);
-        $query->expects($this->once())->method('select')->with([
-            'Companies__id' => 'Companies.id',
-            'Companies__company_name' => 'Companies.company_name'
-        ]);
-        $association->attachTo($query, ['matching' => true]);
-    }
-
-    /**
      * Test the cascading delete of BelongsTo.
      *
      * @return void

+ 184 - 1
tests/TestCase/ORM/QueryTest.php

@@ -2214,7 +2214,8 @@ class QueryTest extends TestCase
             'matching' => [
                 'articles' => [
                     'queryBuilder' => null,
-                    'matching' => true
+                    'matching' => true,
+                    'joinType' => 'INNER'
                 ]
             ],
             'extraOptions' => ['foo' => 'bar'],
@@ -2661,4 +2662,186 @@ class QueryTest extends TestCase
         $this->assertFalse($table->find()->isEmpty());
         $this->assertTrue($table->find()->where(['id' => -1])->isEmpty());
     }
+
+    /**
+     * Tests that leftJoinWith() creates a left join with a given association and
+     * that no fields from such association are loaded.
+     *
+     * @return void
+     */
+    public function testLeftJoinWith()
+    {
+        $table = TableRegistry::get('authors');
+        $table->hasMany('articles');
+        $table->articles->deleteAll(['author_id' => 4]);
+        $results = $table
+            ->find()
+            ->select(['total_articles' => 'count(articles.id)'])
+            ->autoFields(true)
+            ->leftJoinWith('articles')
+            ->group(['authors.id']);
+
+        $expected = [
+            1 => 2,
+            2 => 0,
+            3 => 1,
+            4 => 0
+        ];
+        $this->assertEquals($expected, $results->combine('id', 'total_articles')->toArray());
+        $fields = ['total_articles', 'id', 'name'];
+        $this->assertEquals($fields, array_keys($results->first()->toArray()));
+
+        $results = $table
+            ->find()
+            ->leftJoinWith('articles')
+            ->where(['articles.id IS' => null]);
+
+        $this->assertEquals([2, 4], $results->extract('id')->toList());
+        $this->assertEquals(['id', 'name'], array_keys($results->first()->toArray()));
+
+        $results = $table
+            ->find()
+            ->leftJoinWith('articles')
+            ->where(['articles.id IS NOT' => null])
+            ->order(['authors.id']);
+
+        $this->assertEquals([1, 1, 3], $results->extract('id')->toList());
+        $this->assertEquals(['id', 'name'], array_keys($results->first()->toArray()));
+    }
+
+    /**
+     * Tests that leftJoinWith() creates a left join with a given association and
+     * that no fields from such association are loaded.
+     *
+     * @return void
+     */
+    public function testLeftJoinWithNested()
+    {
+        $table = TableRegistry::get('authors');
+        $articles = $table->hasMany('articles');
+        $articles->belongsToMany('tags');
+
+        $results = $table
+            ->find()
+            ->select(['total_articles' => 'count(articles.id)'])
+            ->leftJoinWith('articles.tags', function ($q) {
+                return $q->where(['tags.name' => 'tag3']);
+            })
+            ->autoFields(true)
+            ->group(['authors.id']);
+
+        $expected = [
+            1 => 2,
+            2 => 0,
+            3 => 1,
+            4 => 0
+        ];
+        $this->assertEquals($expected, $results->combine('id', 'total_articles')->toArray());
+        $fields = ['total_articles', 'id', 'name'];
+        $this->assertEquals($fields, array_keys($results->first()->toArray()));
+    }
+
+    /**
+     * Tests that leftJoinWith() can be used with select()
+     *
+     * @return void
+     */
+    public function testLeftJoinWithSelect()
+    {
+        $table = TableRegistry::get('authors');
+        $articles = $table->hasMany('articles');
+        $articles->belongsToMany('tags');
+        $results = $table
+            ->find()
+            ->leftJoinWith('articles.tags', function ($q) {
+                return $q
+                    ->select(['articles.id', 'articles.title', 'tags.name'])
+                    ->where(['tags.name' => 'tag3']);
+            })
+            ->autoFields(true)
+            ->where(['ArticlesTags.tag_id' => 3])
+            ->distinct(['authors.id'])
+            ->all();
+
+        $expected = ['id' => 2, 'title' => 'Second Article'];
+        $this->assertEquals(
+            $expected,
+            $results->first()->_matchingData['articles']->toArray()
+        );
+        $this->assertEquals(
+            ['name' => 'tag3'],
+            $results->first()->_matchingData['tags']->toArray()
+        );
+    }
+
+    /**
+     * Tests innerJoinWith()
+     *
+     * @return void
+     */
+    public function testInnerJoinWith()
+    {
+        $table = TableRegistry::get('authors');
+        $table->hasMany('articles');
+        $results = $table
+            ->find()
+            ->innerJoinWith('articles', function ($q) {
+                return $q->where(['articles.title' => 'Third Article']);
+            });
+        $expected = [
+            [
+            'id' => 1,
+            'name' => 'mariano'
+            ]
+        ];
+        $this->assertEquals($expected, $results->hydrate(false)->toArray());
+    }
+
+    /**
+     * Tests innerJoinWith() with nested associations
+     *
+     * @return void
+     */
+    public function testInnerJoinWithNested()
+    {
+        $table = TableRegistry::get('authors');
+        $articles = $table->hasMany('articles');
+        $articles->belongsToMany('tags');
+        $results = $table
+            ->find()
+            ->innerJoinWith('articles.tags', function ($q) {
+                return $q->where(['tags.name' => 'tag3']);
+            });
+        $expected = [
+            [
+            'id' => 3,
+            'name' => 'larry'
+            ]
+        ];
+        $this->assertEquals($expected, $results->hydrate(false)->toArray());
+    }
+
+    /**
+     * Tests innerJoinWith() with select
+     *
+     * @return void
+     */
+    public function testInnerJoinWithSelect()
+    {
+        $table = TableRegistry::get('authors');
+        $table->hasMany('articles');
+        $results = $table
+            ->find()
+            ->autoFields(true)
+            ->innerJoinWith('articles', function ($q) {
+                return $q->select(['id', 'author_id', 'title', 'body', 'published']);
+            })
+            ->toArray();
+
+        $expected = $table
+            ->find()
+            ->matching('articles')
+            ->toArray();
+        $this->assertEquals($expected, $results);
+    }
 }