Commit 923b2e65 authored by catch's avatar catch
Browse files

Issue #2133215 by alexpott, dww, uplex_slink, dawehner, daffie, daniel.bosen,...

Issue #2133215 by alexpott, dww, uplex_slink, dawehner, daffie, daniel.bosen, Lendude, larowlan: Fix IndexTidDepth views argument plugin and TaxonomyIndexTidDepth views filter plugin performance
parent 4122e26f
Loading
Loading
Loading
Loading
+3 −29
Changes for core/modules/taxonomy/src/Plugin/views/argument/IndexTidDepth.php: 3 added lines, 29 removed lines.
Original line number Diff line number Diff line
@@ -2,10 +2,10 @@

namespace Drupal\taxonomy\Plugin\views\argument;

use Drupal\Core\Database\Database;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\taxonomy\TaxonomyIndexDepthQueryTrait;
use Drupal\views\Plugin\views\argument\ArgumentPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;

@@ -20,6 +20,7 @@
 * @ViewsArgument("taxonomy_index_tid_depth")
 */
class IndexTidDepth extends ArgumentPluginBase implements ContainerFactoryPluginInterface {
  use TaxonomyIndexDepthQueryTrait;

  /**
   * @var \Drupal\Core\Entity\EntityStorageInterface
@@ -101,40 +102,13 @@ public function query($group_by = FALSE) {
      if ($break->value === [-1]) {
        return FALSE;
      }

      $operator = (count($break->value) > 1) ? 'IN' : '=';
      $tids = $break->value;
    }
    else {
      $operator = "=";
      $tids = $this->argument;
    }
    // Now build the subqueries.
    $subquery = Database::getConnection()->select('taxonomy_index', 'tn');
    $subquery->addField('tn', 'nid');
    $where = ($this->view->query->getConnection()->condition('OR'))->condition('tn.tid', $tids, $operator);
    $last = "tn";

    if ($this->options['depth'] > 0) {
      $subquery->leftJoin('taxonomy_term__parent', 'th', "[th].[entity_id] = [tn].[tid]");
      $last = "th";
      foreach (range(1, abs($this->options['depth'])) as $count) {
        $subquery->leftJoin('taxonomy_term__parent', "th$count", "[$last].[parent_target_id] = [th$count].[entity_id]");
        $where->condition("th$count.entity_id", $tids, $operator);
        $last = "th$count";
      }
    }
    elseif ($this->options['depth'] < 0) {
      foreach (range(1, abs($this->options['depth'])) as $count) {
        $field = $count == 1 ? 'tid' : 'entity_id';
        $subquery->leftJoin('taxonomy_term__parent', "th$count", "[$last].[$field] = [th$count].[parent_target_id]");
        $where->condition("th$count.entity_id", $tids, $operator);
        $last = "th$count";
      }
    }

    $subquery->condition($where);
    $this->query->addWhere(0, "$this->tableAlias.$this->realField", $subquery, 'IN');
    $this->addSubQueryJoin($tids);
  }

  public function title() {
+3 −31
Changes for core/modules/taxonomy/src/Plugin/views/filter/TaxonomyIndexTidDepth.php: 3 added lines, 31 removed lines.
Original line number Diff line number Diff line
@@ -2,8 +2,8 @@

namespace Drupal\taxonomy\Plugin\views\filter;

use Drupal\Core\Database\Database;
use Drupal\Core\Form\FormStateInterface;
use Drupal\taxonomy\TaxonomyIndexDepthQueryTrait;

/**
 * Filter handler for taxonomy terms with depth.
@@ -16,6 +16,7 @@
 * @ViewsFilter("taxonomy_index_tid_depth")
 */
class TaxonomyIndexTidDepth extends TaxonomyIndexTid {
  use TaxonomyIndexDepthQueryTrait;

