CRM_Utils_SQL_* - Properly interpolate NULL values
authorTim Otten <totten@civicrm.org>
Wed, 15 May 2019 08:23:37 +0000 (09:23 +0100)
committerTim Otten <totten@civicrm.org>
Wed, 15 May 2019 08:50:22 +0000 (09:50 +0100)
Overview
--------

The `CRM_Utils_SQL_*` classes are used to generate SQL expressions with
properly escaped data. However, in many cases, the values of `NULL` are
processed in a way which clearly doesn't work, e.g.

Before
------

When passing in a parameter with a `NULL` value, the parameter *is not evaluated at all*.

```
>>> CRM_Utils_SQL_Select::from('civicrm_contact')->where('do_not_email IS @foo', ['foo' => NULL])->toSQL()
=> """
   SELECT *\n
   FROM civicrm_contact\n
   WHERE (do_not_email IS @foo)\n
   """
```

Specifically:

* `CRM_Utils_SQL_Select` - PHP NULL parameters are ignored (as if the value does not exist). No test coverage.
* `CRM_Utils_SQL_Delete` - PHP NULL parameters are ignored (as if the value does not exist). No test coverage.
* `CRM_Utils_SQL_Insert` - PHP NULL inputs are represented as SQL NULLs. No test coverage.

After
-----

When passing in a parameter with a PHP `NULL` value, the parameter is encoded as a SQL `NULL` value.

```
>>> CRM_Utils_SQL_Select::from('civicrm_contact')->where('do_not_email IS @foo', ['foo' => NULL])->toSQL()
=> """
   SELECT *\n
   FROM civicrm_contact\n
   WHERE (do_not_email IS NULL)\n
   """
```

Specifically:

* `CRM_Utils_SQL_Select` - PHP NULL inputs are represented as SQL NULLs. Has test coverage.
* `CRM_Utils_SQL_Delete` - PHP NULL inputs are represented as SQL NULLs. Has test coverage.
* `CRM_Utils_SQL_Insert` - PHP NULL inputs are represented as SQL NULLs. Has test coverage.

Comment
-------

I'm generally hesitant about changing behavior like this.  However, given
that the old behavior was clearly non-functional, I'm struggling to figure a
way where this would be problematic. Moreover, I'm a bit encouraged by the
fact that:

* There are unit-tests specifically for these classes,
* When grepping core for references to `CRM_Utils_SQL_ `, there are several
  matches involving APIv3 and ActionSchedules -- both of which have a long
  practice of unit-testing.
* When grepping my local ext's, there are several matches for things in
  `apiv4` and `civicase` - which also some test-coverage.

CRM/Utils/SQL/BaseParamQuery.php
tests/phpunit/CRM/Utils/SQL/DeleteTest.php
tests/phpunit/CRM/Utils/SQL/InsertTest.php
tests/phpunit/CRM/Utils/SQL/SelectTest.php

index f48d05556c6de6ba1e89153729ad5a187e215666..159f56a5fad60f2832034fc54a100a36023f2d06 100644 (file)
@@ -95,10 +95,10 @@ class CRM_Utils_SQL_BaseParamQuery implements ArrayAccess {
 
       $select = $this;
       return preg_replace_callback('/([#!@])([a-zA-Z0-9_]+)/', function($m) use ($select, $args) {
-        if (isset($args[$m[2]])) {
+        if (array_key_exists($m[2], $args)) {
           $values = $args[$m[2]];
         }
-        elseif (isset($args[$m[1] . $m[2]])) {
+        elseif (array_key_exists($m[1] . $m[2], $args)) {
           // Backward compat. Keys in $args look like "#myNumber" or "@myString".
           $values = $args[$m[1] . $m[2]];
         }
index 25d39722b4c68f15bd37053a49220e25fbfe3ac2..f0903be19ecae19ff3a76d09dc6a301438d93cb0 100644 (file)
@@ -25,6 +25,15 @@ class CRM_Utils_SQL_DeleteTest extends CiviUnitTestCase {
     $this->assertLike('DELETE FROM foo WHERE (foo = "not\\"valid") AND (whiz > "in\\"valid") AND (frob != "in\\"valid")', $del->toSQL());
   }
 
+  public function testWhereNullArg() {
+    $del = CRM_Utils_SQL_Delete::from('foo')
+      ->where('foo IS @value', array('@value' => NULL))
+      ->where('nonexistent IS @nonexistent', [])
+      ->where('morenonexistent IS @nonexistent', NULL)
+      ->where('bar IS @value', array('@value' => 'null'));
+    $this->assertLike('DELETE FROM foo WHERE (foo IS NULL) AND (nonexistent IS @nonexistent) AND (morenonexistent IS @nonexistent) AND (bar IS "null")', $del->toSQL());
+  }
+
   /**
    * @param $expected
    * @param $actual
index ba997cca805c272b69a526709303865654852348..36a48a060eafb856c60f270c638ca9168473785d 100644 (file)
@@ -25,13 +25,15 @@ class CRM_Utils_SQL_InsertTest extends CiviUnitTestCase {
         array('second' => '2b', 'first' => '1b'),
         array('first' => '1c', 'second' => '2c'),
       ))
-      ->row(array('second' => '2d', 'first' => '1d'));
+      ->row(array('second' => '2d', 'first' => '1d'))
+      ->row(array('first' => NULL, 'second' => '2e'));
     $expected = '
       INSERT INTO foo (`first`,`second`) VALUES
       ("1","2"),
       ("1b","2b"),
       ("1c","2c"),
-      ("1d","2d")
+      ("1d","2d"),
+      (NULL,"2e")
     ';
     $this->assertLike($expected, $insert->toSQL());
   }
index 5cecf42c6e768a751200b3d8e83c0293b889413c..f9ff68d3aa4f8776d4c31f21e1af9e2b6b912ad1 100644 (file)
@@ -72,6 +72,15 @@ class CRM_Utils_SQL_SelectTest extends CiviUnitTestCase {
     $this->assertLike('SELECT * FROM foo WHERE (foo = "not\\"valid") AND (whiz > "in\\"valid") AND (frob != "in\\"valid")', $select->toSQL());
   }
 
+  public function testWhereNullArg() {
+    $select = CRM_Utils_SQL_Select::from('foo')
+      ->where('foo IS @value', array('@value' => NULL))
+      ->where('nonexistent IS @nonexistent', [])
+      ->where('morenonexistent IS @nonexistent', NULL)
+      ->where('bar IS @value', array('@value' => 'null'));
+    $this->assertLike('SELECT * FROM foo WHERE (foo IS NULL) AND (nonexistent IS @nonexistent) AND (morenonexistent IS @nonexistent) AND (bar IS "null")', $select->toSQL());
+  }
+
   public function testGroupByPlain() {
     $select = CRM_Utils_SQL_Select::from('foo')
       ->groupBy("bar_id")