APIv4 - Fix CONTAINS operator to work with more types of serialized fields
authorcolemanw <coleman@civicrm.org>
Sat, 27 May 2023 18:08:34 +0000 (14:08 -0400)
committercolemanw <coleman@civicrm.org>
Sat, 27 May 2023 19:06:53 +0000 (15:06 -0400)
Civi/Api4/Query/Api4SelectQuery.php
tests/phpunit/api/v4/Api4TestBase.php
tests/phpunit/api/v4/Custom/CustomContactRefTest.php
tests/phpunit/api/v4/Entity/GroupTest.php

index 0ba463c33483d1878d2c4720855214bc9345914a..7c93362e28c43817138789fdc53fa676687dcca0 100644 (file)
@@ -592,8 +592,13 @@ class Api4SelectQuery {
       }
       return $sql ? implode(' AND ', $sql) : NULL;
     }
+
+    // The CONTAINS operator matches a substring for strings. For arrays & serialized fields,
+    // it only matches a complete (not partial) string within the array.
     if ($operator === 'CONTAINS') {
+      $sep = \CRM_Core_DAO::VALUE_SEPARATOR;
       switch ($field['serialize'] ?? NULL) {
+
         case \CRM_Core_DAO::SERIALIZE_JSON:
           $operator = 'LIKE';
           $value = '%"' . $value . '"%';
@@ -603,7 +608,23 @@ class Api4SelectQuery {
 
         case \CRM_Core_DAO::SERIALIZE_SEPARATOR_BOOKEND:
           $operator = 'LIKE';
-          $value = '%' . \CRM_Core_DAO::VALUE_SEPARATOR . $value . \CRM_Core_DAO::VALUE_SEPARATOR . '%';
+          // This is easy to query because the string is always bookended by separators.
+          $value = '%' . $sep . $value . $sep . '%';
+          break;
+
+        case \CRM_Core_DAO::SERIALIZE_SEPARATOR_TRIMMED:
+          $operator = 'REGEXP';
+          // This is harder to query because there's no bookend.
+          // Use regex to match string within separators or content boundary
+          // Escaping regex per https://stackoverflow.com/questions/3782379/whats-the-best-way-to-escape-user-input-for-regular-expressions-in-mysql
+          $value = "(^|$sep)" . preg_quote($value, '&') . "($sep|$)";
+          break;
+
+        case \CRM_Core_DAO::SERIALIZE_COMMA:
+          $operator = 'REGEXP';
+          // Match string within commas or content boundary
+          // Escaping regex per https://stackoverflow.com/questions/3782379/whats-the-best-way-to-escape-user-input-for-regular-expressions-in-mysql
+          $value = '(^|,)' . preg_quote($value, '&') . '(,|$)';
           break;
 
         default:
index 8ae16a8600a2bff147e7af64350f66e8c96a33a8..44f9a7449dc70afc1dff1b15caa4e14a9a015538 100644 (file)
@@ -64,7 +64,7 @@ class Api4TestBase extends TestCase implements HeadlessInterface {
   /**
    * Quick clean by emptying tables created for the test.
    *
-   * @param array $params
+   * @param array{tablesToTruncate: array} $params
    */
   public function cleanup(array $params): void {
     $params += [
index 1e9473ee79a65a02d5f5e801e20724a4dd33da3c..2d80b306dfb506e91a365a7b544434ccf14d4d79 100644 (file)
@@ -104,7 +104,7 @@ class CustomContactRefTest extends CustomTestBase {
 
     $result = Contact::get(FALSE)
       ->addSelect('id')
-      ->addWhere('MyContactRef.FavPeople.first_name', 'CONTAINS', 'First')
+      ->addWhere('MyContactRef.FavPeople.first_name', 'CONTAINS', 'FirstFav')
       ->execute()
       ->single();
 
@@ -112,7 +112,7 @@ class CustomContactRefTest extends CustomTestBase {
 
     $result = Contact::get(FALSE)
       ->addSelect('id')
-      ->addWhere('MyContactRef.FavPeople.first_name', 'CONTAINS', 'Second')
+      ->addWhere('MyContactRef.FavPeople.first_name', 'CONTAINS', 'SecondFav')
       ->execute();
 
     $this->assertCount(2, $result);
index bfe4ab1c49b8dfd551b7d608f8cfc92ce8bdb37e..9a7730bbcc00ac071cffb1bdbc6cc4f825a62290 100644 (file)
@@ -84,6 +84,43 @@ class GroupTest extends Api4TestBase {
       ->execute();
   }
 
+  public function testParentsInWhereClause() {
+    // Create 10 groups - at least 1 id will be 2-digit and contain the number 1
+    $groups = $this->saveTestRecords('Group', [
+      'records' => array_fill(0, 10, []),
+    ]);
+
+    $child1 = $this->createTestRecord('Group', [
+      'parents' => [$groups[1]['id'], $groups[2]['id']],
+    ]);
+    $child2 = $this->createTestRecord('Group', [
+      'parents' => [$groups[8]['id']],
+    ]);
+    $child3 = $this->createTestRecord('Group', [
+      'parents' => [$groups[8]['id'], $groups[9]['id']],
+    ]);
+
+    // Check that a digit of e.g. "1" doesn't match a value of e.g. "10"
+    $firstDigit = substr($groups[9]['id'], 0, 1);
+    $found = Group::get(FALSE)
+      ->addWhere('parents', 'CONTAINS', $firstDigit)
+      ->selectRowCount()
+      ->execute();
+    $this->assertCount(0, $found);
+
+    $found = Group::get(FALSE)
+      ->addWhere('parents', 'CONTAINS', $groups[8]['id'])
+      ->selectRowCount()
+      ->execute();
+    $this->assertCount(2, $found);
+
+    $found = Group::get(FALSE)
+      ->addWhere('parents', 'CONTAINS', $groups[9]['id'])
+      ->execute();
+    $this->assertCount(1, $found);
+    $this->assertEquals($child3['id'], $found[0]['id']);
+  }
+
   public function testGetParents() {
     $parent1 = Group::create(FALSE)
       ->addValue('title', uniqid())