APIv4 - Add Individual, Household, Organization pseudo-entities
authorcolemanw <coleman@civicrm.org>
Wed, 27 Sep 2023 19:52:35 +0000 (15:52 -0400)
committercolemanw <coleman@civicrm.org>
Sun, 1 Oct 2023 00:29:56 +0000 (20:29 -0400)
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.

30 files changed:
CRM/Contact/BAO/ContactType.php
CRM/Contact/DAO/Contact.php
CRM/Core/BAO/CustomGroup.php
Civi/Api4/Action/Contact/GetDuplicates.php
Civi/Api4/Contact.php
Civi/Api4/CustomValue.php
Civi/Api4/Entity.php
Civi/Api4/Generic/AbstractEntity.php
Civi/Api4/Generic/DAOGetFieldsAction.php
Civi/Api4/Generic/Traits/DAOActionTrait.php
Civi/Api4/Household.php [new file with mode: 0644]
Civi/Api4/Individual.php [new file with mode: 0644]
Civi/Api4/Organization.php [new file with mode: 0644]
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/Service/Autocomplete/ActivityAutocompleteProvider.php
Civi/Api4/Service/Autocomplete/CaseAutocompleteProvider.php
Civi/Api4/Service/Autocomplete/ContactAutocompleteProvider.php
Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php
Civi/Api4/Service/Spec/SpecGatherer.php
Civi/Api4/Utils/CoreUtil.php
tests/phpunit/api/v4/Action/ContactAclTest.php
tests/phpunit/api/v4/Action/ContactGetTest.php
tests/phpunit/api/v4/Action/ContactIsDeletedTest.php
tests/phpunit/api/v4/Action/GetExtraFieldsTest.php
tests/phpunit/api/v4/Api4TestBase.php
tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php
tests/phpunit/api/v4/Custom/CustomGroupACLTest.php
tests/phpunit/api/v4/Entity/ContactTypeTest.php
tests/phpunit/api/v4/Entity/EntityTest.php
xml/schema/Contact/Contact.xml

