Problem/Motivation

To implement a complex join using the IN operator in a hook__views_query_alter implementation, I created a join for the view using code similar to this:

 	 
//Set up the join
$configuration = [
'table' => 'node__field_expert_subjects',
'field' => 'entity_id',
'left_field' => 'nid',
'operator' => '=',
'extra' => [
0 => [
'field' => 'field_expert_subjects_target_id',
'value' => $tids,
]
],
];
 	 
$join = Views::pluginManager('join')->createInstance('standard', $configuration);
 	 
$query->addRelationship('subject_expert_link', $join, 'node_field_data');
 	 
$query->addWhereExpression(0, 'subject_expert_link.entity_id IS NOT NULL');
 	 

where $tids is an array of taxonomy TIDs.

The view, when executed, generates invalid SQL, and I get a fatal error in core/lib/Drupal/Core/Database/Connection.php, in expandArguments().

It appears that in core/modules/views/src/Plugin/views/join/JoinPluginBase.php, the code to render out the SQL in this case was never changed to conform with the fix for SA-CORE-2014-005 (#2358079: Require a specific placeholder format in db_query() in order to trigger argument expansion, and require explicit 'IN' parameter for conditions), and the test coverage did not cover this particular corner case.

Proposed resolution

  • Patch JoinPluginBase.php to use the new style of parameters for arrays.
  • Add or modify the Views tests to hit this use of 'extra'

Remaining tasks

User interface changes

None.

API changes

None; this just makes the current API work.

Data model changes

None.

Comments

Torenware created an issue. See original summary.

Torenware’s picture

StatusFileSize
new1.78 KB

Initial patch so I can check this against the test bot. No test yet.

Torenware’s picture

Status: Active » Needs review

Status: Needs review » Needs work

The last submitted patch, 2: issue-2658438-bad-join-sql.patch, failed testing.

Torenware’s picture

Looking at the test failure, it turns out that everything passes except \Drupal\views\Tests\Plugin\JoinTest::testBasePlugin(). These are:

Fail Ref. Failed Condition Group File Line Number Full Path
#1 Make sure the first extra join condition appears in the query and uses the first placeholder. Other JoinTest.php 153 Drupal\views\Tests\Plugin\JoinTest->testBasePlugin()
#2 Make sure the second extra join condition appears in the query and uses the second placeholder. Other JoinTest.php 154 Drupal\views\Tests\Plugin\JoinTest->testBasePlugin()
#3 Make sure the first extra join condition appears in the query. Other JoinTest.php 179 Drupal\views\Tests\Plugin\JoinTest->testBasePlugin()
#4 The IN condition for the join is properly formed. Other JoinTest.php 180 Drupal\views\Tests\Plugin\JoinTest->testBasePlugin()
#5 Make sure the first extra join condition appears in the query. Other JoinTest.php 205 Drupal\views\Tests\Plugin\JoinTest->testBasePlugin()
#6 Make sure the second extra join condition appears in the query. Other JoinTest.php 206 Drupal\views\Tests\Plugin\JoinTest->testBasePlugin()
#7 Make sure the third extra join condition appears in the query. Other JoinTest.php 207 Drupal\views\Tests\Plugin\JoinTest->testBasePlugin()

My patch does two things. First, it adds parenthesis around argument values, to make sure that a JOIN made with the IN operator will be valid SQL. The change is

@@ -333,7 +330,7 @@ public function buildJoin($select_query, $table, $view_query) {
             $join_table_field = "$left[alias].$info[left_field]";
             $arguments[$placeholder] = $info['value'];
           }
-          $extras[] = "$join_table_field $operator $placeholder";
+          $extras[] = "$join_table_field $operator ($placeholder)";
         }

This is harmless if the operator is not expecting a list (at least, AFAIK), but essential if it is, as it is for IN and NOT IN.

