From d176ac0576d4cdefe1e0de648c81ccc9ca969cff Mon Sep 17 00:00:00 2001 From: colemanw Date: Wed, 27 Sep 2023 15:52:35 -0400 Subject: [PATCH] APIv4 - Add Individual, Household, Organization pseudo-entities These behave exactly as the Contact entity but with a pre-set value for contact_type. Includes tests to ensure joins, ACLs and permissions work. --- CRM/Contact/BAO/ContactType.php | 12 +++- CRM/Contact/DAO/Contact.php | 3 +- CRM/Core/BAO/CustomGroup.php | 3 +- Civi/Api4/Action/Contact/GetDuplicates.php | 7 ++- Civi/Api4/Contact.php | 45 +++++++++++--- Civi/Api4/CustomValue.php | 9 ++- Civi/Api4/Entity.php | 5 ++ Civi/Api4/Generic/AbstractEntity.php | 11 +++- Civi/Api4/Generic/DAOGetFieldsAction.php | 8 +++ Civi/Api4/Generic/Traits/DAOActionTrait.php | 7 +++ Civi/Api4/Household.php | 27 ++++++++ Civi/Api4/Individual.php | 26 ++++++++ Civi/Api4/Organization.php | 26 ++++++++ Civi/Api4/Query/Api4SelectQuery.php | 11 ++++ .../ActivityAutocompleteProvider.php | 3 +- .../Autocomplete/CaseAutocompleteProvider.php | 3 +- .../ContactAutocompleteProvider.php | 3 +- .../Spec/Provider/ContactGetSpecProvider.php | 15 ++--- Civi/Api4/Service/Spec/SpecGatherer.php | 26 ++++---- Civi/Api4/Utils/CoreUtil.php | 30 +++++++-- .../phpunit/api/v4/Action/ContactAclTest.php | 42 +++++++++++-- .../phpunit/api/v4/Action/ContactGetTest.php | 61 +++++++++++++++++++ .../api/v4/Action/ContactIsDeletedTest.php | 40 ++++++------ .../api/v4/Action/GetExtraFieldsTest.php | 37 +++++------ tests/phpunit/api/v4/Api4TestBase.php | 2 +- .../v4/Custom/CustomFieldGetFieldsTest.php | 29 ++++++++- .../api/v4/Custom/CustomGroupACLTest.php | 28 ++++++++- .../phpunit/api/v4/Entity/ContactTypeTest.php | 33 ++++++++++ tests/phpunit/api/v4/Entity/EntityTest.php | 33 ++++++++-- xml/schema/Contact/Contact.xml | 1 - 30 files changed, 484 insertions(+), 102 deletions(-) create mode 100644 Civi/Api4/Household.php create mode 100644 Civi/Api4/Individual.php create mode 100644 Civi/Api4/Organization.php diff --git a/CRM/Contact/BAO/ContactType.php b/CRM/Contact/BAO/ContactType.php index cec5abe030..de18235c96 100644 --- a/CRM/Contact/BAO/ContactType.php +++ b/CRM/Contact/BAO/ContactType.php @@ -69,7 +69,7 @@ class CRM_Contact_BAO_ContactType extends CRM_Contact_DAO_ContactType implements * @throws \CRM_Core_Exception * @throws \Civi\API\Exception\UnauthorizedException */ - public static function basicTypes($all = FALSE) { + public static function basicTypes($all = FALSE): array { return array_keys(self::basicTypeInfo($all)); } @@ -859,6 +859,16 @@ WHERE ($subtypeClause)"; return $contactTypes; } + /** + * Get contact type by name + * + * @param string $name + * @return array|null + */ + public static function getContactType(string $name): ?array { + return self::getAllContactTypes()[$name] ?? NULL; + } + /** * @param string $entityName * @param string $action diff --git a/CRM/Contact/DAO/Contact.php b/CRM/Contact/DAO/Contact.php index edae63298b..af30aab877 100644 --- a/CRM/Contact/DAO/Contact.php +++ b/CRM/Contact/DAO/Contact.php @@ -6,7 +6,7 @@ * * Generated from xml/schema/CRM/Contact/Contact.xml * DO NOT EDIT. Generated by CRM_Core_CodeGen - * (GenCodeChecksum:4066902548c1bc5ba4358b9e0195a3fa) + * (GenCodeChecksum:0470d0df786c3ac33a435555a3d026fb) */ /** @@ -604,7 +604,6 @@ class CRM_Contact_DAO_Contact extends CRM_Core_DAO { ], 'where' => 'civicrm_contact.contact_type', 'export' => TRUE, - 'contactType' => NULL, 'table_name' => 'civicrm_contact', 'entity' => 'Contact', 'bao' => 'CRM_Contact_BAO_Contact', diff --git a/CRM/Core/BAO/CustomGroup.php b/CRM/Core/BAO/CustomGroup.php index b9b82c5900..0c9dfe9d21 100644 --- a/CRM/Core/BAO/CustomGroup.php +++ b/CRM/Core/BAO/CustomGroup.php @@ -2433,8 +2433,7 @@ SELECT civicrm_custom_group.id as groupID, civicrm_custom_group.title as groupT /** * List all possible values for `CustomGroup.extends`. * - * This includes the fake entities "Individual", "Organization", "Household" - * but not the extra options from `custom_data_type` used on the form ("ParticipantStatus", etc). + * This includes the pseudo-entities "Individual", "Organization", "Household". * * Returns a mix of hard-coded array and `cg_extend_objects` OptionValues. * - 'id' return key (maps to `cg_extend_objects.value`). diff --git a/Civi/Api4/Action/Contact/GetDuplicates.php b/Civi/Api4/Action/Contact/GetDuplicates.php index 9829af3b03..3ac73b7819 100644 --- a/Civi/Api4/Action/Contact/GetDuplicates.php +++ b/Civi/Api4/Action/Contact/GetDuplicates.php @@ -42,7 +42,8 @@ class GetDuplicates extends \Civi\Api4\Generic\DAOCreateAction { */ protected function getRuleGroupNames() { $rules = []; - foreach (\CRM_Contact_BAO_ContactType::basicTypes() as $contactType) { + $contactTypes = $this->getEntityName() === 'Contact' ? \CRM_Contact_BAO_ContactType::basicTypes() : [$this->getEntityName()]; + foreach ($contactTypes as $contactType) { $rules[] = $contactType . '.Unsupervised'; $rules[] = $contactType . '.Supervised'; } @@ -142,14 +143,14 @@ class GetDuplicates extends \Civi\Api4\Generic\DAOCreateAction { public static function fields(BasicGetFieldsAction $action) { $fields = []; $ignore = ['id', 'contact_id', 'is_primary', 'on_hold', 'location_type_id', 'phone_type_id']; - foreach (['Contact', 'Email', 'Phone', 'Address', 'IM'] as $entity) { + foreach ([$action->getEntityName(), 'Email', 'Phone', 'Address', 'IM'] as $entity) { $entityFields = (array) civicrm_api4($entity, 'getFields', [ 'checkPermissions' => FALSE, 'action' => 'create', 'loadOptions' => $action->getLoadOptions(), 'where' => [['name', 'NOT IN', $ignore], ['type', 'IN', ['Field', 'Custom']]], ]); - if ($entity !== 'Contact') { + if ($entity !== $action->getEntityName()) { $prefix = strtolower($entity) . '_primary.'; foreach ($entityFields as &$field) { $field['name'] = $prefix . $field['name']; diff --git a/Civi/Api4/Contact.php b/Civi/Api4/Contact.php index eeb05eb9a1..2645674fef 100644 --- a/Civi/Api4/Contact.php +++ b/Civi/Api4/Contact.php @@ -33,7 +33,7 @@ class Contact extends Generic\DAOEntity { * @return Action\Contact\Create */ public static function create($checkPermissions = TRUE) { - return (new Action\Contact\Create(__CLASS__, __FUNCTION__)) + return (new Action\Contact\Create(self::getEntityName(), __FUNCTION__)) ->setCheckPermissions($checkPermissions); } @@ -42,7 +42,7 @@ class Contact extends Generic\DAOEntity { * @return Action\Contact\Update */ public static function update($checkPermissions = TRUE) { - return (new Action\Contact\Update(__CLASS__, __FUNCTION__)) + return (new Action\Contact\Update(self::getEntityName(), __FUNCTION__)) ->setCheckPermissions($checkPermissions); } @@ -51,7 +51,7 @@ class Contact extends Generic\DAOEntity { * @return Action\Contact\Save */ public static function save($checkPermissions = TRUE) { - return (new Action\Contact\Save(__CLASS__, __FUNCTION__)) + return (new Action\Contact\Save(self::getEntityName(), __FUNCTION__)) ->setCheckPermissions($checkPermissions); } @@ -60,7 +60,7 @@ class Contact extends Generic\DAOEntity { * @return Action\Contact\Delete */ public static function delete($checkPermissions = TRUE) { - return (new Action\Contact\Delete(__CLASS__, __FUNCTION__)) + return (new Action\Contact\Delete(self::getEntityName(), __FUNCTION__)) ->setCheckPermissions($checkPermissions); } @@ -69,7 +69,7 @@ class Contact extends Generic\DAOEntity { * @return Action\Contact\GetChecksum */ public static function getChecksum($checkPermissions = TRUE) { - return (new Action\Contact\GetChecksum(__CLASS__, __FUNCTION__)) + return (new Action\Contact\GetChecksum(self::getEntityName(), __FUNCTION__)) ->setCheckPermissions($checkPermissions); } @@ -78,7 +78,7 @@ class Contact extends Generic\DAOEntity { * @return Action\Contact\ValidateChecksum */ public static function validateChecksum($checkPermissions = TRUE) { - return (new Action\Contact\ValidateChecksum(__CLASS__, __FUNCTION__)) + return (new Action\Contact\ValidateChecksum(self::getEntityName(), __FUNCTION__)) ->setCheckPermissions($checkPermissions); } @@ -87,7 +87,7 @@ class Contact extends Generic\DAOEntity { * @return Action\Contact\GetDuplicates */ public static function getDuplicates($checkPermissions = TRUE) { - return (new Action\Contact\GetDuplicates(__CLASS__, __FUNCTION__)) + return (new Action\Contact\GetDuplicates(self::getEntityName(), __FUNCTION__)) ->setCheckPermissions($checkPermissions); } @@ -96,8 +96,37 @@ class Contact extends Generic\DAOEntity { * @return Action\Contact\MergeDuplicates */ public static function mergeDuplicates($checkPermissions = TRUE) { - return (new Action\Contact\MergeDuplicates(__CLASS__, __FUNCTION__)) + return (new Action\Contact\MergeDuplicates(self::getEntityName(), __FUNCTION__)) ->setCheckPermissions($checkPermissions); } + protected static function getDaoName(): string { + // Child classes (Individual, Organization, Household) need this. + return 'CRM_Contact_DAO_Contact'; + } + + /** + * @inheritDoc + */ + public static function getInfo(): array { + $info = parent::getInfo(); + $contactType = static::getEntityName(); + // Adjust info for child classes (Individual, Organization, Household) + if ($contactType !== 'Contact') { + $info['icon'] = \CRM_Contact_BAO_ContactType::getContactType($contactType)['icon'] ?? $info['icon']; + $info['type'] = ['DAOEntity', 'ContactType']; + // This forces the value into get and create api actions + $info['where'] = ['contact_type' => $contactType]; + } + return $info; + } + + /** + * @inheritDoc + */ + public static function permissions() { + $permissions = \CRM_Core_Permission::getEntityActionPermissions(); + return ($permissions['contact'] ?? []) + $permissions['default']; + } + } diff --git a/Civi/Api4/CustomValue.php b/Civi/Api4/CustomValue.php index 2ea7a3274a..4201d3397a 100644 --- a/Civi/Api4/CustomValue.php +++ b/Civi/Api4/CustomValue.php @@ -135,6 +135,13 @@ class CustomValue { ]; } + /** + * @return \CRM_Core_DAO|string|null + */ + protected static function getDaoName(): ?string { + return 'CRM_Core_BAO_CustomValue'; + } + /** * @see \Civi\Api4\Generic\AbstractEntity::getInfo() * @return array @@ -145,7 +152,7 @@ class CustomValue { 'type' => ['CustomValue', 'DAOEntity'], 'searchable' => 'secondary', 'primary_key' => ['id'], - 'dao' => 'CRM_Core_BAO_CustomValue', + 'dao' => self::getDaoName(), 'see' => [ 'https://docs.civicrm.org/user/en/latest/organising-your-data/creating-custom-fields/#multiple-record-fieldsets', '\Civi\Api4\CustomGroup', diff --git a/Civi/Api4/Entity.php b/Civi/Api4/Entity.php index fb2b4780e9..0ceac96d6b 100644 --- a/Civi/Api4/Entity.php +++ b/Civi/Api4/Entity.php @@ -118,6 +118,11 @@ class Entity extends Generic\AbstractEntity { 'data_type' => 'Array', 'description' => 'Arguments needed by php action factory functions (used when multiple entities share a class, e.g. CustomValue).', ], + [ + 'name' => 'where', + 'data_type' => 'Array', + 'description' => 'Constant values which will be force-set when reading/writing this entity (e.g. [contact_type => Individual])', + ], [ 'name' => 'bridge', 'data_type' => 'Array', diff --git a/Civi/Api4/Generic/AbstractEntity.php b/Civi/Api4/Generic/AbstractEntity.php index 5caca47384..26dd7b0e1d 100644 --- a/Civi/Api4/Generic/AbstractEntity.php +++ b/Civi/Api4/Generic/AbstractEntity.php @@ -87,7 +87,7 @@ abstract class AbstractEntity { */ protected static function getEntityTitle(bool $plural = FALSE): string { $name = static::getEntityName(); - $dao = \CRM_Core_DAO_AllCoreTables::getFullName($name); + $dao = self::getDaoName(); return $dao ? $dao::getEntityTitle($plural) : ($plural ? \CRM_Utils_String::pluralize($name) : $name); } @@ -116,6 +116,13 @@ abstract class AbstractEntity { return $actionObject; } + /** + * @return \CRM_Core_DAO|string|null + */ + protected static function getDaoName(): ?string { + return \CRM_Core_DAO_AllCoreTables::getFullName(static::getEntityName()); + } + /** * Reflection function called by Entity::get() * @@ -137,7 +144,7 @@ abstract class AbstractEntity { 'searchable' => 'secondary', ]; // Add info for entities with a corresponding DAO - $dao = \CRM_Core_DAO_AllCoreTables::getFullName($info['name']); + $dao = static::getDaoName(); if ($dao) { $info['paths'] = $dao::getEntityPaths(); $info['primary_key'] = $dao::$_primaryKey; diff --git a/Civi/Api4/Generic/DAOGetFieldsAction.php b/Civi/Api4/Generic/DAOGetFieldsAction.php index a3e2072d4c..8b4a5655ff 100644 --- a/Civi/Api4/Generic/DAOGetFieldsAction.php +++ b/Civi/Api4/Generic/DAOGetFieldsAction.php @@ -29,6 +29,14 @@ class DAOGetFieldsAction extends BasicGetFieldsAction { protected function getRecords() { $fieldsToGet = $this->_itemsToGet('name'); $typesToGet = $this->_itemsToGet('type'); + // Force-set values supplied by entity definition + // e.g. if this is a ContactType pseudo-entity, set `contact_type` value which is used by the following: + // @see \Civi\Api4\Service\Spec\Provider\ContactGetSpecProvider + // @see \Civi\Api4\Service\Spec\SpecGatherer::addDAOFields + $presetValues = CoreUtil::getInfoItem($this->getEntityName(), 'where') ?? []; + foreach ($presetValues as $presetField => $presetValue) { + $this->addValue($presetField, $presetValue); + } /** @var \Civi\Api4\Service\Spec\SpecGatherer $gatherer */ $gatherer = \Civi::container()->get('spec_gatherer'); $includeCustom = TRUE; diff --git a/Civi/Api4/Generic/Traits/DAOActionTrait.php b/Civi/Api4/Generic/Traits/DAOActionTrait.php index 0f6c5cf8cd..feabd9bc49 100644 --- a/Civi/Api4/Generic/Traits/DAOActionTrait.php +++ b/Civi/Api4/Generic/Traits/DAOActionTrait.php @@ -111,6 +111,9 @@ trait DAOActionTrait { } } + // Values specified by entity definition (e.g. 'Individual', 'Organization', 'Household' pseudo-entities specify `contact_type`) + $presetValues = CoreUtil::getInfoItem($this->getEntityName(), 'where') ?? []; + $result = []; $idField = CoreUtil::getIdFieldName($this->getEntityName()); @@ -119,6 +122,10 @@ trait DAOActionTrait { FormattingUtil::formatWriteParams($item, $this->entityFields()); $this->formatCustomParams($item, $entityId); + if (!$entityId) { + $item = $presetValues + $item; + } + // Adjust weights for sortable entities if ($updateWeights) { $this->updateWeight($item); diff --git a/Civi/Api4/Household.php b/Civi/Api4/Household.php new file mode 100644 index 0000000000..10fd4acb60 --- /dev/null +++ b/Civi/Api4/Household.php @@ -0,0 +1,27 @@ +query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $this->getEntity(), [], $this->getWhere())); + // Add required conditions if specified by entity + $requiredConditions = CoreUtil::getInfoItem($this->getEntity(), 'where') ?? []; + foreach ($requiredConditions as $requiredField => $requiredValue) { + $this->api->addWhere($requiredField, '=', $requiredValue); + } + // Add explicit joins. Other joins implied by dot notation may be added later $this->addExplicitJoins(); } @@ -467,6 +473,11 @@ class Api4SelectQuery extends Api4Query { $side = 'LEFT'; $this->api->addWhere("$alias.id", 'IS NULL'); } + // Add required conditions if specified by entity + $requiredConditions = CoreUtil::getInfoItem($entity, 'where') ?? []; + foreach ($requiredConditions as $requiredField => $requiredValue) { + $join[] = [$alias . '.' . $requiredField, '=', "'$requiredValue'"]; + } // Add all fields from joined entity to spec $joinEntityGet = \Civi\API\Request::create($entity, 'get', ['version' => 4, 'checkPermissions' => $this->getCheckPermissions()]); $joinEntityFields = $joinEntityGet->entityFields(); diff --git a/Civi/Api4/Service/Autocomplete/ActivityAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/ActivityAutocompleteProvider.php index 843ef5fea3..70d305e359 100644 --- a/Civi/Api4/Service/Autocomplete/ActivityAutocompleteProvider.php +++ b/Civi/Api4/Service/Autocomplete/ActivityAutocompleteProvider.php @@ -12,6 +12,7 @@ namespace Civi\Api4\Service\Autocomplete; +use Civi\Api4\Utils\CoreUtil; use Civi\Core\Event\GenericHookEvent; use Civi\Core\HookInterface; @@ -95,7 +96,7 @@ class ActivityAutocompleteProvider extends \Civi\Core\Service\AutoService implem // If the savedSearch includes a contact join, add it to the output and the sort. foreach ($e->savedSearch['api_params']['join'] ?? [] as $join) { [$entity, $contactAlias] = explode(' AS ', $join[0]); - if ($entity === 'Contact') { + if (CoreUtil::isContact($entity)) { array_unshift($e->display['settings']['sort'], ["$contactAlias.sort_name", 'ASC']); $e->display['settings']['columns'][0]['rewrite'] = "[$contactAlias.sort_name] - [subject]"; $e->display['settings']['columns'][0]['empty_value'] = "[$contactAlias.sort_name] (" . ts('no subject') . ')'; diff --git a/Civi/Api4/Service/Autocomplete/CaseAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/CaseAutocompleteProvider.php index e28e6fbaf9..e3a4c7c956 100644 --- a/Civi/Api4/Service/Autocomplete/CaseAutocompleteProvider.php +++ b/Civi/Api4/Service/Autocomplete/CaseAutocompleteProvider.php @@ -12,6 +12,7 @@ namespace Civi\Api4\Service\Autocomplete; +use Civi\Api4\Utils\CoreUtil; use Civi\Core\Event\GenericHookEvent; use Civi\Core\HookInterface; @@ -92,7 +93,7 @@ class CaseAutocompleteProvider extends \Civi\Core\Service\AutoService implements // If the savedSearch includes a contact join, add it to the output and the sort. foreach ($e->savedSearch['api_params']['join'] ?? [] as $join) { [$entity, $contactAlias] = explode(' AS ', $join[0]); - if ($entity === 'Contact') { + if (CoreUtil::isContact($entity)) { array_unshift($e->display['settings']['sort'], ["$contactAlias.sort_name", 'ASC']); $e->display['settings']['columns'][0]['rewrite'] = "[$contactAlias.sort_name] - [subject]"; $e->display['settings']['columns'][0]['empty_value'] = "[$contactAlias.sort_name] (" . ts('no subject') . ')'; diff --git a/Civi/Api4/Service/Autocomplete/ContactAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/ContactAutocompleteProvider.php index b676baa692..27440a7824 100644 --- a/Civi/Api4/Service/Autocomplete/ContactAutocompleteProvider.php +++ b/Civi/Api4/Service/Autocomplete/ContactAutocompleteProvider.php @@ -13,6 +13,7 @@ namespace Civi\Api4\Service\Autocomplete; use Civi\API\Event\PrepareEvent; +use Civi\Api4\Utils\CoreUtil; use Civi\Core\Event\GenericHookEvent; use Civi\Core\HookInterface; @@ -49,7 +50,7 @@ class ContactAutocompleteProvider extends \Civi\Core\Service\AutoService impleme * @param \Civi\Core\Event\GenericHookEvent $e */ public static function on_civi_search_defaultDisplay(GenericHookEvent $e) { - if ($e->display['settings'] || $e->display['type'] !== 'autocomplete' || $e->savedSearch['api_entity'] !== 'Contact') { + if ($e->display['settings'] || $e->display['type'] !== 'autocomplete' || !CoreUtil::isContact($e->savedSearch['api_entity'])) { return; } $e->display['settings'] = [ diff --git a/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php b/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php index 1b01bc1154..9d630092b9 100644 --- a/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php +++ b/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php @@ -15,6 +15,7 @@ namespace Civi\Api4\Service\Spec\Provider; use Civi\Api4\Query\Api4SelectQuery; use Civi\Api4\Service\Spec\FieldSpec; use Civi\Api4\Service\Spec\RequestSpec; +use Civi\Api4\Utils\CoreUtil; /** * @service @@ -27,7 +28,7 @@ class ContactGetSpecProvider extends \Civi\Core\Service\AutoService implements G */ public function modifySpec(RequestSpec $spec) { // Groups field - $field = new FieldSpec('groups', 'Contact', 'Array'); + $field = new FieldSpec('groups', $spec->getEntity(), 'Array'); $field->setLabel(ts('In Groups')) ->setTitle(ts('Groups')) ->setColumnName('id') @@ -40,11 +41,10 @@ class ContactGetSpecProvider extends \Civi\Core\Service\AutoService implements G ->setOptionsCallback([__CLASS__, 'getGroupList']); $spec->addFieldSpec($field); - // The following fields are specific to Individuals, so omit them if - // `contact_type` value was passed to `getFields` and is not "Individual" + // The following fields are specific to Individuals if (!$spec->getValue('contact_type') || $spec->getValue('contact_type') === 'Individual') { // Age field - $field = new FieldSpec('age_years', 'Contact', 'Integer'); + $field = new FieldSpec('age_years', $spec->getEntity(), 'Integer'); $field->setLabel(ts('Age (years)')) ->setTitle(ts('Age (years)')) ->setColumnName('birth_date') @@ -56,7 +56,7 @@ class ContactGetSpecProvider extends \Civi\Core\Service\AutoService implements G $spec->addFieldSpec($field); // Birthday field - $field = new FieldSpec('next_birthday', 'Contact', 'Integer'); + $field = new FieldSpec('next_birthday', $spec->getEntity(), 'Integer'); $field->setLabel(ts('Next Birthday in (days)')) ->setTitle(ts('Next Birthday in (days)')) ->setColumnName('birth_date') @@ -116,7 +116,7 @@ class ContactGetSpecProvider extends \Civi\Core\Service\AutoService implements G foreach ($entities as $entity => $types) { foreach ($types as $type => $info) { $name = strtolower($entity) . '_' . $type; - $field = new FieldSpec($name, 'Contact', 'Integer'); + $field = new FieldSpec($name, $spec->getEntity(), 'Integer'); $field->setLabel($info['label']) ->setTitle($info['title']) ->setColumnName('id') @@ -136,7 +136,8 @@ class ContactGetSpecProvider extends \Civi\Core\Service\AutoService implements G * @return bool */ public function applies($entity, $action) { - return $entity === 'Contact' && $action === 'get'; + // Applies to 'Contact' plus pseudo-entities 'Individual', 'Organization', 'Household' + return CoreUtil::isContact($entity) && $action === 'get'; } /** diff --git a/Civi/Api4/Service/Spec/SpecGatherer.php b/Civi/Api4/Service/Spec/SpecGatherer.php index 56fa359ef1..0e5225723d 100644 --- a/Civi/Api4/Service/Spec/SpecGatherer.php +++ b/Civi/Api4/Service/Spec/SpecGatherer.php @@ -91,7 +91,7 @@ class SpecGatherer extends AutoService { $DAOFields = $this->getDAOFields($entityName); foreach ($DAOFields as $DAOField) { - if (array_key_exists('contactType', $DAOField) && $spec->getValue('contact_type') && $DAOField['contactType'] != $spec->getValue('contact_type')) { + if (isset($DAOField['contactType']) && $spec->getValue('contact_type') && $DAOField['contactType'] !== $spec->getValue('contact_type')) { continue; } if (!empty($DAOField['component']) && !\CRM_Core_Component::isEnabled($DAOField['component'])) { @@ -146,13 +146,24 @@ class SpecGatherer extends AutoService { * @see \CRM_Core_SelectValues::customGroupExtends */ private function addCustomFields(string $entity, RequestSpec $spec, bool $checkPermissions) { + $values = $spec->getValues(); + + // Handle contact type pseudo-entities + $contactTypes = \CRM_Contact_BAO_ContactType::basicTypes(); + // If contact type is given + if ($entity === 'Contact' && !empty($values['contact_type'])) { + $entity = $values['contact_type']; + } + $customInfo = \Civi\Api4\Utils\CoreUtil::getCustomGroupExtends($entity); if (!$customInfo) { return; } - $values = $spec->getValues(); $extends = $customInfo['extends']; $grouping = $customInfo['grouping']; + if ($entity === 'Contact' || in_array($entity, $contactTypes, TRUE)) { + $grouping = 'contact_sub_type'; + } $query = CustomField::get(FALSE) ->setSelect(['custom_group_id.name', 'custom_group_id.title', '*']) @@ -169,17 +180,6 @@ class SpecGatherer extends AutoService { $query->addWhere('custom_group_id', 'IN', $allowedGroups); } - // 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'; - } if (is_string($grouping) && array_key_exists($grouping, $values)) { if (empty($values[$grouping])) { $query->addWhere('custom_group_id.extends_entity_column_value', 'IS EMPTY'); diff --git a/Civi/Api4/Utils/CoreUtil.php b/Civi/Api4/Utils/CoreUtil.php index fb5df8e6d3..725acf2369 100644 --- a/Civi/Api4/Utils/CoreUtil.php +++ b/Civi/Api4/Utils/CoreUtil.php @@ -28,10 +28,17 @@ class CoreUtil { * auto-completion of static methods */ public static function getBAOFromApiName($entityName) { + // TODO: It would be nice to just call self::getInfoItem($entityName, 'dao') + // but that currently causes test failures, probably due to early-bootstrap issues. if ($entityName === 'CustomValue' || strpos($entityName, 'Custom_') === 0) { - return 'CRM_Core_BAO_CustomValue'; + $dao = \Civi\Api4\CustomValue::getInfo()['dao']; + } + else { + $dao = AllCoreTables::getFullName($entityName); + } + if (!$dao && self::isContact($entityName)) { + $dao = 'CRM_Contact_DAO_Contact'; } - $dao = AllCoreTables::getFullName($entityName); return $dao ? AllCoreTables::getBAOClassName($dao) : NULL; } @@ -49,7 +56,7 @@ class CoreUtil { } /** - * @param $entityName + * @param string $entityName * @return string|\Civi\Api4\Generic\AbstractEntity */ public static function getApiClass($entityName) { @@ -60,6 +67,13 @@ class CoreUtil { return self::getInfoItem($entityName, 'class'); } + /** + * Returns TRUE if `entityName` is 'Contact', 'Individual', 'Organization' or 'Household' + */ + public static function isContact(string $entityName): bool { + return $entityName === 'Contact' || in_array($entityName, \CRM_Contact_BAO_ContactType::basicTypes(TRUE), TRUE); + } + /** * Get a piece of metadata about an entity * @@ -145,11 +159,19 @@ class CoreUtil { * @return array{extends: array, column: string, grouping: mixed}|null */ public static function getCustomGroupExtends(string $entityName) { + $contactTypes = \CRM_Contact_BAO_ContactType::basicTypes(); // Custom_group.extends pretty much maps 1-1 with entity names, except for Contact. + if (in_array($entityName, $contactTypes, TRUE)) { + return [ + 'extends' => ['Contact', $entityName], + 'column' => 'id', + 'grouping' => ['contact_type', 'contact_sub_type'], + ]; + } switch ($entityName) { case 'Contact': return [ - 'extends' => array_merge(['Contact'], array_keys(\CRM_Core_SelectValues::contactType())), + 'extends' => array_merge(['Contact'], $contactTypes), 'column' => 'id', 'grouping' => ['contact_type', 'contact_sub_type'], ]; diff --git a/tests/phpunit/api/v4/Action/ContactAclTest.php b/tests/phpunit/api/v4/Action/ContactAclTest.php index 92769ba4ca..27bfc094cb 100644 --- a/tests/phpunit/api/v4/Action/ContactAclTest.php +++ b/tests/phpunit/api/v4/Action/ContactAclTest.php @@ -22,7 +22,9 @@ namespace api\v4\Action; use api\v4\Api4TestBase; use Civi\Api4\Contact; use Civi\Api4\Email; +use Civi\Api4\Individual; use Civi\Api4\LocationType; +use Civi\Api4\Organization; use Civi\Core\HookInterface; use Civi\Test\TransactionalInterface; @@ -33,6 +35,17 @@ class ContactAclTest extends Api4TestBase implements TransactionalInterface, Hoo use \Civi\Test\ACLPermissionTrait; + public function testPermissionInfo(): void { + foreach (['Contact', 'Individual', 'Organization', 'Household'] as $entity) { + $apiClass = '\Civi\Api4\\' . $entity; + $permissions = $apiClass::permissions(); + $this->assertEquals([], $permissions['get']); + $this->assertContains('add contacts', $permissions['create']); + $this->assertContains('delete contacts', $permissions['delete']); + $this->assertContains('merge duplicate contacts', $permissions['merge']); + } + } + public function testBasicContactPermissions(): void { $this->createLoggedInUser(); \CRM_Core_Config::singleton()->userPermissionClass->permissions = [ @@ -40,7 +53,7 @@ class ContactAclTest extends Api4TestBase implements TransactionalInterface, Hoo 'view all contacts', ]; - $this->createTestRecord('Contact'); + $this->createTestRecord('Individual'); $result = Contact::get()->execute(); $this->assertGreaterThan(0, $result->count()); @@ -53,10 +66,16 @@ class ContactAclTest extends Api4TestBase implements TransactionalInterface, Hoo $result = Contact::get()->execute(); $this->assertCount(0, $result); + + $result = Individual::get()->execute(); + $this->assertCount(0, $result); + + $result = Organization::get()->execute(); + $this->assertCount(0, $result); } - public function testContactAclForRelatedEntity() { - $cid = $this->saveTestRecords('Contact', ['records' => 4]) + public function testContactAclForRelatedEntity(): void { + $cid = $this->saveTestRecords('Individual', ['records' => 4]) ->column('id'); $email = $this->saveTestRecords('Email', [ 'records' => [ @@ -79,8 +98,8 @@ class ContactAclTest extends Api4TestBase implements TransactionalInterface, Hoo $this->assertEquals(1, substr_count($allowedEmails->debug['sql'][0], 'civicrm_acl_contact_cache')); } - public function testContactAclClauseDedupe() { - $cid = $this->saveTestRecords('Contact', ['records' => 4]) + public function testContactAclClauseDedupe(): void { + $cid = $this->saveTestRecords('Individual', ['records' => 4]) ->column('id'); $locationType = $this->createTestRecord('LocationType'); $email = $this->saveTestRecords('Email', [ @@ -97,6 +116,9 @@ class ContactAclTest extends Api4TestBase implements TransactionalInterface, Hoo \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'view debug output']; \CRM_Utils_Hook::singleton()->setHook('civicrm_aclWhereClause', [$this, 'aclWhereMultipleContacts']); + // Should now have access to 3 contacts + $this->assertCount(3, Individual::get()->execute()); + // Acl clause is added only once and shared by the joined entities $contactGet = Contact::get()->setDebug(TRUE) ->addSelect('email.id') @@ -107,6 +129,16 @@ class ContactAclTest extends Api4TestBase implements TransactionalInterface, Hoo // ACL clause should have been inserted once $this->assertEquals(1, substr_count($contactGet->debug['sql'][0], 'civicrm_acl_contact_cache')); + // Same should work with Individual api as Contact + $contactGet = Individual::get()->setDebug(TRUE) + ->addSelect('email.id') + ->addJoin('Email AS email', 'LEFT', ['email.contact_id', '=', 'id']) + ->execute(); + $this->assertCount(3, $contactGet); + $this->assertEquals(array_slice($email, 1), $contactGet->column('email.id')); + // ACL clause should have been inserted once + $this->assertEquals(1, substr_count($contactGet->debug['sql'][0], 'civicrm_acl_contact_cache')); + // Joining through another entity does not allow acl bypass $locationTypeGet = LocationType::get()->setDebug(TRUE) ->addSelect('email.id') diff --git a/tests/phpunit/api/v4/Action/ContactGetTest.php b/tests/phpunit/api/v4/Action/ContactGetTest.php index 9e10513aca..7e776833f3 100644 --- a/tests/phpunit/api/v4/Action/ContactGetTest.php +++ b/tests/phpunit/api/v4/Action/ContactGetTest.php @@ -21,6 +21,7 @@ namespace api\v4\Action; use api\v4\Api4TestBase; use Civi\Api4\Contact; +use Civi\Api4\Email; use Civi\Api4\Relationship; use Civi\Test\TransactionalInterface; @@ -421,4 +422,64 @@ class ContactGetTest extends Api4TestBase implements TransactionalInterface { $this->assertCount(0, $resultCount); } + public function testContactPseudoEntityGet(): void { + $allCids = []; + $cids = []; + // Create a different number of contacts of each type + $contactTypes = [ + 'Individual' => 1, + 'Organization' => 2, + 'Household' => 3, + ]; + foreach ($contactTypes as $contactType => $count) { + $saved = $this->saveTestRecords($contactType, ['records' => $count])->column('id'); + $allCids = array_merge($allCids, $saved); + $cids[$contactType] = $saved; + } + $getAll = Contact::get(FALSE) + ->addWhere('id', 'IN', $allCids) + ->execute(); + $this->assertCount(6, $getAll); + // Each pseudo-entity will only return contacts of that type + foreach ($contactTypes as $contactType => $count) { + $get[$contactType] = civicrm_api4($contactType, 'get', [ + 'where' => [['id', 'IN', $allCids]], + 'debug' => TRUE, + ]); + $this->assertStringContainsString("`contact_type` = \"$contactType\"", $get[$contactType]->debug['sql'][0]); + $this->assertCount($count, $get[$contactType]); + } + // Ensure fields are returned appropriate to contact type: Individual + $this->assertArrayHasKey('first_name', $get['Individual'][0]); + $this->assertArrayNotHasKey('organization_name', $get['Individual'][0]); + $this->assertArrayNotHasKey('household_name', $get['Individual'][0]); + // Ensure fields are returned appropriate to contact type: Organization + $this->assertArrayNotHasKey('first_name', $get['Organization'][0]); + $this->assertArrayHasKey('organization_name', $get['Organization'][0]); + $this->assertArrayNotHasKey('household_name', $get['Organization'][0]); + // Ensure fields are returned appropriate to contact type: Household + $this->assertArrayNotHasKey('first_name', $get['Household'][0]); + $this->assertArrayNotHasKey('organization_name', $get['Household'][0]); + $this->assertArrayHasKey('household_name', $get['Household'][0]); + + // Ensure contact type condition is added to the ON clause + foreach ($allCids as $cid) { + $emails[] = $this->createTestRecord('Email', ['contact_id' => $cid])['id']; + } + $getAll = Email::get(FALSE) + ->addWhere('id', 'IN', $emails) + ->addJoin('Contact AS contact', 'INNER', ['contact_id', '=', 'contact.id']) + ->execute(); + $this->assertCount(6, $getAll); + foreach ($contactTypes as $contactType => $count) { + $get = Email::get(FALSE) + ->addWhere('id', 'IN', $emails) + ->addJoin("$contactType AS contact", 'INNER', ['contact_id', '=', 'contact.id']) + ->setDebug(TRUE) + ->execute(); + $this->assertStringContainsString("`contact`.`contact_type` = \"$contactType\"", $get->debug['sql'][0]); + $this->assertCount($count, $get); + } + } + } diff --git a/tests/phpunit/api/v4/Action/ContactIsDeletedTest.php b/tests/phpunit/api/v4/Action/ContactIsDeletedTest.php index cc09b40a26..79a9584a2f 100644 --- a/tests/phpunit/api/v4/Action/ContactIsDeletedTest.php +++ b/tests/phpunit/api/v4/Action/ContactIsDeletedTest.php @@ -51,33 +51,33 @@ class ContactIsDeletedTest extends Api4TestBase implements TransactionalInterfac */ public function testIsDeletedPermission(): void { $contact = $this->createLoggedInUser(); + $this->createTestRecord('Individual', ['first_name' => 'phoney']); \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'view all contacts']; $originalQuery = civicrm_api4('Contact', 'get', [ 'checkPermissions' => TRUE, 'select' => ['id', 'display_name', 'is_deleted'], 'where' => [['first_name', '=', 'phoney']], ]); + $this->assertGreaterThan(0, $originalQuery->countFetched()); - try { - $isDeletedQuery = civicrm_api4('Contact', 'get', [ - 'checkPermissions' => TRUE, - 'select' => ['id', 'display_name'], - 'where' => [['first_name', '=', 'phoney'], ['is_deleted', '=', 0]], - ]); - $this->assertEquals(count($originalQuery), count($isDeletedQuery)); - } - catch (\CRM_Core_Exception $e) { - $this->fail('An Exception Should not have been raised'); - } - try { - $isDeletedJoinTest = civicrm_api4('Email', 'get', [ - 'checkPermissions' => TRUE, - 'where' => [['contact_id.first_name', '=', 'phoney'], ['contact_id.is_deleted', '=', 0]], - ]); - } - catch (\CRM_Core_Exception $e) { - $this->fail('An Exception Should not have been raised'); - } + $isDeletedQuery = civicrm_api4('Contact', 'get', [ + 'checkPermissions' => TRUE, + 'select' => ['id', 'display_name'], + 'where' => [['first_name', '=', 'phoney'], ['is_deleted', '=', 0]], + ]); + $this->assertEquals(count($originalQuery), count($isDeletedQuery)); + + $isDeletedQuery = civicrm_api4('Individual', 'get', [ + 'checkPermissions' => TRUE, + 'select' => ['id', 'display_name'], + 'where' => [['first_name', '=', 'phoney'], ['is_deleted', '=', 0]], + ]); + $this->assertEquals(count($originalQuery), count($isDeletedQuery)); + + civicrm_api4('Email', 'get', [ + 'checkPermissions' => TRUE, + 'where' => [['contact_id.first_name', '=', 'phoney'], ['contact_id.is_deleted', '=', 0]], + ]); } } diff --git a/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php b/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php index d3b5a5f4ce..ec50d50973 100644 --- a/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php +++ b/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php @@ -23,6 +23,7 @@ use api\v4\Api4TestBase; use Civi\Api4\Activity; use Civi\Api4\Address; use Civi\Api4\Contact; +use Civi\Api4\Household; use Civi\Api4\Tag; /** @@ -31,7 +32,7 @@ use Civi\Api4\Tag; class GetExtraFieldsTest extends Api4TestBase { public function testGetFieldsByContactType(): void { - $getFields = Contact::getFields(FALSE)->addSelect('name')->addWhere('type', '=', 'Field'); + $getFields = Contact::getFields(FALSE)->addWhere('type', '=', 'Field'); $baseFields = array_column(\CRM_Contact_BAO_Contact::fields(), 'name'); $returnedFields = $getFields->execute()->column('name'); @@ -40,25 +41,25 @@ class GetExtraFieldsTest extends Api4TestBase { // With no contact_type specified, all fields should be returned $this->assertEmpty($notReturned); - $individualFields = $getFields->setValues(['contact_type' => 'Individual'])->execute()->column('name'); - $this->assertNotContains('sic_code', $individualFields); - $this->assertNotContains('contact_type', $individualFields); - $this->assertContains('first_name', $individualFields); + $individualFields = (array) $getFields->setValues(['contact_type' => 'Individual'])->execute()->indexBy('name'); + $this->assertArrayNotHasKey('sic_code', $individualFields); + $this->assertTrue($individualFields['contact_type']['readonly']); + $this->assertArrayHasKey('first_name', $individualFields); $orgId = Contact::create(FALSE)->addValue('contact_type', 'Organization')->execute()->first()['id']; - $organizationFields = $getFields->setValues(['id' => $orgId])->execute()->column('name'); - $this->assertContains('organization_name', $organizationFields); - $this->assertContains('sic_code', $organizationFields); - $this->assertNotContains('contact_type', $organizationFields); - $this->assertNotContains('first_name', $organizationFields); - $this->assertNotContains('household_name', $organizationFields); - - $hhId = Contact::create(FALSE)->addValue('contact_type', 'Household')->execute()->first()['id']; - $householdFields = $getFields->setValues(['id' => $hhId])->execute()->column('name'); - $this->assertNotContains('sic_code', $householdFields); - $this->assertNotContains('contact_type', $householdFields); - $this->assertNotContains('first_name', $householdFields); - $this->assertContains('household_name', $householdFields); + $organizationFields = (array) $getFields->setValues(['id' => $orgId])->execute()->indexBy('name'); + $this->assertArrayHasKey('organization_name', $organizationFields); + $this->assertArrayHasKey('sic_code', $organizationFields); + $this->assertTrue($organizationFields['contact_type']['readonly']); + $this->assertArrayNotHasKey('first_name', $organizationFields); + $this->assertArrayNotHasKey('household_name', $organizationFields); + + $hhId = Household::create(FALSE)->execute()->first()['id']; + $householdFields = (array) $getFields->setValues(['id' => $hhId])->execute()->indexBy('name'); + $this->assertArrayNotHasKey('sic_code', $householdFields); + $this->assertTrue($householdFields['contact_type']['readonly']); + $this->assertArrayNotHasKey('first_name', $householdFields); + $this->assertArrayHasKey('household_name', $householdFields); } public function testGetOptionsAddress(): void { diff --git a/tests/phpunit/api/v4/Api4TestBase.php b/tests/phpunit/api/v4/Api4TestBase.php index 3a9d3cee9d..0d7489b65c 100644 --- a/tests/phpunit/api/v4/Api4TestBase.php +++ b/tests/phpunit/api/v4/Api4TestBase.php @@ -89,7 +89,7 @@ class Api4TestBase extends TestCase implements HeadlessInterface { * @throws \CRM_Core_Exception */ public function createLoggedInUser(): int { - $contactID = $this->createTestRecord('Contact')['id']; + $contactID = $this->createTestRecord('Individual')['id']; UFMatch::delete(FALSE)->addWhere('uf_id', '=', 6)->execute(); $this->createTestRecord('UFMatch', [ 'contact_id' => $contactID, diff --git a/tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php b/tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php index 409f622cea..4a8c8a938b 100644 --- a/tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php +++ b/tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php @@ -25,6 +25,8 @@ use Civi\Api4\ContactType; use Civi\Api4\CustomField; use Civi\Api4\CustomGroup; use Civi\Api4\Event; +use Civi\Api4\Individual; +use Civi\Api4\Organization; use Civi\Api4\Participant; /** @@ -88,11 +90,11 @@ class CustomFieldGetFieldsTest extends CustomTestBase { ->addValue('parent_id:name', 'Individual') ->execute(); - $contact1 = Contact::create(FALSE) + $contact1 = Individual::create(FALSE) ->execute()->first(); - $contact2 = Contact::create(FALSE)->addValue('contact_sub_type', [$this->subTypeName]) + $contact2 = Individual::create(FALSE)->addValue('contact_sub_type', [$this->subTypeName]) ->execute()->first(); - $org = Contact::create(FALSE)->addValue('contact_type', 'Organization') + $org = Organization::create(FALSE) ->execute()->first(); // Individual sub-type custom group @@ -141,6 +143,20 @@ class CustomFieldGetFieldsTest extends CustomTestBase { $this->assertArrayNotHasKey('org_group.sub_field', $fieldsWithSubtype); $this->assertArrayHasKey('always.on', $fieldsWithSubtype); + $fieldsWithSubtype = Individual::getFields(FALSE) + ->addValue('id', $contact2['id']) + ->execute()->indexBy('name'); + $this->assertArrayHasKey('contact_sub.sub_field', $fieldsWithSubtype); + $this->assertArrayNotHasKey('org_group.sub_field', $fieldsWithSubtype); + $this->assertArrayHasKey('always.on', $fieldsWithSubtype); + + $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); + $this->assertArrayHasKey('always.on', $fieldsWithSubtype); + $fieldsNoSubtype = Contact::getFields(FALSE) ->addValue('id', $contact1['id']) ->execute()->indexBy('name'); @@ -148,6 +164,13 @@ class CustomFieldGetFieldsTest extends CustomTestBase { $this->assertArrayNotHasKey('org_group.sub_field', $fieldsNoSubtype); $this->assertArrayHasKey('always.on', $fieldsNoSubtype); + $groupFields = Organization::getFields(FALSE) + ->addValue('id', $org['id']) + ->execute()->indexBy('name'); + $this->assertArrayNotHasKey('contact_sub.sub_field', $groupFields); + $this->assertArrayHasKey('org_group.sub_field', $groupFields); + $this->assertArrayHasKey('always.on', $groupFields); + $groupFields = Contact::getFields(FALSE) ->addValue('id', $org['id']) ->execute()->indexBy('name'); diff --git a/tests/phpunit/api/v4/Custom/CustomGroupACLTest.php b/tests/phpunit/api/v4/Custom/CustomGroupACLTest.php index ab5c47324b..d0be7b81e3 100644 --- a/tests/phpunit/api/v4/Custom/CustomGroupACLTest.php +++ b/tests/phpunit/api/v4/Custom/CustomGroupACLTest.php @@ -24,6 +24,7 @@ use Civi\Api4\Contact; use Civi\Api4\CustomField; use Civi\Api4\CustomGroup; use Civi\Api4\CustomValue; +use Civi\Api4\Individual; /** * @group headless @@ -95,7 +96,7 @@ class CustomGroupACLTest extends CustomTestBase { \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'view all contacts', 'edit all contacts']; - // Ensure ACLs apply to APIv4 get + // Ensure ACLs apply to APIv4 Contact.get $result = Contact::get() ->addWhere('id', '=', $cid) ->addSelect('custom.*') @@ -104,6 +105,15 @@ class CustomGroupACLTest extends CustomTestBase { $this->assertEquals('456', $result['MyReadOnlySingle.MyField']); $this->assertArrayNotHasKey('MySuperSecretSingle.MyField', $result); + // Ensure ACLs apply to APIv4 Individual.get + $result = Individual::get() + ->addWhere('id', '=', $cid) + ->addSelect('custom.*') + ->execute()->single(); + $this->assertEquals('123', $result['MyReadWriteSingle.MyField']); + $this->assertEquals('456', $result['MyReadOnlySingle.MyField']); + $this->assertArrayNotHasKey('MySuperSecretSingle.MyField', $result); + // Ensure ACLs apply to APIv3 get $result = civicrm_api3('Contact', 'get', [ 'id' => $cid, @@ -115,7 +125,7 @@ class CustomGroupACLTest extends CustomTestBase { $this->assertEquals('456', $result[$v3['single']['readOnly']]); // Try to update all fields - ACLs will restrict based on write access - Contact::update()->setValues([ + Individual::update()->setValues([ 'id' => $cid, 'first_name' => 'test1234', 'MyReadWriteSingle.MyField' => '1234', @@ -202,6 +212,20 @@ class CustomGroupACLTest extends CustomTestBase { $this->assertArrayNotHasKey("customGroup.MyField", $row); } } + // Same check but for Individual entity + $result = Individual::get() + ->addWhere('id', '=', $cid) + ->addJoin("Custom_$groupName AS customGroup") + ->addSelect("customGroup.MyField") + ->execute(); + if ($groupName !== 'MySuperSecretMulti') { + $this->assertEquals($values, $result->column("customGroup.MyField")); + } + else { + foreach ($result as $row) { + $this->assertArrayNotHasKey("customGroup.MyField", $row); + } + } try { CustomValue::create($groupName) ->addValue('MyField', 'new') diff --git a/tests/phpunit/api/v4/Entity/ContactTypeTest.php b/tests/phpunit/api/v4/Entity/ContactTypeTest.php index 9a82fff831..6d766eeabb 100644 --- a/tests/phpunit/api/v4/Entity/ContactTypeTest.php +++ b/tests/phpunit/api/v4/Entity/ContactTypeTest.php @@ -23,7 +23,9 @@ use Civi\Api4\Contact; use api\v4\Api4TestBase; use Civi\Api4\ContactType; use Civi\Api4\Email; +use Civi\Api4\Individual; use Civi\Api4\Navigation; +use Civi\Api4\Organization; use Civi\Test\TransactionalInterface; /** @@ -166,4 +168,35 @@ class ContactTypeTest extends Api4TestBase implements TransactionalInterface { $this->assertEquals('Organization', $result['contact_type']); } + public function testContactTypeWontChange(): void { + $hhId = $this->createTestRecord('Household')['id']; + $orgId = $this->createTestRecord('Organization')['id']; + + $orgUpdate = Organization::update(FALSE) + ->addWhere('id', 'IN', [$hhId, $orgId]) + ->addValue('organization_name', 'Foo') + ->execute(); + $this->assertCount(1, $orgUpdate); + + $indUpdate = Individual::update(FALSE) + ->addWhere('id', 'IN', [$hhId, $orgId]) + ->addValue('first_name', 'Foo') + ->execute(); + $this->assertCount(0, $indUpdate); + + $orgUpdate = Organization::update(FALSE) + ->addWhere('id', '=', $hhId) + ->addValue('organization_name', 'Foo') + ->execute(); + // This seems unexpected but is due to the fact that for efficiency the api + // will skip lookups and go straight to writeRecord when given a single id. + // Commented out assertion doesn't work: + // $this->assertCount(0, $orgUpdate); + + $household = Contact::get(FALSE)->addWhere('id', '=', $hhId)->execute()->single(); + + $this->assertEquals('Household', $household['contact_type']); + $this->assertTrue(empty($household['organization_name'])); + } + } diff --git a/tests/phpunit/api/v4/Entity/EntityTest.php b/tests/phpunit/api/v4/Entity/EntityTest.php index 1f60de79ce..e38745c898 100644 --- a/tests/phpunit/api/v4/Entity/EntityTest.php +++ b/tests/phpunit/api/v4/Entity/EntityTest.php @@ -34,13 +34,7 @@ class EntityTest extends Api4TestBase { ->indexBy('name'); $this->assertArrayHasKey('Entity', $result, "Entity::get missing itself"); - $this->assertEquals('CRM_Contact_DAO_Contact', $result['Contact']['dao']); - $this->assertEquals(['DAOEntity'], $result['Contact']['type']); - $this->assertEquals(['id'], $result['Contact']['primary_key']); - // Contact icon fields - $this->assertEquals(['contact_sub_type:icon', 'contact_type:icon'], $result['Contact']['icon_field']); // Label fields - $this->assertEquals('display_name', $result['Contact']['label_field']); $this->assertEquals('title', $result['Event']['label_field']); // Search fields $this->assertEquals(['sort_name'], $result['Contact']['search_fields']); @@ -48,6 +42,33 @@ class EntityTest extends Api4TestBase { $this->assertEquals(['contact_id.sort_name', 'event_id.title'], $result['Participant']['search_fields']); } + public function testContactPseudoEntityGet(): void { + $result = Entity::get(FALSE) + ->execute() + ->indexBy('name'); + + foreach (['Contact', 'Individual', 'Organization', 'Household'] as $contactType) { + $this->assertEquals('CRM_Contact_DAO_Contact', $result[$contactType]['dao']); + $this->assertContains('DAOEntity', $result[$contactType]['type']); + $this->assertEquals('display_name', $result[$contactType]['label_field']); + $this->assertEquals(['id'], $result[$contactType]['primary_key']); + // Contact icon fields + $this->assertEquals(['contact_sub_type:icon', 'contact_type:icon'], $result[$contactType]['icon_field']); + } + + foreach (['Individual', 'Organization', 'Household'] as $contactType) { + $this->assertContains('ContactType', $result[$contactType]['type']); + $this->assertEquals($contactType, $result[$contactType]['where']['contact_type']); + } + + $this->assertEquals('Individual', $result['Individual']['title']); + $this->assertEquals('Individuals', $result['Individual']['title_plural']); + $this->assertEquals('Household', $result['Household']['title']); + $this->assertEquals('Households', $result['Household']['title_plural']); + $this->assertEquals('Organization', $result['Organization']['title']); + $this->assertEquals('Organizations', $result['Organization']['title_plural']); + } + public function testEntity(): void { $result = Entity::getActions(FALSE) ->execute() diff --git a/xml/schema/Contact/Contact.xml b/xml/schema/Contact/Contact.xml index c1d50300f6..778dab43be 100644 --- a/xml/schema/Contact/Contact.xml +++ b/xml/schema/Contact/Contact.xml @@ -53,7 +53,6 @@ true 1.1 3.1 - null index_contact_type -- 2.25.1