SearchKit - Better support for calculated fields as Afform filters
authorColeman Watts <coleman@civicrm.org>
Fri, 2 Sep 2022 14:37:39 +0000 (10:37 -0400)
committerColeman Watts <coleman@civicrm.org>
Sun, 11 Sep 2022 15:56:41 +0000 (11:56 -0400)
Improves support for calculated fields in the Afform Admin UI, allowing them to be
configured more like real fields. This allows e.g. a SUM() aggregate to be made a
"search by range" filter.

Civi/Api4/Service/Spec/SpecFormatter.php
ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php
ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js
ext/afform/admin/ang/afGuiEditor/afGuiSearch.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
ext/afform/core/Civi/Afform/AfformMetadataInjector.php
ext/afform/mock/ang/testContactEmailSearchForm.aff.html
ext/search_kit/Civi/Search/AfformSearchMetadataInjector.php
ext/search_kit/Civi/Search/Meta.php [new file with mode: 0644]
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchAfformTest.php

index 81ac207389f107335feae47479a2bb8bac5af885..0697a64b3eca8d649fd2ec26b6a7eb44e4deaa36 100644 (file)
@@ -287,6 +287,10 @@ class SpecFormatter {
     if ($inputType == 'Date' && !empty($inputAttrs['formatType'])) {
       self::setLegacyDateFormat($inputAttrs);
     }
+    // Number input for integer fields
+    if ($inputType === 'Text' && $dataTypeName === 'Int') {
+      $inputType = 'Number';
+    }
     // Date/time settings from custom fields
     if ($inputType == 'Date' && !empty($data['custom_group_id'])) {
       $inputAttrs['time'] = empty($data['time_format']) ? FALSE : ($data['time_format'] == 1 ? 12 : 24);
index 5c61ace1a5731e18ff35d41b4ad25a26d852e266..9ee74c6585bbd216b51f88487bebc340c29805bb 100644 (file)
@@ -5,7 +5,6 @@ namespace Civi\Api4\Action\Afform;
 use Civi\AfformAdmin\AfformAdminMeta;
 use Civi\Api4\Afform;
 use Civi\Api4\Utils\CoreUtil;
-use Civi\Api4\Query\SqlExpression;
 
 /**
  * This action is used by the Afform Admin extension to load metadata for the Admin GUI.
@@ -186,7 +185,10 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
         $display = $displayGet
           ->addSelect('*', 'type:name', 'type:icon', 'saved_search_id.name', 'saved_search_id.label', 'saved_search_id.api_entity', 'saved_search_id.api_params')
           ->execute()->first();
-        $display['calc_fields'] = $this->getCalcFields($display['saved_search_id.api_entity'], $display['saved_search_id.api_params']);
+        if (!$display) {
+          continue;
+        }
+        $display['calc_fields'] = \Civi\Search\Meta::getCalcFields($display['saved_search_id.api_entity'], $display['saved_search_id.api_params']);
         $display['filters'] = empty($displayTag['filters']) ? NULL : (\CRM_Utils_JS::getRawProps($displayTag['filters']) ?: NULL);
         $info['search_displays'][] = $display;
         if ($newForm) {
@@ -264,51 +266,6 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
     }
   }
 
-  /**
-   * @param string $apiEntity
-   * @param array $apiParams
-   * @return array
-   */
-  private function getCalcFields($apiEntity, $apiParams) {
-    $calcFields = [];
-    $api = \Civi\API\Request::create($apiEntity, 'get', $apiParams);
-    $selectQuery = new \Civi\Api4\Query\Api4SelectQuery($api);
-    $joinMap = $joinCount = [];
-    foreach ($apiParams['join'] ?? [] as $join) {
-      [$entityName, $alias] = explode(' AS ', $join[0]);
-      $num = '';
-      if (!empty($joinCount[$entityName])) {
-        $num = ' ' . (++$joinCount[$entityName]);
-      }
-      else {
-        $joinCount[$entityName] = 1;
-      }
-      $label = CoreUtil::getInfoItem($entityName, 'title');
-      $joinMap[$alias] = $label . $num;
-    }
-
-    foreach ($apiParams['select'] ?? [] as $select) {
-      if (strstr($select, ' AS ')) {
-        $expr = SqlExpression::convert($select, TRUE);
-        $label = $expr::getTitle();
-        foreach ($expr->getFields() as $num => $fieldName) {
-          $field = $selectQuery->getField($fieldName);
-          $joinName = explode('.', $fieldName)[0];
-          $label .= ($num ? ', ' : ': ') . (isset($joinMap[$joinName]) ? $joinMap[$joinName] . ' ' : '') . $field['title'];
-        }
-        $calcFields[] = [
-          '#tag' => 'af-field',
-          'name' => $expr->getAlias(),
-          'defn' => [
-            'label' => $label,
-            'input_type' => 'Text',
-          ],
-        ];
-      }
-    }
-    return $calcFields;
-  }
-
   /**
    * @return array[]
    */
index 04c006921a78db0a07f0496c8c4fb6ce56c48f2b..d33e3441e2ecb770891f8dceb1f2bcbeba8e6108 100644 (file)
@@ -14,6 +14,7 @@
       $scope.controls = {};
       $scope.fieldList = [];
       $scope.calcFieldList = [];
+      $scope.calcFieldTitles = [];
       $scope.blockList = [];
       $scope.blockTitles = [];
       $scope.elementList = [];
@@ -29,7 +30,7 @@
           fieldGroups.push({
             text: ts('Calculated Fields'),
             children: _.transform(ctrl.display.settings.calc_fields, function(fields, el) {
-              fields.push({id: el.name, text: el.defn.label, disabled: ctrl.fieldInUse(el.name)});
+              fields.push({id: el.name, text: el.label, disabled: ctrl.fieldInUse(el.name)});
             }, [])
           });
         }
         return entity || ctrl.display.settings['saved_search_id.api_entity'];
       };
 
