Unverified Commit 702159f4 authored by Alex Pott's avatar Alex Pott
Browse files

perf: #2875033 Optimize joins and table selection in SQL entity query implementation

By: catch
By: driskell
By: berdir
By: amateescu
By: kaythay
By: joachim
By: rlmumford
By: heddn
By: ghost of drupal past
By: alexbrut
By: etroid
By: andreyks
By: cilefen
By: damondt
By: davidwhthomas
By: itsekhmistro
By: mstef
By: alexpott
parent 5f815c3b
Loading
Loading
Loading
Loading
Loading
+20 −0
Changes for core/lib/Drupal/Core/Entity/Query/ConditionFundamentals.php: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -89,4 +89,24 @@ public function __clone() {
    }
  }

  /**
   * {@inheritdoc}
   */
  public function setQuery(QueryInterface $query): static {
    $this->query = $query;
    foreach ($this->conditions as $condition) {
      if ($condition['field'] instanceof ConditionInterface) {
        $condition['field']->setQuery($query);
      }
    }
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function getQuery(): QueryInterface {
    return $this->query;
  }

}
+18 −0
Changes for core/lib/Drupal/Core/Entity/Query/ConditionInterface.php: 18 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -91,4 +91,22 @@ public function &conditions();
   */
  public function compile($query);

  /**
   * Sets query that is holding this condition.
   *
   * @param \Drupal\Core\Entity\Query\QueryInterface $query
   *   New query to use.
   *
   * @return static
   */
  public function setQuery(QueryInterface $query): static;

  /**
   * Gets the query that is holding this condition.
   *
   * @return \Drupal\Core\Entity\Query\QueryInterface
   *   The query object.
   */
  public function getQuery(): QueryInterface;

}
+27 −3
Changes for core/lib/Drupal/Core/Entity/Query/Sql/Condition.php: 27 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -18,6 +18,14 @@ class Condition extends ConditionBase {
   */
  protected $nestedInsideOrCondition = FALSE;

  /**
   * The index and depth of an embedded condition.
   *
   * This allows nested AND conditions to join tables again for multi-value
   * fields.
   */
  protected string $tableIndexPrefix = '';

  /**
   * The SQL entity query object this condition belongs to.
   *
@@ -43,19 +51,35 @@ public function compile($conditionContainer) {
    // can join tables as necessary. On the other hand, conditions need to be
    // added to the $conditionContainer object to keep grouping.
    $sql_query = $conditionContainer instanceof SelectInterface ? $conditionContainer : $this->sqlQuery;
    $tables = $this->query->getTables($sql_query);
    foreach ($this->conditions as $condition) {
    // Pass the sql_query in and suppress deprecation handling, so that we can
    // manually handle the deprecation for each condition.
    $tables = $this->query->doGetTables($sql_query);
    foreach ($this->conditions as $key => $condition) {
      if ($condition['field'] instanceof ConditionInterface) {
        $sql_condition = $sql_query->getConnection()->condition($condition['field']->getConjunction());
        // Deprecate using getQuery()->condition() passed into another
        // getQuery() as the objects mismatch. The doGetTables($sql_query)
        // will prevent crashing, but joins will be duplicated.
        if ($condition['field']->getQuery() !== $this->getQuery()) {
          @trigger_error('Passing a Condition to \Drupal\Core\Entity\Query\Sql\Query::condition() that was generated by a different query is deprecated in drupal:11.4.0 and removed in drupal:13.0.0. See https://www.drupal.org/node/3585318', E_USER_DEPRECATED);
        }
        // Add the SQL query to the object before calling this method again.
        $condition['field']->sqlQuery = $sql_query;
        // When using an AND condition group, join tables again for fields
        // inside, but once only per field, so that other AND condition
        // groups can do the same. This ensures that for cardinality > 1
        // fields, the correct number of joins are made to allow us to
        // use condition groups to fetch where a entity has X AND Y.
        // If we did not join the table again, the values only exist once
        // and the AND fails to match even though an entity has both X and Y.
        $condition['field']->tableIndexPrefix = $this->conjunction === 'AND' ? $this->tableIndexPrefix . '.' . $key : $this->tableIndexPrefix;
        $condition['field']->nestedInsideOrCondition = $this->nestedInsideOrCondition || strtoupper($this->conjunction) === 'OR';
        $condition['field']->compile($sql_condition);
        $conditionContainer->condition($sql_condition);
      }
      else {
        $type = $this->nestedInsideOrCondition || strtoupper($this->conjunction) === 'OR' || $condition['operator'] === 'IS NULL' ? 'LEFT' : 'INNER';
        $field = $tables->addField($condition['field'], $type, $condition['langcode']);
        $field = $tables->addField($condition['field'], $this->tableIndexPrefix ? "$type:{$this->tableIndexPrefix}" : $type, $condition['langcode']);
        // If the field is trying to query on %delta for a single value field
        // then the only supported delta is 0. No other value than 0 makes
        // sense. \Drupal\Core\Entity\Query\Sql\Tables::addField() returns 0 as
+44 −10
Changes for core/lib/Drupal/Core/Entity/Query/Sql/Query.php: 44 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -105,15 +105,16 @@ protected function prepare() {
        throw new QueryException("No base table for " . $this->entityTypeId . ", invalid query.");
      }
    }
    $simple_query = TRUE;
    if ($this->entityType->getDataTable()) {
      $simple_query = FALSE;
    }
    $this->sqlQuery = $this->connection->select($base_table, 'base_table', ['conjunction' => $this->conjunction]);
    // Reset the tables structure, as it might have been built for a previous
    // execution of this query.
    $this->tables = NULL;
    $this->sqlQuery->addMetaData('entity_type', $this->entityTypeId);
    // Store the base table in the query metadata for use by Tables class, so
    // it knows to avoid joining the base table again. We use the key
    // 'entity_query_base_table' to avoid confusion with other usages of
    // 'base_table' metadata, such as in node access queries.
    $this->sqlQuery->addMetaData('entity_query_base_table', $base_table);
    $id_field = $this->entityType->getKey('id');
    // Add the key field for fetchAllKeyed().
    if (!$revision_field = $this->entityType->getKey('revision')) {
@@ -179,7 +180,7 @@ protected function prepare() {
    // This now contains first the table containing entity properties and
    // last the entity base table. They might be the same.
    $this->sqlQuery->addMetaData('all_revisions', $this->allRevisions);
    $this->sqlQuery->addMetaData('simple_query', $simple_query);
    $this->sqlQuery->addMetaData('simple_query', TRUE);
    return $this;
  }

@@ -302,7 +303,7 @@ protected function result() {
   */
  protected function getSqlField($field, $langcode) {
    if (!isset($this->tables)) {
      $this->tables = $this->getTables($this->sqlQuery);
      $this->tables = $this->getTables();
    }
    $base_property = "base_table.$field";
    if (isset($this->sqlFields[$base_property])) {
@@ -328,27 +329,60 @@ protected function isSimpleQuery() {
  /**
   * Implements the magic __clone method.
   *
   * Reset fields and GROUP BY when cloning.
   * Reset SQL query and tables collected.
   * Reset fields and GROUP BY.
   * Ensure condition points to the new query.
   */
  public function __clone() {
    parent::__clone();
    $this->sqlQuery = NULL;
    $this->tables = NULL;
    $this->sqlFields = [];
    $this->sqlGroupBy = [];
    $this->condition->setQuery($this);
  }

  /**
   * Gets the Tables object for this query.
   *
   * @param \Drupal\Core\Database\Query\SelectInterface $sql_query
   *   The SQL query object being built.
   * @param ?\Drupal\Core\Database\Query\SelectInterface $sql_query
   *   The SQL query object being built. Deprecated, do not use.
   *
   * @return \Drupal\Core\Entity\Query\Sql\TablesInterface
   *   The object that adds tables and fields to the SQL query object.
   */
  public function getTables(SelectInterface $sql_query) {
  public function getTables(?SelectInterface $sql_query = NULL) {
    if (isset($sql_query)) {
      @trigger_error('Passing $sql_query to \Drupal\Core\Entity\Query\Sql\Query::getTables() is deprecated in drupal:11.4.0 and removed in drupal:13.0.0. See https://www.drupal.org/node/3585318', E_USER_DEPRECATED);
    }
    return $this->doGetTables($sql_query);
  }

  /**
   * Gets the Tables object for this query.
   *
   * @param ?\Drupal\Core\Database\Query\SelectInterface $sql_query
   *   The SQL query object being built. Deprecated, do not use.
   *
   * @return \Drupal\Core\Entity\Query\Sql\TablesInterface
   *   The object that adds tables and fields to the SQL query object.
   *
   * @internal
   */
  public function doGetTables(?SelectInterface $sql_query = NULL) {
    if (isset($sql_query)) {
      if ($sql_query !== $this->sqlQuery) {
        // Old behavior if a different query is passed in.
        $class = static::getClass($this->namespaces, 'Tables');
        return new $class($sql_query);
      }
    }
    if (!$this->tables) {
      $class = static::getClass($this->namespaces, 'Tables');
      $this->tables = new $class($this->sqlQuery);
    }
    return $this->tables;
  }

  /**
   * Implements the magic __toString method.
+63 −13
Changes for core/lib/Drupal/Core/Entity/Query/Sql/Tables.php: 63 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -9,6 +9,7 @@
use Drupal\Core\Entity\Sql\TableMappingInterface;
use Drupal\Core\Entity\TypedData\EntityDataDefinitionInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\TypedData\DataReferenceDefinitionInterface;

/**
@@ -43,6 +44,16 @@ class Tables implements TablesInterface {
   */
  protected $fieldTables = [];

  /**
   * Next base tables array.
   *
   * Key is table this joins to and its column, value is alias. This array
   * contains one entry per joined table via addNextBaseTable.
   *
   * @var array
   */
  protected $nextBaseTables = [];

  /**
   * The entity type manager.
   *
@@ -72,6 +83,9 @@ public function __construct(SelectInterface $sql_query) {
    $this->sqlQuery = $sql_query;
    $this->entityTypeManager = \Drupal::entityTypeManager();
    $this->entityFieldManager = \Drupal::service('entity_field.manager');
    if ($base_table = $sql_query->getMetaData('entity_query_base_table')) {
      $this->entityTables['base_table.' . $base_table] = 'base_table';
    }
  }

  /**
@@ -88,7 +102,23 @@ public function addField($field, $type, $langcode) {
    // The first two should use the same table but the last one needs to be a
    // new table. So for the first two, the table array index will be 'tags'
    // while the third will be 'node_reference.nid.tags'.
    // Similarly, for an AND condition group, we ensure separate tables are
    // used for each condition group if the field is multi-valued. For
    // example, given the following conditions:
    // ->andConditionGroup()->condition('multi_value', '1')
    // ->andConditionGroup()->condition('multi_value', '2')
    // The two conditions should use different tables to ensure that where
    // an entity contains both values (higher cardinality fields) the correct
    // results are returned. Conditions within a group are against a single
    // value, whereas conditions across groups are against different values.
    // This is appended to index_prefix only if the field is multi-valued.
    $index_prefix = '';
    if (str_contains($type, ':')) {
      [$type, $condition_prefix] = explode(':', $type, 2);
    }
    else {
      $condition_prefix = '';
    }
    $specifiers = explode('.', $field);
    $base_table = 'base_table';
    $count = count($specifiers) - 1;
@@ -189,6 +219,9 @@ public function addField($field, $type, $langcode) {
            $next_index_prefix = "$relationship_specifier.$column";
          }
        }
        if ($index_prefix === '' && $field_storage->getCardinality() > 1) {
          $index_prefix = $condition_prefix;
        }
        $table = $this->ensureFieldTable($index_prefix, $field_storage, $type, $langcode, $base_table, $entity_id_field, $field_id_field, $delta);
        $sql_column = $table_mapping->getFieldColumnName($field_storage, $column);
      }
@@ -213,7 +246,13 @@ public function addField($field, $type, $langcode) {
          }
        }
        if ($data_table) {
          if (!$langcode) {
            // Without a langcode, the data table join can return one row per
            // translation, so the query is no longer simple. A
            // langcode-restricted join already limits the result to a single
            // row per entity.
            $this->sqlQuery->addMetaData('simple_query', FALSE);
          }
          $entity_tables[$data_table] = $this->getTableMapping($data_table, $entity_type_id);
        }
        if ($revision_table) {
@@ -362,11 +401,11 @@ protected function ensureEntityTable($index_prefix, $property, $type, $langcode,
    foreach ($entity_tables as $table => $mapping) {
      if (isset($mapping[$property])) {
        // Ensure a table joined multiple times through different index prefixes
        // has unique entityTables entries by concatenating the index prefix
        // and the base table alias. This way, if the same entity table is
        // joined several times for different entity reference fields, each join
        // gets a unique alias.
        $key = $index_prefix . ($base_table === 'base_table' ? $table : $base_table);
        // has unique entityTables entries by concatenating the index prefix,
        // the base table alias joined to, and the desired table. This way, if
        // the same entity table is joined several times for different entity
        // reference fields, each join gets a unique alias.
        $key = $index_prefix . $base_table . '.' . $table . ($langcode ? '.' . $langcode : '');
        if (!isset($this->entityTables[$key])) {
          $this->entityTables[$key] = $this->addJoin($type, $table, "[%alias].[$id_field] = [$base_table].[$id_field]", $langcode);
        }
@@ -406,7 +445,8 @@ protected function ensureEntityTable($index_prefix, $property, $type, $langcode,
   */
  protected function ensureFieldTable($index_prefix, &$field, $type, $langcode, $base_table, $entity_id_field, $field_id_field, $delta) {
    $field_name = $field->getName();
    if (!isset($this->fieldTables[$index_prefix . $field_name])) {
    $key = $index_prefix . $field_name . (is_numeric($delta) ? '.' . $delta : '') . ($langcode ? '.' . $langcode : '');
    if (!isset($this->fieldTables[$key])) {
      $entity_type_id = $this->sqlQuery->getMetaData('entity_type');
      /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
      $table_mapping = $this->entityTypeManager->getStorage($entity_type_id)->getTableMapping();
@@ -414,9 +454,9 @@ protected function ensureFieldTable($index_prefix, &$field, $type, $langcode, $b
      if ($field->getCardinality() != 1) {
        $this->sqlQuery->addMetaData('simple_query', FALSE);
      }
      $this->fieldTables[$index_prefix . $field_name] = $this->addJoin($type, $table, "[%alias].[$field_id_field] = [$base_table].[$entity_id_field]", $langcode, $delta);
      $this->fieldTables[$key] = $this->addJoin($type, $table, "[%alias].[$field_id_field] = [$base_table].[$entity_id_field]", $langcode, $delta);
    }
    return $this->fieldTables[$index_prefix . $field_name];
    return $this->fieldTables[$key];
  }

  /**
@@ -443,10 +483,16 @@ protected function addJoin($type, $table, $join_condition, $langcode, $delta = N
      $entity_type = $this->entityTypeManager->getActiveDefinition($entity_type_id);
      // For a data table, get the entity language key from the entity type.
      // A dedicated field table has a hard-coded 'langcode' column.
      $langcode_key = match($table) {
        $entity_type->getDataTable(), $entity_type->getRevisionDataTable() => $entity_type->getKey('langcode'),
        default => 'langcode',
      };
      if ($entity_type->getDataTable() === $table || $entity_type->getRevisionDataTable() === $table) {
        $langcode_key = $entity_type->getKey('langcode');
        if ($langcode === LanguageInterface::LANGCODE_DEFAULT) {
          $langcode_key = $entity_type->getKey('default_langcode');
          $langcode = 1;
        }
      }
      else {
        $langcode_key = 'langcode';
      }
      $placeholder = ':langcode' . $this->sqlQuery->nextPlaceholder();
      $join_condition .= ' AND [%alias].[' . $langcode_key . '] = ' . $placeholder;
      $arguments[$placeholder] = $langcode;
@@ -505,8 +551,12 @@ protected function getTableMapping($table, $entity_type_id) {
   *   The alias of the next entity table joined in.
   */
  protected function addNextBaseTable(EntityType $entity_type, $table, $sql_column, FieldStorageDefinitionInterface $field_storage) {
    $key = $table . '.' . $sql_column;
    if (!isset($this->nextBaseTables[$key])) {
      $join_condition = '[%alias].[' . $entity_type->getKey('id') . "] = [$table].[$sql_column]";
    return $this->sqlQuery->leftJoin($entity_type->getBaseTable(), NULL, $join_condition);
      $this->nextBaseTables[$key] = $this->sqlQuery->leftJoin($entity_type->getBaseTable(), NULL, $join_condition);
    }
    return $this->nextBaseTables[$key];
  }

}
Loading