All of the seven FAILS only test the format of the generated SQL (the tests don't attempt to execute it). All of my FAILS except #4 are because the string match objects to unneeded (if harmless) parenthesis.

#4, however, generates SQL that violates the assumptions of the SA-CORE-2014-005 fix. #4 will cause a fatal exception if our PDO wrappers ever try to execute it. The second part of the patch changes the parameters used for array valued "right sides" to a format with the added [] at the end, in place of multiple parameters, which will now crash the Database API.

I can make the patch a bit more specific if needed, if I need to pass the 6 extra fails w/o changing the test. But line 180 must change, since the SQL we're testing for will not execute:

    $this->assertTrue(strpos($join_info['condition'], "users4.name IN ( :views_join_condition_3, :views_join_condition_4, :views_join_condition_5 )") !== FALSE, 'The IN condition for the join is properly formed.');

since the 3 parameters need to change to something like :views_join_condition_3[].

Torenware’s picture

Status: Needs work » Needs review
StatusFileSize
new4.69 KB

Only add parenthesis for the array-valued case, and change the one assert that must change for validity.

No interdiff, since this is pretty small.

Torenware’s picture

Issue tags: +SprintWeekend2016

Tagging the issue for 2016 sprint weekend, since this should be low hanging fruit for somebody.

lokapujya’s picture

+++ b/core/modules/views/src/Plugin/views/join/JoinPluginBase.php
@@ -333,7 +331,13 @@ public function buildJoin($select_query, $table, $view_query) {
+            $extras[] = "$join_table_field $operator $placeholder";          ¶

extra whitespace.

Also, can you please post a "test only" patch so that we can see the failure (with the new assert) for review purposes?

Torenware’s picture

@lokapujya -- hm, thought I got that bit of extra white space. I'll re-roll with it gone.

As for a test-only patch: what do you need? Currently, the test in core/modules/views/src/Tests/Plugin/JoinTest.php only tests if the generated SQL fragments are as the test expects. If I remove my modification of the test, Fail #4 from the table in Comment #5 will fail, but everything else will pass.

Right now, there are no tests related the Join plugins that actually test if the SQL will execute. If you want to actually see the crash, either I'd need to add code to one of the test modules that generates a query of the right type (using code like the snippet from Problem/Motivation section) and then do a SimpleTest, or I'd need to figure out how to do a unit test that is set up to execute a view query in isolation. is this what you're asking for?

Torenware’s picture

StatusFileSize
new4.68 KB

From IRC today:

rtoren: lokapujya: the code in JoinTest.php at line 180 assumes a usage of parameters that is no longer valid; a new syntax superseeds it.
lokapujya: I get it, so the test-only isn't needed, you can disregard that comment.
rtoren: OK, I'll reroll fixing the white space issue.

The new patch should resolve the whitespace issue. So this should be ready-to-go per lokapujya.

Version: 8.0.x-dev » 8.1.x-dev

Drupal 8.0.6 was released on April 6 and is the final bugfix release for the Drupal 8.0.x series. Drupal 8.0.x will not receive any further development aside from security fixes. Drupal 8.1.0-rc1 is now available and sites should prepare to update to 8.1.0.

Bug reports should be targeted against the 8.1.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.2.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

kristiaanvandeneynde’s picture

Status: Needs review » Reviewed & tested by the community

Reviewed and tested this as part of the patch in #2706495-16: add Views reverse relationships from entities to Group Content.

Looks good, does what it promises and has green tests. RTBC.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs reroll

error: core/modules/views/src/Tests/Plugin/JoinTest.php: does not exist in index

Looks like the test has been moved.

kristiaanvandeneynde’s picture

Status: Needs work » Needs review
StatusFileSize
new5.22 KB

Straight reroll, so if tests go green this goes back to RTBC.

The test moved into a Kernel subfolder is all.

kristiaanvandeneynde’s picture

kristiaanvandeneynde’s picture

Status: Needs review » Reviewed & tested by the community

Green so back to RTBC.

dawehner’s picture

This looks great for me!

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
  1. +++ b/core/modules/views/src/Plugin/views/join/JoinPluginBase.php
    @@ -303,19 +303,17 @@ public function buildJoin($select_query, $table, $view_query) {
                 $local_arguments = array();
    ...
    +            $local_arguments[$placeholder] = $info['value'];
    ...
                 $arguments += $local_arguments;
    

    I think we can do away with the $local_arguments completely. Ie I think we can just do
    $arguments[$placeholder] = $info['value'];.

  2. +++ b/core/modules/views/tests/src/Kernel/Plugin/JoinTest.php
    @@ -172,7 +172,7 @@ public function testBasePlugin() {
    +    $this->assertTrue(strpos($join_info['condition'], "users4.name IN ( :views_join_condition_3[] )") !== FALSE, 'The IN condition for the join is properly formed.');	 ¶
    
    @@ -197,8 +197,8 @@ public function testBasePlugin() {
    +    $this->assertTrue(strpos($join_info['condition'], "views_test_data.status = :views_join_condition_5") !== FALSE, 'Make sure the second extra join condition appears in the query.');		    ¶
    

    Space at the end of the line.

dawehner’s picture

I think we can do away with the $local_arguments completely. Ie I think we can just do
$arguments[$placeholder] = $info['value'];.

I think so, $placeholder is unique.

kristiaanvandeneynde’s picture

Status: Needs work » Needs review
StatusFileSize
new5.21 KB
new3.21 KB

Both good points. Looking at the code it is indeed now possible to set the value directly.

dawehner’s picture

Status: Needs review » Reviewed & tested by the community

Nice!

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: -Needs reroll
+++ b/core/modules/views/src/Plugin/views/join/JoinPluginBase.php
@@ -303,19 +303,15 @@ public function buildJoin($select_query, $table, $view_query) {
+          $use_parenthesis = FALSE;
...
+            $placeholder = ':views_join_condition_' . $select_query->nextPlaceholder() . '[]';
...
-            $placeholder = '( ' . implode(', ', array_keys($local_arguments)) . ' )';
...
+            $use_parenthesis = TRUE;

@@ -340,7 +336,13 @@ public function buildJoin($select_query, $table, $view_query) {
+          if ($use_parenthesis) {
+            $extras[] = "$join_table_field $operator ( $placeholder )";
+          }
+          else {
+            $extras[] = "$join_table_field $operator $placeholder";
+          }

Is there any reason we don't do as before and add the parenthesis into the placeholder? I can't see one and this would mean there is less logic in the method. As the whole if ($use_parenthesis) { change would be unnecessary.

Sorry I should have asked this in earlier reviews.

kristiaanvandeneynde’s picture

Status: Needs work » Needs review
StatusFileSize
new1.78 KB
new4.9 KB

Nah, good point. I left the comment in there, as it doesn't hurt to have some in such a bloated function :)

outermedia-dku’s picture

Issue summary: View changes

If you prefer the old IN(...) SQL syntax, there is probably a very short fix for the problem.

@@ -331,7 +331,7 @@ class JoinPluginBase extends PluginBase
             if (isset($info['left_field'])) {
               $placeholder = "$left[alias].$info[left_field]";
             }
-            else {
+            elseif (!is_array($info['value'])) {
               $arguments[$placeholder] = $info['value'];
             }
           }
outermedia-dku’s picture

Issue summary: View changes
outermedia-dku’s picture

If you prefer the IN(...) SQL syntax, there is probably a very short fix for the problem.

@@ -331,7 +331,7 @@ class JoinPluginBase extends PluginBase
             if (isset($info['left_field'])) {
               $placeholder = "$left[alias].$info[left_field]";
             }
-            else {
+            elseif (!is_array($info['value'])) {
               $arguments[$placeholder] = $info['value'];
             }
           }
kristiaanvandeneynde’s picture

I'm not sure I know what you're getting at dku. The issue is about the joins not working at all because the plugin never incorporated the logic from the security advisory and the latest patch fixes that.

dawehner’s picture

Status: Needs review » Reviewed & tested by the community

@dku
This really sounds like a different problem, maybe reporting this in its own issue would be great!

Nice find @alexpott, this simplifies the code a bit!

kristiaanvandeneynde’s picture

StatusFileSize
new65.43 KB

Actually, the parenthesis change lead to this patch breaking when used because the arguments array now has a key containing parentheses whereas Drupal looks for the placeholder without parentheses.

Reverting to the old patch from #20 and leaving as RTBC again because that one was properly reviewed and worked in #2706495: add Views reverse relationships from entities to Group Content, whereas the latest one doesn't.

kristiaanvandeneynde’s picture

Status: Reviewed & tested by the community » Needs work

Actually, back to needs work :(

It throws notices in this part of views_query_views_alter():

  // Replaces substitutions in tables.
  foreach ($tables as $table_name => $table_metadata) {
    foreach ($table_metadata['arguments'] as $replacement_key => $value) {
      if (isset($substitutions[$value])) {
        $tables[$table_name]['arguments'][$replacement_key] = $substitutions[$value];
      }
    }
  }

$value is an array so invalid for use as an array key in the isset() call. Any suggestions on how to address that one? It's the final piece of this puzzle for it to be completed :)

Torenware’s picture

views_query_views_alter() is making some assumptions as to what can legally be in $substitutions. Looking through sources a bit, $substitutions gets its value from

    // Add all query substitutions as metadata.
    $query->addMetaData('views_substitutions', \Drupal::moduleHandler()->invokeAll('views_query_substitutions', array($this->view)));

which is in Drupal\views\Plugin\views\query\Sql, near the very end. So we have two possibilities here:

  1. views_query_views_alter() is DOING IT WRONG, or
  2. Some module that is calling hook_views_query_substitutions() is DOING IT WRONG.

So, do you know what the stack trace is from where you're getting the notice? This may tell us that we need to sanity check the input in the former, or that we need to fix the hook implementation in the latter.

Torenware’s picture

Somebody is violating the "contract" for our hook. Look at the definition:

/**
 * Replace special strings in the query before it is executed.
 *
 * The idea is that certain dynamic values can be placed in a query when it is
 * built, and substituted at run-time, allowing the query to be cached and
 * still work correctly when executed.
 *
 * @param \Drupal\views\ViewExecutable $view
 *   The View being executed.
 *
 * @return array
 *   An associative array where each key is a string to be replaced, and the
 *   corresponding value is its replacement. The strings to replace are often
 *   surrounded with '***', as illustrated in the example implementation, to
 *   avoid collisions with other values in the query.
 */
function hook_views_query_substitutions(ViewExecutable $view) {
  // Example from views_views_query_substitutions().
  return array(
    '***CURRENT_VERSION***' => \Drupal::VERSION,
    '***CURRENT_TIME***' => REQUEST_TIME,
    '***LANGUAGE_language_content***' => \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(),
    PluginBase::VIEWS_QUERY_LANGUAGE_SITE_DEFAULT => \Drupal::languageManager()->getDefaultLanguage()->getId(),
  );
}

So it's the responsibility of the caller to return a string for each of the key values.

I"m going to see if I can track down who's violating the contract. But we probably need to do two things here: check our input in views_query_views_alter() (either throwing something or writing to the log if we see something that will not cast to string), and find the caller that's doing this here.

Torenware’s picture

Here's where we need to look, I'd guess:

./node/node.views_execution.inc:13: * Implements hook_views_query_substitutions().
./node/node.views_execution.inc:15:function node_views_query_substitutions(ViewExecutable $view) {
./user/user.views_execution.inc:11: * Implements hook_views_query_substitutions().
./user/user.views_execution.inc:15:function user_views_query_substitutions(ViewExecutable $view) {
./views/tests/modules/views_test_data/views_test_data.views_execution.inc:13: * Implements hook_views_query_substitutions().
./views/tests/modules/views_test_data/views_test_data.views_execution.inc:15:function views_test_data_views_query_substitutions(ViewExecutable $view) {
./views/tests/modules/views_test_data/views_test_data.views_execution.inc:16:  \Drupal::state()->set('views_hook_test_views_query_substitutions', TRUE);
./views/views.module:653: * add in substitutions from hook_views_query_substitutions().
./views/views.views_execution.inc:12: * Implements hook_views_query_substitutions().
./views/views.views_execution.inc:20:function views_views_query_substitutions(ViewExecutable $view) {
kristiaanvandeneynde’s picture

How about a much simpler approach?

We refactor views_query_views_alter() to account for $value potentially being an array and then loop over that. That way, a join condition of WHERE foo IN ( :bar[] ) with :bar expanding to ***CURRENT_TIME*** and ***CURRENT_VERSION*** would also work as expected.

Something like this:

// Replaces substitutions in tables.
foreach ($tables as $table_name => $table_metadata) {
  foreach ($table_metadata['arguments'] as $replacement_key => $value) {
    if (!is_array($value)) {
      if (isset($substitutions[$value])) {
        $tables[$table_name]['arguments'][$replacement_key] = $substitutions[$value];
      }
    }
    else {
      foreach ($value as $sub_key => $sub_value) {
        if (isset($substitutions[$sub_value])) {
          $tables[$table_name]['arguments'][$replacement_key][$sub_key] = $substitutions[$sub_value];
        }
      }
    }
  }
}

The funny part is: This is broken for all of Views, not just the functionality in this patch. Looking at the original code, there is no way an IN-query of any kind would have ever properly replaced its substitution keys with the actual values. So perhaps we should ignore it here and file a separate issue for it?

kristiaanvandeneynde’s picture

So, as per #29 we can go back to RTBC for the patch in #20. As per #34, a follow-up should be created for views_query_views_alter() because it (should throw/throws) notices for more than just this issue.

alexpott’s picture

+++ b/core/modules/views/src/Plugin/views/join/JoinPluginBase.php
@@ -303,19 +303,15 @@ public function buildJoin($select_query, $table, $view_query) {
+            $placeholder = ':views_join_condition_' . $select_query->nextPlaceholder() . '[]';
             $operator = !empty($info['operator']) ? $info['operator'] : 'IN';
-            $placeholder = '( ' . implode(', ', array_keys($local_arguments)) . ' )';
-            $arguments += $local_arguments;
+            $arguments[$placeholder] = $info['value'];
+            $use_parenthesis = TRUE;

This could still be written as

  $placeholder = ':views_join_condition_' . $select_query->nextPlaceholder() . '[]';
  $operator = !empty($info['operator']) ? $info['operator'] : 'IN';
  $arguments[$placeholder] = $info['value'];
  $placeholder = '( ' . $placeholder . ' )';

Which would rid us of the conditional and $use_parenthesis.

dawehner’s picture

We refactor views_query_views_alter() to account for $value potentially being an array and then loop over that. That way, a join condition of WHERE foo IN ( :bar[] ) with :bar expanding to ***CURRENT_TIME*** and ***CURRENT_VERSION*** would also work as expected.

That sounds sensible

kristiaanvandeneynde’s picture

Status: Needs work » Needs review
StatusFileSize
new5.97 KB
new3.33 KB

So the $placeholder variable was both used for the array key in $arguments and the SQL string all the way at the end. Because there was some extra code later on, we can't simply encapsulate $placeholder in parentheses because we run the risk of the arguments array having the wrong key again.

The attached patch solves this by introducing a second $placeholder_sql variable which is only used in the generated SQL, which means we can safely keep using $placeholder without parentheses. I also added a test which checks for the arguments key to be a correct placeholder.

Edit: will post a patch saying $placeholder_sql instead of $place_holder_sql after this one goes green.

kristiaanvandeneynde’s picture

Version: 8.1.x-dev » 8.1.2
StatusFileSize
new6.03 KB
new2.09 KB

Tests are currently failing on 8.1.x due to a B/C break in #2735045: Convert StandardTest to BrowserTestBase by introducing a AssertLegacyTrait and new assertions on the WebAssert so flagging this issue as 8.1.2 and uploading the patch from #38 without the typo and with a slightly cleaner variable assignment.

Also created a spin-off for the warnings in the logs at #2744069: views_query_views_alter() does not handle IN queries

The last submitted patch, 38: issue-2658438-bad-join-sql-37.patch, failed testing.

kristiaanvandeneynde’s picture

JoinTest actually passed in that test, so the tests we want to go green went green.

dawehner’s picture

Status: Needs review » Reviewed & tested by the community

This looks perfect for me now. The changes are fairly minimal, we have some test.

alexpott’s picture

Version: 8.1.2 » 8.1.x-dev
Status: Reviewed & tested by the community » Fixed

Committed 4bab1f7 and pushed to 8.1.x and 8.2.x. Thanks!

  • alexpott committed c19740f on 8.2.x
    Issue #2658438 by kristiaanvandeneynde, Torenware, dawehner, alexpott:...

  • alexpott committed 4bab1f7 on 8.1.x
    Issue #2658438 by kristiaanvandeneynde, Torenware, dawehner, alexpott:...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.