APIv4 - Filter custom fields based on supplied values
authorColeman Watts <coleman@civicrm.org>
Mon, 9 May 2022 00:13:07 +0000 (20:13 -0400)
committerColeman Watts <coleman@civicrm.org>
Wed, 18 May 2022 18:53:42 +0000 (14:53 -0400)
This allows targeted getfields for a particular entity or type of entity

CRM/Core/DAO/CustomGroup.php
Civi/Api4/Service/Spec/RequestSpec.php
Civi/Api4/Service/Spec/SpecGatherer.php
Civi/Api4/Utils/CoreUtil.php
tests/phpunit/api/v4/Custom/BasicCustomFieldTest.php
tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php [new file with mode: 0644]
xml/schema/Core/CustomGroup.xml

index 56f2fa07c494a645d40cf8ed8e767557b61de090..4a21ade78b14caf4ffab1e7426f915b1122881e8 100644 (file)
@@ -6,7 +6,7 @@
  *
  * Generated from xml/schema/CRM/Core/CustomGroup.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:fb6ed39a4e35bd2bcdc1a6cd549f4976)
+ * (GenCodeChecksum:22712766c53c71ece90631f131f338a4)
  */
 
 /**
@@ -364,7 +364,8 @@ class CRM_Core_DAO_CustomGroup extends CRM_Core_DAO {
           'bao' => 'CRM_Core_BAO_CustomGroup',
           'localizable' => 0,
           'html' => [
-            'type' => 'Select',
+            'type' => 'ChainSelect',
+            'controlField' => 'extends',
           ],
           'pseudoconstant' => [
             'optionGroupName' => 'custom_data_type',
@@ -385,6 +386,10 @@ class CRM_Core_DAO_CustomGroup extends CRM_Core_DAO {
           'bao' => 'CRM_Core_BAO_CustomGroup',
           'localizable' => 0,
           'serialize' => self::SERIALIZE_SEPARATOR_BOOKEND,
+          'html' => [
+            'type' => 'ChainSelect',
+            'controlField' => 'extends_entity_column_id',
+          ],
           'pseudoconstant' => [
             'callback' => 'CRM_Core_BAO_CustomGroup::getExtendsEntityColumnValueOptions',
           ],
index f51c10985f9c280ec1f756121bc04d1cbaef609c..7899ebe46ef65e5a33bcc1e3b7ff61e95fd91ffb 100644 (file)
@@ -50,9 +50,23 @@ class RequestSpec implements \Iterator {
     $this->entity = $entity;
     $this->action = $action;
     $this->entityTableName = CoreUtil::getTableName($entity);
-    // Set contact_type from id if possible
-    if ($entity === 'Contact' && empty($values['contact_type']) && !empty($values['id'])) {
-      $values['contact_type'] = \CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $values['id'], 'contact_type');
+
+    // If `id` given, lookup other values needed to filter custom fields
+    $customInfo = \Civi\Api4\Utils\CoreUtil::getCustomGroupExtends($entity);
+    $idCol = $customInfo['column'] ?? NULL;
+    if ($idCol && !empty($values[$idCol])) {
+      $grouping = (array) $customInfo['grouping'];
+      $lookupNeeded = array_diff($grouping, array_keys($values));
+      if ($lookupNeeded) {
+        $record = \civicrm_api4($entity, 'get', [
+          'checkPermissions' => FALSE,
+          'where' => [[$idCol, '=', $values[$idCol]]],
+          'select' => $lookupNeeded,
+        ])->first();
+        if ($record) {
+          $values += $record;
+        }
+      }
     }
     $this->values = $values;
   }
index 55b517e8338e900b7c730c9492dce4f9a458eb80..94a4abc073a9eade95903be3c45899ac3cfc4aa0 100644 (file)
@@ -116,21 +116,74 @@ class SpecGatherer {
     if (!$customInfo) {
       return;
     }
-    // If a contact_type was passed in, exclude custom groups for other contact types
-    if ($entity === 'Contact' && $spec->getValue('contact_type')) {
-      $extends = ['Contact', $spec->getValue('contact_type')];
+    $values = $spec->getValues();
+    $extends = $customInfo['extends'];
+    $grouping = $customInfo['grouping'];
+
+    $query = CustomField::get(FALSE)
+      ->setSelect(['custom_group_id.name', 'custom_group_id.title', '*'])
+      ->addWhere('custom_group_id.is_multiple', '=', '0');
+
+    // Contact custom groups are extra complicated because contact_type can be a value for extends
+    if ($entity === 'Contact') {
+      if (array_key_exists('contact_type', $values)) {
+        $extends = ['Contact'];
+        if ($values['contact_type']) {
+          $extends[] = $values['contact_type'];
+        }
+      }
+      // Now grouping can be treated normally
+      $grouping = 'contact_sub_type';
     }
-    else {
-      $extends = $customInfo['extends'];
+    if (is_string($grouping) && array_key_exists($grouping, $values)) {
+      if (empty($values[$grouping])) {
+        $query->addWhere('custom_group_id.extends_entity_column_value', 'IS EMPTY');
+      }
+      else {
+        $clause = [];
+        foreach ((array) $values[$grouping] as $value) {
+          $clause[] = ['custom_group_id.extends_entity_column_value', 'CONTAINS', $value];
+        }
+        $query->addClause('OR', $clause);
+      }
     }
-    // FIXME: filter by entity sub-type if passed in values
-    $customFields = CustomField::get(FALSE)
-      ->addWhere('custom_group_id.extends', 'IN', $extends)
-      ->addWhere('custom_group_id.is_multiple', '=', '0')
-      ->setSelect(['custom_group_id.name', 'custom_group_id.title', '*'])
-      ->execute();
+    // Handle multiple groupings
+    // (In core, only Participant custom fields have multiple groupings)
+    elseif (is_array($grouping)) {
+      $clauses = [];
+      foreach ($grouping as $columnId => $group) {
+        if (array_key_exists($group, $values)) {
+          if (empty($values[$group])) {
+            $clauses[] = [
+              'AND',
+              [
+                ['custom_group_id.extends_entity_column_id', '=', $columnId],
+                ['custom_group_id.extends_entity_column_value', 'IS EMPTY'],
+              ],
+            ];
+          }
+          else {
+            $clause = [];
+            foreach ((array) $values[$group] as $value) {
+              $clause[] = ['custom_group_id.extends_entity_column_value', 'CONTAINS', $value];
+            }
+            $clauses[] = [
+              'AND',
+              [
+                ['custom_group_id.extends_entity_column_id', '=', $columnId],
+                ['OR', $clause],
+              ],
+            ];
+          }
+        }
+      }
+      if ($clauses) {
+        $query->addClause('OR', $clauses);
+      }
+    }
+    $query->addWhere('custom_group_id.extends', 'IN', $extends);
 
-    foreach ($customFields as $fieldArray) {
+    foreach ($query->execute() as $fieldArray) {
       $field = SpecFormatter::arrayToField($fieldArray, $entity);
       $spec->addFieldSpec($field);
     }
index 8bb34dedcef3e58af67aaa4968ea0ca4b6888ba4..22e87b73ced886e6a3f1867fe953b8c5a3609664 100644 (file)
@@ -110,9 +110,7 @@ class CoreUtil {
    * For a given API Entity, return the types of custom fields it supports and the column they join to.
    *
    * @param string $entityName
-   * @return array|mixed|null
-   * @throws \API_Exception
-   * @throws \Civi\API\Exception\UnauthorizedException
+   * @return array{extends: array, column: string, grouping: mixed}|null
    */
   public static function getCustomGroupExtends(string $entityName) {
     // Custom_group.extends pretty much maps 1-1 with entity names, except for Contact.
@@ -121,18 +119,23 @@ class CoreUtil {
         return [
           'extends' => array_merge(['Contact'], array_keys(\CRM_Core_SelectValues::contactType())),
           'column' => 'id',
+          'grouping' => ['contact_type', 'contact_sub_type'],
         ];
 
       case 'RelationshipCache':
         return [
           'extends' => ['Relationship'],
           'column' => 'relationship_id',
+          'grouping' => 'relationship_type_id',
         ];
     }
-    if (array_key_exists($entityName, \CRM_Core_SelectValues::customGroupExtends())) {
+    $customGroupExtends = array_column(\CRM_Core_BAO_CustomGroup::getCustomGroupExtendsOptions(), NULL, 'id');
+    $extendsSubGroups = \CRM_Core_BAO_CustomGroup::getExtendsEntityColumnIdOptions();
+    if (array_key_exists($entityName, $customGroupExtends)) {
       return [
         'extends' => [$entityName],
         'column' => 'id',
+        'grouping' => ($customGroupExtends[$entityName]['grouping'] ?: array_column(\CRM_Utils_Array::findAll($extendsSubGroups, ['extends' => $entityName]), 'grouping', 'id')) ?: NULL,
       ];
     }
     return NULL;
index b69940e2e0d941f79c2a44979b2eec688511fc90..c12421fdfe2ad2fac88a1890250c4eb4679c3a57 100644 (file)
@@ -20,6 +20,7 @@
 namespace api\v4\Custom;
 
 use Civi\Api4\Contact;
+use Civi\Api4\Contribution;
 use Civi\Api4\CustomField;
 use Civi\Api4\CustomGroup;
 use Civi\Api4\OptionGroup;
@@ -554,12 +555,34 @@ class BasicCustomFieldTest extends CustomTestBase {
       'is_deductible' => TRUE,
       'is_reserved' => FALSE,
     ]);
+    $financialType2 = $this->createTestRecord('FinancialType', [
+      'name' => 'Fake_Type',
+      'is_deductible' => TRUE,
+      'is_reserved' => FALSE,
+    ]);
     $contributionGroup = CustomGroup::create(FALSE)
       ->addValue('extends', 'Contribution')
-      ->addValue('title', 'Contribution Fields')
+      ->addValue('title', 'Contribution_Fields')
       ->addValue('extends_entity_column_value:name', ['Test_Type'])
+      ->addChain('fields', CustomField::create()
+        ->addValue('custom_group_id', '$id')
+        ->addValue('label', 'Dummy')
+        ->addValue('html_type', 'Text')
+      )
       ->execute()->single();
     $this->assertContains($financialType['id'], $contributionGroup['extends_entity_column_value']);
+
+    $getFieldsWithTestType = Contribution::getFields(FALSE)
+      ->addValue('financial_type_id:name', 'Test_Type')
+      ->execute()->indexBy('name');
+    // Field should be included due to financial type
+    $this->assertArrayHasKey('Contribution_Fields.Dummy', $getFieldsWithTestType);
+
+    $getFieldsWithoutTestType = Contribution::getFields(FALSE)
+      ->addValue('financial_type_id:name', 'Fake_Type')
+      ->execute()->indexBy('name');
+    // Field should be excluded due to financial type
+    $this->assertArrayNotHasKey('Contribution_Fields.Dummy', $getFieldsWithoutTestType);
   }
 
   public function testExtendsParticipantMetadata() {
diff --git a/tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php b/tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php
new file mode 100644 (file)
index 0000000..c2074cd
--- /dev/null
@@ -0,0 +1,222 @@
+<?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       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+
+namespace api\v4\Custom;
+
+use Civi\Api4\Contact;
+use Civi\Api4\ContactType;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Api4\Event;
+use Civi\Api4\Participant;
+
+/**
+ * @group headless
+ */
+class CustomFieldGetFieldsTest extends CustomTestBase {
+
+  private $subTypeName = 'Sub_Tester';
+
+  public function tearDown(): void {
+    parent::tearDown();
+    Contact::delete(FALSE)
+      ->addWhere('id', '>', 0)
+      ->execute();
+    Participant::delete(FALSE)
+      ->addWhere('id', '>', 0)
+      ->execute();
+    Event::delete(FALSE)
+      ->addWhere('id', '>', 0)
+      ->execute();
+    ContactType::delete(FALSE)
+      ->addWhere('name', '=', $this->subTypeName)
+      ->execute();
+  }
+
+  public function testCustomGetFieldsWithContactSubType() {
+    ContactType::create(FALSE)
+      ->addValue('name', $this->subTypeName)
+      ->addValue('label', $this->subTypeName)
+      ->addValue('parent_id:name', 'Individual')
+      ->execute();
+
+    $contact1 = Contact::create(FALSE)
+      ->execute()->first();
+    $contact2 = Contact::create(FALSE)->addValue('contact_sub_type', [$this->subTypeName])
+      ->execute()->first();
+    $org = Contact::create(FALSE)->addValue('contact_type', 'Organization')
+      ->execute()->first();
+
+    // Individual sub-type custom group
+    CustomGroup::create(FALSE)
+      ->addValue('extends', 'Individual')
+      ->addValue('extends_entity_column_value', [$this->subTypeName])
+      ->addValue('title', 'contact_sub')
+      ->execute();
+    CustomField::create(FALSE)
+      ->addValue('custom_group_id.name', 'contact_sub')
+      ->addValue('label', 'sub_field')
+      ->addValue('html_type', 'Text')
+      ->execute();
+
+    // Organization custom group
+    CustomGroup::create(FALSE)
+      ->addValue('extends', 'Organization')
+      ->addValue('title', 'org_group')
+      ->execute();
+    CustomField::create(FALSE)
+      ->addValue('custom_group_id.name', 'org_group')
+      ->addValue('label', 'sub_field')
+      ->addValue('html_type', 'Text')
+      ->execute();
+
+    $allFields = Contact::getFields(FALSE)
+      ->execute()->indexBy('name');
+    $this->assertArrayHasKey('contact_sub.sub_field', $allFields);
+    $this->assertArrayHasKey('org_group.sub_field', $allFields);
+
+    $fieldsWithSubtype = Contact::getFields(FALSE)
+      ->addValue('id', $contact2['id'])
+      ->execute()->indexBy('name');
+    $this->assertArrayHasKey('contact_sub.sub_field', $fieldsWithSubtype);
+    $this->assertArrayNotHasKey('org_group.sub_field', $fieldsWithSubtype);
+
+    $fieldsNoSubtype = Contact::getFields(FALSE)
+      ->addValue('id', $contact1['id'])
+      ->execute()->indexBy('name');
+    $this->assertArrayNotHasKey('contact_sub.sub_field', $fieldsNoSubtype);
+    $this->assertArrayNotHasKey('org_group.sub_field', $fieldsNoSubtype);
+
+    $groupFields = Contact::getFields(FALSE)
+      ->addValue('id', $org['id'])
+      ->execute()->indexBy('name');
+    $this->assertArrayNotHasKey('contact_sub.sub_field', $groupFields);
+    $this->assertArrayHasKey('org_group.sub_field', $groupFields);
+  }
+
+  public function testCustomGetFieldsForParticipantSubTypes() {
+    $event1 = Event::create(FALSE)
+      ->addValue('title', 'Test1')
+      ->addValue('event_type_id:name', 'Meeting')
+      ->addValue('start_date', 'now')
+      ->execute()->first();
+    $event2 = Event::create(FALSE)
+      ->addValue('title', 'Test2')
+      ->addValue('event_type_id:name', 'Meeting')
+      ->addValue('start_date', 'now')
+      ->execute()->first();
+    $event3 = Event::create(FALSE)
+      ->addValue('title', 'Test3')
+      ->addValue('event_type_id:name', 'Conference')
+      ->addValue('start_date', 'now')
+      ->execute()->first();
+    $event4 = Event::create(FALSE)
+      ->addValue('title', 'Test4')
+      ->addValue('event_type_id:name', 'Fundraiser')
+      ->addValue('start_date', 'now')
+      ->execute()->first();
+
+    $cid = Contact::create(FALSE)->execute()->single()['id'];
+
+    $sampleData = [
+      ['event_id' => $event1['id'], 'role_id:name' => ['Attendee']],
+      ['event_id' => $event2['id'], 'role_id:name' => ['Attendee', 'Volunteer']],
+      ['event_id' => $event3['id'], 'role_id:name' => ['Attendee']],
+      ['event_id' => $event4['id'], 'role_id:name' => ['Host']],
+    ];
+    $participants = Participant::save(FALSE)
+      ->addDefault('contact_id', $cid)
+      ->addDefault('status_id:name', 'Registered')
+      ->setRecords($sampleData)
+      ->execute();
+
+    // CustomGroup based on Event Type
+    CustomGroup::create(FALSE)
+      ->addValue('extends', 'Participant')
+      ->addValue('extends_entity_column_id:name', 'ParticipantEventType')
+      ->addValue('extends_entity_column_value:name', ['Meeting', 'Conference'])
+      ->addValue('title', 'meeting_conference')
+      ->addChain('field', CustomField::create()
+        ->addValue('custom_group_id', '$id')
+        ->addValue('label', 'sub_field')
+        ->addValue('html_type', 'Text')
+      )
+      ->execute();
+
+    // CustomGroup based on Participant Status
+    CustomGroup::create(FALSE)
+      ->addValue('extends', 'Participant')
+      ->addValue('extends_entity_column_id:name', 'ParticipantRole')
+      ->addValue('extends_entity_column_value:name', ['Volunteer', 'Host'])
+      ->addValue('title', 'volunteer_host')
+      ->addChain('field', CustomField::create()
+        ->addValue('custom_group_id', '$id')
+        ->addValue('label', 'sub_field')
+        ->addValue('html_type', 'Text')
+      )
+      ->execute();
+
+    // CustomGroup based on Specific Events
+    CustomGroup::create(FALSE)
+      ->addValue('extends', 'Participant')
+      ->addValue('extends_entity_column_id:name', 'ParticipantEventName')
+      ->addValue('extends_entity_column_value', [$event2['id'], $event3['id']])
+      ->addValue('title', 'event_2_and_3')
+      ->addChain('field', CustomField::create()
+        ->addValue('custom_group_id', '$id')
+        ->addValue('label', 'sub_field')
+        ->addValue('html_type', 'Text')
+      )
+      ->execute();
+
+    $allFields = Participant::getFields(FALSE)->execute()->indexBy('name');
+    $this->assertArrayHasKey('meeting_conference.sub_field', $allFields);
+    $this->assertArrayHasKey('volunteer_host.sub_field', $allFields);
+    $this->assertArrayHasKey('event_2_and_3.sub_field', $allFields);
+
+    $participant0Fields = Participant::getFields(FALSE)
+      ->addValue('id', $participants[0]['id'])
+      ->execute()->indexBy('name');
+    $this->assertArrayHasKey('meeting_conference.sub_field', $participant0Fields);
+    $this->assertArrayNotHasKey('volunteer_host.sub_field', $participant0Fields);
+    $this->assertArrayNotHasKey('event_2_and_3.sub_field', $participant0Fields);
+
+    $participant1Fields = Participant::getFields(FALSE)
+      ->addValue('id', $participants[1]['id'])
+      ->execute()->indexBy('name');
+    $this->assertArrayHasKey('meeting_conference.sub_field', $participant1Fields);
+    $this->assertArrayHasKey('volunteer_host.sub_field', $participant1Fields);
+    $this->assertArrayHasKey('event_2_and_3.sub_field', $participant1Fields);
+
+    $participant2Fields = Participant::getFields(FALSE)
+      ->addValue('id', $participants[2]['id'])
+      ->execute()->indexBy('name');
+    $this->assertArrayHasKey('meeting_conference.sub_field', $participant2Fields);
+    $this->assertArrayNotHasKey('volunteer_host.sub_field', $participant2Fields);
+    $this->assertArrayHasKey('event_2_and_3.sub_field', $participant2Fields);
+
+    $participant3Fields = Participant::getFields(FALSE)
+      ->addValue('id', $participants[3]['id'])
+      ->execute()->indexBy('name');
+    $this->assertArrayNotHasKey('meeting_conference.sub_field', $participant3Fields);
+    $this->assertArrayHasKey('volunteer_host.sub_field', $participant3Fields);
+    $this->assertArrayNotHasKey('event_3_and_3.sub_field', $participant3Fields);
+  }
+
+}
index ca3d0cc5e775e5fffdf16db7cb200a7b38f3c25f..fad46a5e1cead6119f111b6e6c880316045e95b1 100644 (file)
@@ -72,7 +72,8 @@
     </pseudoconstant>
     <add>2.2</add>
     <html>
-      <type>Select</type>
+      <type>ChainSelect</type>
+      <controlField>extends</controlField>
     </html>
   </field>
   <field>
     <pseudoconstant>
       <callback>CRM_Core_BAO_CustomGroup::getExtendsEntityColumnValueOptions</callback>
     </pseudoconstant>
+    <html>
+      <type>ChainSelect</type>
+      <controlField>extends_entity_column_id</controlField>
+    </html>
     <add>1.6</add>
   </field>
   <field>