+      function fieldDefaults(field, prefix) {
+        var tag = {
+          "#tag": "af-field",
+          name: prefix + field.name
+        };
+        if (field.input_type === 'Select' || field.input_type === 'ChainSelect') {
+          tag.defn = {input_attrs: {multiple: true}};
+        } else if (field.input_type === 'Date') {
+          tag.defn = {input_type: 'Select', search_range: true};
+        } else if (field.options) {
+          tag.defn = {input_type: 'Select', input_attrs: {multiple: true}};
+        }
+        return tag;
+      }
+
       function buildCalcFieldList(search) {
         $scope.calcFieldList.length = 0;
+        $scope.calcFieldTitles.length = 0;
         _.each(_.cloneDeep(ctrl.display.settings.calc_fields), function(field) {
-          if (!search || _.contains(field.defn.label.toLowerCase(), search)) {
-            $scope.calcFieldList.push(field);
+          if (!search || _.contains(field.label.toLowerCase(), search)) {
+            $scope.calcFieldList.push(fieldDefaults(field, ''));
+            $scope.calcFieldTitles.push(field.label);
           }
         });
       }
             }
           }, []);
         }
-
-        function fieldDefaults(field, prefix) {
-          var tag = {
-            "#tag": "af-field",
-            name: prefix + field.name
-          };
-          if (field.input_type === 'Select' || field.input_type === 'ChainSelect') {
-            tag.defn = {input_attrs: {multiple: true}};
-          } else if (field.input_type === 'Date') {
-            tag.defn = {input_type: 'Select', search_range: true};
-          } else if (field.options) {
-            tag.defn = {input_type: 'Select', input_attrs: {multiple: true}};
-          }
-          return tag;
-        }
       }
 
       function buildElementList(search) {
index 76e42ba6b8efaa37aadcfcb1ec1954dfc59439ca..1d1001f88815fa3816450bb6b4f86fc4c9072c1f 100644 (file)
@@ -61,7 +61,7 @@
         <label>{{:: ts('Calculated Fields') }}</label>
         <div ui-sortable="$ctrl.editor.getSortableOptions($ctrl.editor.getSelectedEntityName())" ui-sortable-update="buildPaletteLists" ng-model="calcFieldList">
           <div ng-repeat="field in calcFieldList" ng-class="{disabled: $ctrl.fieldInUse(field.name)}">
-            <div class="af-gui-palette-item">{{:: field.defn.label }}</div>
+            <div class="af-gui-palette-item">{{:: calcFieldTitles[$index] }}</div>
           </div>
         </div>
       </div>
index ba9d313f95770485720236b07f3b39345f4b2e34..a61c47e1447f144337d570d72723d432b43cd8a5 100644 (file)
               }
             });
           }