index cec5abe03006c607ae67a3d3ad61c1bad621fa43..de18235c96444c5494890dc4cf03081e514382fc 100644 (file)
@@ -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
index edae63298bead15a245862c0f8e020cc701684af..af30aab877b025c975fd2499f74fa10902b745ea 100644 (file)
@@ -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',
index b9b82c590053b7e136472347283a6bd5386751de..0c9dfe9d210ed81bf94c0f410314455711f30df7 100644 (file)
@@ -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`).
index 9829af3b03b4d522724a3526364f8681a6252007..3ac73b7819454638610538d0fd2ccc7a97821afc 100644 (file)
@@ -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'];
index eeb05eb9a10200c07448654210f51a97b1be7574..2645674fefcc6d43f3f21254c259e4fdebe37d79 100644 (file)
@@ -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'];
+  }
+
 }
index 2ea7a3274a9ea5ba76a0a7e4048ff50b9d912db5..4201d3397afd7c7a0840a1e76ba8f32ec8db1082 100644 (file)
@@ -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',
index fb2b4780e9841152334c0af0dac47a0022f0fa64..0ceac96d6bb70c23d5b25da671209dce7dcabf58 100644 (file)
@@ -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',
index 5caca47384fc9148447bebb361ddbc5526b2d6af..26dd7b0e1d23491dd90144651528f20ac9f7fa54 100644 (file)
@@ -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;
index a3e2072d4cb92e6a209c0b0d2cbf3d140ea49411..8b4a5655ffd2c09ba798ae603548dc5c68899017 100644 (file)
@@ -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;
index 0f6c5cf8cdb8214f59a0f8892b3861af530c7b08..feabd9bc4914a349f272136e39ebfae00e3a2f87 100644 (file)
@@ -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 (file)
index 0000000..10fd4ac
--- /dev/null
@@ -0,0 +1,27 @@
+<?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\Api4;
+
+/**
+ * Contacts of type Household.
+ *
+ * @inheritDoc
+ * @searchable secondary
+ * @since 5.67
+ * @package Civi\Api4
+ */
+class Household extends Contact {
+
+  protected static function getEntityTitle(bool $plural = FALSE): string {
+    return $plural ? ts('Households') : ts('Household');
+  }
+
+}
diff --git a/Civi/Api4/Individual.php b/Civi/Api4/Individual.php
new file mode 100644 (file)
index 0000000..2224919
--- /dev/null
@@ -0,0 +1,26 @@
+<?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\Api4;
+
+/**
+ * Contacts of type Individual.
+ *
+ * @inheritDoc
+ * @since 5.67
+ * @package Civi\Api4
+ */
+class Individual extends Contact {
+
+  protected static function getEntityTitle(bool $plural = FALSE): string {
+    return $plural ? ts('Individuals') : ts('Individual');
+  }
+
+}
diff --git a/Civi/Api4/Organization.php b/Civi/Api4/Organization.php
new file mode 100644 (file)
index 0000000..b14d72c
--- /dev/null
@@ -0,0 +1,26 @@
+<?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\Api4;
+
+/**
+ * Contacts of type Organization.
+ *
+ * @inheritDoc
+ * @since 5.67
+ * @package Civi\Api4
+ */
+class Organization extends Contact {
+
+  protected static function getEntityTitle(bool $plural = FALSE): string {
+    return $plural ? ts('Organizations') : ts('Organization');
+  }
+
+}
index 2d42a349603fb5f30be20873b30295e87e735600..5a85139c772e6a91550c2b67f09f289219af43b2 100644 (file)
@@ -87,6 +87,12 @@ class Api4SelectQuery extends Api4Query {
     // Add ACLs first to avoid redundant subclauses
     $this->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();
index 843ef5fea36120fa6113a5e1356ed243e5c70827..70d305e359b80f115b8c14246bd2874e418128a4 100644 (file)
@@ -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') . ')';
index e28e6fbaf960652e693dbb14324a7e0b09724cb6..e3a4c7c956e3ad52861ebd3f84f10b1601f1440e 100644 (file)
@@ -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') . ')';
index b676baa692b517355303565c8ac055f1a0cbdeb0..27440a78241dda585008ddd48226ec5617dada0e 100644 (file)
@@ -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'] = [
index 1b01bc11549ad1d54bbff27df447ba456c94aeda..9d630092b9c12d4f3ae27136b87192b1bdbded0f 100644 (file)
@@ -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';
   }
 
   /**
index 56fa359ef11b9b3e7f9814c967be26ef2fed892a..0e5225723dfe1dcf9b345156d48f0abb60db1565 100644 (file)
@@ -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');
index fb5df8e6d3c310366a48665ba6c9df45f61749de..725acf23691cec8e0f866b4c027c726f1bf60a9d 100644 (file)
@@ -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'],
         ];
index 92769ba4ca4daac65b92910220bb6481808658df..27bfc094cb5a80f1a0b3d0ce0b683478e2dc8ffc 100644 (file)
@@ -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')
index 9e10513acaf8840cd30210b574bd848e8be07743..7e776833f3f3ab3a0c1de857f8774eeafc3b4506 100644 (file)
@@ -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);
+    }
+  }
+
 }
index cc09b40a2693152e2d4be0e147a8a20aab869734..79a9584a2fe6ff78830477f2a2adc11f1fd5c6a6 100644 (file)
@@ -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]],
+    ]);
   }
 
 }
index d3b5a5f4ce8a2026ee2c9466c9b5fe70d382df79..ec50d50973269c40a97f702525d812ae409adf26 100644 (file)
@@ -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 {
index 3a9d3cee9df009e3c46ec56c9ce8e5f293af550f..0d7489b65cf330b4a7c4cb7012e540fe9a49057a 100644 (file)
@@ -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,
index 409f622ceaa99e126c83268c307cf79b78265338..4a8c8a938ba81681a101690bc52ae1e152f0734c 100644 (file)
@@ -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');
index ab5c47324b10addc2d1bae41221cff3261e20ee1..d0be7b81e3a9458dd54616031e9af8ffc9addd37 100644 (file)
@@ -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')
index 9a82fff831561812db8d3a811b196e8261533077..6d766eeabb21e1e34c75e3c4cf7ee9c96ec4f358 100644 (file)
@@ -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']));
+  }
+
 }
index 1f60de79ce82dbccc627a76a06aef8130002db68..e38745c898360452c970612cb1a0c777bf303287 100644 (file)
@@ -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()
index c1d50300f69342e5a5bbca52c6df47f5d374bc45..778dab43bee584c28d7ac9f3db90729be41cde63 100644 (file)
@@ -53,7 +53,6 @@
     <readonly>true</readonly>
     <add>1.1</add>
     <change>3.1</change>
-    <contactType>null</contactType>
   </field>
   <index>
     <name>index_contact_type</name>