  public function operatorOptions($which = 'title') {
    return [
@@ -52,10 +53,6 @@ public function query() {
      if (is_array($this->value)) {
        $this->value = current($this->value);
      }
      $operator = '=';
    }
    else {
      $operator = 'IN';
    }

    // The normal use of ensureMyTable() here breaks Views.
@@ -70,32 +67,7 @@ public function query() {
      $this->tableAlias = $this->query->ensureTable($this->view->storage->get('base_table'));
    }

    // Now build the subqueries.
    $subquery = Database::getConnection()->select('taxonomy_index', 'tn');
    $subquery->addField('tn', 'nid');
    $where = ($this->view->query->getConnection()->condition('OR'))->condition('tn.tid', $this->value, $operator);
    $last = "tn";

    if ($this->options['depth'] > 0) {
      $subquery->leftJoin('taxonomy_term__parent', 'th', "[th].[entity_id] = [tn].[tid]");
      $last = "th";
      foreach (range(1, abs($this->options['depth'])) as $count) {
        $subquery->leftJoin('taxonomy_term__parent', "th$count", "[$last].[parent_target_id] = [th$count].[entity_id]");
        $where->condition("th$count.entity_id", $this->value, $operator);
        $last = "th$count";
      }
    }
    elseif ($this->options['depth'] < 0) {
      foreach (range(1, abs($this->options['depth'])) as $count) {
        $field = $count == 1 ? 'tid' : 'entity_id';
        $subquery->leftJoin('taxonomy_term__parent', "th$count", "[$last].[$field] = [th$count].[parent_target_id]");
        $where->condition("th$count.entity_id", $this->value, $operator);
        $last = "th$count";
      }
    }

    $subquery->condition($where);
    $this->query->addWhere($this->options['group'], "$this->tableAlias.$this->realField", $subquery, 'IN');
    $this->addSubQueryJoin($this->value);
  }

}
+94 −0
Changes for core/modules/taxonomy/src/TaxonomyIndexDepthQueryTrait.php: 94 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

namespace Drupal\taxonomy;

use Drupal\views\Views;

/**
 * Builds a performant depth subquery and adds it as a join to the query.
 *
 * This is performant because:
 * - It creates multiple queries on taxonomy_index with inner joins to
 *   taxonomy_term__parent. These queries are combined together into a subquery
 *   using unions to select all the node IDs with the terms in the hierarchy.
 * - It joins the resulting query to the main views query using an INNER JOIN.
 *
 * For example, if the $tids value is '718' and depth is configured to 2, the
 * resulting JOIN to node_field_data will be:
 * @code
 * INNER JOIN (SELECT tn.nid AS nid
 * FROM
 * taxonomy_index tn
 * WHERE tn.tid = '718' UNION SELECT tn.nid AS nid
 * FROM
 * taxonomy_index tn
 * INNER JOIN taxonomy_term__parent th ON tn.tid = th.entity_id
 * INNER JOIN taxonomy_term__parent th1 ON th.parent_target_id = th1.entity_id
 * WHERE th1.entity_id = '718' UNION SELECT tn.nid AS nid
 * FROM
 * taxonomy_index tn
 * INNER JOIN taxonomy_term__parent th ON tn.tid = th.entity_id
 * INNER JOIN taxonomy_term__parent th1 ON th.parent_target_id = th1.entity_id
 * INNER JOIN taxonomy_term__parent th2 ON th1.parent_target_id = th2.entity_id
 * WHERE th2.entity_id = '718') taxonomy_index_depth ON node_field_data.nid = taxonomy_index_depth.nid
 * @endcode
 */
trait TaxonomyIndexDepthQueryTrait {