-          if (!entityType && fieldKey && afGui.getField(searchDisplay['saved_search_id.api_entity'], fieldKey)) {
+          if (!entityType) {
             entityType = searchDisplay['saved_search_id.api_entity'];
           }
         }
index 8de37bbf5fc1b8c9ce4198d51191cdb5b92c8a44..882ea3fd1a91996f7539adcfd39ee947e77430a3 100644 (file)
       // Returns the original field definition from metadata
       this.getDefn = function() {
         var defn = afGui.getField(ctrl.container.getFieldEntityType(ctrl.node.name), ctrl.node.name);
+        // Calc fields are specific to a search display, not part of the schema
+        if (!defn && ctrl.container.getSearchDisplay(ctrl.container.node)) {
+          var searchDisplay = ctrl.container.getSearchDisplay(ctrl.container.node);
+          defn = _.findWhere(searchDisplay.calc_fields, {name: ctrl.node.name});
+        }
         defn = defn || {
           label: ts('Untitled'),
           required: false
index 88c602bdc1a0fd5b2c65849225b9873d483efedd..c2b128c62f6676486573d79a891efc5ab09b2362 100644 (file)
@@ -80,77 +80,101 @@ class AfformMetadataInjector {
   }
 
   /**
-   * Merge field definition metadata into an afform field's definition
-   *
-   * @param string|array $entityNames
+   * @param $entityNames
    * @param string $action
-   * @param \DOMElement $afField
-   * @throws \API_Exception
+   * @param string $fieldName
+   * @return array|null
    */
-  private static function fillFieldMetadata($entityNames, $action, \DOMElement $afField) {
-    $fieldName = $afField->getAttribute('name');
+  private static function getFieldMetadata($entityNames, string $action, string $fieldName):? array {
     foreach ((array) $entityNames as $entityName) {
       $fieldInfo = self::getField($entityName, $fieldName, $action);
       if ($fieldInfo) {
-        break;
+        return $fieldInfo;
       }
     }
-    // Merge field definition data with whatever's already in the markup.
+    return NULL;
+  }
+
+  /**
+   * Merge a field's definition with whatever's already in the markup
+   *
+   * @param \DOMElement $afField
+   * @param array $fieldInfo
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   * @throws \Civi\API\Exception\NotImplementedException
+   */
+  public static function setFieldMetadata(\DOMElement $afField, array $fieldInfo):void {
     $deep = ['input_attrs'];
-    if ($fieldInfo) {
-      // Defaults for attributes not in spec
-      $fieldInfo['search_range'] = FALSE;
+    // Defaults for attributes not in spec
+    $fieldInfo['search_range'] = FALSE;
 
-      $existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
-      if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
-        // If it's not an object, don't mess with it.
-        return;
-      }
+    $existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
+    if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
+      // If it's not an object, don't mess with it.
+      return;
+    }
 
-      // Get field defn from afform markup
-      $fieldDefn = $existingFieldDefn ? \CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
-      // This is the input type set on the form (may be different from the default input type in the field spec)
-      $inputType = !empty($fieldDefn['input_type']) ? \CRM_Utils_JS::decode($fieldDefn['input_type']) : $fieldInfo['input_type'];
-      // On a search form, search_range will present a pair of fields (or possibly 3 fields for date select + range)
-      $isSearchRange = !empty($fieldDefn['search_range']) && \CRM_Utils_JS::decode($fieldDefn['search_range']);
+    // Get field defn from afform markup
+    $fieldDefn = $existingFieldDefn ? \CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
+    // This is the input type set on the form (may be different from the default input type in the field spec)
+    $inputType = !empty($fieldDefn['input_type']) ? \CRM_Utils_JS::decode($fieldDefn['input_type']) : $fieldInfo['input_type'];
+    // On a search form, search_range will present a pair of fields (or possibly 3 fields for date select + range)
+    $isSearchRange = !empty($fieldDefn['search_range']) && \CRM_Utils_JS::decode($fieldDefn['search_range']);
 
-      // Default placeholder for select inputs
-      if ($inputType === 'Select' || $inputType === 'ChainSelect') {
-        $fieldInfo['input_attrs']['placeholder'] = E::ts('Select');
-      }
-      elseif ($inputType === 'EntityRef') {
-        $info = civicrm_api4('Entity', 'get', [
-          'where' => [['name', '=', $fieldInfo['fk_entity']]],
-          'checkPermissions' => FALSE,
-          'select' => ['title', 'title_plural'],
-        ], 0);
-        $label = empty($fieldInfo['input_attrs']['multiple']) ? $info['title'] : $info['title_plural'];
-        $fieldInfo['input_attrs']['placeholder'] = E::ts('Select %1', [1 => $label]);
-      }
+    // Default placeholder for select inputs
+    if ($inputType === 'Select' || $inputType === 'ChainSelect') {
+      $fieldInfo['input_attrs']['placeholder'] = E::ts('Select');
+    }
+    elseif ($inputType === 'EntityRef') {
+      $info = civicrm_api4('Entity', 'get', [
+        'where' => [['name', '=', $fieldInfo['fk_entity']]],
+        'checkPermissions' => FALSE,
+        'select' => ['title', 'title_plural'],
+      ], 0);
+      $label = empty($fieldInfo['input_attrs']['multiple']) ? $info['title'] : $info['title_plural'];
+      $fieldInfo['input_attrs']['placeholder'] = E::ts('Select %1', [1 => $label]);
+    }
 
-      if ($fieldInfo['input_type'] === 'Date') {
-        // This flag gets used by the afField controller
-        $fieldDefn['is_date'] = TRUE;
-        // For date fields that have been converted to Select
-        if ($inputType === 'Select') {
-          $dateOptions = \CRM_Utils_Array::makeNonAssociative(\CRM_Core_OptionGroup::values('relative_date_filters'), 'id', 'label');
-          if ($isSearchRange) {
-            $dateOptions = array_merge([['id' => '{}', 'label' => E::ts('Choose Date Range')]], $dateOptions);
-          }
-          $fieldInfo['options'] = $dateOptions;
+    if ($fieldInfo['input_type'] === 'Date') {
+      // This flag gets used by the afField controller
+      $fieldDefn['is_date'] = TRUE;
+      // For date fields that have been converted to Select
+      if ($inputType === 'Select') {
+        $dateOptions = \CRM_Utils_Array::makeNonAssociative(\CRM_Core_OptionGroup::values('relative_date_filters'), 'id', 'label');
+        if ($isSearchRange) {
+          $dateOptions = array_merge([['id' => '{}', 'label' => E::ts('Choose Date Range')]], $dateOptions);
         }
+        $fieldInfo['options'] = $dateOptions;
       }
+    }
 
-      foreach ($fieldInfo as $name => $prop) {
-        // Merge array props 1 level deep
-        if (in_array($name, $deep) && !empty($fieldDefn[$name])) {
-          $fieldDefn[$name] = \CRM_Utils_JS::writeObject(\CRM_Utils_JS::getRawProps($fieldDefn[$name]) + array_map(['\CRM_Utils_JS', 'encode'], $prop));
-        }
-        elseif (!isset($fieldDefn[$name])) {
-          $fieldDefn[$name] = \CRM_Utils_JS::encode($prop);
-        }
+    foreach ($fieldInfo as $name => $prop) {
+      // Merge array props 1 level deep
+      if (in_array($name, $deep) && !empty($fieldDefn[$name])) {
+        $fieldDefn[$name] = \CRM_Utils_JS::writeObject(\CRM_Utils_JS::getRawProps($fieldDefn[$name]) + array_map(['\CRM_Utils_JS', 'encode'], $prop));
       }
-      pq($afField)->attr('defn', htmlspecialchars(\CRM_Utils_JS::writeObject($fieldDefn)));
+      elseif (!isset($fieldDefn[$name])) {
+        $fieldDefn[$name] = \CRM_Utils_JS::encode($prop);
+      }
+    }
+    pq($afField)->attr('defn', htmlspecialchars(\CRM_Utils_JS::writeObject($fieldDefn)));
+  }
+
+  /**
+   * Merge field definition metadata into an afform field's definition
+   *
+   * @param string|array $entityNames
+   * @param string $action
+   * @param \DOMElement $afField
+   * @throws \API_Exception
+   */
+  private static function fillFieldMetadata($entityNames, string $action, \DOMElement $afField):void {
+    $fieldName = $afField->getAttribute('name');
+    $fieldInfo = self::getFieldMetadata($entityNames, $action, $fieldName);
+    // Merge field definition data with whatever's already in the markup.
+    if ($fieldInfo) {
+      self::setFieldMetadata($afField, $fieldInfo);
     }
   }
 
index 90de6fee163d4ab35923387fef1d5d7276645ff2..f0629148e3997bd2ca02ba7d2a25cdac0236e59e 100644 (file)
@@ -2,6 +2,7 @@
   <af-field name="source" />
   <div class="af-container af-layout-inline">
     <af-field name="Contact_Email_contact_id_01.email" />
+    <af-field name="YEAR_birth_date" defn="{search_range: true}" />
     <af-field name="Contact_Email_contact_id_01.location_type_id" defn="{input_attrs: {multiple: true}}" />
   </div>
   <crm-search-display-table filters="{last_name: 'AfformTest', contact_type: dummy_var}" search-name="TestContactEmailSearch" display-name="TestContactEmailDisplay"></crm-search-display-table>
index 478c491d2e67a0c8ee29f15485cce0da6043c811..76c48b77e18bcc523d93aaba79debc3eb3b642e0 100644 (file)
@@ -65,6 +65,13 @@ class AfformSearchMetadataInjector {
                     }
                   }
                   $fieldset->attr('api-entities', htmlspecialchars(\CRM_Utils_JS::encode($entityList)));
+                  // Add field metadata for aggregate fields because they are not in the schema.
+                  // Normal entity fields will be handled by AfformMetadataInjector
+                  foreach (Meta::getCalcFields($display['saved_search_id.api_entity'], $display['saved_search_id.api_params']) as $fieldInfo) {
+                    foreach (pq("af-field[name='{$fieldInfo['name']}']", $doc) as $afField) {
+                      \Civi\Afform\AfformMetadataInjector::setFieldMetadata($afField, $fieldInfo);
+                    }
+                  }
                 }
               }
             }
@@ -72,7 +79,6 @@ class AfformSearchMetadataInjector {
         }
       });
     $e->angular->add($changeSet);
