diff --git a/core/lib/Drupal/Core/Entity/Query/Sql/Query.php b/core/lib/Drupal/Core/Entity/Query/Sql/Query.php index ad66a42..70a1cc5 100644 --- a/core/lib/Drupal/Core/Entity/Query/Sql/Query.php +++ b/core/lib/Drupal/Core/Entity/Query/Sql/Query.php @@ -41,19 +41,28 @@ class Query extends QueryBase implements QueryInterface { protected $sqlFields = []; /** - * An array of strings added as to the group by, keyed by the string to avoid - * duplicates. + * An array added as to the group by, key by the string to avoid duplicates. * * @var array */ protected $sqlGroupBy = []; /** + * The database connection. + * * @var \Drupal\Core\Database\Connection */ protected $connection; /** + * Stores the aliases of expressions that optimize the latest revisions query. + * + * @var array + * An array of expressions aliases keyed by entity key. + */ + protected $latestRevisionAliases = []; + + /** * Constructs a query object. * * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type @@ -73,6 +82,9 @@ public function __construct(EntityTypeInterface $entity_type, $conjunction, Conn /** * {@inheritdoc} + * + * @throws \Drupal\Core\Entity\Query\QueryException + * Thrown if the base table does not exist. */ public function execute() { return $this @@ -119,21 +131,38 @@ protected function prepare() { $this->sqlFields["base_table.$id_field"] = ['base_table', $id_field]; // Now add the value column for fetchAllKeyed(). This is always the // entity id. - $this->sqlFields["base_table.$id_field" . '_1'] = ['base_table', $id_field]; + $this->sqlFields["base_table.$id_field" . '_1'] = [ + 'base_table', $id_field, + ]; } else { // When there is revision support, the key field is the revision key. - $this->sqlFields["base_table.$revision_field"] = ['base_table', $revision_field]; + $this->sqlFields["base_table.$revision_field"] = [ + 'base_table', $revision_field, + ]; // Now add the value column for fetchAllKeyed(). This is always the // entity id. $this->sqlFields["base_table.$id_field"] = ['base_table', $id_field]; } - // Add a self-join to the base revision table if we're querying only the - // latest revisions. + // Use max and group by to only return the latest revision in the most + // optimal way. if ($this->latestRevision && $revision_field) { - $this->sqlQuery->leftJoin($base_table, 'base_table_2', "[base_table].[$id_field] = [base_table_2].[$id_field] AND [base_table].[$revision_field] < [base_table_2].[$revision_field]"); - $this->sqlQuery->isNull("base_table_2.$id_field"); + $can_optimize = array_reduce($this->sort, function ($carry, $sort) use ($id_field, $revision_field) { + return $carry && ($sort['field'] == $id_field || $sort['field'] == $revision_field); + }, TRUE); + + if ($can_optimize) { + unset($this->sqlFields["base_table.$revision_field"]); + unset($this->sqlFields["base_table.$id_field"]); + $this->latestRevisionAliases[$revision_field] = $this->sqlQuery->addExpression("MAX(base_table.$revision_field)"); + $this->latestRevisionAliases[$id_field] = $this->sqlQuery->addExpression("base_table.$id_field"); + $this->sqlQuery->groupBy($this->latestRevisionAliases[$id_field]); + } + else { + $this->sqlQuery->leftJoin($base_table, 'base_table_2', "base_table.$id_field = base_table_2.$id_field AND base_table.$revision_field < base_table_2.$revision_field"); + $this->sqlQuery->isNull("base_table_2.$id_field"); + } } if (is_null($this->accessCheck)) { @@ -210,11 +239,18 @@ protected function addSort() { $this->sqlGroupBy[$group_by] = $group_by; } } + $revision_field = $this->entityType->getKey('revision'); + // Now we know whether this is a simple query or not, actually do the // sorting. foreach ($sort as $key => $sql_alias) { $direction = $this->sort[$key]['direction']; - if ($simple_query || isset($this->sqlGroupBy[$sql_alias])) { + // Query optimizations for latest revision queries for revisionable + // entities mean we have to sort using the expression aliases. + if ($this->latestRevision && $revision_field && isset($this->latestRevisionAliases[$this->sort[$key]['field']])) { + $this->sqlQuery->orderBy($this->latestRevisionAliases[$this->sort[$key]['field']], $direction); + } + elseif ($simple_query || isset($this->sqlGroupBy[$sql_alias])) { // Simple queries, and the grouped columns of complicated queries // can be ordered normally, without the aggregation function. $this->sqlQuery->orderBy($sql_alias, $direction); @@ -300,6 +336,7 @@ protected function getSqlField($field, $langcode) { * Determines whether the query requires GROUP BY and ORDER BY MIN/MAX. * * @return bool + * TRUE if the query requires GROUP BY and ORDER, FALSE otherwise. */ protected function isSimpleQuery() { return (!$this->pager && !$this->range && !$this->count) || $this->sqlQuery->getMetaData('simple_query'); @@ -314,6 +351,7 @@ public function __clone() { parent::__clone(); $this->sqlFields = []; $this->sqlGroupBy = []; + $this->latestRevisionAliases = []; } /** diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityQueryTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityQueryTest.php index bf35a51..0673a1e 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityQueryTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityQueryTest.php @@ -30,6 +30,8 @@ class EntityQueryTest extends EntityKernelTestBase { protected static $modules = ['field_test', 'language']; /** + * Stores the query results for alter comparison. + * * @var array */ protected $queryResults; @@ -62,6 +64,9 @@ class EntityQueryTest extends EntityKernelTestBase { */ protected $storage; + /** + * {@inheritdoc} + */ protected function setUp(): void { parent::setUp(); @@ -99,26 +104,26 @@ protected function setUp(): void { } // Each unit is a list of field name, langcode and a column-value array. $units[] = [$figures, 'en', [ - 'color' => 'red', - 'shape' => 'triangle', - ], + 'color' => 'red', + 'shape' => 'triangle', + ], ]; $units[] = [$figures, 'en', [ - 'color' => 'blue', - 'shape' => 'circle', - ], + 'color' => 'blue', + 'shape' => 'circle', + ], ]; // To make it easier to test sorting, the greetings get formats according // to their langcode. $units[] = [$greetings, 'tr', [ - 'value' => 'merhaba', - 'format' => 'format-tr', - ], + 'value' => 'merhaba', + 'format' => 'format-tr', + ], ]; $units[] = [$greetings, 'pl', [ - 'value' => 'siema', - 'format' => 'format-pl', - ], + 'value' => 'siema', + 'format' => 'format-pl', + ], ]; // Make these languages available to the greetings field. ConfigurableLanguage::createFromLangcode('tr')->save(); @@ -322,7 +327,10 @@ public function testEntityQuery() { ->sort('revision_id') ->execute(); // The query only matches the original revisions. - $this->assertRevisionResult([4, 5, 6, 7, 12, 13, 14, 15], [4, 5, 6, 7, 12, 13, 14, 15]); + $this->assertRevisionResult( + [4, 5, 6, 7, 12, 13, 14, 15], + [4, 5, 6, 7, 12, 13, 14, 15] + ); $results = $this->storage ->getQuery() ->accessCheck(FALSE) @@ -331,7 +339,20 @@ public function testEntityQuery() { ->execute(); // This matches both the original and new current revisions, multiple // revisions are returned for some entities. - $assert = [16 => '4', 17 => '5', 18 => '6', 19 => '7', 8 => '8', 9 => '9', 10 => '10', 11 => '11', 20 => '12', 21 => '13', 22 => '14', 23 => '15']; + $assert = [ + 16 => '4', + 17 => '5', + 18 => '6', + 19 => '7', + 8 => '8', + 9 => '9', + 10 => '10', + 11 => '11', + 20 => '12', + 21 => '13', + 22 => '14', + 23 => '15', + ]; $this->assertSame($assert, $results); $results = $this->storage ->getQuery() @@ -360,7 +381,24 @@ public function testEntityQuery() { ->sort('revision_id') ->execute(); // Now we get everything. - $assert = [4 => '4', 5 => '5', 6 => '6', 7 => '7', 8 => '8', 9 => '9', 10 => '10', 11 => '11', 12 => '12', 20 => '12', 13 => '13', 21 => '13', 14 => '14', 22 => '14', 15 => '15', 23 => '15']; + $assert = [ + 4 => '4', + 5 => '5', + 6 => '6', + 7 => '7', + 8 => '8', + 9 => '9', + 10 => '10', + 11 => '11', + 12 => '12', + 20 => '12', + 13 => '13', + 21 => '13', + 14 => '14', + 22 => '14', + 15 => '15', + 23 => '15', + ]; $this->assertSame($assert, $results); // Check that a query on the latest revisions without any condition returns @@ -372,7 +410,83 @@ public function testEntityQuery() { ->sort('id') ->sort('revision_id') ->execute(); - $expected = [1 => '1', 2 => '2', 3 => '3', 16 => '4', 17 => '5', 18 => '6', 19 => '7', 8 => '8', 9 => '9', 10 => '10', 11 => '11', 20 => '12', 21 => '13', 22 => '14', 23 => '15']; + $expected = [ + 1 => '1', + 2 => '2', + 3 => '3', + 16 => '4', + 17 => '5', + 18 => '6', + 19 => '7', + 8 => '8', + 9 => '9', + 10 => '10', + 11 => '11', + 20 => '12', + 21 => '13', + 22 => '14', + 23 => '15', + ]; + $this->assertSame($expected, $results); + + // Now test descending sort only on revision ID. + $results = $this->storage + ->getQuery() + ->latestRevision() + ->sort('revision_id', 'DESC') + ->execute(); + $expected = [ + 23 => '15', + 22 => '14', + 21 => '13', + 20 => '12', + 19 => '7', + 18 => '6', + 17 => '5', + 16 => '4', + 11 => '11', + 10 => '10', + 9 => '9', + 8 => '8', + 3 => '3', + 2 => '2', + 1 => '1', + ]; + $this->assertSame($expected, $results); + + $results = $this->storage + ->getQuery() + ->latestRevision() + ->exists($greetings, 'tr') + ->condition("$figures.color", 'red') + ->sort('id') + ->sort($greetings) + ->execute(); + // As unit 0 was the red triangle and unit 2 was the turkish greeting, + // bit 0 and bit 2 needs to be set. + $expected = [17 => '5', 19 => '7', 21 => '13', 23 => '15']; + $this->assertSame($expected, $results); + + $results = $this->queryResults = $this->storage + ->getQuery() + ->latestRevision() + ->notExists("$figures.color") + ->execute(); + $expected = [16 => '4', 8 => '8', 20 => '12']; + $this->assertSame($expected, $results); + + // Update an entity. + $entity = EntityTestMulRev::load(4); + $entity->setNewRevision(); + $entity->$figures->color = 'red'; + $entity->save(); + + $results = $this->queryResults = $this->storage + ->getQuery() + ->latestRevision() + ->notExists("$figures.color") + ->execute(); + $expected = [16 => '4', 8 => '8', 20 => '12']; $this->assertSame($expected, $results); } @@ -407,36 +521,29 @@ public function testSort() { // language codes, already in order, with the first occurrence of the // entity id marked with *: // 8 NULL pl * - // 12 NULL pl * - + // 12 NULL pl *. // 4 NULL tr * // 12 NULL tr - // 2 blue NULL * // 3 blue NULL * - // 10 blue pl * // 11 blue pl * // 14 blue pl * // 15 blue pl * - // 6 blue tr * // 7 blue tr * // 14 blue tr // 15 blue tr - // 1 red NULL // 3 red NULL - // 9 red pl * // 11 red pl // 13 red pl * // 15 red pl - // 5 red tr * // 7 red tr // 13 red tr - // 15 red tr + // 15 red tr. $count_query = clone $query; $this->assertEquals(15, $count_query->count()->execute()); $this->queryResults = $query->execute(); @@ -721,6 +828,8 @@ public function testDelta() { } /** + * Asserts the results as expected regardless of order. + * * @internal */ protected function assertResult(): void { @@ -736,6 +845,13 @@ protected function assertResult(): void { } /** + * Asserts the results as expected regardless of reverse order. + * + * @param array $keys + * Array of keys. + * @param array $expected + * Array of expected entity IDs. + * * @internal */ protected function assertRevisionResult(array $keys, array $expected): void { @@ -747,6 +863,11 @@ protected function assertRevisionResult(array $keys, array $expected): void { } /** + * Asserts the results as expected orders. + * + * @param string $order + * The sort order. + * * @internal */ protected function assertBundleOrder(string $order): void { @@ -1200,8 +1321,7 @@ public function testPendingRevisions() { } /** - * Tests against SQL inject of condition field. This covers a - * database driver's EntityQuery\Condition class. + * Tests against SQL inject of condition field EntityQuery\Condition class. */ public function testInjectionInCondition() { $this->expectException(\Exception::class);