  /**
   * Builds a performant depth subquery and adds it as a join to the query.
   *
   * @param string|array $tids
   *   The terms ID(s) to do a depth search for.
   */
  protected function addSubQueryJoin($tids): void {
    $connection = $this->query->getConnection();
    $operator = is_array($tids) ? 'IN' : '=';
    // Create the depth 0 subquery.
    $subquery = $connection->select('taxonomy_index', 'tn');
    $subquery->addField('tn', 'nid');
    $subquery->condition('tn.tid', $tids, $operator);

    if ($this->options['depth'] !== 0) {
      // Set $left_field and $right_field depending on whether we are traversing
      // up or down the hierarchy.
      if ($this->options['depth'] > 0) {
        $left_field = 'parent_target_id';
        $right_field = 'entity_id';
      }
      else {
        $left_field = 'entity_id';
        $right_field = 'parent_target_id';
      }
      // Traverse the hierarchy to check the child or parent terms.
      foreach (range(1, abs($this->options['depth'])) as $count) {
        $union_query = $connection->select('taxonomy_index', 'tn');
        $union_query->addField('tn', 'nid');
        $left_join = "[tn].[tid]";
        if ($this->options['depth'] > 0) {
          $union_query->join('taxonomy_term__parent', "th", "$left_join = [th].[entity_id]");
          $left_join = "[th].[$left_field]";
        }
        foreach (range(1, $count) as $inner_count) {
          $union_query->join('taxonomy_term__parent', "th$inner_count", "$left_join = [th$inner_count].[$right_field]");
          $left_join = "[th$inner_count].[$left_field]";
        }
        $union_query->condition("th$inner_count.entity_id", $tids, $operator);
        $subquery->union($union_query);
      }
    }

    // Add the subquery as a join.
    $definition['left_table'] = $this->tableAlias;
    $definition['left_field'] = $this->realField;
    $definition['field'] = 'nid';
    $definition['type'] = 'INNER';
    $definition['adjusted'] = TRUE;
    $definition['table formula'] = $subquery;
    $join = Views::pluginManager('join')->createInstance('standard', $definition);

    // There is no $base as we are joining to a query.
    $this->query->addRelationship('taxonomy_index_depth', $join, NULL, $this->relationship);
  }

}
+16 −1
Changes for core/modules/taxonomy/tests/modules/taxonomy_test_views/test_views/views.view.test_filter_taxonomy_index_tid_depth.yml: 16 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -169,7 +169,22 @@ display:
          depth: -2
          error_message: true
          plugin_id: taxonomy_index_tid_depth
      sorts: {  }
      sorts:
        created:
          id: created
          table: node_field_data
          field: created
          order: DESC
          entity_type: node
          entity_field: created
          plugin_id: date
          relationship: none
          group_type: group
          admin_label: ''
          exposed: false
          expose:
            label: ''
          granularity: second
      header: {  }
      footer: {  }
      empty: {  }
+193 −0
Changes for core/modules/taxonomy/tests/src/Kernel/Views/TaxonomyTermArgumentDepthTest.php: 193 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\taxonomy\Kernel\Views;

use Drupal\views\Views;

/**
 * Test the taxonomy term with depth argument.
 *
 * @group taxonomy
 */
class TaxonomyTermArgumentDepthTest extends TaxonomyTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'taxonomy',
    'taxonomy_test_views',
    'views',
    'node',
  ];

  /**
   * Views IDs used by this test.
   *
   * @var string[]
   */
  public static $testViews = ['test_argument_taxonomy_index_tid_depth'];

  /**
   * The terms used in the test.
   *
   * @var \Drupal\taxonomy\TermInterface[]
   */
  protected $terms = [];

  /**
   * The view executable used in the test.
   *
   * @var \Drupal\views\ViewExecutable
   */
  protected $view;

  /**
   * {@inheritdoc}
   */
  protected function setUp($import_test_views = TRUE): void {
    parent::setUp($import_test_views);

    // Install node_access schema in order to successfully re-save nodes.
    $this->installSchema('node', ['node_access']);

    // Create a hierarchy 5 deep. Note the parent setup function creates two
    // top-level terms w/o children.
    $first = $this->createTerm(['name' => 'First']);
    $second = $this->createTerm(['name' => 'Second', 'parent' => $first->id()]);
    $third = $this->createTerm(['name' => 'Third', 'parent' => $second->id()]);
    $fourth = $this->createTerm(['name' => 'Fourth', 'parent' => $third->id()]);
    $fifth = $this->createTerm(['name' => 'Fifth', 'parent' => $fourth->id()]);
    $this->terms = [$first, $second, $third, $fourth, $fifth];

    // Create a node w/o any terms.
    $settings = ['type' => 'article'];
    $this->nodes[] = $this->drupalCreateNode($settings);

    // Create a node with only the top level term.
    $settings['field_views_testing_tags'][0]['target_id'] = $first->id();
    $this->nodes[] = $this->drupalCreateNode($settings);

    // Create a node with only the third level term.
    $settings['field_views_testing_tags'][0]['target_id'] = $third->id();
    $this->nodes[] = $this->drupalCreateNode($settings);

    // Create a node with only the fifth level term.
    $settings['field_views_testing_tags'][0]['target_id'] = $fifth->id();
    $this->nodes[] = $this->drupalCreateNode($settings);

    $this->view = Views::getView(self::$testViews[0]);

    // Fix the created date to match the expectations of the order by in the
    // view. Node 1 should be the most recent node and node 6 should be the
    // oldest.
    $time = \Drupal::time();
    foreach ($this->nodes as $i => $node) {
      $node->setCreatedTime($time->getRequestTime() - $i)->save();
    }
  }