-
   }
 
 }
diff --git a/ext/search_kit/Civi/Search/Meta.php b/ext/search_kit/Civi/Search/Meta.php
new file mode 100644 (file)
index 0000000..826fb23
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Search;
+
+use CRM_Search_ExtensionUtil as E;
+use Civi\Api4\Query\SqlExpression;
+use Civi\Api4\Utils\CoreUtil;
+
+/**
+ * Search Metadata utilities
+ * @package Civi\Search
+ */
+class Meta {
+
+  /**
+   * Get calculated fields used by a saved search
+   *
+   * @param string $apiEntity
+   * @param array $apiParams
+   * @return array
+   */
+  public static function getCalcFields($apiEntity, $apiParams): array {
+    $calcFields = [];
+    $api = \Civi\API\Request::create($apiEntity, 'get', $apiParams);
+    $selectQuery = new \Civi\Api4\Query\Api4SelectQuery($api);
+    $joinMap = $joinCount = [];
+    foreach ($apiParams['join'] ?? [] as $join) {
+      [$entityName, $alias] = explode(' AS ', $join[0]);
+      $num = '';
+      if (!empty($joinCount[$entityName])) {
+        $num = ' ' . (++$joinCount[$entityName]);
+      }
+      else {
+        $joinCount[$entityName] = 1;
+      }
+      $label = CoreUtil::getInfoItem($entityName, 'title');
+      $joinMap[$alias] = $label . $num;
+    }
+
+    $dataTypeToInputType = [
+      'Integer' => 'Number',
+      'Date' => 'Date',
+      'Timestamp' => 'Date',
+      'Boolean' => 'CheckBox',
+    ];
+
+    foreach ($apiParams['select'] ?? [] as $select) {
+      if (strstr($select, ' AS ')) {
+        $expr = SqlExpression::convert($select, TRUE);
+        $label = $expr::getTitle();
+        foreach ($expr->getFields() as $num => $fieldName) {
+          $field = $selectQuery->getField($fieldName);
+          $joinName = explode('.', $fieldName)[0];
+          $label .= ($num ? ', ' : ': ') . (isset($joinMap[$joinName]) ? $joinMap[$joinName] . ' ' : '') . $field['title'];
+        }
+        if ($expr::getDataType()) {
+          $dataType = $expr::getDataType();
+          $inputType = $dataTypeToInputType[$dataType] ?? 'Text';
+        }
+        else {
+          $dataType = $field['data_type'] ?? 'String';
+          $inputType = $field['input_type'] ?? $dataTypeToInputType[$dataType] ?? 'Text';
+        }
+
+        $calcFields[] = [
+          'name' => $expr->getAlias(),
+          'label' => $label,
+          'input_type' => $inputType,
+          'data_type' => $dataType,
+        ];
+      }
+    }
+    return $calcFields;
+  }
+
+}
index 15d5425f39c713741453183cf1438fc986ea31be..377cb8d7b99327829b2f98a32d28fa90921100e0 100644 (file)
@@ -45,6 +45,7 @@ class SearchAfformTest extends \PHPUnit\Framework\TestCase implements HeadlessIn
             'id',
             'display_name',
             'GROUP_CONCAT(DISTINCT Contact_Email_contact_id_01.email) AS GROUP_CONCAT_Contact_Email_contact_id_01_email',
