From 3801645375b19c3cd548c4177150363ff6d5fc21 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Sun, 8 May 2022 20:13:07 -0400 Subject: [PATCH] APIv4 - Filter custom fields based on supplied values This allows targeted getfields for a particular entity or type of entity --- CRM/Core/DAO/CustomGroup.php | 9 +- Civi/Api4/Service/Spec/RequestSpec.php | 20 +- Civi/Api4/Service/Spec/SpecGatherer.php | 77 +++++- Civi/Api4/Utils/CoreUtil.php | 11 +- .../api/v4/Custom/BasicCustomFieldTest.php | 25 +- .../v4/Custom/CustomFieldGetFieldsTest.php | 222 ++++++++++++++++++ xml/schema/Core/CustomGroup.xml | 7 +- 7 files changed, 348 insertions(+), 23 deletions(-) create mode 100644 tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php diff --git a/CRM/Core/DAO/CustomGroup.php b/CRM/Core/DAO/CustomGroup.php index 56f2fa07c4..4a21ade78b 100644 --- a/CRM/Core/DAO/CustomGroup.php +++ b/CRM/Core/DAO/CustomGroup.php @@ -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', ], diff --git a/Civi/Api4/Service/Spec/RequestSpec.php b/Civi/Api4/Service/Spec/RequestSpec.php index f51c10985f..7899ebe46e 100644 --- a/Civi/Api4/Service/Spec/RequestSpec.php +++ b/Civi/Api4/Service/Spec/RequestSpec.php @@ -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; } diff --git a/Civi/Api4/Service/Spec/SpecGatherer.php b/Civi/Api4/Service/Spec/SpecGatherer.php index 55b517e833..94a4abc073 100644 --- a/Civi/Api4/Service/Spec/SpecGatherer.php +++ b/Civi/Api4/Service/Spec/SpecGatherer.php @@ -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); } diff --git a/Civi/Api4/Utils/CoreUtil.php b/Civi/Api4/Utils/CoreUtil.php index 8bb34dedce..22e87b73ce 100644 --- a/Civi/Api4/Utils/CoreUtil.php +++ b/Civi/Api4/Utils/CoreUtil.php @@ -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; diff --git a/tests/phpunit/api/v4/Custom/BasicCustomFieldTest.php b/tests/phpunit/api/v4/Custom/BasicCustomFieldTest.php index b69940e2e0..c12421fdfe 100644 --- a/tests/phpunit/api/v4/Custom/BasicCustomFieldTest.php +++ b/tests/phpunit/api/v4/Custom/BasicCustomFieldTest.php @@ -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 index 0000000000..c2074cd32d --- /dev/null +++ b/tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php @@ -0,0 +1,222 @@ +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); + } + +} diff --git a/xml/schema/Core/CustomGroup.xml b/xml/schema/Core/CustomGroup.xml index ca3d0cc5e7..fad46a5e1c 100644 --- a/xml/schema/Core/CustomGroup.xml +++ b/xml/schema/Core/CustomGroup.xml @@ -72,7 +72,8 @@ 2.2 - Select + ChainSelect + extends @@ -85,6 +86,10 @@ CRM_Core_BAO_CustomGroup::getExtendsEntityColumnValueOptions + + ChainSelect + extends_entity_column_id + 1.6 -- 2.25.1