  /**
   * Tests the terms with depth filter.
   */
  public function testTermWithDepthFilter(): void {
    // Default view has an empty value for this filter, so all nodes should be
    // returned.
    $expected = [
      ['nid' => 1],
      ['nid' => 2],
      ['nid' => 3],
      ['nid' => 4],
      ['nid' => 5],
      ['nid' => 6],
    ];
    $this->executeView($this->view);
    $this->assertIdenticalResultsetHelper($this->view, $expected, ['nid' => 'nid'], 'assertIdentical');

    // Set filter to search on top-level term, with depth 0.
    $expected = [['nid' => 4]];
    $this->assertTermWithDepthResult($expected, $this->terms[0]->id(), 0);

    // Top-level term, depth 1.
    $expected = [['nid' => 4]];
    $this->assertTermWithDepthResult($expected, $this->terms[0]->id(), 1);

    // Top-level term, depth 2.
    $expected = [['nid' => 4], ['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[0]->id(), 2);

    // Top-level term, depth 9.
    $expected = [['nid' => 4], ['nid' => 5], ['nid' => 6]];
    $this->assertTermWithDepthResult($expected, $this->terms[0]->id(), 9);

    // Second-level term, depth 1.
    $expected = [['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[1]->id(), 1);

    // Third-level term, depth 0.
    $expected = [['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[2]->id(), 0);

    // Third-level term, depth 1.
    $expected = [['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[2]->id(), 1);

    // Third-level term, depth -2.
    $expected = [['nid' => 4], ['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[2]->id(), -2);

    // Third-level term, depth -9.
    $expected = [['nid' => 4], ['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[2]->id(), -9);

    // Fifth-level term, depth -9.
    $expected = [['nid' => 4], ['nid' => 5], ['nid' => 6]];
    $this->assertTermWithDepthResult($expected, $this->terms[4]->id(), -9);

    // Third-level term, depth -1.
    $expected = [['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[2]->id(), -1);

    // Third-level and second-level term, depth -1, using a plus sign.
    $expected = [['nid' => 4], ['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[2]->id() . '+' . $this->terms[1]->id(), -1, TRUE);

    // Third-level and second-level term, depth -1, using a comma. Note that due
    // to performance the "and" meaning of comma is not supported.
    $expected = [['nid' => 4], ['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[2]->id() . ',' . $this->terms[1]->id(), -1, TRUE);

    // Top-level term and second level term, depth 1, using a plus sign.
    $expected = [['nid' => 4], ['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[0]->id() . '+' . $this->terms[1]->id(), 1, TRUE);

    // Top-level term and second level term, depth 1, using a comma. Note that
    // due to performance the "and" meaning of comma is not supported.
    $expected = [['nid' => 4], ['nid' => 5]];
    $this->assertTermWithDepthResult($expected, $this->terms[0]->id() . ',' . $this->terms[1]->id(), 1, TRUE);
  }

  /**
   * Asserts the result of the view for the given arguments.
   *
   * @param array $expected
   *   The expected views result.
   * @param int|string $tid
   *   The term ID or IDs to use as an argument.
   * @param int $depth
   *   The depth to search.
   * @param bool $break_phrase
   *   Whether to break the argument up into multiple terms.
   */
  protected function assertTermWithDepthResult(array $expected, $tid, int $depth, bool $break_phrase = FALSE): void {
    $this->view->destroy();
    $this->view->initDisplay();
    $arguments = $this->view->displayHandlers->get('default')->getOption('arguments');
    $arguments['term_node_tid_depth']['depth'] = $depth;
    $arguments['term_node_tid_depth']['break_phrase'] = $break_phrase;
    $this->view->displayHandlers->get('default')->setOption('arguments', $arguments);
    $this->executeView($this->view, [$tid]);
    $this->assertIdenticalResultsetHelper($this->view, $expected, ['nid' => 'nid'], 'assertIdentical');
  }

}
Loading