+            'YEAR(birth_date) AS YEAR_birth_date',
           ],
           'orderBy' => [],
           'where' => [],
@@ -89,6 +90,12 @@ class SearchAfformTest extends \PHPUnit\Framework\TestCase implements HeadlessIn
               'dataType' => 'String',
               'type' => 'field',
             ],
+            [
+              'key' => 'YEAR_birth_date',
+              'label' => 'Contact ID',
+              'dataType' => 'Integer',
+              'type' => 'field',
+            ],
           ],
         ],
         'acl_bypass' => FALSE,
@@ -101,6 +108,7 @@ class SearchAfformTest extends \PHPUnit\Framework\TestCase implements HeadlessIn
       ->addValue('first_name', 'tester')
       ->addValue('last_name', 'AfformTest')
       ->addValue('source', 'afform_test')
+      ->addValue('birth_date', '2020-01-01')
       ->addChain('emails', Email::save()
         ->addDefault('contact_id', '$id')
         ->addRecord(['email' => $email, 'location_type_id:name' => 'Home'])
@@ -112,6 +120,7 @@ class SearchAfformTest extends \PHPUnit\Framework\TestCase implements HeadlessIn
       ->addValue('first_name', 'tester2')
       ->addValue('last_name', 'AfformTest')
       ->addValue('source', 'afform_test2')
+      ->addValue('birth_date', '2010-01-01')
       ->addChain('emails', Email::save()
         ->addDefault('contact_id', '$id')
         ->addRecord(['email' => 'other@test.com', 'location_type_id:name' => 'Other'])
@@ -162,6 +171,13 @@ class SearchAfformTest extends \PHPUnit\Framework\TestCase implements HeadlessIn
     $params['filters'] = ['Contact_Email_contact_id_01.email' => $email];
     $result = civicrm_api4('SearchDisplay', 'run', $params);
     $this->assertCount(1, $result);
+
+    // Filter by YEAR(birth_date)
+    $params['filters'] = [
+      'YEAR_birth_date' => ['>=' => 2019],
+    ];
+    $result = civicrm_api4('SearchDisplay', 'run', $params);
+    $this->assertCount(1, $result);
   }
 
   public function testRunMultipleSearchForm() {