Add is empty filter to search / api
[civicrm-core.git] / Civi / Api4 / Query / Api4SelectQuery.php
index ad05e798c72c5582ddd1c39209ea41fdc7fc9890..e588e710853bc946af3c6f5631f5c3338b1c447c 100644 (file)
@@ -76,6 +76,11 @@ class Api4SelectQuery {
    */
   public $forceSelectId = TRUE;
 
+  /**
+   * @var array
+   */
+  private $explicitJoins = [];
+
   /**
    * @param \Civi\Api4\Generic\DAOGetAction $apiGet
    */
@@ -98,6 +103,9 @@ class Api4SelectQuery {
     // Add ACLs first to avoid redundant subclauses
     $baoName = CoreUtil::getBAOFromApiName($this->getEntity());
     $this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName));
+
+    // Add explicit joins. Other joins implied by dot notation may be added later
+    $this->addExplicitJoins();
   }
 
   /**
@@ -108,8 +116,6 @@ class Api4SelectQuery {
    * @throws \CRM_Core_Exception
    */
   public function getSql() {
-    // Add explicit joins. Other joins implied by dot notation may be added later
-    $this->addExplicitJoins();
     $this->buildSelectClause();
     $this->buildWhereClause();
     $this->buildOrderBy();
@@ -147,7 +153,6 @@ class Api4SelectQuery {
    * @throws \API_Exception
    */
   public function getCount() {
-    $this->addExplicitJoins();
     $this->buildWhereClause();
     // If no having or groupBy, we only need to select count
     if (!$this->getHaving() && !$this->getGroupBy()) {
@@ -206,10 +211,12 @@ class Api4SelectQuery {
         return strpos($item, '*') !== FALSE && strpos($item, '.') !== FALSE && strpos($item, '(') === FALSE && strpos($item, ' ') === FALSE;
       });
 
-      foreach ($wildFields as $item) {
-        $pos = array_search($item, array_values($select));
-        $this->autoJoinFK($item);
-        $matches = SelectUtil::getMatchingFields($item, array_keys($this->apiFieldSpec));
+      foreach ($wildFields as $wildField) {
+        $pos = array_search($wildField, array_values($select));
+        // If the joined_entity.id isn't in the fieldspec already, autoJoinFK will attempt to add the entity.
+        $idField = substr($wildField, 0, strrpos($wildField, '.')) . '.id';
+        $this->autoJoinFK($idField);
+        $matches = SelectUtil::getMatchingFields($wildField, array_keys($this->apiFieldSpec));
         array_splice($select, $pos, 1, $matches);
       }
       $select = array_unique($select);
@@ -268,19 +275,32 @@ class Api4SelectQuery {
       if ($dir !== 'ASC' && $dir !== 'DESC') {
         throw new \API_Exception("Invalid sort direction. Cannot order by $item $dir");
       }
-      $expr = $this->getExpression($item);
-      $column = $expr->render($this->apiFieldSpec);
-
-      // Use FIELD() function to sort on pseudoconstant values
-      $suffix = strstr($item, ':');
-      if ($suffix && $expr->getType() === 'SqlField') {
-        $field = $this->getField($item);
-        $options = FormattingUtil::getPseudoconstantList($field, substr($suffix, 1));
-        if ($options) {
-          asort($options);
-          $column = "FIELD($column,'" . implode("','", array_keys($options)) . "')";
+
+      try {
+        $expr = $this->getExpression($item);
+        $column = $expr->render($this->apiFieldSpec);
+
+        // Use FIELD() function to sort on pseudoconstant values
+        $suffix = strstr($item, ':');
+        if ($suffix && $expr->getType() === 'SqlField') {
+          $field = $this->getField($item);
+          $options = FormattingUtil::getPseudoconstantList($field, substr($suffix, 1));
+          if ($options) {
+            asort($options);
+            $column = "FIELD($column,'" . implode("','", array_keys($options)) . "')";
+          }
+        }
+      }
+      // If the expression could not be rendered, it might be a field alias
+      catch (\API_Exception $e) {
+        if (!empty($this->selectAliases[$item])) {
+          $column = '`' . $item . '`';
+        }
+        else {
+          throw new \API_Exception("Invalid field '{$item}'");
         }
       }
+
       $this->query->orderBy("$column $dir");
     }
   }
@@ -414,7 +434,7 @@ class Api4SelectQuery {
         if ($fieldName && $valExpr->getType() === 'SqlString') {
           $value = $valExpr->getExpr();
           FormattingUtil::formatInputValue($value, $fieldName, $this->apiFieldSpec[$fieldName], $operator);
-          return \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
+          return $this->createSQLClause($fieldAlias, $operator, $value, $this->apiFieldSpec[$fieldName]);
         }
         else {
           $value = $valExpr->render($this->apiFieldSpec);
@@ -427,6 +447,22 @@ class Api4SelectQuery {
       }
     }
 
+    $sqlClause = $this->createSQLClause($fieldAlias, $operator, $value, $field ?? NULL);
+    if ($sqlClause === NULL) {
+      throw new \API_Exception("Invalid value in $type clause for '$expr'");
+    }
+    return $sqlClause;
+  }
+
+  /**
+   * @param string $fieldAlias
+   * @param string $operator
+   * @param mixed $value
+   * @param array|null $field
+   * @return array|string|NULL
+   * @throws \Exception
+   */
+  protected function createSQLClause($fieldAlias, $operator, $value, $field) {
     if ($operator === 'CONTAINS') {
       switch ($field['serialize'] ?? NULL) {
         case \CRM_Core_DAO::SERIALIZE_JSON:
@@ -448,11 +484,18 @@ class Api4SelectQuery {
       }
     }
 
-    $sql_clause = \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
-    if ($sql_clause === NULL) {
-      throw new \API_Exception("Invalid value in $type clause for '$expr'");
+    if ($operator === 'IS EMPTY' || $operator === 'IS NOT EMPTY') {
+      // If field is not a string or number, this will pass through and use IS NULL/IS NOT NULL
+      $operator = str_replace('EMPTY', 'NULL', $operator);
+      // For strings & numbers, create an OR grouping of empty value OR null
+      if (in_array($field['data_type'] ?? NULL, ['String', 'Integer', 'Float'], TRUE)) {
+        $emptyVal = $field['data_type'] === 'String' ? '""' : '0';
+        $isEmptyClause = $operator === 'IS NULL' ? "= $emptyVal OR" : "<> $emptyVal AND";
+        return "($fieldAlias $isEmptyClause $fieldAlias $operator)";
+      }
     }
-    return $sql_clause;
+
+    return \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
   }
 
   /**
@@ -517,7 +560,9 @@ class Api4SelectQuery {
     if ($strict && !$field) {
       throw new \API_Exception("Invalid field '$fieldName'");
     }
-    $this->apiFieldSpec[$expr] = $field;
+    if ($field) {
+      $this->apiFieldSpec[$expr] = $field;
+    }
     return $field;
   }
 
@@ -545,7 +590,16 @@ class Api4SelectQuery {
         $field['sql_name'] = '`' . $alias . '`.`' . $field['column_name'] . '`';
         $this->addSpecField($alias . '.' . $field['name'], $field);
       }
+      $tableName = CoreUtil::getTableName($entity);
+      // Save join info to be retrieved by $this->getExplicitJoin()
+      $this->explicitJoins[$alias] = [
+        'entity' => $entity,
+        'table' => $tableName,
+        'bridge' => NULL,
+      ];
+      // If the first condition is a string, it's the name of a bridge entity
       if (!empty($join[0]) && is_string($join[0]) && \CRM_Utils_Rule::alphanumeric($join[0])) {
+        $this->explicitJoins[$alias]['bridge'] = $join[0];
         $conditions = $this->getBridgeJoin($join, $entity, $alias);
       }
       else {
@@ -554,7 +608,6 @@ class Api4SelectQuery {
       foreach (array_filter($join) as $clause) {
         $conditions[] = $this->treeWalkClauses($clause, 'ON');
       }
-      $tableName = CoreUtil::getTableName($entity);
       $this->join($side, $tableName, $alias, $conditions);
     }
   }
@@ -739,14 +792,13 @@ class Api4SelectQuery {
     $joiner = \Civi::container()->get('joiner');
     // The last item in the path is the field name. We don't care about that; we'll add all fields from the joined entity.
     array_pop($pathArray);
-    $pathString = implode('.', $pathArray);
 
-    if (!$joiner->canAutoJoin($this->getFrom(), $pathString)) {
+    try {
+      $joinPath = $joiner->autoJoin($this, $pathArray);
+    }
+    catch (\Exception $e) {
       return;
     }
-
-    $joinPath = $joiner->join($this, $pathString);
-
     $lastLink = array_pop($joinPath);
 
     // Custom field names are already prefixed
@@ -861,6 +913,14 @@ class Api4SelectQuery {
     return $this->api->getCheckPermissions();
   }
 
+  /**
+   * @param string $alias
+   * @return array|NULL
+   */
+  public function getExplicitJoin($alias) {
+    return $this->explicitJoins[$alias] ?? NULL;
+  }
+
   /**
    * @param string $path
    * @param array $field