--- /dev/null
+<?php
+
+class CRM_Api4_Page_AJAX extends CRM_Core_Page {
+
+ /**
+ * Handler for api4 ajax requests
+ */
+ public function run() {
+ try {
+ // Call multiple
+ if (empty($this->urlPath[3])) {
+ $calls = CRM_Utils_Request::retrieve('calls', 'String', CRM_Core_DAO::$_nullObject, TRUE, NULL, 'POST', TRUE);
+ $calls = json_decode($calls, TRUE);
+ $response = [];
+ foreach ($calls as $index => $call) {
+ $response[$index] = call_user_func_array([$this, 'execute'], $call);
+ }
+ }
+ // Call single
+ else {
+ $entity = $this->urlPath[3];
+ $action = $this->urlPath[4];
+ $params = CRM_Utils_Request::retrieve('params', 'String');
+ $params = $params ? json_decode($params, TRUE) : [];
+ $index = CRM_Utils_Request::retrieve('index', 'String');
+ $response = $this->execute($entity, $action, $params, $index);
+ }
+ }
+ catch (Exception $e) {
+ http_response_code(500);
+ $response = [
+ 'error_code' => $e->getCode(),
+ ];
+ if (CRM_Core_Permission::check('view debug output')) {
+ $response['error_message'] = $e->getMessage();
+ if (\Civi::settings()->get('backtrace')) {
+ $response['backtrace'] = $e->getTrace();
+ }
+ }
+ }
+ CRM_Utils_System::setHttpHeader('Content-Type', 'application/json');
+ echo json_encode($response);
+ CRM_Utils_System::civiExit();
+ }
+
+ /**
+ * Run api call & prepare result for json encoding
+ *
+ * @param string $entity
+ * @param string $action
+ * @param array $params
+ * @param string $index
+ * @return array
+ */
+ protected function execute($entity, $action, $params = [], $index = NULL) {
+ $params['checkPermissions'] = TRUE;
+
+ // Handle numeric indexes later so we can get the count
+ $itemAt = CRM_Utils_Type::validate($index, 'Integer', FALSE);
+
+ $result = civicrm_api4($entity, $action, $params, isset($itemAt) ? NULL : $index);
+
+ // Convert arrayObject into something more suitable for json
+ $vals = ['values' => isset($itemAt) ? $result->itemAt($itemAt) : (array) $result];
+ foreach (get_class_vars(get_class($result)) as $key => $val) {
+ $vals[$key] = $result->$key;
+ }
+ $vals['count'] = $result->count();
+ return $vals;
+ }
+
+}
--- /dev/null
+<?php
+
+class CRM_Api4_Page_Api4Explorer extends CRM_Core_Page {
+
+ public function run() {
+ $vars = [
+ 'operators' => \CRM_Core_DAO::acceptedSQLOperators(),
+ 'basePath' => Civi::resources()->getUrl('org.civicrm.api4'),
+ 'schema' => (array) \Civi\Api4\Entity::get()->setChain(['fields' => ['$name', 'getFields']])->execute(),
+ 'links' => (array) \Civi\Api4\Entity::getLinks()->execute(),
+ ];
+ Civi::resources()
+ ->addVars('api4', $vars)
+ ->addScriptFile('org.civicrm.api4', 'js/load-bootstrap.js')
+ ->addScriptFile('civicrm', 'bower_components/google-code-prettify/bin/prettify.min.js')
+ ->addStyleFile('civicrm', 'bower_components/google-code-prettify/bin/prettify.min.css');
+
+ $loader = new Civi\Angular\AngularLoader();
+ $loader->setModules(['api4Explorer']);
+ $loader->setPageName('civicrm/api4');
+ $loader->useApp([
+ 'defaultRoute' => '/explorer',
+ ]);
+ $loader->load();
+ parent::run();
+ }
+
+}
--- /dev/null
+<?php
+
+use Symfony\Component\DependencyInjection\Reference;
+use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
+use Symfony\Component\Config\FileLocator;
+
+class CRM_Api4_Services {
+
+ /**
+ * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
+ */
+ public static function hook_container($container) {
+ $loader = new XmlFileLoader($container, new FileLocator(dirname(dirname(__DIR__))));
+ $loader->load('Civi/Api4/services.xml');
+
+ self::loadServices('Civi\Api4\Service\Spec\Provider', 'spec_provider', $container);
+ self::loadServices('Civi\Api4\Event\Subscriber', 'event_subscriber', $container);
+
+ $container->getDefinition('civi_api_kernel')->addMethodCall(
+ 'registerApiProvider',
+ [new Reference('action_object_provider')]
+ );
+
+ // add event subscribers$container->get(
+ $dispatcher = $container->getDefinition('dispatcher');
+ $subscribers = $container->findTaggedServiceIds('event_subscriber');
+
+ foreach (array_keys($subscribers) as $subscriber) {
+ $dispatcher->addMethodCall(
+ 'addSubscriber',
+ [new Reference($subscriber)]
+ );
+ }
+
+ // add spec providers
+ $providers = $container->findTaggedServiceIds('spec_provider');
+ $gatherer = $container->getDefinition('spec_gatherer');
+
+ foreach (array_keys($providers) as $provider) {
+ $gatherer->addMethodCall(
+ 'addSpecProvider',
+ [new Reference($provider)]
+ );
+ }
+
+ if (defined('CIVICRM_UF') && CIVICRM_UF === 'UnitTests') {
+ $loader->load('tests/phpunit/api/v4/services.xml');
+ }
+ }
+
+ /**
+ * Load all services in a given directory
+ *
+ * @param string $namespace
+ * @param string $tag
+ * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
+ */
+ public static function loadServices($namespace, $tag, $container) {
+ $namespace = \CRM_Utils_File::addTrailingSlash($namespace, '\\');
+ foreach (\CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles() as $ext) {
+ $path = \CRM_Utils_File::addTrailingSlash(dirname($ext['filePath'])) . str_replace('\\', DIRECTORY_SEPARATOR, $namespace);
+ foreach (glob("$path*.php") as $file) {
+ $matches = [];
+ preg_match('/(\w*).php/', $file, $matches);
+ $serviceName = $namespace . array_pop($matches);
+ $serviceClass = new \ReflectionClass($serviceName);
+ if ($serviceClass->isInstantiable()) {
+ $definition = $container->register(str_replace('\\', '_', $serviceName), $serviceName);
+ $definition->addTag($tag);
+ }
+ }
+ }
+ }
+
+}
--- /dev/null
+<?xml version="1.0"?>
+<menu>
+ <item>
+ <path>civicrm/ajax/api4</path>
+ <page_callback>CRM_Api4_Page_AJAX</page_callback>
+ <access_arguments>access CiviCRM</access_arguments>
+ </item>
+ <item>
+ <path>civicrm/api4</path>
+ <page_callback>CRM_Api4_Page_Api4Explorer</page_callback>
+ <title>CiviCRM</title>
+ <access_arguments>access CiviCRM</access_arguments>
+ </item>
+</menu>
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * ACL Entity.
+ *
+ * This entity holds the ACL informatiom. With this entity you add/update/delete an ACL permission which consists of
+ * an Operation (e.g. 'View' or 'Edit'), a set of Data that the operation can be performed on (e.g. a group of contacts),
+ * and a Role that has permission to do this operation. For more info refer to
+ * https://docs.civicrm.org/user/en/latest/initial-set-up/permissions-and-access-control for more info.
+ *
+ * Creating a new ACL requires at minimum a entity table, entity ID and object_table
+ *
+ * @package Civi\Api4
+ */
+class ACL extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\Address;
+
+/**
+ * @inheritDoc
+ * @method bool getStreetParsing()
+ * @method $this setStreetParsing(bool $streetParsing)
+ * @method bool getSkipGeocode()
+ * @method $this setSkipGeocode(bool $skipGeocode)
+ * @method bool getFixAddress()
+ * @method $this setFixAddress(bool $fixAddress)
+ */
+trait AddressSaveTrait {
+
+ /**
+ * Optional param to indicate you want the street_address field parsed into individual params
+ *
+ * @var bool
+ */
+ protected $streetParsing = FALSE;
+
+ /**
+ * Optional param to indicate you want to skip geocoding (useful when importing a lot of addresses at once, the job Geocode and Parse Addresses can execute this task after the import)
+ *
+ * @var bool
+ */
+ protected $skipGeocode = FALSE;
+
+ /**
+ * When true, apply various fixes to the address before insert.
+ *
+ * @var bool
+ */
+ protected $fixAddress = TRUE;
+
+ /**
+ * @inheritDoc
+ */
+ protected function writeObjects($items) {
+ foreach ($items as &$item) {
+ if ($this->streetParsing && !empty($item['street_address'])) {
+ $item = array_merge($item, \CRM_Core_BAO_Address::parseStreetAddress($item['street_address']));
+ }
+ $item['skip_geocode'] = $this->skipGeocode;
+ }
+ return parent::writeObjects($items);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\Address;
+
+/**
+ * @inheritDoc
+ */
+class Create extends \Civi\Api4\Generic\DAOCreateAction {
+ use AddressSaveTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\Address;
+
+/**
+ * @inheritDoc
+ */
+class Save extends \Civi\Api4\Generic\DAOSaveAction {
+ use AddressSaveTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\Address;
+
+/**
+ * @inheritDoc
+ */
+class Update extends \Civi\Api4\Generic\DAOUpdateAction {
+ use AddressSaveTrait;
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\Campaign;
+
+/**
+ * @inheritDoc
+ *
+ * Set current = true to get active, non past campaigns.
+ */
+class Get extends \Civi\Api4\Generic\DAOGetAction {
+ use \Civi\Api4\Generic\Traits\IsCurrentTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\Contact;
+
+use Civi\Api4\Generic\Result;
+
+/**
+ * Generate a security checksum for anonymous access to CiviCRM.
+ *
+ * @method $this setContactId(int $cid) Set contact ID (required)
+ * @method int getContactId() Get contact ID param
+ * @method $this setTtl(int $ttl) Set TTL param
+ * @method int getTtl() Get TTL param
+ */
+class GetChecksum extends \Civi\Api4\Generic\AbstractAction {
+
+ /**
+ * ID of contact
+ *
+ * @var int
+ * @required
+ */
+ protected $contactId;
+
+ /**
+ * Expiration time (hours). Defaults to 168 (24 * [7 or value of checksum_timeout system setting]).
+ *
+ * Set to 0 for infinite.
+ *
+ * @var int
+ */
+ protected $ttl = NULL;
+
+ /**
+ * @param \Civi\Api4\Generic\Result $result
+ */
+ public function _run(Result $result) {
+ $ttl = ($this->ttl === 0 || $this->ttl === '0') ? 'inf' : $this->ttl;
+ $result[] = [
+ 'id' => $this->contactId,
+ 'checksum' => \CRM_Contact_BAO_Contact_Utils::generateChecksum($this->contactId, NULL, $ttl),
+ ];
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\Contact;
+
+use Civi\Api4\Generic\DAOGetFieldsAction;
+
+class GetFields extends DAOGetFieldsAction {
+
+ protected function getRecords() {
+ $fields = parent::getRecords();
+
+ $apiKeyPerms = ['edit api keys', 'administer CiviCRM'];
+ if ($this->checkPermissions && !\CRM_Core_Permission::check([$apiKeyPerms])) {
+ unset($fields['api_key']);
+ }
+
+ return $fields;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\Contact;
+
+use Civi\Api4\Generic\Result;
+
+/**
+ * Generate a security checksum for anonymous access to CiviCRM.
+ *
+ * @method $this setContactId(int $cid) Set contact ID (required)
+ * @method int getContactId() Get contact ID param
+ * @method $this setChecksum(string $checksum) Set checksum param (required)
+ * @method string getChecksum() Get checksum param
+ */
+class ValidateChecksum extends \Civi\Api4\Generic\AbstractAction {
+
+ /**
+ * ID of contact
+ *
+ * @var int
+ * @required
+ */
+ protected $contactId;
+
+ /**
+ * Value of checksum
+ *
+ * @var string
+ * @required
+ */
+ protected $checksum;
+
+ /**
+ * @param \Civi\Api4\Generic\Result $result
+ */
+ public function _run(Result $result) {
+ $result[] = [
+ 'valid' => \CRM_Contact_BAO_Contact_Utils::validChecksum($this->contactId, $this->checksum),
+ ];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+/**
+ * @inheritDoc
+ */
+class Create extends \Civi\Api4\Generic\DAOCreateAction {
+ use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+/**
+ * Delete one or more items, based on criteria specified in Where param.
+ */
+class Delete extends \Civi\Api4\Generic\DAODeleteAction {
+ use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+/**
+ * Get fields for a custom group.
+ */
+class Get extends \Civi\Api4\Generic\DAOGetAction {
+ use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+/**
+ * @inheritDoc
+ */
+class GetActions extends \Civi\Api4\Action\GetActions {
+ use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+use Civi\Api4\Service\Spec\SpecFormatter;
+
+/**
+ * Get fields for a custom group.
+ */
+class GetFields extends \Civi\Api4\Generic\DAOGetFieldsAction {
+ use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+ protected function getRecords() {
+ $fields = $this->_itemsToGet('name');
+ /** @var \Civi\Api4\Service\Spec\SpecGatherer $gatherer */
+ $gatherer = \Civi::container()->get('spec_gatherer');
+ $spec = $gatherer->getSpec('Custom_' . $this->getCustomGroup(), $this->getAction(), $this->includeCustom);
+ return SpecFormatter::specToArray($spec->getFields($fields), $this->loadOptions);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getParamInfo($param = NULL) {
+ $info = parent::getParamInfo($param);
+ if (!$param) {
+ // This param is meaningless here.
+ unset($info['includeCustom']);
+ }
+ return $info;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+/**
+ * Given a set of records, will appropriately update the database.
+ */
+class Replace extends \Civi\Api4\Generic\BasicReplaceAction {
+ use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+/**
+ * @inheritDoc
+ */
+class Save extends \Civi\Api4\Generic\DAOSaveAction {
+ use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+/**
+ * Update one or more records with new values. Use the where clause to select them.
+ */
+class Update extends \Civi\Api4\Generic\DAOUpdateAction {
+ use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\Domain;
+
+/**
+ * @inheritDoc
+ */
+class Get extends \Civi\Api4\Generic\DAOGetAction {
+
+ /**
+ * Return only the current domain.
+ *
+ * @var bool
+ */
+ protected $currentDomain = FALSE;
+
+ /**
+ * @inheritDoc
+ */
+ protected function getObjects() {
+ if ($this->currentDomain) {
+ $this->addWhere('id', '=', \CRM_Core_Config::domainID());
+ }
+ return parent::getObjects();
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\Entity;
+
+use Civi\Api4\CustomGroup;
+use Civi\Api4\Utils\ReflectionUtils;
+
+/**
+ * Get entities
+ *
+ * @method $this setIncludeCustom(bool $value)
+ * @method bool getIncludeCustom()
+ */
+class Get extends \Civi\Api4\Generic\BasicGetAction {
+
+ /**
+ * Include custom-field-based pseudo-entities?
+ *
+ * @var bool
+ */
+ protected $includeCustom = TRUE;
+
+ /**
+ * Scan all api directories to discover entities
+ */
+ protected function getRecords() {
+ $entities = [];
+ foreach (\CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles() as $ext) {
+ $dir = \CRM_Utils_File::addTrailingSlash(dirname($ext['filePath'])) . 'Civi/Api4';
+ if (is_dir($dir)) {
+ foreach (glob("$dir/*.php") as $file) {
+ $matches = [];
+ preg_match('/(\w*).php/', $file, $matches);
+ $entity = ['name' => $matches[1]];
+ if ($this->_isFieldSelected('description') || $this->_isFieldSelected('comment')) {
+ $this->addDocs($entity);
+ }
+ $entities[$matches[1]] = $entity;
+ }
+ }
+ }
+ unset($entities['CustomValue']);
+
+ if ($this->includeCustom) {
+ $this->addCustomEntities($entities);
+ }
+
+ ksort($entities);
+ return $entities;
+ }
+
+ /**
+ * Add custom-field pseudo-entities
+ *
+ * @param $entities
+ * @throws \API_Exception
+ */
+ private function addCustomEntities(&$entities) {
+ $customEntities = CustomGroup::get()
+ ->addWhere('is_multiple', '=', 1)
+ ->addWhere('is_active', '=', 1)
+ ->setSelect(['name', 'title', 'help_pre', 'help_post', 'extends'])
+ ->setCheckPermissions(FALSE)
+ ->execute();
+ foreach ($customEntities as $customEntity) {
+ $fieldName = 'Custom_' . $customEntity['name'];
+ $entities[$fieldName] = [
+ 'name' => $fieldName,
+ 'description' => $customEntity['title'] . ' custom group - extends ' . $customEntity['extends'],
+ ];
+ if (!empty($customEntity['help_pre'])) {
+ $entities[$fieldName]['comment'] = $this->plainTextify($customEntity['help_pre']);
+ }
+ if (!empty($customEntity['help_post'])) {
+ $pre = empty($entities[$fieldName]['comment']) ? '' : $entities[$fieldName]['comment'] . "\n\n";
+ $entities[$fieldName]['comment'] = $pre . $this->plainTextify($customEntity['help_post']);
+ }
+ }
+ }
+
+ /**
+ * Convert html to plain text.
+ *
+ * @param $input
+ * @return mixed
+ */
+ private function plainTextify($input) {
+ return html_entity_decode(strip_tags($input), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ }
+
+ /**
+ * Add info from code docblock.
+ *
+ * @param $entity
+ */
+ private function addDocs(&$entity) {
+ $reflection = new \ReflectionClass("\\Civi\\Api4\\" . $entity['name']);
+ $entity += ReflectionUtils::getCodeDocs($reflection);
+ unset($entity['package'], $entity['method']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\Entity;
+
+use Civi\Api4\Utils\CoreUtil;
+
+/**
+ * Get a list of FK links between entities
+ */
+class GetLinks extends \Civi\Api4\Generic\BasicGetAction {
+
+ public function getRecords() {
+ $result = [];
+ /** @var \Civi\Api4\Service\Schema\SchemaMap $schema */
+ $schema = \Civi::container()->get('schema_map');
+ foreach ($schema->getTables() as $table) {
+ $entity = CoreUtil::getApiNameFromTableName($table->getName());
+ // Since this is an api function, exclude tables that don't have an api
+ if (class_exists('\Civi\Api4\\' . $entity)) {
+ $item = [
+ 'entity' => $entity,
+ 'table' => $table->getName(),
+ 'links' => [],
+ ];
+ foreach ($table->getTableLinks() as $link) {
+ $link = $link->toArray();
+ $link['entity'] = CoreUtil::getApiNameFromTableName($link['targetTable']);
+ $item['links'][] = $link;
+ }
+ $result[] = $item;
+ }
+ }
+ return $result;
+ }
+
+ public function fields() {
+ return [
+ [
+ 'name' => 'entity',
+ ],
+ [
+ 'name' => 'table',
+ ],
+ [
+ 'name' => 'links',
+ 'data_type' => 'Array',
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\Event;
+
+/**
+ * @inheritDoc
+ *
+ * Set current = true to get active, non past events.
+ */
+class Get extends \Civi\Api4\Generic\DAOGetAction {
+ use \Civi\Api4\Generic\Traits\IsCurrentTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action;
+
+use Civi\API\Exception\NotImplementedException;
+use Civi\Api4\Generic\BasicGetAction;
+use Civi\Api4\Utils\ActionUtil;
+use Civi\Api4\Utils\ReflectionUtils;
+
+/**
+ * Get actions for an entity with a list of accepted params
+ */
+class GetActions extends BasicGetAction {
+
+ private $_actions = [];
+
+ private $_actionsToGet;
+
+ protected function getRecords() {
+ $this->_actionsToGet = $this->_itemsToGet('name');
+
+ $entityReflection = new \ReflectionClass('\Civi\Api4\\' . $this->_entityName);
+ foreach ($entityReflection->getMethods(\ReflectionMethod::IS_STATIC | \ReflectionMethod::IS_PUBLIC) as $method) {
+ $actionName = $method->getName();
+ if ($actionName != 'permissions' && $actionName[0] != '_') {
+ $this->loadAction($actionName);
+ }
+ }
+ if (!$this->_actionsToGet || count($this->_actionsToGet) > count($this->_actions)) {
+ // Search entity-specific actions (including those provided by extensions)
+ foreach (\CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles() as $ext) {
+ $dir = \CRM_Utils_File::addTrailingSlash(dirname($ext['filePath']));
+ $this->scanDir($dir . 'Civi/Api4/Action/' . $this->_entityName);
+ }
+ }
+ ksort($this->_actions);
+ return $this->_actions;
+ }
+
+ /**
+ * @param $dir
+ */
+ private function scanDir($dir) {
+ if (is_dir($dir)) {
+ foreach (glob("$dir/*.php") as $file) {
+ $matches = [];
+ preg_match('/(\w*).php/', $file, $matches);
+ $actionName = array_pop($matches);
+ $actionClass = new \ReflectionClass('\\Civi\\Api4\\Action\\' . $this->_entityName . '\\' . $actionName);
+ if ($actionClass->isInstantiable() && $actionClass->isSubclassOf('\\Civi\\Api4\\Generic\\AbstractAction')) {
+ $this->loadAction(lcfirst($actionName));
+ }
+ }
+ }
+ }
+
+ /**
+ * @param $actionName
+ */
+ private function loadAction($actionName) {
+ try {
+ if (!isset($this->_actions[$actionName]) && (!$this->_actionsToGet || in_array($actionName, $this->_actionsToGet))) {
+ $action = ActionUtil::getAction($this->getEntityName(), $actionName);
+ if (is_object($action)) {
+ $this->_actions[$actionName] = ['name' => $actionName];
+ if ($this->_isFieldSelected('description') || $this->_isFieldSelected('comment')) {
+ $actionReflection = new \ReflectionClass($action);
+ $actionInfo = ReflectionUtils::getCodeDocs($actionReflection);
+ unset($actionInfo['method']);
+ $this->_actions[$actionName] += $actionInfo;
+ }
+ if ($this->_isFieldSelected('params')) {
+ $this->_actions[$actionName]['params'] = $action->getParamInfo();
+ // Language param is only relevant on multilingual sites
+ $languageLimit = (array) \Civi::settings()->get('languageLimit');
+ if (count($languageLimit) < 2) {
+ unset($this->_actions[$actionName]['params']['language']);
+ }
+ elseif (isset($this->_actions[$actionName]['params']['language'])) {
+ $this->_actions[$actionName]['params']['language']['options'] = array_keys($languageLimit);
+ }
+ }
+ }
+ }
+ }
+ catch (NotImplementedException $e) {
+ }
+ }
+
+ public function fields() {
+ return [
+ [
+ 'name' => 'name',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'description',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'comment',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'params',
+ 'data_type' => 'Array',
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\GroupContact;
+
+/**
+ * @inheritDoc
+ */
+class Create extends \Civi\Api4\Generic\DAOCreateAction {
+ use GroupContactSaveTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\GroupContact;
+
+/**
+ * @inheritDoc
+ *
+ * @method $this setMethod(string $method) Indicate who added/removed the group.
+ * @method string getMethod()
+ * @method $this setTracking(string $tracking) Specify ip address or other tracking info.
+ * @method string getTracking()
+ */
+trait GroupContactSaveTrait {
+
+ /**
+ * String to indicate who added/removed the group.
+ *
+ * @var string
+ */
+ protected $method = 'API';
+
+ /**
+ * IP address or other tracking info about who performed this group subscription.
+ *
+ * @var string
+ */
+ protected $tracking = '';
+
+ /**
+ * @inheritDoc
+ */
+ protected function writeObjects($items) {
+ foreach ($items as &$item) {
+ $item['method'] = $this->method;
+ $item['tracking'] = $this->tracking;
+ }
+ return parent::writeObjects($items);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\GroupContact;
+
+/**
+ * @inheritDoc
+ */
+class Save extends \Civi\Api4\Generic\DAOSaveAction {
+ use GroupContactSaveTrait;
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\GroupContact;
+
+/**
+ * @inheritDoc
+ */
+class Update extends \Civi\Api4\Generic\DAOUpdateAction {
+ use GroupContactSaveTrait;
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\Relationship;
+
+/**
+ * @inheritDoc
+ *
+ * Set current = true to get active, non past relationships.
+ */
+class Get extends \Civi\Api4\Generic\DAOGetAction {
+ use \Civi\Api4\Generic\Traits\IsCurrentTrait;
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\Setting;
+
+use Civi\Api4\Domain;
+use Civi\Api4\Generic\Result;
+
+/**
+ * Base class for setting actions.
+ *
+ * @method int getDomainId
+ * @method $this setDomainId(int $domainId)
+ */
+abstract class AbstractSettingAction extends \Civi\Api4\Generic\AbstractAction {
+
+ /**
+ * Domain id of setting. Leave NULL for default domain.
+ *
+ * @var int|string|array
+ */
+ protected $domainId;
+
+ /**
+ * Contact - if this is a contact-related setting.
+ *
+ * @var int
+ */
+ protected $contactId;
+
+ public function _run(Result $result) {
+ $this->findDomains();
+ $meta = [];
+ foreach ($this->domainId as $domain) {
+ $meta[$domain] = $this->validateSettings($domain);
+ }
+ foreach ($this->domainId as $domain) {
+ $settingsBag = $this->contactId ? \Civi::contactSettings($this->contactId, $domain) : \Civi::settings($domain);
+ $this->processSettings($result, $settingsBag, $meta[$domain], $domain);
+ }
+ }
+
+ /**
+ * Checks that really ought to be taken care of by Civi::settings
+ *
+ * @param int $domain
+ * @return array
+ * @throws \API_Exception
+ */
+ protected function validateSettings($domain) {
+ $meta = \Civi\Core\SettingsMetadata::getMetadata([], $domain);
+ $names = isset($this->values) ? array_keys($this->values) : $this->select;
+ $invalid = array_diff($names, array_keys($meta));
+ if ($invalid) {
+ throw new \API_Exception("Unknown settings for domain $domain: " . implode(', ', $invalid));
+ }
+ if (isset($this->values)) {
+ foreach ($this->values as $name => &$value) {
+ \CRM_Core_BAO_Setting::validateSetting($value, $meta[$name]);
+ }
+ }
+ return $meta;
+ }
+
+ protected function findDomains() {
+ if ($this->domainId == 'all') {
+ $this->domainId = Domain::get()->setCheckPermissions(FALSE)->addSelect('id')->execute()->column('id');
+ }
+ elseif ($this->domainId) {
+ $this->domainId = (array) $this->domainId;
+ $domains = Domain::get()->setCheckPermissions(FALSE)->addSelect('id')->execute()->column('id');
+ $invalid = array_diff($this->domainId, $domains);
+ if ($invalid) {
+ throw new \API_Exception('Invalid domain id: ' . implode(', ', $invalid));
+ }
+ }
+ else {
+ $this->domainId = [\CRM_Core_Config::domainID()];
+ }
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\Setting;
+
+use Civi\Api4\Generic\Result;
+
+/**
+ * Get the value of one or more CiviCRM settings.
+ *
+ * @method array getSelect
+ * @method $this addSelect(string $name)
+ * @method $this setSelect(array $select)
+ */
+class Get extends AbstractSettingAction {
+
+ /**
+ * Names of settings to retrieve
+ *
+ * @var array
+ */
+ protected $select = [];
+
+ /**
+ * @param \Civi\Api4\Generic\Result $result
+ * @param \Civi\Core\SettingsBag $settingsBag
+ * @param array $meta
+ * @param int $domain
+ * @throws \Exception
+ */
+ protected function processSettings(Result $result, $settingsBag, $meta, $domain) {
+ if ($this->select) {
+ foreach ($this->select as $name) {
+ $result[] = [
+ 'name' => $name,
+ 'value' => $settingsBag->get($name),
+ 'domain_id' => $domain,
+ ];
+ }
+ }
+ else {
+ foreach ($settingsBag->all() as $name => $value) {
+ $result[] = [
+ 'name' => $name,
+ 'value' => $value,
+ 'domain_id' => $domain,
+ ];
+ }
+ }
+ foreach ($result as $name => &$setting) {
+ if (isset($setting['value']) && !empty($meta[$name]['serialize'])) {
+ $setting['value'] = \CRM_Core_DAO::unSerializeField($setting['value'], $meta[$name]['serialize']);
+ }
+ }
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\Setting;
+
+/**
+ * Get information about CiviCRM settings.
+ *
+ * @method int getDomainId
+ * @method $this setDomainId(int $domainId)
+ */
+class GetFields extends \Civi\Api4\Generic\BasicGetFieldsAction {
+
+ /**
+ * Domain id of settings. Leave NULL for default domain.
+ *
+ * @var int
+ */
+ protected $domainId;
+
+ protected function getRecords() {
+ // TODO: Waiting for filter handling to get fixed in core
+ // $names = $this->_itemsToGet('name');
+ // $filter = $names ? ['name' => $names] : [];
+ $filter = [];
+ return \Civi\Core\SettingsMetadata::getMetadata($filter, $this->domainId, $this->loadOptions);
+ }
+
+ public function fields() {
+ return [
+ [
+ 'name' => 'name',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'title',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'description',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'help_text',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'default',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'pseudoconstant',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'options',
+ 'data_type' => 'Array',
+ ],
+ [
+ 'name' => 'group_name',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'group',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'html_type',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'add',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'serialize',
+ 'data_type' => 'Integer',
+ ],
+ [
+ 'name' => 'data_type',
+ 'data_type' => 'Integer',
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\Setting;
+
+use Civi\Api4\Generic\Result;
+
+/**
+ * Revert one or more CiviCRM settings to their default value.
+ *
+ * @method array getSelect
+ * @method $this addSelect(string $name)
+ * @method $this setSelect(array $select)
+ */
+class Revert extends AbstractSettingAction {
+
+ /**
+ * Names of settings to revert
+ *
+ * @var array
+ * @required
+ */
+ protected $select = [];
+
+ /**
+ * @param \Civi\Api4\Generic\Result $result
+ * @param \Civi\Core\SettingsBag $settingsBag
+ * @param array $meta
+ * @param int $domain
+ * @throws \Exception
+ */
+ protected function processSettings(Result $result, $settingsBag, $meta, $domain) {
+ foreach ($this->select as $name) {
+ $settingsBag->revert($name);
+ $result[] = [
+ 'name' => $name,
+ 'value' => $settingsBag->get($name),
+ 'domain_id' => $domain,
+ ];
+ }
+ foreach ($result as $name => &$setting) {
+ if (isset($setting['value']) && !empty($meta[$name]['serialize'])) {
+ $setting['value'] = \CRM_Core_DAO::unSerializeField($setting['value'], $meta[$name]['serialize']);
+ }
+ }
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\Setting;
+
+use Civi\Api4\Generic\Result;
+
+/**
+ * Set the value of one or more CiviCRM settings.
+ *
+ * @method array getValues
+ * @method $this setValues(array $value)
+ * @method $this addValue(string $name, mixed $value)
+ */
+class Set extends AbstractSettingAction {
+
+ /**
+ * Setting names/values to set.
+ *
+ * @var mixed
+ * @required
+ */
+ protected $values = [];
+
+ /**
+ * @param \Civi\Api4\Generic\Result $result
+ * @param \Civi\Core\SettingsBag $settingsBag
+ * @param array $meta
+ * @param int $domain
+ * @throws \Exception
+ */
+ protected function processSettings(Result $result, $settingsBag, $meta, $domain) {
+ foreach ($this->values as $name => $value) {
+ if (isset($value) && !empty($meta[$name]['serialize'])) {
+ $value = \CRM_Core_DAO::serializeField($value, $meta[$name]['serialize']);
+ }
+ $settingsBag->set($name, $value);
+ $result[] = [
+ 'name' => $name,
+ 'value' => $this->values[$name],
+ 'domain_id' => $domain,
+ ];
+ }
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\System;
+
+/**
+ * Retrieve system notices, warnings, errors, etc.
+ */
+class Check extends \Civi\Api4\Generic\BasicGetAction {
+
+ protected function getRecords() {
+ $messages = [];
+ foreach (\CRM_Utils_Check::checkAll() as $message) {
+ $messages[] = $message->toArray();
+ }
+ return $messages;
+ }
+
+ public static function fields() {
+ return [
+ [
+ 'name' => 'name',
+ 'title' => 'Name',
+ 'description' => 'Unique identifier',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'title',
+ 'title' => 'Title',
+ 'description' => 'Short title text',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'message',
+ 'title' => 'Message',
+ 'description' => 'Long description html',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'help',
+ 'title' => 'Help',
+ 'description' => 'Optional extra help (html string)',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'icon',
+ 'description' => 'crm-i class of icon to display with message',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'severity',
+ 'title' => 'Severity',
+ 'description' => 'Psr\Log\LogLevel string',
+ 'data_type' => 'String',
+ 'options' => array_combine(\CRM_Utils_Check::getSeverityList(), \CRM_Utils_Check::getSeverityList()),
+ ],
+ [
+ 'name' => 'severity_id',
+ 'title' => 'Severity ID',
+ 'description' => 'Integer representation of Psr\Log\LogLevel',
+ 'data_type' => 'Integer',
+ 'options' => \CRM_Utils_Check::getSeverityList(),
+ ],
+ [
+ 'name' => 'is_visible',
+ 'title' => 'is visible',
+ 'description' => '0 if message has been hidden by the user',
+ 'data_type' => 'Boolean',
+ ],
+ [
+ 'name' => 'hidden_until',
+ 'title' => 'Hidden until',
+ 'description' => 'When will hidden message be visible again?',
+ 'data_type' => 'Date',
+ ],
+ [
+ 'name' => 'actions',
+ 'title' => 'Actions',
+ 'description' => 'List of actions user can perform',
+ 'data_type' => 'Array',
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Action\System;
+
+/**
+ * Clear CiviCRM caches, and optionally rebuild triggers and reset sessions.
+ *
+ * @method bool getTriggers
+ * @method $this setTriggers(bool $triggers)
+ * @method bool getSession
+ * @method $this setSession(bool $session)
+ */
+class Flush extends \Civi\Api4\Generic\AbstractAction {
+
+ /**
+ * Rebuild db triggers
+ *
+ * @var bool
+ */
+ protected $triggers = FALSE;
+
+ /**
+ * Reset sessions
+ *
+ * @var bool
+ */
+ protected $session = FALSE;
+
+ public function _run(\Civi\Api4\Generic\Result $result) {
+ \CRM_Core_Invoke::rebuildMenuAndCaches($this->triggers, $this->session);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * ActionSchedule Entity.
+ *
+ * This entity exposes CiviCRM schedule reminders, which allows us to send messages (through email or SMS)
+ * to contacts when certain criteria are met. Using this API you can create schedule reminder for
+ * supported entities like Contact, Activity, Event, Membership or Contribution.
+ *
+ * Creating a new ActionSchedule requires at minimum a title, mapping_id and entity_value.
+ *
+ * @package Civi\Api4
+ */
+class ActionSchedule extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Activity entity.
+ *
+ * This entity adds record of any scheduled or completed interaction with one or more contacts.
+ * Each activity record is tightly linked to other CiviCRM constituents. With this API you can manually
+ * create an activity of desired type for your organisation or any other contact.
+ *
+ * Creating a new Activity requires at minimum a activity_type_id, entity ID and object_table
+ *
+ * An activity is a record of some type of interaction with one or more contacts.
+ *
+ * @package Civi\Api4
+ */
+class Activity extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * ActivityContact Entity.
+ *
+ * This entity adds a record which relate a contact to activity.
+ *
+ * Creating a new ActivityContact requires at minimum a contact_id and activity_id.
+ *
+ * @package Civi\Api4
+ */
+class ActivityContact extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Address Entity.
+ *
+ * This entity holds the address informatiom of a contact. Each contact may hold
+ * one or more addresses but must have different location types respectively.
+ *
+ * Creating a new address requires at minimum a contact's ID and location type ID
+ * and other attributes (although optional) like street address, city, country etc.
+ *
+ * @package Civi\Api4
+ */
+class Address extends Generic\DAOEntity {
+
+ /**
+ * @return \Civi\Api4\Action\Address\Create
+ */
+ public static function create() {
+ return new \Civi\Api4\Action\Address\Create(__CLASS__, __FUNCTION__);
+ }
+
+ /**
+ * @return \Civi\Api4\Action\Address\Save
+ */
+ public static function save() {
+ return new \Civi\Api4\Action\Address\Save(__CLASS__, __FUNCTION__);
+ }
+
+ /**
+ * @return \Civi\Api4\Action\Address\Update
+ */
+ public static function update() {
+ return new \Civi\Api4\Action\Address\Update(__CLASS__, __FUNCTION__);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Campaign entity.
+ *
+ * @package Civi\Api4
+ */
+class Campaign extends Generic\DAOEntity {
+
+ /**
+ * @return \Civi\Api4\Action\Campaign\Get
+ */
+ public static function get() {
+ return new \Civi\Api4\Action\Campaign\Get(__CLASS__, __FUNCTION__);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Contacts - Individuals, Organizations, Households.
+ *
+ * This is the central entity in the CiviCRM database, and links to
+ * many other entities (Email, Phone, Participant, etc.).
+ *
+ * Creating a new contact requires at minimum a name or email address.
+ *
+ * @package Civi\Api4
+ */
+class Contact extends Generic\DAOEntity {
+
+ public static function getFields() {
+ return new Action\Contact\GetFields(__CLASS__, __FUNCTION__);
+ }
+
+ public static function getChecksum() {
+ return new Action\Contact\GetChecksum(__CLASS__, __FUNCTION__);
+ }
+
+ public static function validateChecksum() {
+ return new Action\Contact\ValidateChecksum(__CLASS__, __FUNCTION__);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * ContactType entity.
+ *
+ * With this entity you can create or update any new or existing Contact type or a sub type
+ * In case of updating existing ContactType, id of that particular ContactType must
+ * be in $params array.
+ *
+ * Creating a new contact type requires at minimum a label and parent_id.
+ *
+ * @package Civi\Api4
+ */
+class ContactType extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Contribution entity.
+ *
+ * @package Civi\Api4
+ */
+class Contribution extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * ContributionPage entity.
+ *
+ * @package Civi\Api4
+ */
+class ContributionPage extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * CustomField entity.
+ *
+ * @package Civi\Api4
+ */
+class CustomField extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * CustomGroup entity.
+ *
+ * @package Civi\Api4
+ */
+class CustomGroup extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * CustomGroup entity.
+ *
+ * @package Civi\Api4
+ */
+class CustomValue extends Generic\AbstractEntity {
+
+ /**
+ * @param string $customGroup
+ * @return Action\CustomValue\Get
+ */
+ public static function get($customGroup) {
+ return new Action\CustomValue\Get($customGroup, __FUNCTION__);
+ }
+
+ /**
+ * @param string $customGroup
+ * @return Action\CustomValue\GetFields
+ */
+ public static function getFields($customGroup = NULL) {
+ return new Action\CustomValue\GetFields($customGroup, __FUNCTION__);
+ }
+
+ /**
+ * @param string $customGroup
+ * @return Action\CustomValue\Save
+ */
+ public static function save($customGroup) {
+ return new Action\CustomValue\Save($customGroup, __FUNCTION__);
+ }
+
+ /**
+ * @param string $customGroup
+ * @return Action\CustomValue\Create
+ */
+ public static function create($customGroup) {
+ return new Action\CustomValue\Create($customGroup, __FUNCTION__);
+ }
+
+ /**
+ * @param string $customGroup
+ * @return Action\CustomValue\Update
+ */
+ public static function update($customGroup) {
+ return new Action\CustomValue\Update($customGroup, __FUNCTION__);
+ }
+
+ /**
+ * @param string $customGroup
+ * @return Action\CustomValue\Delete
+ */
+ public static function delete($customGroup) {
+ return new Action\CustomValue\Delete($customGroup, __FUNCTION__);
+ }
+
+ /**
+ * @param string $customGroup
+ * @return Action\CustomValue\Replace
+ */
+ public static function replace($customGroup) {
+ return new Action\CustomValue\Replace($customGroup, __FUNCTION__);
+ }
+
+ /**
+ * @param string $customGroup
+ * @return Action\CustomValue\GetActions
+ */
+ public static function getActions($customGroup = NULL) {
+ return new Action\CustomValue\GetActions($customGroup, __FUNCTION__);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public static function permissions() {
+ $entity = 'contact';
+ $permissions = \CRM_Core_Permission::getEntityActionPermissions();
+
+ // Merge permissions for this entity with the defaults
+ return \CRM_Utils_Array::value($entity, $permissions, []) + $permissions['default'];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Domains - multisite instances of CiviCRM.
+ *
+ * @package Civi\Api4
+ */
+class Domain extends Generic\DAOEntity {
+
+ public static function get() {
+ return new \Civi\Api4\Action\Domain\Get(__CLASS__, __FUNCTION__);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Email entity.
+ *
+ * This entity allows user to add, update, retrieve or delete emails address(es) of a contact.
+ *
+ * Creating a new email address requires at minimum a contact's ID and email
+ *
+ * @package Civi\Api4
+ */
+class Email extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Retrieves information about all Api4 entities.
+ *
+ * @package Civi\Api4
+ */
+class Entity extends Generic\AbstractEntity {
+
+ /**
+ * @return Action\Entity\Get
+ */
+ public static function get() {
+ return new Action\Entity\Get('Entity', __FUNCTION__);
+ }
+
+ /**
+ * @return \Civi\Api4\Generic\BasicGetFieldsAction
+ */
+ public static function getFields() {
+ return new \Civi\Api4\Generic\BasicGetFieldsAction('Entity', __FUNCTION__, function() {
+ return [
+ ['name' => 'name'],
+ ['name' => 'description'],
+ ['name' => 'comment'],
+ ];
+ });
+ }
+
+ /**
+ * @return Action\Entity\GetLinks
+ */
+ public static function getLinks() {
+ return new Action\Entity\GetLinks('Entity', __FUNCTION__);
+ }
+
+ /**
+ * @return array
+ */
+ public static function permissions() {
+ return [
+ 'default' => ['access CiviCRM'],
+ ];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * EntityTag - links tags to contacts, activities, etc.
+ *
+ * @package Civi\Api4
+ */
+class EntityTag extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Event entity.
+ *
+ * @package Civi\Api4
+ */
+class Event extends Generic\DAOEntity {
+
+ /**
+ * @return \Civi\Api4\Action\Event\Get
+ */
+ public static function get() {
+ return new \Civi\Api4\Action\Event\Get(__CLASS__, __FUNCTION__);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event;
+
+class Events {
+
+ /**
+ * Prepare the specification for a request. Fired from within a request to
+ * get fields.
+ *
+ * @see GetSpecEvent
+ */
+ const GET_SPEC = 'civi.api.get_spec';
+
+ /**
+ * Build the database schema, allow adding of custom joins and tables.
+ */
+ const SCHEMA_MAP_BUILD = 'api.schema_map.build';
+
+ /**
+ * Alter query results of APIv4 select query
+ */
+ const POST_SELECT_QUERY = 'api.select_query.post';
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event;
+
+use Civi\Api4\Generic\AbstractAction;
+use Symfony\Component\EventDispatcher\Event as BaseEvent;
+
+class GetSpecEvent extends BaseEvent {
+ /**
+ * @var \Civi\Api4\Generic\AbstractAction
+ */
+ protected $request;
+
+ /**
+ * @param \Civi\Api4\Generic\AbstractAction $request
+ */
+ public function __construct(AbstractAction $request) {
+ $this->request = $request;
+ }
+
+ /**
+ * @return \Civi\Api4\Generic\AbstractAction
+ */
+ public function getRequest() {
+ return $this->request;
+ }
+
+ /**
+ * @param $request
+ */
+ public function setRequest(AbstractAction $request) {
+ $this->request = $request;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event;
+
+use Civi\Api4\Query\Api4SelectQuery;
+use Symfony\Component\EventDispatcher\Event;
+
+class PostSelectQueryEvent extends Event {
+
+ /**
+ * @var array
+ */
+ protected $results;
+
+ /**
+ * @var \Civi\Api4\Query\Api4SelectQuery
+ */
+ protected $query;
+
+ /**
+ * PostSelectQueryEvent constructor.
+ * @param array $results
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ */
+ public function __construct(array $results, Api4SelectQuery $query) {
+ $this->results = $results;
+ $this->query = $query;
+ }
+
+ /**
+ * @return array
+ */
+ public function getResults() {
+ return $this->results;
+ }
+
+ /**
+ * @param array $results
+ * @return $this
+ */
+ public function setResults($results) {
+ $this->results = $results;
+
+ return $this;
+ }
+
+ /**
+ * @return \Civi\Api4\Query\Api4SelectQuery
+ */
+ public function getQuery() {
+ return $this->query;
+ }
+
+ /**
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ * @return $this
+ */
+ public function setQuery($query) {
+ $this->query = $query;
+
+ return $this;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event;
+
+use Civi\Api4\Service\Schema\SchemaMap;
+use Symfony\Component\EventDispatcher\Event as BaseEvent;
+
+class SchemaMapBuildEvent extends BaseEvent {
+ /**
+ * @var \Civi\Api4\Service\Schema\SchemaMap
+ */
+ protected $schemaMap;
+
+ /**
+ * @param \Civi\Api4\Service\Schema\SchemaMap $schemaMap
+ */
+ public function __construct(SchemaMap $schemaMap) {
+ $this->schemaMap = $schemaMap;
+ }
+
+ /**
+ * @return \Civi\Api4\Service\Schema\SchemaMap
+ */
+ public function getSchemaMap() {
+ return $this->schemaMap;
+ }
+
+ /**
+ * @param \Civi\Api4\Service\Schema\SchemaMap $schemaMap
+ *
+ * @return $this
+ */
+ public function setSchemaMap($schemaMap) {
+ $this->schemaMap = $schemaMap;
+
+ return $this;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Api4\Generic\DAOCreateAction;
+use Civi\Api4\OptionValue;
+
+class ActivityPreCreationSubscriber extends Generic\PreCreationSubscriber {
+
+ /**
+ * @param \Civi\Api4\Generic\DAOCreateAction $request
+ * @throws \API_Exception
+ * @throws \Exception
+ */
+ protected function modify(DAOCreateAction $request) {
+ $activityType = $request->getValue('activity_type');
+ if ($activityType) {
+ $result = OptionValue::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('name', '=', $activityType)
+ ->addWhere('option_group.name', '=', 'activity_type')
+ ->execute();
+
+ if ($result->count() !== 1) {
+ throw new \Exception('Activity type must match a *single* type');
+ }
+
+ $request->addValue('activity_type_id', $result->first()['value']);
+ }
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\DAOCreateAction $request
+ *
+ * @return bool
+ */
+ protected function applies(DAOCreateAction $request) {
+ return $request->getEntityName() === 'Activity';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Api4\Event\Events;
+use Civi\Api4\Event\SchemaMapBuildEvent;
+use Civi\Api4\Service\Schema\Joinable\ActivityToActivityContactAssigneesJoinable;
+use Civi\Api4\Service\Schema\Joinable\BridgeJoinable;
+use Civi\Api4\Service\Schema\Joinable\Joinable;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+class ActivitySchemaMapSubscriber implements EventSubscriberInterface {
+
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents() {
+ return [
+ Events::SCHEMA_MAP_BUILD => 'onSchemaBuild',
+ ];
+ }
+
+ /**
+ * @param \Civi\Api4\Event\SchemaMapBuildEvent $event
+ */
+ public function onSchemaBuild(SchemaMapBuildEvent $event) {
+ $schema = $event->getSchemaMap();
+ $table = $schema->getTableByName('civicrm_activity');
+
+ $middleAlias = \CRM_Utils_String::createRandom(10, implode(range('a', 'z')));
+ $middleLink = new ActivityToActivityContactAssigneesJoinable($middleAlias);
+
+ $bridge = new BridgeJoinable('civicrm_contact', 'id', 'assignees', $middleLink);
+ $bridge->setBaseTable('civicrm_activity_contact');
+ $bridge->setJoinType(Joinable::JOIN_TYPE_ONE_TO_MANY);
+
+ $table->addTableLink('contact_id', $bridge);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Api4\Generic\AbstractAction;
+
+class ContactPreSaveSubscriber extends Generic\PreSaveSubscriber {
+
+ public $supportedOperation = 'create';
+
+ public function modify(&$contact, AbstractAction $request) {
+ // Guess which type of contact is being created
+ if (empty($contact['contact_type']) && !empty($contact['organization_name'])) {
+ $contact['contact_type'] = 'Organization';
+ }
+ if (empty($contact['contact_type']) && !empty($contact['household_name'])) {
+ $contact['contact_type'] = 'Household';
+ }
+ }
+
+ public function applies(AbstractAction $request) {
+ return $request->getEntityName() === 'Contact';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Api4\Event\Events;
+use Civi\Api4\Event\SchemaMapBuildEvent;
+use Civi\Api4\Service\Schema\Joinable\Joinable;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+class ContactSchemaMapSubscriber implements EventSubscriberInterface {
+
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents() {
+ return [
+ Events::SCHEMA_MAP_BUILD => 'onSchemaBuild',
+ ];
+ }
+
+ /**
+ * @param \Civi\Api4\Event\SchemaMapBuildEvent $event
+ */
+ public function onSchemaBuild(SchemaMapBuildEvent $event) {
+ $schema = $event->getSchemaMap();
+ $table = $schema->getTableByName('civicrm_contact');
+ $this->addCreatedActivitiesLink($table);
+ $this->fixPreferredLanguageAlias($table);
+ }
+
+ /**
+ * @param \Civi\Api4\Service\Schema\Table $table
+ */
+ private function addCreatedActivitiesLink($table) {
+ $alias = 'created_activities';
+ $joinable = new Joinable('civicrm_activity_contact', 'contact_id', $alias);
+ $joinable->addCondition($alias . '.record_type_id = 1');
+ $joinable->setJoinType($joinable::JOIN_TYPE_ONE_TO_MANY);
+ $table->addTableLink('id', $joinable);
+ }
+
+ /**
+ * @param \Civi\Api4\Service\Schema\Table $table
+ */
+ private function fixPreferredLanguageAlias($table) {
+ foreach ($table->getExternalLinks() as $link) {
+ if ($link->getAlias() === 'languages') {
+ $link->setAlias('preferred_language');
+ return;
+ }
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Api4\Generic\AbstractAction;
+
+class ContributionPreSaveSubscriber extends Generic\PreSaveSubscriber {
+
+ public function modify(&$record, AbstractAction $request) {
+ // Required by Contribution BAO
+ $record['skipCleanMoney'] = TRUE;
+ }
+
+ public function applies(AbstractAction $request) {
+ return $request->getEntityName() === 'Contribution';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Api4\Generic\AbstractAction;
+
+class CustomFieldPreSaveSubscriber extends Generic\PreSaveSubscriber {
+
+ public $supportedOperation = 'create';
+
+ public function modify(&$field, AbstractAction $request) {
+ if (!empty($field['option_values'])) {
+ $weight = 0;
+ foreach ($field['option_values'] as $key => $value) {
+ // Translate simple key/value pairs into full-blown option values
+ if (!is_array($value)) {
+ $value = [
+ 'label' => $value,
+ 'value' => $key,
+ 'is_active' => 1,
+ 'weight' => $weight,
+ ];
+ $key = $weight++;
+ }
+ $field['option_label'][$key] = $value['label'];
+ $field['option_value'][$key] = $value['value'];
+ $field['option_status'][$key] = $value['is_active'];
+ $field['option_weight'][$key] = $value['weight'];
+ }
+ }
+ $field['option_type'] = !empty($field['option_values']);
+ }
+
+ public function applies(AbstractAction $request) {
+ return $request->getEntityName() === 'CustomField';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Api4\Generic\DAOCreateAction;
+
+class CustomGroupPreCreationSubscriber extends Generic\PreCreationSubscriber {
+
+ /**
+ * @param \Civi\Api4\Generic\DAOCreateAction $request
+ */
+ protected function modify(DAOCreateAction $request) {
+ $extends = $request->getValue('extends');
+ $title = $request->getValue('title');
+ $name = $request->getValue('name');
+
+ if (is_string($extends)) {
+ $request->addValue('extends', [$extends]);
+ }
+
+ if (NULL === $title && $name) {
+ $request->addValue('title', $name);
+ }
+ }
+
+ protected function applies(DAOCreateAction $request) {
+ return $request->getEntityName() === 'CustomGroup';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber\Generic;
+
+use Civi\API\Event\PrepareEvent;
+use Civi\API\Events;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+abstract class AbstractPrepareSubscriber implements EventSubscriberInterface {
+
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents() {
+ return [
+ Events::PREPARE => 'onApiPrepare',
+ ];
+ }
+
+ /**
+ * @param \Civi\API\Event\PrepareEvent $event
+ */
+ abstract public function onApiPrepare(PrepareEvent $event);
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber\Generic;
+
+use Civi\API\Event\PrepareEvent;
+use Civi\Api4\Generic\DAOCreateAction;
+
+abstract class PreCreationSubscriber extends AbstractPrepareSubscriber {
+
+ /**
+ * @param \Civi\API\Event\PrepareEvent $event
+ */
+ public function onApiPrepare(PrepareEvent $event) {
+ $apiRequest = $event->getApiRequest();
+ if (!$apiRequest instanceof DAOCreateAction) {
+ return;
+ }
+
+ $this->addDefaultCreationValues($apiRequest);
+ if ($this->applies($apiRequest)) {
+ $this->modify($apiRequest);
+ }
+ }
+
+ /**
+ * Modify the request
+ *
+ * @param \Civi\Api4\Generic\DAOCreateAction $request
+ *
+ * @return void
+ */
+ abstract protected function modify(DAOCreateAction $request);
+
+ /**
+ * Check if this subscriber should be applied to the request
+ *
+ * @param \Civi\Api4\Generic\DAOCreateAction $request
+ *
+ * @return bool
+ */
+ abstract protected function applies(DAOCreateAction $request);
+
+ /**
+ * Sets default values common to all creation requests
+ *
+ * @param \Civi\Api4\Generic\DAOCreateAction $request
+ */
+ protected function addDefaultCreationValues(DAOCreateAction $request) {
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber\Generic;
+
+use Civi\API\Event\PrepareEvent;
+use Civi\Api4\Generic\AbstractAction;
+use Civi\Api4\Generic\AbstractCreateAction;
+use Civi\Api4\Generic\AbstractUpdateAction;
+
+abstract class PreSaveSubscriber extends AbstractPrepareSubscriber {
+
+ /**
+ * @var string
+ * create|update|both
+ */
+ public $supportedOperation = 'both';
+
+ /**
+ * @param \Civi\API\Event\PrepareEvent $event
+ */
+ public function onApiPrepare(PrepareEvent $event) {
+ $apiRequest = $event->getApiRequest();
+
+ if ($apiRequest instanceof AbstractAction && $this->applies($apiRequest)) {
+ if (
+ ($apiRequest instanceof AbstractCreateAction && $this->supportedOperation !== 'update') ||
+ ($apiRequest instanceof AbstractUpdateAction && $this->supportedOperation !== 'create')
+ ) {
+ $values = $apiRequest->getValues();
+ $this->modify($values, $apiRequest);
+ $apiRequest->setValues($values);
+ }
+ }
+ }
+
+ /**
+ * Modify the item about to be saved
+ *
+ * @param array $item
+ * @param \Civi\Api4\Generic\AbstractAction $request
+ *
+ */
+ abstract protected function modify(&$item, AbstractAction $request);
+
+ /**
+ * Check if this subscriber should be applied to the request
+ *
+ * @param \Civi\Api4\Generic\AbstractAction $request
+ *
+ * @return bool
+ */
+ abstract protected function applies(AbstractAction $request);
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\API\Event\PrepareEvent;
+use Civi\Api4\Utils\ReflectionUtils;
+
+/**
+ * Process $current api param for Get actions
+ *
+ * @see \Civi\Api4\Generic\Traits\IsCurrentTrait
+ */
+class IsCurrentSubscriber extends Generic\AbstractPrepareSubscriber {
+
+ public function onApiPrepare(PrepareEvent $event) {
+ /** @var \Civi\Api4\Generic\AbstractQueryAction $action */
+ $action = $event->getApiRequest();
+ if ($action['version'] == 4 && method_exists($action, 'getCurrent')
+ && in_array('Civi\Api4\Generic\Traits\IsCurrentTrait', ReflectionUtils::getTraits($action))
+ ) {
+ $fields = $action->entityFields();
+ if ($action->getCurrent()) {
+ if (isset($fields['is_active'])) {
+ $action->addWhere('is_active', '=', '1');
+ }
+ $action->addClause('OR', ['start_date', 'IS NULL'], ['start_date', '<=', 'now']);
+ $action->addClause('OR', ['end_date', 'IS NULL'], ['end_date', '>=', 'now']);
+ }
+ elseif ($action->getCurrent() === FALSE) {
+ $conditions = [['end_date', '<', 'now'], ['start_date', '>', 'now']];
+ if (isset($fields['is_active'])) {
+ $conditions[] = ['is_active', '=', '0'];
+ }
+ $action->addClause('OR', $conditions);
+ }
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Api4\Generic\DAOCreateAction;
+use Civi\Api4\OptionGroup;
+
+class OptionValuePreCreationSubscriber extends Generic\PreCreationSubscriber {
+
+ /**
+ * @param \Civi\Api4\Generic\DAOCreateAction $request
+ */
+ protected function modify(DAOCreateAction $request) {
+ $this->setOptionGroupId($request);
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\DAOCreateAction $request
+ *
+ * @return bool
+ */
+ protected function applies(DAOCreateAction $request) {
+ return $request->getEntityName() === 'OptionValue';
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\DAOCreateAction $request
+ * @throws \API_Exception
+ * @throws \Exception
+ */
+ private function setOptionGroupId(DAOCreateAction $request) {
+ $optionGroupName = $request->getValue('option_group');
+ if (!$optionGroupName || $request->getValue('option_group_id')) {
+ return;
+ }
+
+ $optionGroup = OptionGroup::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('id')
+ ->addWhere('name', '=', $optionGroupName)
+ ->execute();
+
+ if ($optionGroup->count() !== 1) {
+ throw new \Exception('Option group name must match only a single group');
+ }
+
+ $request->addValue('option_group_id', $optionGroup->first()['id']);
+ }
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.7 |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2017 |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM. |
+ | |
+ | CiviCRM is free software; you can copy, modify, and distribute it |
+ | under the terms of the GNU Affero General Public License |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
+ | |
+ | CiviCRM is distributed in the hope that it will be useful, but |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
+ | See the GNU Affero General Public License for more details. |
+ | |
+ | You should have received a copy of the GNU Affero General Public |
+ | License and the CiviCRM Licensing Exception along |
+ | with this program; if not, contact CiviCRM LLC |
+ | at info[AT]civicrm[DOT]org. If you have questions about the |
+ | GNU Affero General Public License or the licensing of CiviCRM, |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\API\Events;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * For any API requests that correspond to a Doctrine entity
+ * ($apiRequest['doctrineClass']), check permissions specified in
+ * Civi\API\Annotation\Permission.
+ */
+class PermissionCheckSubscriber implements EventSubscriberInterface {
+
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents() {
+ return [
+ Events::AUTHORIZE => [
+ ['onApiAuthorize', Events::W_LATE],
+ ],
+ ];
+ }
+
+ /**
+ * @param \Civi\API\Event\AuthorizeEvent $event
+ * API authorization event.
+ */
+ public function onApiAuthorize(\Civi\API\Event\AuthorizeEvent $event) {
+ /* @var \Civi\Api4\Generic\AbstractAction $apiRequest */
+ $apiRequest = $event->getApiRequest();
+ if ($apiRequest['version'] == 4) {
+ if (!$apiRequest->getCheckPermissions() || $apiRequest->isAuthorized()) {
+ $event->authorize();
+ $event->stopPropagation();
+ }
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Api4\Event\Events;
+use Civi\Api4\Event\PostSelectQueryEvent;
+use Civi\Api4\Query\Api4SelectQuery;
+use Civi\Api4\Utils\ArrayInsertionUtil;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Changes the results of a select query, doing 1-n joins and unserializing data
+ */
+class PostSelectQuerySubscriber implements EventSubscriberInterface {
+
+ /**
+ * @inheritdoc
+ */
+ public static function getSubscribedEvents() {
+ return [
+ Events::POST_SELECT_QUERY => 'onPostQuery',
+ ];
+ }
+
+ /**
+ * @param \Civi\Api4\Event\PostSelectQueryEvent $event
+ */
+ public function onPostQuery(PostSelectQueryEvent $event) {
+ $results = $event->getResults();
+ $event->setResults($this->postRun($results, $event->getQuery()));
+ }
+
+ /**
+ * @param array $results
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ *
+ * @return array
+ */
+ protected function postRun(array $results, Api4SelectQuery $query) {
+ if (empty($results)) {
+ return $results;
+ }
+
+ $fieldSpec = $query->getApiFieldSpec();
+ $this->unserializeFields($results, $query->getEntity(), $fieldSpec);
+
+ // Group the selects to avoid queries for each field
+ $groupedSelects = $this->getNtoManyJoinSelects($query);
+ foreach ($groupedSelects as $finalAlias => $selects) {
+ $joinPath = $query->getPathJoinTypes($selects[0]);
+ $selects = $this->formatSelects($finalAlias, $selects, $query);
+ $joinResults = $this->getJoinResults($query, $finalAlias, $selects);
+ $this->formatJoinResults($joinResults, $query, $finalAlias);
+
+ // Insert join results into original result
+ foreach ($results as &$primaryResult) {
+ $baseId = $primaryResult['id'];
+ $filtered = array_filter($joinResults, function ($res) use ($baseId) {
+ return ($res['_base_id'] === $baseId);
+ });
+ $filtered = array_values($filtered);
+ ArrayInsertionUtil::insert($primaryResult, $joinPath, $filtered);
+ }
+ }
+
+ return array_values($results);
+ }
+
+ /**
+ * @param array $joinResults
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ * @param string $alias
+ */
+ private function formatJoinResults(&$joinResults, $query, $alias) {
+ $join = $query->getJoinedTable($alias);
+ $fields = [];
+ foreach ($join->getEntityFields() as $field) {
+ $name = explode('.', $field->getName());
+ $fields[array_pop($name)] = $field->toArray();
+ }
+ if ($fields) {
+ $this->unserializeFields($joinResults, NULL, $fields);
+ }
+ }
+
+ /**
+ * Unserialize values
+ *
+ * @param array $results
+ * @param string $entity
+ * @param array $fields
+ */
+ protected function unserializeFields(&$results, $entity, $fields = []) {
+ foreach ($results as &$result) {
+ foreach ($result as $field => &$value) {
+ if (!empty($fields[$field]['serialize']) && is_string($value)) {
+ $serializationType = $fields[$field]['serialize'];
+ $value = \CRM_Core_DAO::unSerializeField($value, $serializationType);
+ }
+ }
+ }
+ }
+
+ /**
+ * Find only those joins that need to be handled by a separate query and weren't done in the main query.
+ *
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ *
+ * @return array
+ */
+ private function getNtoManyJoinSelects(Api4SelectQuery $query) {
+ $fkAliases = $query->getFkSelectAliases();
+ $joinedDotSelects = array_filter(
+ $query->getSelect(),
+ function ($select) use ($fkAliases, $query) {
+ return isset($fkAliases[$select]) && array_filter($query->getPathJoinTypes($select));
+ }
+ );
+
+ $selects = [];
+ // group related selects by alias so they can be executed in one query
+ foreach ($joinedDotSelects as $select) {
+ $parts = explode('.', $select);
+ $finalAlias = $parts[count($parts) - 2];
+ $selects[$finalAlias][] = $select;
+ }
+
+ // sort by depth, e.g. email selects should be done before email.location
+ uasort($selects, function ($a, $b) {
+ $aFirst = $a[0];
+ $bFirst = $b[0];
+ return substr_count($aFirst, '.') > substr_count($bFirst, '.');
+ });
+
+ return $selects;
+ }
+
+ /**
+ * @param array $selects
+ * @param $serializationType
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ *
+ * @return array
+ */
+ private function getResultsForSerializedField(
+ array $selects,
+ $serializationType,
+ Api4SelectQuery $query
+ ) {
+ // Get the alias (Selects are grouped and all target the same table)
+ $sampleField = current($selects);
+ $alias = strstr($sampleField, '.', TRUE);
+
+ // Fetch the results with the serialized field
+ $selects['serialized'] = $query::MAIN_TABLE_ALIAS . '.' . $alias;
+ $serializedResults = $this->runWithNewSelects($selects, $query);
+ $newResults = [];
+
+ // Create a new results array, with a separate entry for each option value
+ foreach ($serializedResults as $result) {
+ $optionValues = \CRM_Core_DAO::unSerializeField(
+ $result['serialized'],
+ $serializationType
+ );
+ unset($result['serialized']);
+ foreach ($optionValues as $value) {
+ $newResults[] = array_merge($result, ['value' => $value]);
+ }
+ }
+
+ $optionValueValues = array_unique(array_column($newResults, 'value'));
+ $optionValues = $this->getOptionValuesFromValues(
+ $selects,
+ $query,
+ $optionValueValues
+ );
+ $valueField = $alias . '.value';
+
+ // Index by value
+ foreach ($optionValues as $key => $subResult) {
+ $optionValues[$subResult['value']] = $subResult;
+ unset($subResult[$key]);
+
+ // Exclude 'value' if not in original selects
+ if (!in_array($valueField, $selects)) {
+ unset($optionValues[$subResult['value']]['value']);
+ }
+ }
+
+ // Replace serialized with the sub-select results
+ foreach ($newResults as &$result) {
+ $result = array_merge($result, $optionValues[$result['value']]);
+ unset($result['value']);
+ }
+
+ return $newResults;
+ }
+
+ /**
+ * Prepares selects for the subquery to fetch join results
+ *
+ * @param string $alias
+ * @param array $selects
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ *
+ * @return array
+ */
+ private function formatSelects($alias, $selects, Api4SelectQuery $query) {
+ $mainAlias = $query::MAIN_TABLE_ALIAS;
+ $selectFields = [];
+
+ foreach ($selects as $select) {
+ $selectAlias = $query->getFkSelectAliases()[$select];
+ $fieldAlias = substr($select, strrpos($select, '.') + 1);
+ $selectFields[$fieldAlias] = $selectAlias;
+ }
+
+ $firstSelect = $selects[0];
+ $pathParts = explode('.', $firstSelect);
+ $numParts = count($pathParts);
+ $parentAlias = $numParts > 2 ? $pathParts[$numParts - 3] : $mainAlias;
+
+ $selectFields['id'] = sprintf('%s.id', $alias);
+ $selectFields['_parent_id'] = $parentAlias . '.id';
+ $selectFields['_base_id'] = $mainAlias . '.id';
+
+ return $selectFields;
+ }
+
+ /**
+ * @param array $selects
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ *
+ * @return array
+ */
+ private function runWithNewSelects(array $selects, Api4SelectQuery $query) {
+ $aliasedSelects = array_map(function ($field, $alias) {
+ return sprintf('%s as "%s"', $field, $alias);
+ }, $selects, array_keys($selects));
+
+ $newSelect = sprintf('SELECT DISTINCT %s', implode(", ", $aliasedSelects));
+ $sql = str_replace("\n", ' ', $query->getQuery()->toSQL());
+ $originalSelect = substr($sql, 0, strpos($sql, ' FROM'));
+ $sql = str_replace($originalSelect, $newSelect, $sql);
+
+ $relatedResults = [];
+ $resultDAO = \CRM_Core_DAO::executeQuery($sql);
+ while ($resultDAO->fetch()) {
+ $relatedResult = [];
+ foreach ($selects as $alias => $column) {
+ $returnName = $alias;
+ $alias = str_replace('.', '_', $alias);
+ if (property_exists($resultDAO, $alias)) {
+ $relatedResult[$returnName] = $resultDAO->$alias;
+ }
+ };
+ $relatedResults[] = $relatedResult;
+ }
+
+ return $relatedResults;
+ }
+
+ /**
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ * @param $alias
+ * @param $selects
+ * @return array
+ */
+ protected function getJoinResults(Api4SelectQuery $query, $alias, $selects) {
+ $apiFieldSpec = $query->getApiFieldSpec();
+ if (!empty($apiFieldSpec[$alias]['serialize'])) {
+ $type = $apiFieldSpec[$alias]['serialize'];
+ $joinResults = $this->getResultsForSerializedField($selects, $type, $query);
+ }
+ else {
+ $joinResults = $this->runWithNewSelects($selects, $query);
+ }
+
+ // Remove results with no matching entries
+ $joinResults = array_filter($joinResults, function ($result) {
+ return !empty($result['id']);
+ });
+
+ return $joinResults;
+ }
+
+ /**
+ * Get all the option_value values required in the query
+ *
+ * @param array $selects
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ * @param array $values
+ *
+ * @return array
+ */
+ private function getOptionValuesFromValues(
+ array $selects,
+ Api4SelectQuery $query,
+ array $values
+ ) {
+ $sampleField = current($selects);
+ $alias = strstr($sampleField, '.', TRUE);
+
+ // Get the option value table that was joined
+ $relatedTable = NULL;
+ foreach ($query->getJoinedTables() as $joinedTable) {
+ if ($joinedTable->getAlias() === $alias) {
+ $relatedTable = $joinedTable;
+ }
+ }
+
+ // We only want subselects related to the joined table
+ $subSelects = array_filter($selects, function ($select) use ($alias) {
+ return strpos($select, $alias) === 0;
+ });
+
+ // Fetch all related option_value entries
+ $valueField = $alias . '.value';
+ $subSelects[] = $valueField;
+ $tableName = $relatedTable->getTargetTable();
+ $conditions = $relatedTable->getExtraJoinConditions();
+ $conditions[] = $valueField . ' IN ("' . implode('", "', $values) . '")';
+ $subQuery = new \CRM_Utils_SQL_Select($tableName . ' ' . $alias);
+ $subQuery->where($conditions);
+ $subQuery->select($subSelects);
+ $subResults = $subQuery->execute()->fetchAll();
+
+ return $subResults;
+ }
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.7 |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2017 |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM. |
+ | |
+ | CiviCRM is free software; you can copy, modify, and distribute it |
+ | under the terms of the GNU Affero General Public License |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
+ | |
+ | CiviCRM is distributed in the hope that it will be useful, but |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
+ | See the GNU Affero General Public License for more details. |
+ | |
+ | You should have received a copy of the GNU Affero General Public |
+ | License and the CiviCRM Licensing Exception along |
+ | with this program; if not, contact CiviCRM LLC |
+ | at info[AT]civicrm[DOT]org. If you have questions about the |
+ | GNU Affero General Public License or the licensing of CiviCRM, |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\API\Event\PrepareEvent;
+
+/**
+ * Validate field inputs based on annotations in the action class
+ */
+class ValidateFieldsSubscriber extends Generic\AbstractPrepareSubscriber {
+
+ /**
+ * @param \Civi\API\Event\PrepareEvent $event
+ * @throws \Exception
+ */
+ public function onApiPrepare(PrepareEvent $event) {
+ /** @var \Civi\Api4\Generic\AbstractAction $apiRequest */
+ $apiRequest = $event->getApiRequest();
+ if (is_a($apiRequest, 'Civi\Api4\Generic\AbstractAction')) {
+ $paramInfo = $apiRequest->getParamInfo();
+ foreach ($paramInfo as $param => $info) {
+ $getParam = 'get' . ucfirst($param);
+ $value = $apiRequest->$getParam();
+ // Required fields
+ if (!empty($info['required']) && (!$value && $value !== 0 && $value !== '0')) {
+ throw new \API_Exception('Parameter "' . $param . '" is required.');
+ }
+ if (!empty($info['type']) && !self::checkType($value, $info['type'])) {
+ throw new \API_Exception('Parameter "' . $param . '" is not of the correct type. Expecting ' . implode(' or ', $info['type']) . '.');
+ }
+ }
+ }
+ }
+
+ /**
+ * Validate variable type on input
+ *
+ * @param $value
+ * @param $types
+ * @return bool
+ * @throws \API_Exception
+ */
+ public static function checkType($value, $types) {
+ if ($value === NULL) {
+ return TRUE;
+ }
+ foreach ($types as $type) {
+ switch ($type) {
+ case 'array':
+ case 'bool':
+ case 'string':
+ case 'object':
+ $tester = 'is_' . $type;
+ if ($tester($value)) {
+ return TRUE;
+ }
+ break;
+
+ case 'int':
+ if (\CRM_Utils_Rule::integer($value)) {
+ return TRUE;
+ }
+ break;
+
+ case 'mixed':
+ return TRUE;
+
+ default:
+ throw new \API_Exception('Unknown parameter type: ' . $type);
+ }
+ }
+ return FALSE;
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Generic;
+
+use Civi\Api4\Utils\ReflectionUtils;
+use Civi\Api4\Utils\ActionUtil;
+
+/**
+ * Base class for all api actions.
+ *
+ * @method $this setCheckPermissions(bool $value)
+ * @method bool getCheckPermissions()
+ * @method $this setChain(array $chain)
+ * @method array getChain()
+ */
+abstract class AbstractAction implements \ArrayAccess {
+
+ /**
+ * Api version number; cannot be changed.
+ *
+ * @var int
+ */
+ protected $version = 4;
+
+ /**
+ * Additional api requests - will be called once per result.
+ *
+ * Keys can be any string - this will be the name given to the output.
+ *
+ * You can reference other values in the api results in this call by prefixing them with $
+ *
+ * For example, you could create a contact and place them in a group by chaining the
+ * GroupContact api to the Contact api:
+ *
+ * Contact::create()
+ * ->setValue('first_name', 'Hello')
+ * ->addChain('add_to_a_group', GroupContact::create()->setValue('contact_id', '$id')->setValue('group_id', 123))
+ *
+ * This will substitute the id of the newly created contact with $id.
+ *
+ * @var array
+ */
+ protected $chain = [];
+
+ /**
+ * Whether to enforce acl permissions based on the current user.
+ *
+ * Setting to FALSE will disable permission checks and override ACLs.
+ * In REST/javascript this cannot be disabled.
+ *
+ * @var bool
+ */
+ protected $checkPermissions = TRUE;
+
+ /**
+ * @var string
+ */
+ protected $_entityName;
+
+ /**
+ * @var string
+ */
+ protected $_actionName;
+
+ /**
+ * @var \ReflectionClass
+ */
+ private $_reflection;
+
+ /**
+ * @var array
+ */
+ private $_paramInfo;
+
+ /**
+ * @var array
+ */
+ private $_entityFields;
+
+ /**
+ * @var array
+ */
+ private $_arrayStorage = [];
+
+ /**
+ * @var int
+ * Used to identify api calls for transactions
+ * @see \Civi\Core\Transaction\Manager
+ */
+ private $_id;
+
+ /**
+ * Action constructor.
+ *
+ * @param string $entityName
+ * @param string $actionName
+ * @throws \API_Exception
+ */
+ public function __construct($entityName, $actionName) {
+ // If a namespaced class name is passed in
+ if (strpos($entityName, '\\') !== FALSE) {
+ $entityName = substr($entityName, strrpos($entityName, '\\') + 1);
+ }
+ $this->_entityName = $entityName;
+ $this->_actionName = $actionName;
+ $this->_id = \Civi\API\Request::getNextId();
+ }
+
+ /**
+ * Strictly enforce api parameters
+ * @param $name
+ * @param $value
+ * @throws \Exception
+ */
+ public function __set($name, $value) {
+ throw new \API_Exception('Unknown api parameter');
+ }
+
+ /**
+ * @param int $val
+ * @return $this
+ * @throws \API_Exception
+ */
+ public function setVersion($val) {
+ if ($val != 4) {
+ throw new \API_Exception('Cannot modify api version');
+ }
+ return $this;
+ }
+
+ /**
+ * @param string $name
+ * Unique name for this chained request
+ * @param \Civi\Api4\Generic\AbstractAction $apiRequest
+ * @param string|int $index
+ * Either a string for how the results should be indexed e.g. 'name'
+ * or the index of a single result to return e.g. 0 for the first result.
+ * @return $this
+ */
+ public function addChain($name, AbstractAction $apiRequest, $index = NULL) {
+ $this->chain[$name] = [$apiRequest->getEntityName(), $apiRequest->getActionName(), $apiRequest->getParams(), $index];
+ return $this;
+ }
+
+ /**
+ * Magic function to provide addFoo, getFoo and setFoo for params.
+ *
+ * @param $name
+ * @param $arguments
+ * @return static|mixed
+ * @throws \API_Exception
+ */
+ public function __call($name, $arguments) {
+ $param = lcfirst(substr($name, 3));
+ if (!$param || $param[0] == '_') {
+ throw new \API_Exception('Unknown api parameter: ' . $name);
+ }
+ $mode = substr($name, 0, 3);
+ // Handle plural when adding to e.g. $values with "addValue" method.
+ if ($mode == 'add' && $this->paramExists($param . 's')) {
+ $param .= 's';
+ }
+ if ($this->paramExists($param)) {
+ switch ($mode) {
+ case 'get':
+ return $this->$param;
+
+ case 'set':
+ $this->$param = $arguments[0];
+ return $this;
+
+ case 'add':
+ if (!is_array($this->$param)) {
+ throw new \API_Exception('Cannot add to non-array param');
+ }
+ if (array_key_exists(1, $arguments)) {
+ $this->{$param}[$arguments[0]] = $arguments[1];
+ }
+ else {
+ $this->{$param}[] = $arguments[0];
+ }
+ return $this;
+ }
+ }
+ throw new \API_Exception('Unknown api parameter: ' . $name);
+ }
+
+ /**
+ * Invoke api call.
+ *
+ * At this point all the params have been sent in and we initiate the api call & return the result.
+ * This is basically the outer wrapper for api v4.
+ *
+ * @return \Civi\Api4\Generic\Result
+ * @throws \Civi\API\Exception\UnauthorizedException
+ */
+ public function execute() {
+ /** @var \Civi\API\Kernel $kernel */
+ $kernel = \Civi::service('civi_api_kernel');
+
+ return $kernel->runRequest($this);
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\Result $result
+ */
+ abstract public function _run(Result $result);
+
+ /**
+ * Serialize this object's params into an array
+ * @return array
+ */
+ public function getParams() {
+ $params = [];
+ foreach ($this->reflect()->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) {
+ $name = $property->getName();
+ // Skip variables starting with an underscore
+ if ($name[0] != '_') {
+ $params[$name] = $this->$name;
+ }
+ }
+ return $params;
+ }
+
+ /**
+ * Get documentation for one or all params
+ *
+ * @param string $param
+ * @return array of arrays [description, type, default, (comment)]
+ */
+ public function getParamInfo($param = NULL) {
+ if (!isset($this->_paramInfo)) {
+ $defaults = $this->getParamDefaults();
+ foreach ($this->reflect()->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) {
+ $name = $property->getName();
+ if ($name != 'version' && $name[0] != '_') {
+ $this->_paramInfo[$name] = ReflectionUtils::getCodeDocs($property, 'Property');
+ $this->_paramInfo[$name]['default'] = $defaults[$name];
+ }
+ }
+ }
+ return $param ? $this->_paramInfo[$param] : $this->_paramInfo;
+ }
+
+ /**
+ * @return string
+ */
+ public function getEntityName() {
+ return $this->_entityName;
+ }
+
+ /**
+ *
+ * @return string
+ */
+ public function getActionName() {
+ return $this->_actionName;
+ }
+
+ /**
+ * @param string $param
+ * @return bool
+ */
+ public function paramExists($param) {
+ return array_key_exists($param, $this->getParams());
+ }
+
+ /**
+ * @return array
+ */
+ protected function getParamDefaults() {
+ return array_intersect_key($this->reflect()->getDefaultProperties(), $this->getParams());
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function offsetExists($offset) {
+ return in_array($offset, ['entity', 'action', 'params', 'version', 'check_permissions', 'id']) || isset($this->_arrayStorage[$offset]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function &offsetGet($offset) {
+ $val = NULL;
+ if (in_array($offset, ['entity', 'action'])) {
+ $offset .= 'Name';
+ }
+ if (in_array($offset, ['entityName', 'actionName', 'params', 'version'])) {
+ $getter = 'get' . ucfirst($offset);
+ $val = $this->$getter();
+ return $val;
+ }
+ if ($offset == 'check_permissions') {
+ return $this->checkPermissions;
+ }
+ if ($offset == 'id') {
+ return $this->_id;
+ }
+ if (isset($this->_arrayStorage[$offset])) {
+ return $this->_arrayStorage[$offset];
+ }
+ return $val;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function offsetSet($offset, $value) {
+ if (in_array($offset, ['entity', 'action', 'entityName', 'actionName', 'params', 'version', 'id'])) {
+ throw new \API_Exception('Cannot modify api4 state via array access');
+ }
+ if ($offset == 'check_permissions') {
+ $this->setCheckPermissions($value);
+ }
+ else {
+ $this->_arrayStorage[$offset] = $value;
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function offsetUnset($offset) {
+ if (in_array($offset, ['entity', 'action', 'entityName', 'actionName', 'params', 'check_permissions', 'version', 'id'])) {
+ throw new \API_Exception('Cannot modify api4 state via array access');
+ }
+ unset($this->_arrayStorage[$offset]);
+ }
+
+ /**
+ * Is this api call permitted?
+ *
+ * This function is called if checkPermissions is set to true.
+ *
+ * @return bool
+ */
+ public function isAuthorized() {
+ $permissions = $this->getPermissions();
+ return \CRM_Core_Permission::check($permissions);
+ }
+
+ /**
+ * @return array
+ */
+ public function getPermissions() {
+ $permissions = call_user_func(["\\Civi\\Api4\\" . $this->_entityName, 'permissions']);
+ $permissions += [
+ // applies to getFields, getActions, etc.
+ 'meta' => ['access CiviCRM'],
+ // catch-all, applies to create, get, delete, etc.
+ 'default' => ['administer CiviCRM'],
+ ];
+ $action = $this->getActionName();
+ if (isset($permissions[$action])) {
+ return $permissions[$action];
+ }
+ elseif (in_array($action, ['getActions', 'getFields'])) {
+ return $permissions['meta'];
+ }
+ return $permissions['default'];
+ }
+
+ /**
+ * Returns schema fields for this entity & action.
+ *
+ * Here we bypass the api wrapper and execute the getFields action directly.
+ * This is because we DON'T want the wrapper to check permissions as this is an internal op,
+ * but we DO want permissions to be checked inside the getFields request so e.g. the api_key
+ * field can be conditionally included.
+ * @see \Civi\Api4\Action\Contact\GetFields
+ *
+ * @return array
+ */
+ public function entityFields() {
+ if (!$this->_entityFields) {
+ $getFields = ActionUtil::getAction($this->getEntityName(), 'getFields');
+ $result = new Result();
+ if (method_exists($this, 'getBaoName')) {
+ $getFields->setIncludeCustom(FALSE);
+ }
+ $getFields
+ ->setCheckPermissions($this->checkPermissions)
+ ->setAction($this->getActionName())
+ ->_run($result);
+ $this->_entityFields = (array) $result->indexBy('name');
+ }
+ return $this->_entityFields;
+ }
+
+ /**
+ * @return \ReflectionClass
+ */
+ public function reflect() {
+ if (!$this->_reflection) {
+ $this->_reflection = new \ReflectionClass($this);
+ }
+ return $this->_reflection;
+ }
+
+ /**
+ * Validates required fields for actions which create a new object.
+ *
+ * @param $values
+ * @return array
+ * @throws \API_Exception
+ */
+ protected function checkRequiredFields($values) {
+ $unmatched = [];
+ foreach ($this->entityFields() as $fieldName => $fieldInfo) {
+ if (!isset($values[$fieldName]) || $values[$fieldName] === '') {
+ if (!empty($fieldInfo['required']) && !isset($fieldInfo['default_value'])) {
+ $unmatched[] = $fieldName;
+ }
+ elseif (!empty($fieldInfo['required_if'])) {
+ if ($this->evaluateCondition($fieldInfo['required_if'], ['values' => $values])) {
+ $unmatched[] = $fieldName;
+ }
+ }
+ }
+ }
+ return $unmatched;
+ }
+
+ /**
+ * This function is used internally for evaluating field annotations.
+ *
+ * It should never be passed raw user input.
+ *
+ * @param string $expr
+ * Conditional in php format e.g. $foo > $bar
+ * @param array $vars
+ * Variable name => value
+ * @return bool
+ * @throws \API_Exception
+ * @throws \Exception
+ */
+ protected function evaluateCondition($expr, $vars) {
+ if (strpos($expr, '}') !== FALSE || strpos($expr, '{') !== FALSE) {
+ throw new \API_Exception('Illegal character in expression');
+ }
+ $tpl = "{if $expr}1{else}0{/if}";
+ return (bool) trim(\CRM_Core_Smarty::singleton()->fetchWith('string:' . $tpl, $vars));
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Base class for all batch actions (Update, Delete, Replace).
+ *
+ * This differs from the AbstractQuery class in that the "Where" clause is required.
+ *
+ * @package Civi\Api4\Generic
+ */
+abstract class AbstractBatchAction extends AbstractQueryAction {
+
+ /**
+ * Criteria for selecting items to process.
+ *
+ * @var array
+ * @required
+ */
+ protected $where = [];
+
+ /**
+ * @var array
+ */
+ private $select;
+
+ /**
+ * BatchAction constructor.
+ * @param string $entityName
+ * @param string $actionName
+ * @param string|array $select
+ * One or more fields to load for each item.
+ */
+ public function __construct($entityName, $actionName, $select = 'id') {
+ $this->select = (array) $select;
+ parent::__construct($entityName, $actionName);
+ }
+
+ /**
+ * @return array
+ */
+ protected function getBatchRecords() {
+ $params = [
+ 'checkPermissions' => $this->checkPermissions,
+ 'where' => $this->where,
+ 'orderBy' => $this->orderBy,
+ 'limit' => $this->limit,
+ 'offset' => $this->offset,
+ ];
+ if (empty($this->reload)) {
+ $params['select'] = $this->select;
+ }
+
+ return (array) civicrm_api4($this->getEntityName(), 'get', $params);
+ }
+
+ /**
+ * @return array
+ */
+ protected function getSelect() {
+ return $this->select;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Base class for all "Create" api actions.
+ *
+ * @method $this setValues(array $values) Set all field values from an array of key => value pairs.
+ * @method $this addValue($field, $value) Set field value.
+ * @method array getValues() Get field values.
+ *
+ * @package Civi\Api4\Generic
+ */
+abstract class AbstractCreateAction extends AbstractAction {
+
+ /**
+ * Field values to set
+ *
+ * @var array
+ */
+ protected $values = [];
+
+ /**
+ * @param string $key
+ *
+ * @return mixed|null
+ */
+ public function getValue($key) {
+ return isset($this->values[$key]) ? $this->values[$key] : NULL;
+ }
+
+ /**
+ * @throws \API_Exception
+ */
+ protected function validateValues() {
+ $unmatched = $this->checkRequiredFields($this->getValues());
+ if ($unmatched) {
+ throw new \API_Exception("Mandatory values missing from Api4 {$this->getEntityName()}::{$this->getActionName()}: " . implode(", ", $unmatched), "mandatory_missing", ["fields" => $unmatched]);
+ }
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Generic;
+
+use Civi\API\Exception\NotImplementedException;
+
+/**
+ * Base class for all api entities.
+ *
+ * When adding your own api from an extension, extend this class only
+ * if your entity does not have an associated DAO. Otherwise extend DAOEntity.
+ *
+ * The recommended way to create a non-DAO-based api is to extend this class
+ * and then add a getFields function and any other actions you wish, e.g.
+ * - a get() function which returns BasicGetAction using your custom getter callback
+ * - a create() function which returns BasicCreateAction using your custom setter callback
+ * - an update() function which returns BasicUpdateAction using your custom setter callback
+ * - a delete() function which returns BasicBatchAction using your custom delete callback
+ * - a replace() function which returns BasicReplaceAction (no callback needed but
+ * depends on the existence of get, create, update & delete actions)
+ *
+ * Note that you can use the same setter callback function for update as create -
+ * that function can distinguish between new & existing records by checking if the
+ * unique identifier has been set (identifier field defaults to "id" but you can change
+ * that when constructing BasicUpdateAction)
+ */
+abstract class AbstractEntity {
+
+ /**
+ * @return \Civi\Api4\Action\GetActions
+ */
+ public static function getActions() {
+ return new \Civi\Api4\Action\GetActions(self::getEntityName(), __FUNCTION__);
+ }
+
+ /**
+ * Should return \Civi\Api4\Generic\BasicGetFieldsAction
+ * @todo make this function abstract when we require php 7.
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ public static function getFields() {
+ throw new NotImplementedException(self::getEntityName() . ' should implement getFields action.');
+ }
+
+ /**
+ * Returns a list of permissions needed to access the various actions in this api.
+ *
+ * @return array
+ */
+ public static function permissions() {
+ $permissions = \CRM_Core_Permission::getEntityActionPermissions();
+
+ // For legacy reasons the permissions are keyed by lowercase entity name
+ // Note: Convert to camel & back in order to circumvent all the api3 naming oddities
+ $lcentity = _civicrm_api_get_entity_name_from_camel(\CRM_Utils_String::convertStringToCamel(self::getEntityName()));
+ // Merge permissions for this entity with the defaults
+ return \CRM_Utils_Array::value($lcentity, $permissions, []) + $permissions['default'];
+ }
+
+ /**
+ * Get entity name from called class
+ *
+ * @return string
+ */
+ protected static function getEntityName() {
+ return substr(static::class, strrpos(static::class, '\\') + 1);
+ }
+
+ /**
+ * Magic method to return the action object for an api.
+ *
+ * @param string $action
+ * @param null $args
+ * @return AbstractAction
+ * @throws NotImplementedException
+ */
+ public static function __callStatic($action, $args) {
+ $entity = self::getEntityName();
+ // Find class for this action
+ $entityAction = "\\Civi\\Api4\\Action\\$entity\\" . ucfirst($action);
+ if (class_exists($entityAction)) {
+ $actionObject = new $entityAction($entity, $action);
+ }
+ else {
+ throw new NotImplementedException("Api $entity $action version 4 does not exist.");
+ }
+ return $actionObject;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Base class for all "Get" api actions.
+ *
+ * @package Civi\Api4\Generic
+ *
+ * @method $this addSelect(string $select)
+ * @method $this setSelect(array $selects)
+ * @method array getSelect()
+ */
+abstract class AbstractGetAction extends AbstractQueryAction {
+
+ /**
+ * Fields to return. Defaults to all fields.
+ *
+ * Set to ["row_count"] to return only the number of items found.
+ *
+ * @var array
+ */
+ protected $select = [];
+
+ /**
+ * Only return the number of found items.
+ *
+ * @return $this
+ */
+ public function selectRowCount() {
+ $this->select = ['row_count'];
+ return $this;
+ }
+
+ /**
+ * Adds field defaults to the where clause.
+ *
+ * Note: it will skip adding field defaults when fetching records by id,
+ * or if that field has already been added to the where clause.
+ *
+ * @throws \API_Exception
+ */
+ protected function setDefaultWhereClause() {
+ if (!$this->_itemsToGet('id')) {
+ $fields = $this->entityFields();
+ foreach ($fields as $field) {
+ if (isset($field['default_value']) && !$this->_whereContains($field['name'])) {
+ $this->addWhere($field['name'], '=', $field['default_value']);
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper to parse the WHERE param for getRecords to perform simple pre-filtering.
+ *
+ * This is intended to optimize some common use-cases e.g. calling the api to get
+ * one or more records by name or id.
+ *
+ * Ex: If getRecords fetches a long list of items each with a unique name,
+ * but the user has specified a single record to retrieve, you can optimize the call
+ * by checking $this->_itemsToGet('name') and only fetching the item(s) with that name.
+ *
+ * @param string $field
+ * @return array|null
+ */
+ protected function _itemsToGet($field) {
+ foreach ($this->where as $clause) {
+ // Look for exact-match operators (=, IN, or LIKE with no wildcard)
+ if ($clause[0] == $field && (in_array($clause[1], ['=', 'IN']) || ($clause[1] == 'LIKE' && !(is_string($clause[2]) && strpos($clause[2], '%') !== FALSE)))) {
+ return (array) $clause[2];
+ }
+ }
+ return NULL;
+ }
+
+ /**
+ * Helper to see if a field should be selected by the getRecords function.
+ *
+ * Checks the SELECT, WHERE and ORDER BY params to see what fields are needed.
+ *
+ * Note that if no SELECT clause has been set then all fields should be selected
+ * and this function will always return TRUE.
+ *
+ * @param string $field
+ * @return bool
+ */
+ protected function _isFieldSelected($field) {
+ if (!$this->select || in_array($field, $this->select) || isset($this->orderBy[$field])) {
+ return TRUE;
+ }
+ return $this->_whereContains($field);
+ }
+
+ /**
+ * Walk through the where clause and check if a field is in use.
+ *
+ * @param string $field
+ * @param array $clauses
+ * @return bool
+ */
+ protected function _whereContains($field, $clauses = NULL) {
+ if ($clauses === NULL) {
+ $clauses = $this->where;
+ }
+ foreach ($clauses as $clause) {
+ if (is_array($clause) && is_string($clause[0])) {
+ if ($clause[0] == $field) {
+ return TRUE;
+ }
+ elseif (is_array($clause[1])) {
+ return $this->_whereContains($field, $clause[1]);
+ }
+ }
+ }
+ return FALSE;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Base class for all actions that need to fetch records (Get, Update, Delete, etc)
+ *
+ * @package Civi\Api4\Generic
+ *
+ * @method $this setWhere(array $wheres)
+ * @method array getWhere()
+ * @method $this setOrderBy(array $order)
+ * @method array getOrderBy()
+ * @method $this setLimit(int $limit)
+ * @method int getLimit()
+ * @method $this setOffset(int $offset)
+ * @method int getOffset()
+ */
+abstract class AbstractQueryAction extends AbstractAction {
+
+ /**
+ * Criteria for selecting items.
+ *
+ * $example->addWhere('contact_type', 'IN', array('Individual', 'Household'))
+ *
+ * @var array
+ */
+ protected $where = [];
+
+ /**
+ * Array of field(s) to use in ordering the results
+ *
+ * Defaults to id ASC
+ *
+ * $example->addOrderBy('sort_name', 'ASC')
+ *
+ * @var array
+ */
+ protected $orderBy = [];
+
+ /**
+ * Maximum number of results to return.
+ *
+ * Defaults to unlimited.
+ *
+ * Note: the Api Explorer sets this to 25 by default to avoid timeouts.
+ * Change or remove this default for your application code.
+ *
+ * @var int
+ */
+ protected $limit = 0;
+
+ /**
+ * Zero-based index of first result to return.
+ *
+ * Defaults to "0" - first record.
+ *
+ * @var int
+ */
+ protected $offset = 0;
+
+ /**
+ * @param string $field
+ * @param string $op
+ * @param mixed $value
+ * @return $this
+ * @throws \API_Exception
+ */
+ public function addWhere($field, $op, $value = NULL) {
+ if (!in_array($op, \CRM_Core_DAO::acceptedSQLOperators())) {
+ throw new \API_Exception('Unsupported operator');
+ }
+ $this->where[] = [$field, $op, $value];
+ return $this;
+ }
+
+ /**
+ * Adds one or more AND/OR/NOT clause groups
+ *
+ * @param string $operator
+ * @param mixed $condition1 ... $conditionN
+ * Either a nested array of arguments, or a variable number of arguments passed to this function.
+ *
+ * @return $this
+ * @throws \API_Exception
+ */
+ public function addClause($operator, $condition1) {
+ if (!is_array($condition1[0])) {
+ $condition1 = array_slice(func_get_args(), 1);
+ }
+ $this->where[] = [$operator, $condition1];
+ return $this;
+ }
+
+ /**
+ * @param string $field
+ * @param string $direction
+ * @return $this
+ */
+ public function addOrderBy($field, $direction = 'ASC') {
+ $this->orderBy[$field] = $direction;
+ return $this;
+ }
+
+ /**
+ * A human-readable where clause, for the reading enjoyment of you humans.
+ *
+ * @param array $whereClause
+ * @param string $op
+ * @return string
+ */
+ protected function whereClauseToString($whereClause = NULL, $op = 'AND') {
+ if ($whereClause === NULL) {
+ $whereClause = $this->where;
+ }
+ $output = '';
+ if (!is_array($whereClause) || !$whereClause) {
+ return $output;
+ }
+ if (in_array($whereClause[0], ['AND', 'OR', 'NOT'])) {
+ $op = array_shift($whereClause);
+ if ($op == 'NOT') {
+ $output = 'NOT ';
+ $op = 'AND';
+ }
+ return $output . '(' . $this->whereClauseToString($whereClause, $op) . ')';
+ }
+ elseif (isset($whereClause[1]) && in_array($whereClause[1], \CRM_Core_DAO::acceptedSQLOperators())) {
+ $output = $whereClause[0] . ' ' . $whereClause[1] . ' ';
+ if (isset($whereClause[2])) {
+ $output .= is_array($whereClause[2]) ? '[' . implode(', ', $whereClause[2]) . ']' : $whereClause[2];
+ }
+ }
+ else {
+ $clauses = [];
+ foreach (array_filter($whereClause) as $clause) {
+ $clauses[] = $this->whereClauseToString($clause, $op);
+ }
+ $output = implode(" $op ", $clauses);
+ }
+ return $output;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Base class for all "Save" api actions.
+ *
+ * @method $this setRecords(array $records) Array of records.
+ * @method $this addRecord($record) Add a record to update.
+ * @method array getRecords()
+ * @method $this setDefaults(array $defaults) Array of defaults.
+ * @method $this addDefault($name, $value) Add a default value.
+ * @method array getDefaults()
+ * @method $this setReload(bool $reload) Specify whether complete objects will be returned after saving.
+ * @method bool getReload()
+ *
+ * @package Civi\Api4\Generic
+ */
+abstract class AbstractSaveAction extends AbstractAction {
+
+ /**
+ * Array of records.
+ *
+ * Should be in the same format as returned by Get.
+ *
+ * @var array
+ * @required
+ */
+ protected $records = [];
+
+ /**
+ * Array of default values.
+ *
+ * These defaults will be applied to all records unless they specify otherwise.
+ *
+ * @var array
+ */
+ protected $defaults = [];
+
+ /**
+ * Reload records after saving.
+ *
+ * By default this api typically returns partial records containing only the fields
+ * that were updated. Set reload to TRUE to do an additional lookup after saving
+ * to return complete records.
+ *
+ * @var bool
+ */
+ protected $reload = FALSE;
+
+ /**
+ * @var string
+ */
+ private $idField;
+
+ /**
+ * BatchAction constructor.
+ * @param string $entityName
+ * @param string $actionName
+ * @param string $idField
+ */
+ public function __construct($entityName, $actionName, $idField = 'id') {
+ // $idField should be a string but some apis (e.g. CustomValue) give us an array
+ $this->idField = array_values((array) $idField)[0];
+ parent::__construct($entityName, $actionName);
+ }
+
+ /**
+ * @throws \API_Exception
+ */
+ protected function validateValues() {
+ $unmatched = [];
+ foreach ($this->records as $record) {
+ if (empty($record[$this->idField])) {
+ $unmatched = array_unique(array_merge($unmatched, $this->checkRequiredFields($record)));
+ }
+ }
+ if ($unmatched) {
+ throw new \API_Exception("Mandatory values missing from Api4 {$this->getEntityName()}::{$this->getActionName()}: " . implode(", ", $unmatched), "mandatory_missing", ["fields" => $unmatched]);
+ }
+ }
+
+ /**
+ * @return string
+ */
+ protected function getIdField() {
+ return $this->idField;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Base class for all "Update" api actions
+ *
+ * @method $this setValues(array $values) Set all field values from an array of key => value pairs.
+ * @method $this addValue($field, $value) Set field value.
+ * @method array getValues() Get field values.
+ * @method $this setReload(bool $reload) Specify whether complete objects will be returned after saving.
+ * @method bool getReload()
+ *
+ * @package Civi\Api4\Generic
+ */
+abstract class AbstractUpdateAction extends AbstractBatchAction {
+
+ /**
+ * Field values to update.
+ *
+ * @var array
+ * @required
+ */
+ protected $values = [];
+
+ /**
+ * Reload objects after saving.
+ *
+ * Setting to TRUE will load complete records and return them as the api result.
+ * If FALSE the api usually returns only the fields specified to be updated.
+ *
+ * @var bool
+ */
+ protected $reload = FALSE;
+
+ /**
+ * @param string $key
+ *
+ * @return mixed|null
+ */
+ public function getValue($key) {
+ return isset($this->values[$key]) ? $this->values[$key] : NULL;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+use Civi\API\Exception\NotImplementedException;
+
+/**
+ * Basic action for deleting or performing some other task with a set of records. Ex:
+ *
+ * $myAction = new BasicBatchAction('Entity', 'action', function($item) {
+ * // Do something with $item
+ * $return $item;
+ * });
+ *
+ * @package Civi\Api4\Generic
+ */
+class BasicBatchAction extends AbstractBatchAction {
+
+ /**
+ * @var callable
+ *
+ * Function(array $item, BasicBatchAction $thisAction) => array
+ */
+ private $doer;
+
+ /**
+ * BasicBatchAction constructor.
+ *
+ * @param string $entityName
+ * @param string $actionName
+ * @param string|array $select
+ * One or more fields to select from each matching item.
+ * @param callable $doer
+ * Function(array $item, BasicBatchAction $thisAction) => array
+ */
+ public function __construct($entityName, $actionName, $select = 'id', $doer = NULL) {
+ parent::__construct($entityName, $actionName, $select);
+ $this->doer = $doer;
+ }
+
+ /**
+ * We pass the doTask function an array representing one item to update.
+ * We expect to get the same format back.
+ *
+ * @param \Civi\Api4\Generic\Result $result
+ */
+ public function _run(Result $result) {
+ foreach ($this->getBatchRecords() as $item) {
+ $result[] = $this->doTask($item);
+ }
+ }
+
+ /**
+ * This Basic Batch class can be used in one of two ways:
+ *
+ * 1. Use this class directly by passing a callable ($doer) to the constructor.
+ * 2. Extend this class and override this function.
+ *
+ * Either way, this function should return an array with an output record
+ * for the item.
+ *
+ * @param array $item
+ * @return array
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ protected function doTask($item) {
+ if (is_callable($this->doer)) {
+ return call_user_func($this->doer, $item, $this);
+ }
+ throw new NotImplementedException('Doer function not found for api4 ' . $this->getEntityName() . '::' . $this->getActionName());
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+use Civi\API\Exception\NotImplementedException;
+
+/**
+ * Create a new object from supplied values.
+ *
+ * This function will create 1 new object. It cannot be used to update existing objects. Use the Update or Replace actions for that.
+ */
+class BasicCreateAction extends AbstractCreateAction {
+
+ /**
+ * @var callable
+ *
+ * Function(array $item, BasicCreateAction $thisAction) => array
+ */
+ private $setter;
+
+ /**
+ * Basic Create constructor.
+ *
+ * @param string $entityName
+ * @param string $actionName
+ * @param callable $setter
+ * Function(array $item, BasicCreateAction $thisAction) => array
+ */
+ public function __construct($entityName, $actionName, $setter = NULL) {
+ parent::__construct($entityName, $actionName);
+ $this->setter = $setter;
+ }
+
+ /**
+ * We pass the writeRecord function an array representing one item to write.
+ * We expect to get the same format back.
+ *
+ * @param \Civi\Api4\Generic\Result $result
+ */
+ public function _run(Result $result) {
+ $this->validateValues();
+ $result->exchangeArray([$this->writeRecord($this->values)]);
+ }
+
+ /**
+ * This Basic Create class can be used in one of two ways:
+ *
+ * 1. Use this class directly by passing a callable ($setter) to the constructor.
+ * 2. Extend this class and override this function.
+ *
+ * Either way, this function should return an array representing the one new object.
+ *
+ * @param array $item
+ * @return array
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ protected function writeRecord($item) {
+ if (is_callable($this->setter)) {
+ return call_user_func($this->setter, $item, $this);
+ }
+ throw new NotImplementedException('Setter function not found for api4 ' . $this->getEntityName() . '::' . $this->getActionName());
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+use Civi\API\Exception\NotImplementedException;
+
+/**
+ * Retrieve items based on criteria specified in the 'where' param.
+ *
+ * Use the 'select' param to determine which fields are returned, defaults to *.
+ */
+class BasicGetAction extends AbstractGetAction {
+ use Traits\ArrayQueryActionTrait;
+
+ /**
+ * @var callable
+ *
+ * Function(BasicGetAction $thisAction) => array<array>
+ */
+ private $getter;
+
+ /**
+ * Basic Get constructor.
+ *
+ * @param string $entityName
+ * @param string $actionName
+ * @param callable $getter
+ */
+ public function __construct($entityName, $actionName, $getter = NULL) {
+ parent::__construct($entityName, $actionName);
+ $this->getter = $getter;
+ }
+
+ /**
+ * Fetch results from the getter then apply filter/sort/select/limit.
+ *
+ * @param \Civi\Api4\Generic\Result $result
+ */
+ public function _run(Result $result) {
+ $this->setDefaultWhereClause();
+ $values = $this->getRecords();
+ $result->exchangeArray($this->queryArray($values));
+ }
+
+ /**
+ * This Basic Get class is a general-purpose api for non-DAO-based entities.
+ *
+ * Useful for fetching records from files or other places.
+ * You can specify any php function to retrieve the records, and this class will
+ * automatically filter, sort, select & limit the raw data from your callback.
+ *
+ * You can implement this action in one of two ways:
+ * 1. Use this class directly by passing a callable ($getter) to the constructor.
+ * 2. Extend this class and override this function.
+ *
+ * Either way, this function should return an array of arrays, each representing one retrieved object.
+ *
+ * The simplest thing for your getter function to do is return every full record
+ * and allow this class to automatically do the sorting and filtering.
+ *
+ * Sometimes however that may not be practical for performance reasons.
+ * To optimize your getter, it can use the following helpers from $this:
+ *
+ * Use this->_itemsToGet() to match records to field values in the WHERE clause.
+ * Note the WHERE clause can potentially be very complex and it is not recommended
+ * to parse $this->where yourself.
+ *
+ * Use $this->_isFieldSelected() to check if a field value is called for - useful
+ * if loading the field involves expensive calculations.
+ *
+ * Be careful not to make assumptions, e.g. if LIMIT 100 is specified and your getter "helpfully" truncates the list
+ * at 100 without accounting for WHERE, ORDER BY and LIMIT clauses, the final filtered result may be very incorrect.
+ *
+ * @return array
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ protected function getRecords() {
+ if (is_callable($this->getter)) {
+ return call_user_func($this->getter, $this);
+ }
+ throw new NotImplementedException('Getter function not found for api4 ' . $this->getEntityName() . '::' . $this->getActionName());
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+use Civi\API\Exception\NotImplementedException;
+use Civi\Api4\Utils\ActionUtil;
+
+/**
+ * Get fields for an entity.
+ *
+ * @method $this setLoadOptions(bool $value)
+ * @method bool getLoadOptions()
+ * @method $this setAction(string $value)
+ */
+class BasicGetFieldsAction extends BasicGetAction {
+
+ /**
+ * Fetch option lists for fields?
+ *
+ * @var bool
+ */
+ protected $loadOptions = FALSE;
+
+ /**
+ * @var string
+ */
+ protected $action = 'get';
+
+ /**
+ * To implement getFields for your own entity:
+ *
+ * 1. From your entity class add a static getFields method.
+ * 2. That method should construct and return this class.
+ * 3. The 3rd argument passed to this constructor should be a function that returns an
+ * array of fields for your entity's CRUD actions.
+ * 4. For non-crud actions that need a different set of fields, you can override the
+ * list from step 3 on a per-action basis by defining a fields() method in that action.
+ * See for example BasicGetFieldsAction::fields() or GetActions::fields().
+ *
+ * @param Result $result
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ public function _run(Result $result) {
+ try {
+ $actionClass = ActionUtil::getAction($this->getEntityName(), $this->getAction());
+ }
+ catch (NotImplementedException $e) {
+ }
+ if (isset($actionClass) && method_exists($actionClass, 'fields')) {
+ $values = $actionClass->fields();
+ }
+ else {
+ $values = $this->getRecords();
+ }
+ $this->padResults($values);
+ $result->exchangeArray($this->queryArray($values));
+ }
+
+ /**
+ * Ensure every result contains, at minimum, the array keys as defined in $this->fields.
+ *
+ * Attempt to set some sensible defaults for some fields.
+ *
+ * In most cases it's not necessary to override this function, even if your entity is really weird.
+ * Instead just override $this->fields and thes function will respect that.
+ *
+ * @param array $values
+ */
+ protected function padResults(&$values) {
+ $fields = array_column($this->fields(), 'name');
+ foreach ($values as &$field) {
+ $defaults = array_intersect_key([
+ 'title' => empty($field['name']) ? NULL : ucwords(str_replace('_', ' ', $field['name'])),
+ 'entity' => $this->getEntityName(),
+ 'required' => FALSE,
+ 'options' => !empty($field['pseudoconstant']),
+ 'data_type' => \CRM_Utils_Array::value('type', $field, 'String'),
+ ], array_flip($fields));
+ $field += $defaults;
+ if (!$this->loadOptions && isset($defaults['options'])) {
+ $field['options'] = (bool) $field['options'];
+ }
+ $field += array_fill_keys($fields, NULL);
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getAction() {
+ // For actions that build on top of other actions, return fields for the simpler action
+ $sub = [
+ 'save' => 'create',
+ 'replace' => 'create',
+ ];
+ return $sub[$this->action] ?? $this->action;
+ }
+
+ public function fields() {
+ return [
+ [
+ 'name' => 'name',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'title',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'description',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'default_value',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'required',
+ 'data_type' => 'Boolean',
+ ],
+ [
+ 'name' => 'required_if',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'options',
+ 'data_type' => 'Array',
+ ],
+ [
+ 'name' => 'data_type',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'input_type',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'input_attrs',
+ 'data_type' => 'Array',
+ ],
+ [
+ 'name' => 'fk_entity',
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'serialize',
+ 'data_type' => 'Integer',
+ ],
+ [
+ 'name' => 'entity',
+ 'data_type' => 'String',
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+use Civi\API\Exception\NotImplementedException;
+use Civi\Api4\Utils\ActionUtil;
+
+/**
+ * Given a set of records, will appropriately update the database.
+ *
+ * @method $this setRecords(array $records) Array of records.
+ * @method $this addRecord($record) Add a record to update.
+ * @method array getRecords()
+ * @method $this setDefaults(array $defaults) Array of defaults.
+ * @method $this addDefault($name, $value) Add a default value.
+ * @method array getDefaults()
+ * @method $this setReload(bool $reload) Specify whether complete objects will be returned after saving.
+ * @method bool getReload()
+ */
+class BasicReplaceAction extends AbstractBatchAction {
+
+ /**
+ * Array of records.
+ *
+ * Should be in the same format as returned by Get.
+ *
+ * @var array
+ * @required
+ */
+ protected $records = [];
+
+ /**
+ * Array of default values.
+ *
+ * Will be merged into $records before saving.
+ *
+ * @var array
+ */
+ protected $defaults = [];
+
+ /**
+ * Reload records after saving.
+ *
+ * By default this api typically returns partial records containing only the fields
+ * that were updated. Set reload to TRUE to do an additional lookup after saving
+ * to return complete records.
+ *
+ * @var bool
+ */
+ protected $reload = FALSE;
+
+ /**
+ * @return \Civi\Api4\Result\ReplaceResult
+ */
+ public function execute() {
+ return parent::execute();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function _run(Result $result) {
+ $items = $this->getBatchRecords();
+
+ // Copy defaults from where clause if the operator is =
+ foreach ($this->where as $clause) {
+ if (is_array($clause) && $clause[1] === '=') {
+ $this->defaults[$clause[0]] = $clause[2];
+ }
+ }
+
+ $idField = $this->getSelect()[0];
+ $toDelete = array_diff_key(array_column($items, NULL, $idField), array_flip(array_filter(\CRM_Utils_Array::collect($idField, $this->records))));
+
+ // Try to delegate to the Save action
+ try {
+ $saveAction = ActionUtil::getAction($this->getEntityName(), 'save');
+ $saveAction
+ ->setCheckPermissions($this->getCheckPermissions())
+ ->setReload($this->reload)
+ ->setRecords($this->records)
+ ->setDefaults($this->defaults);
+ $result->exchangeArray((array) $saveAction->execute());
+ }
+ // Fall back on Create/Update if Save doesn't exist
+ catch (NotImplementedException $e) {
+ foreach ($this->records as $record) {
+ $record += $this->defaults;
+ if (!empty($record[$idField])) {
+ $result[] = civicrm_api4($this->getEntityName(), 'update', [
+ 'reload' => $this->reload,
+ 'where' => [[$idField, '=', $record[$idField]]],
+ 'values' => $record,
+ 'checkPermissions' => $this->getCheckPermissions(),
+ ])->first();
+ }
+ else {
+ $result[] = civicrm_api4($this->getEntityName(), 'create', [
+ 'values' => $record,
+ 'checkPermissions' => $this->getCheckPermissions(),
+ ])->first();
+ }
+ }
+ }
+
+ if ($toDelete) {
+ $result->deleted = (array) civicrm_api4($this->getEntityName(), 'delete', [
+ 'where' => [[$idField, 'IN', array_keys($toDelete)]],
+ 'checkPermissions' => $this->getCheckPermissions(),
+ ]);
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+use Civi\API\Exception\NotImplementedException;
+use Civi\Api4\Utils\ActionUtil;
+
+/**
+ * Create or update one or more records.
+ *
+ * If creating more than one record with similar values, use the "defaults" param.
+ *
+ * Set "reload" if you need the api to return complete records.
+ */
+class BasicSaveAction extends AbstractSaveAction {
+
+ /**
+ * @var callable
+ *
+ * Function(array $item, BasicCreateAction $thisAction) => array
+ */
+ private $setter;
+
+ /**
+ * Basic Create constructor.
+ *
+ * @param string $entityName
+ * @param string $actionName
+ * @param string $idField
+ * @param callable $setter
+ * Function(array $item, BasicCreateAction $thisAction) => array
+ */
+ public function __construct($entityName, $actionName, $idField = 'id', $setter = NULL) {
+ parent::__construct($entityName, $actionName, $idField);
+ $this->setter = $setter;
+ }
+
+ /**
+ * We pass the writeRecord function an array representing one item to write.
+ * We expect to get the same format back.
+ *
+ * @param \Civi\Api4\Generic\Result $result
+ */
+ public function _run(Result $result) {
+ $this->validateValues();
+ foreach ($this->records as $record) {
+ $record += $this->defaults;
+ $result[] = $this->writeRecord($record);
+ }
+ if ($this->reload) {
+ /** @var BasicGetAction $get */
+ $get = ActionUtil::getAction($this->getEntityName(), 'get');
+ $get
+ ->setCheckPermissions($this->getCheckPermissions())
+ ->addWhere($this->getIdField(), 'IN', (array) $result->column($this->getIdField()));
+ $result->exchangeArray((array) $get->execute());
+ }
+ }
+
+ /**
+ * This Basic Save class can be used in one of two ways:
+ *
+ * 1. Use this class directly by passing a callable ($setter) to the constructor.
+ * 2. Extend this class and override this function.
+ *
+ * Either way, this function should return an array representing the one new object.
+ *
+ * @param array $item
+ * @return array
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ protected function writeRecord($item) {
+ if (is_callable($this->setter)) {
+ return call_user_func($this->setter, $item, $this);
+ }
+ throw new NotImplementedException('Setter function not found for api4 ' . $this->getEntityName() . '::' . $this->getActionName());
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+use Civi\API\Exception\NotImplementedException;
+
+/**
+ * Update one or more records with new values.
+ *
+ * Use the where clause (required) to select them.
+ */
+class BasicUpdateAction extends AbstractUpdateAction {
+
+ /**
+ * @var callable
+ *
+ * Function(array $item, BasicUpdateAction $thisAction) => array
+ */
+ private $setter;
+
+ /**
+ * BasicUpdateAction constructor.
+ *
+ * @param string $entityName
+ * @param string $actionName
+ * @param string|array $select
+ * One or more fields to select from each matching item.
+ * @param callable $setter
+ * Function(array $item, BasicUpdateAction $thisAction) => array
+ */
+ public function __construct($entityName, $actionName, $select = 'id', $setter = NULL) {
+ parent::__construct($entityName, $actionName, $select);
+ $this->setter = $setter;
+ }
+
+ /**
+ * We pass the writeRecord function an array representing one item to update.
+ * We expect to get the same format back.
+ *
+ * @param \Civi\Api4\Generic\Result $result
+ * @throws \API_Exception
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ public function _run(Result $result) {
+ foreach ($this->getBatchRecords() as $item) {
+ $result[] = $this->writeRecord($this->values + $item);
+ }
+
+ if (!$result->count()) {
+ throw new \API_Exception('Cannot ' . $this->getActionName() . ' ' . $this->getEntityName() . ', no records found with ' . $this->whereClauseToString());
+ }
+ }
+
+ /**
+ * This Basic Update class can be used in one of two ways:
+ *
+ * 1. Use this class directly by passing a callable ($setter) to the constructor.
+ * 2. Extend this class and override this function.
+ *
+ * Either way, this function should return an array representing the one modified object.
+ *
+ * @param array $item
+ * @return array
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ protected function writeRecord($item) {
+ if (is_callable($this->setter)) {
+ return call_user_func($this->setter, $item, $this);
+ }
+ throw new NotImplementedException('Setter function not found for api4 ' . $this->getEntityName() . '::' . $this->getActionName());
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Create a new object from supplied values.
+ *
+ * This function will create 1 new object. It cannot be used to update existing objects. Use the Update or Replace actions for that.
+ */
+class DAOCreateAction extends AbstractCreateAction {
+ use Traits\DAOActionTrait;
+
+ /**
+ * @inheritDoc
+ */
+ public function _run(Result $result) {
+ $this->validateValues();
+ $params = $this->values;
+ $this->fillDefaults($params);
+
+ $resultArray = $this->writeObjects([$params]);
+
+ $result->exchangeArray($resultArray);
+ }
+
+ /**
+ * @throws \API_Exception
+ */
+ protected function validateValues() {
+ if (!empty($this->values['id'])) {
+ throw new \API_Exception('Cannot pass id to Create action. Use Update action instead.');
+ }
+ parent::validateValues();
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Delete one or more items, based on criteria specified in Where param (required).
+ */
+class DAODeleteAction extends AbstractBatchAction {
+ use Traits\DAOActionTrait;
+
+ /**
+ * Batch delete function
+ */
+ public function _run(Result $result) {
+ $defaults = $this->getParamDefaults();
+ if ($defaults['where'] && !array_diff_key($this->where, $defaults['where'])) {
+ throw new \API_Exception('Cannot delete ' . $this->getEntityName() . ' with no "where" parameter specified');
+ }
+
+ $items = $this->getObjects();
+
+ if (!$items) {
+ throw new \API_Exception('Cannot delete ' . $this->getEntityName() . ', no records found with ' . $this->whereClauseToString());
+ }
+
+ $ids = $this->deleteObjects($items);
+
+ $result->exchangeArray($ids);
+ }
+
+ /**
+ * @param $items
+ * @return array
+ * @throws \API_Exception
+ */
+ protected function deleteObjects($items) {
+ $ids = [];
+ $baoName = $this->getBaoName();
+
+ if ($this->getCheckPermissions()) {
+ foreach ($items as $item) {
+ $this->checkContactPermissions($baoName, $item);
+ }
+ }
+
+ if ($this->getEntityName() !== 'EntityTag' && method_exists($baoName, 'del')) {
+ foreach ($items as $item) {
+ $args = [$item['id']];
+ $bao = call_user_func_array([$baoName, 'del'], $args);
+ if ($bao !== FALSE) {
+ $ids[] = ['id' => $item['id']];
+ }
+ else {
+ throw new \API_Exception("Could not delete {$this->getEntityName()} id {$item['id']}");
+ }
+ }
+ }
+ else {
+ foreach ($items as $item) {
+ $bao = new $baoName();
+ $bao->id = $item['id'];
+ // delete it
+ $action_result = $bao->delete();
+ if ($action_result) {
+ $ids[] = ['id' => $item['id']];
+ }
+ else {
+ throw new \API_Exception("Could not delete {$this->getEntityName()} id {$item['id']}");
+ }
+ }
+ }
+ return $ids;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Base class for DAO-based entities.
+ */
+abstract class DAOEntity extends AbstractEntity {
+
+ /**
+ * @return DAOGetAction
+ */
+ public static function get() {
+ return new DAOGetAction(static::class, __FUNCTION__);
+ }
+
+ /**
+ * @return DAOGetAction
+ */
+ public static function save() {
+ return new DAOSaveAction(static::class, __FUNCTION__);
+ }
+
+ /**
+ * @return DAOGetFieldsAction
+ */
+ public static function getFields() {
+ return new DAOGetFieldsAction(static::class, __FUNCTION__);
+ }
+
+ /**
+ * @return DAOCreateAction
+ */
+ public static function create() {
+ return new DAOCreateAction(static::class, __FUNCTION__);
+ }
+
+ /**
+ * @return DAOUpdateAction
+ */
+ public static function update() {
+ return new DAOUpdateAction(static::class, __FUNCTION__);
+ }
+
+ /**
+ * @return DAODeleteAction
+ */
+ public static function delete() {
+ return new DAODeleteAction(static::class, __FUNCTION__);
+ }
+
+ /**
+ * @return BasicReplaceAction
+ */
+ public static function replace() {
+ return new BasicReplaceAction(static::class, __FUNCTION__);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Retrieve items based on criteria specified in the 'where' param.
+ *
+ * Use the 'select' param to determine which fields are returned, defaults to *.
+ *
+ * Perform joins on other related entities using a dot notation.
+ */
+class DAOGetAction extends AbstractGetAction {
+ use Traits\DAOActionTrait;
+
+ public function _run(Result $result) {
+ $this->setDefaultWhereClause();
+ $result->exchangeArray($this->getObjects());
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+use Civi\Api4\Service\Spec\SpecFormatter;
+
+/**
+ * Get fields for a DAO-based entity.
+ *
+ * @method $this setIncludeCustom(bool $value)
+ * @method bool getIncludeCustom()
+ */
+class DAOGetFieldsAction extends BasicGetFieldsAction {
+
+ /**
+ * Include custom fields for this entity, or only core fields?
+ *
+ * @var bool
+ */
+ protected $includeCustom = TRUE;
+
+ /**
+ * Get fields for a DAO-based entity
+ *
+ * @return array
+ */
+ protected function getRecords() {
+ $fields = $this->_itemsToGet('name');
+ /** @var \Civi\Api4\Service\Spec\SpecGatherer $gatherer */
+ $gatherer = \Civi::container()->get('spec_gatherer');
+ // Any fields name with a dot in it is custom
+ if ($fields) {
+ $this->includeCustom = strpos(implode('', $fields), '.') !== FALSE;
+ }
+ $spec = $gatherer->getSpec($this->getEntityName(), $this->getAction(), $this->includeCustom);
+ return SpecFormatter::specToArray($spec->getFields($fields), $this->loadOptions);
+ }
+
+ public function fields() {
+ $fields = parent::fields();
+ $fields[] = [
+ 'name' => 'custom_field_id',
+ 'data_type' => 'Integer',
+ ];
+ $fields[] = [
+ 'name' => 'custom_group_id',
+ 'data_type' => 'Integer',
+ ];
+ return $fields;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Create or update one or more records.
+ *
+ * If creating more than one record with similar values, use the "defaults" param.
+ *
+ * Set "reload" if you need the api to return complete records.
+ */
+class DAOSaveAction extends AbstractSaveAction {
+ use Traits\DAOActionTrait;
+
+ /**
+ * @inheritDoc
+ */
+ public function _run(Result $result) {
+ foreach ($this->records as &$record) {
+ $record += $this->defaults;
+ if (empty($record['id'])) {
+ $this->fillDefaults($record);
+ }
+ }
+ $this->validateValues();
+
+ $resultArray = $this->writeObjects($this->records);
+
+ $result->exchangeArray($resultArray);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Update one or more records with new values.
+ *
+ * Use the where clause (required) to select them.
+ */
+class DAOUpdateAction extends AbstractUpdateAction {
+ use Traits\DAOActionTrait;
+
+ /**
+ * Criteria for selecting items to update.
+ *
+ * Required if no id is supplied in values.
+ *
+ * @var array
+ */
+ protected $where = [];
+
+ /**
+ * @inheritDoc
+ */
+ public function _run(Result $result) {
+ // Add ID from values to WHERE clause and check for mismatch
+ if (!empty($this->values['id'])) {
+ $wheres = array_column($this->where, NULL, 0);
+ if (!isset($wheres['id'])) {
+ $this->addWhere('id', '=', $this->values['id']);
+ }
+ elseif (!($wheres['id'][1] === '=' && $wheres['id'][2] == $this->values['id'])) {
+ throw new \Exception("Cannot update the id of an existing " . $this->getEntityName() . '.');
+ }
+ }
+
+ // Require WHERE if we didn't get an ID from values
+ if (!$this->where) {
+ throw new \API_Exception('Parameter "where" is required unless an id is supplied in values.');
+ }
+
+ // Update a single record by ID unless select requires more than id
+ if ($this->getSelect() === ['id'] && count($this->where) === 1 && $this->where[0][0] === 'id' && $this->where[0][1] === '=' && !empty($this->where[0][2])) {
+ $this->values['id'] = $this->where[0][2];
+ $result->exchangeArray($this->writeObjects([$this->values]));
+ return;
+ }
+
+ // Batch update 1 or more records based on WHERE clause
+ $items = $this->getObjects();
+ foreach ($items as &$item) {
+ $item = $this->values + $item;
+ }
+
+ if (!$items) {
+ throw new \API_Exception('Cannot ' . $this->getActionName() . ' ' . $this->getEntityName() . ', no records found with ' . $this->whereClauseToString());
+ }
+
+ $result->exchangeArray($this->writeObjects($items));
+ }
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.7 |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2015 |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM. |
+ | |
+ | CiviCRM is free software; you can copy, modify, and distribute it |
+ | under the terms of the GNU Affero General Public License |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
+ | |
+ | CiviCRM is distributed in the hope that it will be useful, but |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
+ | See the GNU Affero General Public License for more details. |
+ | |
+ | You should have received a copy of the GNU Affero General Public |
+ | License and the CiviCRM Licensing Exception along |
+ | with this program; if not, contact CiviCRM LLC |
+ | at info[AT]civicrm[DOT]org. If you have questions about the |
+ | GNU Affero General Public License or the licensing of CiviCRM, |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Generic;
+
+/**
+ * Container for api results.
+ */
+class Result extends \ArrayObject {
+ /**
+ * @var string
+ */
+ public $entity;
+ /**
+ * @var string
+ */
+ public $action;
+ /**
+ * Api version
+ * @var int
+ */
+ public $version = 4;
+
+ private $indexedBy;
+
+ /**
+ * Return first result.
+ * @return array|null
+ */
+ public function first() {
+ foreach ($this as $values) {
+ return $values;
+ }
+ return NULL;
+ }
+
+ /**
+ * Return last result.
+ * @return array|null
+ */
+ public function last() {
+ $items = $this->getArrayCopy();
+ return array_pop($items);
+ }
+
+ /**
+ * @param int $index
+ * @return array|null
+ */
+ public function itemAt($index) {
+ $length = $index < 0 ? 0 - $index : $index + 1;
+ if ($length > count($this)) {
+ return NULL;
+ }
+ return array_slice(array_values($this->getArrayCopy()), $index, 1)[0];
+ }
+
+ /**
+ * Re-index the results array (which by default is non-associative)
+ *
+ * Drops any item from the results that does not contain the specified key
+ *
+ * @param string $key
+ * @return $this
+ * @throws \API_Exception
+ */
+ public function indexBy($key) {
+ $this->indexedBy = $key;
+ if (count($this)) {
+ $newResults = [];
+ foreach ($this as $values) {
+ if (isset($values[$key])) {
+ $newResults[$values[$key]] = $values;
+ }
+ }
+ if (!$newResults) {
+ throw new \API_Exception("Key $key not found in api results");
+ }
+ $this->exchangeArray($newResults);
+ }
+ return $this;
+ }
+
+ /**
+ * Returns the number of results
+ *
+ * @return int
+ */
+ public function count() {
+ $count = parent::count();
+ if ($count == 1 && is_array($this->first()) && array_keys($this->first()) == ['row_count']) {
+ return $this->first()['row_count'];
+ }
+ return $count;
+ }
+
+ /**
+ * Reduce each result to one field
+ *
+ * @param $name
+ * @return array
+ */
+ public function column($name) {
+ return array_column($this->getArrayCopy(), $name, $this->indexedBy);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic\Traits;
+
+use Civi\API\Exception\NotImplementedException;
+
+/**
+ * Helper functions for performing api queries on arrays of data.
+ *
+ * @package Civi\Api4\Generic
+ */
+trait ArrayQueryActionTrait {
+
+ /**
+ * @param array $values
+ * List of all rows
+ * @return array
+ * Filtered list of rows
+ */
+ protected function queryArray($values) {
+ $values = $this->filterArray($values);
+ $values = $this->sortArray($values);
+ $values = $this->limitArray($values);
+ $values = $this->selectArray($values);
+ return $values;
+ }
+
+ /**
+ * @param array $values
+ * @return array
+ */
+ protected function filterArray($values) {
+ if ($this->getWhere()) {
+ $values = array_filter($values, [$this, 'evaluateFilters']);
+ }
+ return array_values($values);
+ }
+
+ /**
+ * @param array $row
+ * @return bool
+ */
+ private function evaluateFilters($row) {
+ $where = $this->getWhere();
+ $allConditions = in_array($where[0], ['AND', 'OR', 'NOT']) ? $where : ['AND', $where];
+ return $this->walkFilters($row, $allConditions);
+ }
+
+ /**
+ * @param array $row
+ * @param array $filters
+ * @return bool
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ private function walkFilters($row, $filters) {
+ switch ($filters[0]) {
+ case 'AND':
+ case 'NOT':
+ $result = TRUE;
+ foreach ($filters[1] as $filter) {
+ if (!$this->walkFilters($row, $filter)) {
+ $result = FALSE;
+ break;
+ }
+ }
+ return $result == ($filters[0] == 'AND');
+
+ case 'OR':
+ $result = !count($filters[1]);
+ foreach ($filters[1] as $filter) {
+ if ($this->walkFilters($row, $filter)) {
+ return TRUE;
+ }
+ }
+ return $result;
+
+ default:
+ return $this->filterCompare($row, $filters);
+ }
+ }
+
+ /**
+ * @param array $row
+ * @param array $condition
+ * @return bool
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ private function filterCompare($row, $condition) {
+ if (!is_array($condition)) {
+ throw new NotImplementedException('Unexpected where syntax; expecting array.');
+ }
+ $value = isset($row[$condition[0]]) ? $row[$condition[0]] : NULL;
+ $operator = $condition[1];
+ $expected = isset($condition[2]) ? $condition[2] : NULL;
+ switch ($operator) {
+ case '=':
+ case '!=':
+ case '<>':
+ $equal = $value == $expected;
+ // PHP is too imprecise about comparing the number 0
+ if ($expected === 0 || $expected === '0') {
+ $equal = ($value === 0 || $value === '0');
+ }
+ // PHP is too imprecise about comparing empty strings
+ if ($expected === '') {
+ $equal = ($value === '');
+ }
+ return $equal == ($operator == '=');
+
+ case 'IS NULL':
+ case 'IS NOT NULL':
+ return is_null($value) == ($operator == 'IS NULL');
+
+ case '>':
+ return $value > $expected;
+
+ case '>=':
+ return $value >= $expected;
+
+ case '<':
+ return $value < $expected;
+
+ case '<=':
+ return $value <= $expected;
+
+ case 'BETWEEN':
+ case 'NOT BETWEEN':
+ $between = ($value >= $expected[0] && $value <= $expected[1]);
+ return $between == ($operator == 'BETWEEN');
+
+ case 'LIKE':
+ case 'NOT LIKE':
+ $pattern = '/^' . str_replace('%', '.*', preg_quote($expected, '/')) . '$/i';
+ return !preg_match($pattern, $value) == ($operator != 'LIKE');
+
+ case 'IN':
+ return in_array($value, $expected);
+
+ case 'NOT IN':
+ return !in_array($value, $expected);
+
+ default:
+ throw new NotImplementedException("Unsupported operator: '$operator' cannot be used with array data");
+ }
+ }
+
+ /**
+ * @param $values
+ * @return array
+ */
+ protected function sortArray($values) {
+ if ($this->getOrderBy()) {
+ usort($values, [$this, 'sortCompare']);
+ }
+ return $values;
+ }
+
+ private function sortCompare($a, $b) {
+ foreach ($this->getOrderBy() as $field => $dir) {
+ $modifier = $dir == 'ASC' ? 1 : -1;
+ if (isset($a[$field]) && isset($b[$field])) {
+ if ($a[$field] == $b[$field]) {
+ continue;
+ }
+ return (strnatcasecmp($a[$field], $b[$field]) * $modifier);
+ }
+ elseif (isset($a[$field]) || isset($b[$field])) {
+ return ((isset($a[$field]) ? 1 : -1) * $modifier);
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * @param $values
+ * @return array
+ */
+ protected function selectArray($values) {
+ if ($this->getSelect() === ['row_count']) {
+ $values = [['row_count' => count($values)]];
+ }
+ elseif ($this->getSelect()) {
+ foreach ($values as &$value) {
+ $value = array_intersect_key($value, array_flip($this->getSelect()));
+ }
+ }
+ return $values;
+ }
+
+ /**
+ * @param $values
+ * @return array
+ */
+ protected function limitArray($values) {
+ if ($this->getOffset() || $this->getLimit()) {
+ $values = array_slice($values, $this->getOffset() ?: 0, $this->getLimit() ?: NULL);
+ }
+ return $values;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Generic\Traits;
+
+use Civi\Api4\Utils\FormattingUtil;
+use Civi\Api4\Utils\CoreUtil;
+
+/**
+ * Helper functions for working with custom values
+ *
+ * @package Civi\Api4\Generic
+ */
+trait CustomValueActionTrait {
+
+ public function __construct($customGroup, $actionName) {
+ $this->customGroup = $customGroup;
+ parent::__construct('CustomValue', $actionName, ['id', 'entity_id']);
+ }
+
+ /**
+ * Custom Group name if this is a CustomValue pseudo-entity.
+ *
+ * @var string
+ */
+ private $customGroup;
+
+ /**
+ * @inheritDoc
+ */
+ public function getEntityName() {
+ return 'Custom_' . $this->getCustomGroup();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function writeObjects($items) {
+ $result = [];
+ $fields = $this->entityFields();
+ foreach ($items as $item) {
+ FormattingUtil::formatWriteParams($item, $this->getEntityName(), $fields);
+
+ // Convert field names to custom_xx format
+ foreach ($fields as $name => $field) {
+ if (!empty($field['custom_field_id']) && isset($item[$name])) {
+ $item['custom_' . $field['custom_field_id']] = $item[$name];
+ unset($item[$name]);
+ }
+ }
+
+ $result[] = \CRM_Core_BAO_CustomValueTable::setValues($item);
+ }
+ return $result;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function deleteObjects($items) {
+ $customTable = CoreUtil::getCustomTableByName($this->getCustomGroup());
+ $ids = [];
+ foreach ($items as $item) {
+ \CRM_Utils_Hook::pre('delete', $this->getEntityName(), $item['id'], \CRM_Core_DAO::$_nullArray);
+ \CRM_Utils_SQL_Delete::from($customTable)
+ ->where('id = #value')
+ ->param('#value', $item['id'])
+ ->execute();
+ \CRM_Utils_Hook::post('delete', $this->getEntityName(), $item['id'], \CRM_Core_DAO::$_nullArray);
+ $ids[] = $item['id'];
+ }
+ return $ids;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function fillDefaults(&$params) {
+ foreach ($this->entityFields() as $name => $field) {
+ if (!isset($params[$name]) && isset($field['default_value'])) {
+ $params[$name] = $field['default_value'];
+ }
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getCustomGroup() {
+ return $this->customGroup;
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Generic\Traits;
+
+use CRM_Utils_Array as UtilsArray;
+use Civi\Api4\Utils\FormattingUtil;
+use Civi\Api4\Query\Api4SelectQuery;
+
+/**
+ * @method string getLanguage()
+ * @method setLanguage(string $language)
+ */
+trait DAOActionTrait {
+
+ /**
+ * Specify the language to use if this is a multi-lingual environment.
+ *
+ * E.g. "en_US" or "fr_CA"
+ *
+ * @var string
+ */
+ protected $language;
+
+ /**
+ * @return \CRM_Core_DAO|string
+ */
+ protected function getBaoName() {
+ require_once 'api/v3/utils.php';
+ return \_civicrm_api3_get_BAO($this->getEntityName());
+ }
+
+ /**
+ * Extract the true fields from a BAO
+ *
+ * (Used by create and update actions)
+ * @param object $bao
+ * @return array
+ */
+ public static function baoToArray($bao) {
+ $fields = $bao->fields();
+ $values = [];
+ foreach ($fields as $key => $field) {
+ $name = $field['name'];
+ if (property_exists($bao, $name)) {
+ $values[$name] = isset($bao->$name) ? $bao->$name : NULL;
+ }
+ }
+ return $values;
+ }
+
+ /**
+ * @return array|int
+ */
+ protected function getObjects() {
+ $query = new Api4SelectQuery($this->getEntityName(), $this->getCheckPermissions(), $this->entityFields());
+ $query->select = $this->getSelect();
+ $query->where = $this->getWhere();
+ $query->orderBy = $this->getOrderBy();
+ $query->limit = $this->getLimit();
+ $query->offset = $this->getOffset();
+ return $query->run();
+ }
+
+ /**
+ * Fill field defaults which were declared by the api.
+ *
+ * Note: default values from core are ignored because the BAO or database layer will supply them.
+ *
+ * @param array $params
+ */
+ protected function fillDefaults(&$params) {
+ $fields = $this->entityFields();
+ $bao = $this->getBaoName();
+ $coreFields = array_column($bao::fields(), NULL, 'name');
+
+ foreach ($fields as $name => $field) {
+ // If a default value in the api field is different than in core, the api should override it.
+ if (!isset($params[$name]) && !empty($field['default_value']) && $field['default_value'] != \CRM_Utils_Array::pathGet($coreFields, [$name, 'default'])) {
+ $params[$name] = $field['default_value'];
+ }
+ }
+ }
+
+ /**
+ * Write bao objects as part of a create/update action.
+ *
+ * @param array $items
+ * The records to write to the DB.
+ * @return array
+ * The records after being written to the DB (e.g. including newly assigned "id").
+ * @throws \API_Exception
+ */
+ protected function writeObjects($items) {
+ $baoName = $this->getBaoName();
+
+ // Some BAOs are weird and don't support a straightforward "create" method.
+ $oddballs = [
+ 'EntityTag' => 'add',
+ 'GroupContact' => 'add',
+ 'Website' => 'add',
+ ];
+ $method = $oddballs[$this->getEntityName()] ?? 'create';
+ if (!method_exists($baoName, $method)) {
+ $method = 'add';
+ }
+
+ $result = [];
+
+ foreach ($items as $item) {
+ $entityId = UtilsArray::value('id', $item);
+ FormattingUtil::formatWriteParams($item, $this->getEntityName(), $this->entityFields());
+ $this->formatCustomParams($item, $entityId);
+ $item['check_permissions'] = $this->getCheckPermissions();
+
+ // For some reason the contact bao requires this
+ if ($entityId && $this->getEntityName() == 'Contact') {
+ $item['contact_id'] = $entityId;
+ }
+
+ if ($this->getCheckPermissions()) {
+ $this->checkContactPermissions($baoName, $item);
+ }
+
+ if ($this->getEntityName() == 'Address') {
+ $createResult = $baoName::add($item, $this->fixAddress);
+ }
+ elseif (method_exists($baoName, $method)) {
+ $createResult = $baoName::$method($item);
+ }
+ else {
+ $createResult = $this->genericCreateMethod($item);
+ }
+
+ if (!$createResult) {
+ $errMessage = sprintf('%s write operation failed', $this->getEntityName());
+ throw new \API_Exception($errMessage);
+ }
+
+ if (!empty($this->reload) && is_a($createResult, 'CRM_Core_DAO')) {
+ $createResult->find(TRUE);
+ }
+
+ // trim back the junk and just get the array:
+ $resultArray = $this->baoToArray($createResult);
+
+ $result[] = $resultArray;
+ }
+ return $result;
+ }
+
+ /**
+ * Fallback when a BAO does not contain create or add functions
+ *
+ * @param $params
+ * @return mixed
+ */
+ private function genericCreateMethod($params) {
+ $baoName = $this->getBaoName();
+ $hook = empty($params['id']) ? 'create' : 'edit';
+
+ \CRM_Utils_Hook::pre($hook, $this->getEntityName(), UtilsArray::value('id', $params), $params);
+ /** @var \CRM_Core_DAO $instance */
+ $instance = new $baoName();
+ $instance->copyValues($params, TRUE);
+ $instance->save();
+ \CRM_Utils_Hook::post($hook, $this->getEntityName(), $instance->id, $instance);
+
+ return $instance;
+ }
+
+ /**
+ * @param array $params
+ * @param int $entityId
+ * @return mixed
+ */
+ protected function formatCustomParams(&$params, $entityId) {
+ $customParams = [];
+
+ // $customValueID is the ID of the custom value in the custom table for this
+ // entity (i guess this assumes it's not a multi value entity)
+ foreach ($params as $name => $value) {
+ if (strpos($name, '.') === FALSE) {
+ continue;
+ }
+
+ list($customGroup, $customField) = explode('.', $name);
+
+ $customFieldId = \CRM_Core_BAO_CustomField::getFieldValue(
+ \CRM_Core_DAO_CustomField::class,
+ $customField,
+ 'id',
+ 'name'
+ );
+ $customFieldType = \CRM_Core_BAO_CustomField::getFieldValue(
+ \CRM_Core_DAO_CustomField::class,
+ $customField,
+ 'html_type',
+ 'name'
+ );
+ $customFieldExtends = \CRM_Core_BAO_CustomGroup::getFieldValue(
+ \CRM_Core_DAO_CustomGroup::class,
+ $customGroup,
+ 'extends',
+ 'name'
+ );
+
+ // todo are we sure we don't want to allow setting to NULL? need to test
+ if ($customFieldId && NULL !== $value) {
+
+ if ($customFieldType == 'CheckBox') {
+ // this function should be part of a class
+ formatCheckBoxField($value, 'custom_' . $customFieldId, $this->getEntityName());
+ }
+
+ \CRM_Core_BAO_CustomField::formatCustomField(
+ $customFieldId,
+ $customParams,
+ $value,
+ $customFieldExtends,
+ // todo check when this is needed
+ NULL,
+ $entityId,
+ FALSE,
+ FALSE,
+ TRUE
+ );
+ }
+ }
+
+ if ($customParams) {
+ $params['custom'] = $customParams;
+ }
+ }
+
+ /**
+ * Check edit/delete permissions for contacts and related entities.
+ *
+ * @param $baoName
+ * @param $item
+ * @throws \Civi\API\Exception\UnauthorizedException
+ */
+ protected function checkContactPermissions($baoName, $item) {
+ if ($baoName == 'CRM_Contact_BAO_Contact' && !empty($item['id'])) {
+ $permission = $this->getActionName() == 'delete' ? \CRM_Core_Permission::DELETE : \CRM_Core_Permission::EDIT;
+ if (!\CRM_Contact_BAO_Contact_Permission::allow($item['id'], $permission)) {
+ throw new \Civi\API\Exception\UnauthorizedException('Permission denied to modify contact record');
+ }
+ }
+ else {
+ // Fixme: decouple from v3
+ require_once 'api/v3/utils.php';
+ _civicrm_api3_check_edit_permissions($baoName, ['check_permissions' => 1] + $item);
+ }
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Generic\Traits;
+
+/**
+ * This trait adds the $current param to a Get action.
+ *
+ * @see \Civi\Api4\Event\Subscriber\IsCurrentSubscriber
+ */
+trait IsCurrentTrait {
+
+ /**
+ * Convenience filter for selecting items that are enabled and are currently within their start/end dates.
+ *
+ * Adding current = TRUE is a shortcut for
+ * WHERE is_active = 1 AND (end_date IS NULL OR end_date >= now) AND (start_date IS NULL OR start_DATE <= now)
+ *
+ * Adding current = FALSE is a shortcut for
+ * WHERE is_active = 0 OR start_date > now OR end_date < now
+ *
+ * @var bool
+ */
+ protected $current;
+
+ /**
+ * @return bool
+ */
+ public function getCurrent() {
+ return $this->current;
+ }
+
+ /**
+ * @param bool $current
+ * @return $this
+ */
+ public function setCurrent($current) {
+ $this->current = $current;
+ return $this;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Group entity.
+ *
+ * @package Civi\Api4
+ */
+class Group extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * GroupContact entity - link between groups and contacts.
+ *
+ * A contact can either be "Added" "Removed" or "Pending" in a group.
+ * CiviCRM only considers them to be "in" a group if their status is "Added".
+ *
+ * @package Civi\Api4
+ */
+class GroupContact extends Generic\DAOEntity {
+
+ /**
+ * @return Action\GroupContact\Create
+ */
+ public static function create() {
+ return new Action\GroupContact\Create(__CLASS__, __FUNCTION__);
+ }
+
+ /**
+ * @return Action\GroupContact\Save
+ */
+ public static function save() {
+ return new Action\GroupContact\Save(__CLASS__, __FUNCTION__);
+ }
+
+ /**
+ * @return Action\GroupContact\Update
+ */
+ public static function update() {
+ return new Action\GroupContact\Update(__CLASS__, __FUNCTION__);
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4;
+
+/**
+ * GroupNesting entity.
+ *
+ * @package Civi\Api4
+ */
+class GroupNesting extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4;
+
+/**
+ * GroupOrganization entity.
+ *
+ * @package Civi\Api4
+ */
+class GroupOrganization extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * IM entity.
+ *
+ * @package Civi\Api4
+ */
+class IM extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * LocationType entity.
+ *
+ * @package Civi\Api4
+ */
+class LocationType extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * MailSettings entity.
+ *
+ * @package Civi\Api4
+ */
+class MailSettings extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Mapping entity.
+ *
+ * This is a collection of MappingFields, for reuse in import, export, etc.
+ *
+ * @package Civi\Api4
+ */
+class Mapping extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * MappingField entity.
+ *
+ * This represents one field in a Mapping collection.
+ *
+ * @package Civi\Api4
+ */
+class MappingField extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Navigation entity.
+ *
+ * @package Civi\Api4
+ */
+class Navigation extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Note entity.
+ *
+ * @package Civi\Api4
+ */
+class Note extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * OpenID entity.
+ *
+ * @package Civi\Api4
+ */
+class OpenID extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * OptionGroup entity.
+ *
+ * @package Civi\Api4
+ */
+class OptionGroup extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * OptionValue entity.
+ *
+ * @package Civi\Api4
+ */
+class OptionValue extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Participant entity, stores the participation record of a contact in an event.
+ *
+ * @package Civi\Api4
+ */
+class Participant extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Phone entity.
+ *
+ * This entity allows user to add, update, retrieve or delete phone number(s) of a contact.
+ *
+ * Creating a new phone of a contact, requires at minimum a contact's ID and phone number
+ *
+ * @package Civi\Api4
+ */
+class Phone extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.7 |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2015 |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM. |
+ | |
+ | CiviCRM is free software; you can copy, modify, and distribute it |
+ | under the terms of the GNU Affero General Public License |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
+ | |
+ | CiviCRM is distributed in the hope that it will be useful, but |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
+ | See the GNU Affero General Public License for more details. |
+ | |
+ | You should have received a copy of the GNU Affero General Public |
+ | License and the CiviCRM Licensing Exception along |
+ | with this program; if not, contact CiviCRM LLC |
+ | at info[AT]civicrm[DOT]org. If you have questions about the |
+ | GNU Affero General Public License or the licensing of CiviCRM, |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Provider;
+
+use Civi\API\Event\ResolveEvent;
+use Civi\API\Provider\ProviderInterface;
+use Civi\Api4\Generic\AbstractAction;
+use Civi\API\Events;
+use Civi\Api4\Utils\ReflectionUtils;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Accept $apiRequests based on \Civi\API\Action
+ */
+class ActionObjectProvider implements EventSubscriberInterface, ProviderInterface {
+
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents() {
+ // Using a high priority allows adhoc implementations
+ // to override standard implementations -- which is
+ // handy for testing/mocking.
+ return [
+ Events::RESOLVE => [
+ ['onApiResolve', Events::W_EARLY],
+ ],
+ ];
+ }
+
+ /**
+ * @param \Civi\API\Event\ResolveEvent $event
+ * API resolution event.
+ */
+ public function onApiResolve(ResolveEvent $event) {
+ $apiRequest = $event->getApiRequest();
+ if ($apiRequest instanceof AbstractAction) {
+ $event->setApiRequest($apiRequest);
+ $event->setApiProvider($this);
+ $event->stopPropagation();
+ }
+ }
+
+ /**
+ * @inheritDoc
+ *
+ * @param \Civi\Api4\Generic\AbstractAction $action
+ *
+ * @return \Civi\Api4\Generic\Result
+ */
+ public function invoke($action) {
+ // Load result class based on @return annotation in the execute() method.
+ $reflection = new \ReflectionClass($action);
+ $doc = ReflectionUtils::getCodeDocs($reflection->getMethod('execute'), 'Method');
+ $resultClass = \CRM_Utils_Array::value('return', $doc, '\\Civi\\Api4\\Generic\\Result');
+ $result = new $resultClass();
+ $result->action = $action->getActionName();
+ $result->entity = $action->getEntityName();
+ $action->_run($result);
+ $this->handleChains($action, $result);
+ return $result;
+ }
+
+ /**
+ * Run each chained action once per row
+ *
+ * @param \Civi\Api4\Generic\AbstractAction $action
+ * @param \Civi\Api4\Generic\Result $result
+ */
+ protected function handleChains($action, $result) {
+ foreach ($action->getChain() as $name => $request) {
+ $request += [NULL, NULL, [], NULL];
+ $request[2]['checkPermissions'] = $action->getCheckPermissions();
+ foreach ($result as &$row) {
+ $row[$name] = $this->runChain($request, $row);
+ }
+ }
+ }
+
+ /**
+ * Run a chained action
+ *
+ * @param $request
+ * @param $row
+ * @return array|\Civi\Api4\Generic\Result|null
+ * @throws \API_Exception
+ */
+ protected function runChain($request, $row) {
+ list($entity, $action, $params, $index) = $request;
+ // Swap out variables in $entity, $action & $params
+ $this->resolveChainLinks($entity, $row);
+ $this->resolveChainLinks($action, $row);
+ $this->resolveChainLinks($params, $row);
+ return (array) civicrm_api4($entity, $action, $params, $index);
+ }
+
+ /**
+ * Swap out variable names
+ *
+ * @param mixed $val
+ * @param array $result
+ */
+ protected function resolveChainLinks(&$val, $result) {
+ if (is_array($val)) {
+ foreach ($val as &$v) {
+ $this->resolveChainLinks($v, $result);
+ }
+ }
+ elseif (is_string($val) && strlen($val) > 1 && substr($val, 0, 1) === '$') {
+ $val = \CRM_Utils_Array::pathGet($result, explode('.', substr($val, 1)));
+ }
+ }
+
+ /**
+ * @inheritDoc
+ * @param int $version
+ * @return array
+ */
+ public function getEntityNames($version) {
+ /** FIXME */
+ return [];
+ }
+
+ /**
+ * @inheritDoc
+ * @param int $version
+ * @param string $entity
+ * @return array
+ */
+ public function getActionNames($version, $entity) {
+ /** FIXME Civi\API\V4\Action\GetActions */
+ return [];
+ }
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.7 |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2015 |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM. |
+ | |
+ | CiviCRM is free software; you can copy, modify, and distribute it |
+ | under the terms of the GNU Affero General Public License |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
+ | |
+ | CiviCRM is distributed in the hope that it will be useful, but |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
+ | See the GNU Affero General Public License for more details. |
+ | |
+ | You should have received a copy of the GNU Affero General Public |
+ | License and the CiviCRM Licensing Exception along |
+ | with this program; if not, contact CiviCRM LLC |
+ | at info[AT]civicrm[DOT]org. If you have questions about the |
+ | GNU Affero General Public License or the licensing of CiviCRM, |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Query;
+
+use Civi\API\SelectQuery;
+use Civi\Api4\Event\Events;
+use Civi\Api4\Event\PostSelectQueryEvent;
+use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable;
+use Civi\Api4\Service\Schema\Joinable\Joinable;
+use Civi\Api4\Utils\FormattingUtil;
+use Civi\Api4\Utils\CoreUtil;
+use CRM_Core_DAO_AllCoreTables as AllCoreTables;
+use CRM_Utils_Array as UtilsArray;
+
+/**
+ * A query `node` may be in one of three formats:
+ *
+ * * leaf: [$fieldName, $operator, $criteria]
+ * * negated: ['NOT', $node]
+ * * branch: ['OR|NOT', [$node, $node, ...]]
+ *
+ * Leaf operators are one of:
+ *
+ * * '=', '<=', '>=', '>', '<', 'LIKE', "<>", "!=",
+ * * "NOT LIKE", 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN',
+ * * 'IS NOT NULL', or 'IS NULL'.
+ */
+class Api4SelectQuery extends SelectQuery {
+
+ /**
+ * @var int
+ */
+ protected $apiVersion = 4;
+
+ /**
+ * @var array
+ * Maps select fields to [<table_alias>, <column_alias>]
+ */
+ protected $fkSelectAliases = [];
+
+ /**
+ * @var \Civi\Api4\Service\Schema\Joinable\Joinable[]
+ * The joinable tables that have been joined so far
+ */
+ protected $joinedTables = [];
+
+ /**
+ * @param string $entity
+ * @param bool $checkPermissions
+ * @param array $fields
+ */
+ public function __construct($entity, $checkPermissions, $fields) {
+ require_once 'api/v3/utils.php';
+ $this->entity = $entity;
+ $this->checkPermissions = $checkPermissions;
+
+ $baoName = CoreUtil::getBAOFromApiName($entity);
+ $bao = new $baoName();
+
+ $this->entityFieldNames = _civicrm_api3_field_names(_civicrm_api3_build_fields_array($bao));
+ $this->apiFieldSpec = (array) $fields;
+
+ \CRM_Utils_SQL_Select::from($this->getTableName($baoName) . ' ' . self::MAIN_TABLE_ALIAS);
+
+ // Add ACLs first to avoid redundant subclauses
+ $this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName));
+ }
+
+ /**
+ * Why walk when you can
+ *
+ * @return array|int
+ */
+ public function run() {
+ $this->addJoins();
+ $this->buildSelectFields();
+ $this->buildWhereClause();
+
+ // Select
+ if (in_array('row_count', $this->select)) {
+ $this->query->select("count(*) as c");
+ }
+ else {
+ foreach ($this->selectFields as $column => $alias) {
+ $this->query->select("$column as `$alias`");
+ }
+ // Order by
+ $this->buildOrderBy();
+ }
+
+ // Limit
+ if (!empty($this->limit) || !empty($this->offset)) {
+ $this->query->limit($this->limit, $this->offset);
+ }
+
+ $results = [];
+ $sql = $this->query->toSQL();
+ $query = \CRM_Core_DAO::executeQuery($sql);
+
+ while ($query->fetch()) {
+ if (in_array('row_count', $this->select)) {
+ $results[]['row_count'] = (int) $query->c;
+ break;
+ }
+ $results[$query->id] = [];
+ foreach ($this->selectFields as $column => $alias) {
+ $returnName = $alias;
+ $alias = str_replace('.', '_', $alias);
+ $results[$query->id][$returnName] = property_exists($query, $alias) ? $query->$alias : NULL;
+ };
+ }
+ $event = new PostSelectQueryEvent($results, $this);
+ \Civi::dispatcher()->dispatch(Events::POST_SELECT_QUERY, $event);
+
+ return $event->getResults();
+ }
+
+ /**
+ * Gets all FK fields and does the required joins
+ */
+ protected function addJoins() {
+ $allFields = array_merge($this->select, array_keys($this->orderBy));
+ $recurse = function($clauses) use (&$allFields, &$recurse) {
+ foreach ($clauses as $clause) {
+ if ($clause[0] === 'NOT' && is_string($clause[1][0])) {
+ $recurse($clause[1][1]);
+ }
+ elseif (in_array($clause[0], ['AND', 'OR', 'NOT'])) {
+ $recurse($clause[1]);
+ }
+ elseif (is_array($clause[0])) {
+ array_walk($clause, $recurse);
+ }
+ else {
+ $allFields[] = $clause[0];
+ }
+ }
+ };
+ $recurse($this->where);
+ $dotFields = array_unique(array_filter($allFields, function ($field) {
+ return strpos($field, '.') !== FALSE;
+ }));
+
+ foreach ($dotFields as $dotField) {
+ $this->joinFK($dotField);
+ }
+ }
+
+ /**
+ * Populate $this->selectFields
+ *
+ * @throws \Civi\API\Exception\UnauthorizedException
+ */
+ protected function buildSelectFields() {
+ $return_all_fields = (empty($this->select) || !is_array($this->select));
+ $return = $return_all_fields ? $this->entityFieldNames : $this->select;
+ if ($return_all_fields || in_array('custom', $this->select)) {
+ foreach (array_keys($this->apiFieldSpec) as $fieldName) {
+ if (strpos($fieldName, 'custom_') === 0) {
+ $return[] = $fieldName;
+ }
+ }
+ }
+
+ // Always select the ID if the table has one.
+ if (array_key_exists('id', $this->apiFieldSpec) || strstr($this->entity, 'Custom_')) {
+ $this->selectFields[self::MAIN_TABLE_ALIAS . ".id"] = "id";
+ }
+
+ // core return fields
+ foreach ($return as $fieldName) {
+ $field = $this->getField($fieldName);
+ if (strpos($fieldName, '.') && !empty($this->fkSelectAliases[$fieldName]) && !array_filter($this->getPathJoinTypes($fieldName))) {
+ $this->selectFields[$this->fkSelectAliases[$fieldName]] = $fieldName;
+ }
+ elseif ($field && in_array($field['name'], $this->entityFieldNames)) {
+ $this->selectFields[self::MAIN_TABLE_ALIAS . "." . UtilsArray::value('column_name', $field, $field['name'])] = $field['name'];
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function buildWhereClause() {
+ foreach ($this->where as $clause) {
+ $sql_clause = $this->treeWalkWhereClause($clause);
+ $this->query->where($sql_clause);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function buildOrderBy() {
+ foreach ($this->orderBy as $field => $dir) {
+ if ($dir !== 'ASC' && $dir !== 'DESC') {
+ throw new \API_Exception("Invalid sort direction. Cannot order by $field $dir");
+ }
+ if ($this->getField($field)) {
+ $this->query->orderBy(self::MAIN_TABLE_ALIAS . '.' . $field . " $dir");
+ }
+ else {
+ throw new \API_Exception("Invalid sort field. Cannot order by $field $dir");
+ }
+ }
+ }
+
+ /**
+ * Recursively validate and transform a branch or leaf clause array to SQL.
+ *
+ * @param array $clause
+ * @return string SQL where clause
+ *
+ * @uses validateClauseAndComposeSql() to generate the SQL etc.
+ * @todo if an 'and' is nested within and 'and' (or or-in-or) then should
+ * flatten that to be a single list of clauses.
+ */
+ protected function treeWalkWhereClause($clause) {
+ switch ($clause[0]) {
+ case 'OR':
+ case 'AND':
+ // handle branches
+ if (count($clause[1]) === 1) {
+ // a single set so AND|OR is immaterial
+ return $this->treeWalkWhereClause($clause[1][0]);
+ }
+ else {
+ $sql_subclauses = [];
+ foreach ($clause[1] as $subclause) {
+ $sql_subclauses[] = $this->treeWalkWhereClause($subclause);
+ }
+ return '(' . implode("\n" . $clause[0], $sql_subclauses) . ')';
+ }
+
+ case 'NOT':
+ // If we get a group of clauses with no operator, assume AND
+ if (!is_string($clause[1][0])) {
+ $clause[1] = ['AND', $clause[1]];
+ }
+ return 'NOT (' . $this->treeWalkWhereClause($clause[1]) . ')';
+
+ default:
+ return $this->validateClauseAndComposeSql($clause);
+ }
+ }
+
+ /**
+ * Validate and transform a leaf clause array to SQL.
+ * @param array $clause [$fieldName, $operator, $criteria]
+ * @return string SQL
+ * @throws \API_Exception
+ * @throws \Exception
+ */
+ protected function validateClauseAndComposeSql($clause) {
+ // Pad array for unary operators
+ list($key, $operator, $value) = array_pad($clause, 3, NULL);
+ $fieldSpec = $this->getField($key);
+ // derive table and column:
+ $table_name = NULL;
+ $column_name = NULL;
+ if (in_array($key, $this->entityFieldNames)) {
+ $table_name = self::MAIN_TABLE_ALIAS;
+ $column_name = $key;
+ }
+ elseif (strpos($key, '.') && isset($this->fkSelectAliases[$key])) {
+ list($table_name, $column_name) = explode('.', $this->fkSelectAliases[$key]);
+ }
+
+ if (!$table_name || !$column_name) {
+ throw new \API_Exception("Invalid field '$key' in where clause.");
+ }
+
+ FormattingUtil::formatValue($value, $fieldSpec, $this->getEntity());
+
+ $sql_clause = \CRM_Core_DAO::createSQLFilter("`$table_name`.`$column_name`", [$operator => $value]);
+ if ($sql_clause === NULL) {
+ throw new \API_Exception("Invalid value in where clause for field '$key'");
+ }
+ return $sql_clause;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getFields() {
+ return $this->apiFieldSpec;
+ }
+
+ /**
+ * Fetch a field from the getFields list
+ *
+ * @param string $fieldName
+ *
+ * @return string|null
+ */
+ protected function getField($fieldName) {
+ if ($fieldName) {
+ $fieldPath = explode('.', $fieldName);
+ if (count($fieldPath) > 1) {
+ $fieldName = implode('.', array_slice($fieldPath, -2));
+ }
+ return UtilsArray::value($fieldName, $this->apiFieldSpec);
+ }
+ return NULL;
+ }
+
+ /**
+ * @param $key
+ * @throws \API_Exception
+ */
+ protected function joinFK($key) {
+ $pathArray = explode('.', $key);
+
+ if (count($pathArray) < 2) {
+ return;
+ }
+
+ /** @var \Civi\Api4\Service\Schema\Joiner $joiner */
+ $joiner = \Civi::container()->get('joiner');
+ $field = array_pop($pathArray);
+ $pathString = implode('.', $pathArray);
+
+ if (!$joiner->canJoin($this, $pathString)) {
+ return;
+ }
+
+ $joinPath = $joiner->join($this, $pathString);
+ /** @var \Civi\Api4\Service\Schema\Joinable\Joinable $lastLink */
+ $lastLink = array_pop($joinPath);
+
+ // Cache field info for retrieval by $this->getField()
+ $prefix = array_pop($pathArray) . '.';
+ if (!isset($this->apiFieldSpec[$prefix . $field])) {
+ $joinEntity = $lastLink->getEntity();
+ // Custom fields are already prefixed
+ if ($lastLink instanceof CustomGroupJoinable) {
+ $prefix = '';
+ }
+ foreach ($lastLink->getEntityFields() as $fieldObject) {
+ $this->apiFieldSpec[$prefix . $fieldObject->getName()] = $fieldObject->toArray() + ['entity' => $joinEntity];
+ }
+ }
+
+ if (!$lastLink->getField($field)) {
+ throw new \API_Exception('Invalid join');
+ }
+
+ // custom groups use aliases for field names
+ if ($lastLink instanceof CustomGroupJoinable) {
+ $field = $lastLink->getSqlColumn($field);
+ }
+
+ $this->fkSelectAliases[$key] = sprintf('%s.%s', $lastLink->getAlias(), $field);
+ }
+
+ /**
+ * @param \Civi\Api4\Service\Schema\Joinable\Joinable $joinable
+ *
+ * @return $this
+ */
+ public function addJoinedTable(Joinable $joinable) {
+ $this->joinedTables[] = $joinable;
+
+ return $this;
+ }
+
+ /**
+ * @return FALSE|string
+ */
+ public function getFrom() {
+ return AllCoreTables::getTableForClass(AllCoreTables::getFullName($this->entity));
+ }
+
+ /**
+ * @return string
+ */
+ public function getEntity() {
+ return $this->entity;
+ }
+
+ /**
+ * @return array
+ */
+ public function getSelect() {
+ return $this->select;
+ }
+
+ /**
+ * @return array
+ */
+ public function getWhere() {
+ return $this->where;
+ }
+
+ /**
+ * @return array
+ */
+ public function getOrderBy() {
+ return $this->orderBy;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getLimit() {
+ return $this->limit;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getOffset() {
+ return $this->offset;
+ }
+
+ /**
+ * @return array
+ */
+ public function getSelectFields() {
+ return $this->selectFields;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isFillUniqueFields() {
+ return $this->isFillUniqueFields;
+ }
+
+ /**
+ * @return \CRM_Utils_SQL_Select
+ */
+ public function getQuery() {
+ return $this->query;
+ }
+
+ /**
+ * @return array
+ */
+ public function getJoins() {
+ return $this->joins;
+ }
+
+ /**
+ * @return array
+ */
+ public function getApiFieldSpec() {
+ return $this->apiFieldSpec;
+ }
+
+ /**
+ * @return array
+ */
+ public function getEntityFieldNames() {
+ return $this->entityFieldNames;
+ }
+
+ /**
+ * @return array
+ */
+ public function getAclFields() {
+ return $this->aclFields;
+ }
+
+ /**
+ * @return bool|string
+ */
+ public function getCheckPermissions() {
+ return $this->checkPermissions;
+ }
+
+ /**
+ * @return int
+ */
+ public function getApiVersion() {
+ return $this->apiVersion;
+ }
+
+ /**
+ * @return array
+ */
+ public function getFkSelectAliases() {
+ return $this->fkSelectAliases;
+ }
+
+ /**
+ * @return \Civi\Api4\Service\Schema\Joinable\Joinable[]
+ */
+ public function getJoinedTables() {
+ return $this->joinedTables;
+ }
+
+ /**
+ * @return \Civi\Api4\Service\Schema\Joinable\Joinable
+ */
+ public function getJoinedTable($alias) {
+ foreach ($this->joinedTables as $join) {
+ if ($join->getAlias() == $alias) {
+ return $join;
+ }
+ }
+ }
+
+ /**
+ * Get table name on basis of entity
+ *
+ * @param string $baoName
+ *
+ * @return void
+ */
+ public function getTableName($baoName) {
+ if (strstr($this->entity, 'Custom_')) {
+ $this->query = \CRM_Utils_SQL_Select::from(CoreUtil::getCustomTableByName(str_replace('Custom_', '', $this->entity)) . ' ' . self::MAIN_TABLE_ALIAS);
+ $this->entityFieldNames = array_keys($this->apiFieldSpec);
+ }
+ else {
+ $bao = new $baoName();
+ $this->query = \CRM_Utils_SQL_Select::from($bao->tableName() . ' ' . self::MAIN_TABLE_ALIAS);
+ }
+ }
+
+ /**
+ * Separates a string like 'emails.location_type.label' into an array, where
+ * each value in the array tells whether it is 1-1 or 1-n join type
+ *
+ * @param string $pathString
+ * Dot separated path to the field
+ *
+ * @return array
+ * Index is table alias and value is boolean whether is 1-to-many join
+ */
+ public function getPathJoinTypes($pathString) {
+ $pathParts = explode('.', $pathString);
+ // remove field
+ array_pop($pathParts);
+ $path = [];
+ $query = $this;
+ $isMultipleChecker = function($alias) use ($query) {
+ foreach ($query->getJoinedTables() as $table) {
+ if ($table->getAlias() === $alias) {
+ return $table->getJoinType() === Joinable::JOIN_TYPE_ONE_TO_MANY;
+ }
+ }
+ return FALSE;
+ };
+
+ foreach ($pathParts as $part) {
+ $path[$part] = $isMultipleChecker($part);
+ }
+
+ return $path;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Relationship entity.
+ *
+ * @package Civi\Api4
+ */
+class Relationship extends Generic\DAOEntity {
+
+ /**
+ * @return \Civi\Api4\Action\Relationship\Get
+ */
+ public static function get() {
+ return new \Civi\Api4\Action\Relationship\Get(static::class, __FUNCTION__);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * RelationshipType entity.
+ *
+ * @package Civi\Api4
+ */
+class RelationshipType extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4\Result;
+
+class ReplaceResult extends \Civi\Api4\Generic\Result {
+ /**
+ * @var array
+ */
+ public $deleted = [];
+
+}
--- /dev/null
+<?php
+namespace Civi\Api4;
+
+use Civi\Api4\Generic\BasicGetFieldsAction;
+
+class Route extends \Civi\Api4\Generic\AbstractEntity {
+
+ /**
+ * @return \Civi\Api4\Generic\BasicGetAction
+ */
+ public static function get() {
+ return new \Civi\Api4\Generic\BasicGetAction(__CLASS__, __FUNCTION__, function ($get) {
+ // Pulling from ::items() rather than DB -- because it provides the final/live/altered data.
+ $items = \CRM_Core_Menu::items();
+ $result = [];
+ foreach ($items as $path => $item) {
+ $result[] = ['path' => $path] + $item;
+ }
+ return $result;
+ });
+ }
+
+ public static function getFields() {
+ return new BasicGetFieldsAction(__CLASS__, __FUNCTION__, function() {
+ return [
+ [
+ 'name' => 'path',
+ 'title' => 'Relative Path',
+ 'required' => TRUE,
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'title',
+ 'title' => 'Page Title',
+ 'required' => TRUE,
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'page_callback',
+ 'title' => 'Page Callback',
+ 'required' => TRUE,
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'page_arguments',
+ 'title' => 'Page Arguments',
+ 'required' => FALSE,
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'path_arguments',
+ 'title' => 'Path Arguments',
+ 'required' => FALSE,
+ 'data_type' => 'String',
+ ],
+ [
+ 'name' => 'access_arguments',
+ 'title' => 'Access Arguments',
+ 'required' => FALSE,
+ 'data_type' => 'Array',
+ ],
+ ];
+ });
+ }
+
+ /**
+ * @return array
+ */
+ public static function permissions() {
+ return [
+ "meta" => ["access CiviCRM"],
+ "default" => ["administer CiviCRM"],
+ ];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Schema\Joinable;
+
+class ActivityToActivityContactAssigneesJoinable extends Joinable {
+ /**
+ * @var string
+ */
+ protected $baseTable = 'civicrm_activity';
+
+ /**
+ * @var string
+ */
+ protected $baseColumn = 'id';
+
+ /**
+ * @param $alias
+ */
+ public function __construct($alias) {
+ $optionValueTable = 'civicrm_option_value';
+ $optionGroupTable = 'civicrm_option_group';
+
+ $subSubSelect = sprintf(
+ 'SELECT id FROM %s WHERE name = "%s"',
+ $optionGroupTable,
+ 'activity_contacts'
+ );
+
+ $subSelect = sprintf(
+ 'SELECT value FROM %s WHERE name = "%s" AND option_group_id = (%s)',
+ $optionValueTable,
+ 'Activity Assignees',
+ $subSubSelect
+ );
+
+ $this->addCondition(sprintf('%s.record_type_id = (%s)', $alias, $subSelect));
+ parent::__construct('civicrm_activity_contact', 'activity_id', $alias);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Schema\Joinable;
+
+class BridgeJoinable extends Joinable {
+ /**
+ * @var Joinable
+ */
+ protected $middleLink;
+
+ public function __construct($targetTable, $targetColumn, $alias, Joinable $middleLink) {
+ parent::__construct($targetTable, $targetColumn, $alias);
+ $this->middleLink = $middleLink;
+ }
+
+ /**
+ * @return Joinable
+ */
+ public function getMiddleLink() {
+ return $this->middleLink;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Schema\Joinable;
+
+use Civi\Api4\CustomField;
+
+class CustomGroupJoinable extends Joinable {
+
+ /**
+ * @var string
+ */
+ protected $joinSide = self::JOIN_SIDE_LEFT;
+
+ /**
+ * @var string
+ *
+ * Name of the custom field column.
+ */
+ protected $columns;
+
+ /**
+ * @param $targetTable
+ * @param $alias
+ * @param bool $isMultiRecord
+ * @param string $entity
+ * @param string $columns
+ */
+ public function __construct($targetTable, $alias, $isMultiRecord, $entity, $columns) {
+ $this->entity = $entity;
+ $this->columns = $columns;
+ parent::__construct($targetTable, 'entity_id', $alias);
+ $this->joinType = $isMultiRecord ?
+ self::JOIN_TYPE_ONE_TO_MANY : self::JOIN_TYPE_ONE_TO_ONE;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getEntityFields() {
+ if (!$this->entityFields) {
+ $fields = CustomField::get()
+ ->setCheckPermissions(FALSE)
+ ->setSelect(['custom_group.name', 'custom_group_id', 'name', 'label', 'data_type', 'html_type', 'is_required', 'is_searchable', 'is_search_range', 'weight', 'is_active', 'is_view', 'option_group_id', 'default_value', 'date_format', 'time_format', 'start_date_years', 'end_date_years'])
+ ->addWhere('custom_group.table_name', '=', $this->getTargetTable())
+ ->execute();
+ foreach ($fields as $field) {
+ $this->entityFields[] = \Civi\Api4\Service\Spec\SpecFormatter::arrayToField($field, $this->getEntity());
+ }
+ }
+ return $this->entityFields;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getField($fieldName) {
+ foreach ($this->getEntityFields() as $field) {
+ $name = $field->getName();
+ if ($name === $fieldName || strrpos($name, '.' . $fieldName) === strlen($name) - strlen($fieldName) - 1) {
+ return $field;
+ }
+ }
+ return NULL;
+ }
+
+ /**
+ * @return string
+ */
+ public function getSqlColumn($fieldName) {
+ return $this->columns[$fieldName];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Schema\Joinable;
+
+use Civi\Api4\Utils\CoreUtil;
+use CRM_Core_DAO_AllCoreTables as AllCoreTables;
+
+class Joinable {
+
+ const JOIN_SIDE_LEFT = 'LEFT';
+ const JOIN_SIDE_INNER = 'INNER';
+
+ const JOIN_TYPE_ONE_TO_ONE = '1_to_1';
+ const JOIN_TYPE_MANY_TO_ONE = 'n_to_1';
+ const JOIN_TYPE_ONE_TO_MANY = '1_to_n';
+
+ /**
+ * @var string
+ */
+ protected $baseTable;
+
+ /**
+ * @var string
+ */
+ protected $baseColumn;
+
+ /**
+ * @var string
+ */
+ protected $targetTable;
+
+ /**
+ * @var string
+ *
+ * Name (or alias) of the target column)
+ */
+ protected $targetColumn;
+
+ /**
+ * @var string
+ */
+ protected $alias;
+
+ /**
+ * @var array
+ */
+ protected $conditions = [];
+
+ /**
+ * @var string
+ */
+ protected $joinSide = self::JOIN_SIDE_LEFT;
+
+ /**
+ * @var int
+ */
+ protected $joinType = self::JOIN_TYPE_ONE_TO_ONE;
+
+ /**
+ * @var string
+ */
+ protected $entity;
+
+ /**
+ * @var array
+ */
+ protected $entityFields;
+
+ /**
+ * @param $targetTable
+ * @param $targetColumn
+ * @param string|null $alias
+ */
+ public function __construct($targetTable, $targetColumn, $alias = NULL) {
+ $this->targetTable = $targetTable;
+ $this->targetColumn = $targetColumn;
+ if (!$this->entity) {
+ $this->entity = CoreUtil::getApiNameFromTableName($targetTable);
+ }
+ $this->alias = $alias ?: str_replace('civicrm_', '', $targetTable);
+ }
+
+ /**
+ * Gets conditions required when joining to a base table
+ *
+ * @param string|null $baseTableAlias
+ * Name of the base table, if aliased.
+ *
+ * @return array
+ */
+ public function getConditionsForJoin($baseTableAlias = NULL) {
+ $baseCondition = sprintf(
+ '%s.%s = %s.%s',
+ $baseTableAlias ?: $this->baseTable,
+ $this->baseColumn,
+ $this->getAlias(),
+ $this->targetColumn
+ );
+
+ return array_merge([$baseCondition], $this->conditions);
+ }
+
+ /**
+ * @return string
+ */
+ public function getBaseTable() {
+ return $this->baseTable;
+ }
+
+ /**
+ * @param string $baseTable
+ *
+ * @return $this
+ */
+ public function setBaseTable($baseTable) {
+ $this->baseTable = $baseTable;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getBaseColumn() {
+ return $this->baseColumn;
+ }
+
+ /**
+ * @param string $baseColumn
+ *
+ * @return $this
+ */
+ public function setBaseColumn($baseColumn) {
+ $this->baseColumn = $baseColumn;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getAlias() {
+ return $this->alias;
+ }
+
+ /**
+ * @param string $alias
+ *
+ * @return $this
+ */
+ public function setAlias($alias) {
+ $this->alias = $alias;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getTargetTable() {
+ return $this->targetTable;
+ }
+
+ /**
+ * @return string
+ */
+ public function getTargetColumn() {
+ return $this->targetColumn;
+ }
+
+ /**
+ * @return string
+ */
+ public function getEntity() {
+ return $this->entity;
+ }
+
+ /**
+ * @param $condition
+ *
+ * @return $this
+ */
+ public function addCondition($condition) {
+ $this->conditions[] = $condition;
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getExtraJoinConditions() {
+ return $this->conditions;
+ }
+
+ /**
+ * @param array $conditions
+ *
+ * @return $this
+ */
+ public function setConditions($conditions) {
+ $this->conditions = $conditions;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getJoinSide() {
+ return $this->joinSide;
+ }
+
+ /**
+ * @param string $joinSide
+ *
+ * @return $this
+ */
+ public function setJoinSide($joinSide) {
+ $this->joinSide = $joinSide;
+
+ return $this;
+ }
+
+ /**
+ * @return int
+ */
+ public function getJoinType() {
+ return $this->joinType;
+ }
+
+ /**
+ * @param int $joinType
+ *
+ * @return $this
+ */
+ public function setJoinType($joinType) {
+ $this->joinType = $joinType;
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray() {
+ return get_object_vars($this);
+ }
+
+ /**
+ * @return \Civi\Api4\Service\Spec\FieldSpec[]
+ */
+ public function getEntityFields() {
+ if (!$this->entityFields) {
+ $bao = AllCoreTables::getClassForTable($this->getTargetTable());
+ if ($bao) {
+ foreach ($bao::fields() as $field) {
+ $this->entityFields[] = \Civi\Api4\Service\Spec\SpecFormatter::arrayToField($field, $this->getEntity());
+ }
+ }
+ }
+ return $this->entityFields;
+ }
+
+ /**
+ * @return \Civi\Api4\Service\Spec\FieldSpec|NULL
+ */
+ public function getField($fieldName) {
+ foreach ($this->getEntityFields() as $field) {
+ if ($field->getName() === $fieldName) {
+ return $field;
+ }
+ }
+ return NULL;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Schema\Joinable;
+
+class OptionValueJoinable extends Joinable {
+ /**
+ * @var string
+ */
+ protected $optionGroupName;
+
+ /**
+ * @param string $optionGroup
+ * Can be either the option group name or ID
+ * @param string|null $alias
+ * The join alias
+ * @param string $keyColumn
+ * Which column to use to join, defaults to "value"
+ */
+ public function __construct($optionGroup, $alias = NULL, $keyColumn = 'value') {
+ $this->optionGroupName = $optionGroup;
+ $optionValueTable = 'civicrm_option_value';
+
+ // default join alias to option group name, e.g. activity_type
+ if (!$alias && !is_numeric($optionGroup)) {
+ $alias = $optionGroup;
+ }
+
+ parent::__construct($optionValueTable, $keyColumn, $alias);
+
+ if (!is_numeric($optionGroup)) {
+ $subSelect = 'SELECT id FROM civicrm_option_group WHERE name = "%s"';
+ $subQuery = sprintf($subSelect, $optionGroup);
+ $condition = sprintf('%s.option_group_id = (%s)', $alias, $subQuery);
+ }
+ else {
+ $condition = sprintf('%s.option_group_id = %d', $alias, $optionGroup);
+ }
+
+ $this->addCondition($condition);
+ }
+
+ /**
+ * The existing condition must also be re-aliased
+ *
+ * @param string $alias
+ *
+ * @return $this
+ */
+ public function setAlias($alias) {
+ foreach ($this->conditions as $index => $condition) {
+ $search = $this->alias . '.';
+ $replace = $alias . '.';
+ $this->conditions[$index] = str_replace($search, $replace, $condition);
+ }
+
+ parent::setAlias($alias);
+
+ return $this;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Schema;
+
+use Civi\Api4\Query\Api4SelectQuery;
+
+class Joiner {
+ /**
+ * @var SchemaMap
+ */
+ protected $schemaMap;
+
+ /**
+ * @var \Civi\Api4\Service\Schema\Joinable\Joinable[][]
+ */
+ protected $cache = [];
+
+ /**
+ * @param SchemaMap $schemaMap
+ */
+ public function __construct(SchemaMap $schemaMap) {
+ $this->schemaMap = $schemaMap;
+ }
+
+ /**
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ * The query object to do the joins on
+ * @param string $joinPath
+ * A path of aliases in dot notation, e.g. contact.phone
+ * @param string $side
+ * Can be LEFT or INNER
+ *
+ * @throws \Exception
+ * @return \Civi\Api4\Service\Schema\Joinable\Joinable[]
+ * The path used to make the join
+ */
+ public function join(Api4SelectQuery $query, $joinPath, $side = 'LEFT') {
+ $fullPath = $this->getPath($query->getFrom(), $joinPath);
+ $baseTable = $query::MAIN_TABLE_ALIAS;
+
+ foreach ($fullPath as $link) {
+ $target = $link->getTargetTable();
+ $alias = $link->getAlias();
+ $conditions = $link->getConditionsForJoin($baseTable);
+
+ $query->join($side, $target, $alias, $conditions);
+ $query->addJoinedTable($link);
+
+ $baseTable = $link->getAlias();
+ }
+
+ return $fullPath;
+ }
+
+ /**
+ * @param \Civi\Api4\Query\Api4SelectQuery $query
+ * @param $joinPath
+ *
+ * @return bool
+ */
+ public function canJoin(Api4SelectQuery $query, $joinPath) {
+ return !empty($this->getPath($query->getFrom(), $joinPath));
+ }
+
+ /**
+ * @param string $baseTable
+ * @param string $joinPath
+ *
+ * @return array
+ * @throws \Exception
+ */
+ protected function getPath($baseTable, $joinPath) {
+ $cacheKey = sprintf('%s.%s', $baseTable, $joinPath);
+ if (!isset($this->cache[$cacheKey])) {
+ $stack = explode('.', $joinPath);
+ $fullPath = [];
+
+ foreach ($stack as $key => $targetAlias) {
+ $links = $this->schemaMap->getPath($baseTable, $targetAlias);
+
+ if (empty($links)) {
+ throw new \Exception(sprintf('Cannot join %s to %s', $baseTable, $targetAlias));
+ }
+ else {
+ $fullPath = array_merge($fullPath, $links);
+ $lastLink = end($links);
+ $baseTable = $lastLink->getTargetTable();
+ }
+ }
+
+ $this->cache[$cacheKey] = $fullPath;
+ }
+
+ return $this->cache[$cacheKey];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Schema;
+
+use Civi\Api4\Service\Schema\Joinable\BridgeJoinable;
+
+class SchemaMap {
+
+ const MAX_JOIN_DEPTH = 3;
+
+ /**
+ * @var Table[]
+ */
+ protected $tables = [];
+
+ /**
+ * @param $baseTableName
+ * @param $targetTableAlias
+ *
+ * @return \Civi\Api4\Service\Schema\Joinable\Joinable[]
+ * Array of links to the target table, empty if no path found
+ */
+ public function getPath($baseTableName, $targetTableAlias) {
+ $table = $this->getTableByName($baseTableName);
+ $path = [];
+
+ if (!$table) {
+ return $path;
+ }
+
+ $this->findPaths($table, $targetTableAlias, 1, $path);
+
+ foreach ($path as $index => $pathLink) {
+ if ($pathLink instanceof BridgeJoinable) {
+ $start = array_slice($path, 0, $index);
+ $middle = [$pathLink->getMiddleLink()];
+ $end = array_slice($path, $index, count($path) - $index);
+ $path = array_merge($start, $middle, $end);
+ }
+ }
+
+ return $path;
+ }
+
+ /**
+ * @return Table[]
+ */
+ public function getTables() {
+ return $this->tables;
+ }
+
+ /**
+ * @param $name
+ *
+ * @return Table|null
+ */
+ public function getTableByName($name) {
+ foreach ($this->tables as $table) {
+ if ($table->getName() === $name) {
+ return $table;
+ }
+ }
+
+ return NULL;
+ }
+
+ /**
+ * Adds a table to the schema map if it has not already been added
+ *
+ * @param Table $table
+ *
+ * @return $this
+ */
+ public function addTable(Table $table) {
+ if (!$this->getTableByName($table->getName())) {
+ $this->tables[] = $table;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param array $tables
+ */
+ public function addTables(array $tables) {
+ foreach ($tables as $table) {
+ $this->addTable($table);
+ }
+ }
+
+ /**
+ * Recursive function to traverse the schema looking for a path
+ *
+ * @param Table $table
+ * The current table to base fromm
+ * @param string $target
+ * The target joinable table alias
+ * @param int $depth
+ * The current level of recursion which reflects the number of joins needed
+ * @param \Civi\Api4\Service\Schema\Joinable\Joinable[] $path
+ * (By-reference) The possible paths to the target table
+ * @param \Civi\Api4\Service\Schema\Joinable\Joinable[] $currentPath
+ * For internal use only to track the path to reach the target table
+ */
+ private function findPaths(Table $table, $target, $depth, &$path, $currentPath = []
+ ) {
+ static $visited = [];
+
+ // reset if new call
+ if ($depth === 1) {
+ $visited = [];
+ }
+
+ $canBeShorter = empty($path) || count($currentPath) + 1 < count($path);
+ $tooFar = $depth > self::MAX_JOIN_DEPTH;
+ $beenHere = in_array($table->getName(), $visited);
+
+ if ($tooFar || $beenHere || !$canBeShorter) {
+ return;
+ }
+
+ // prevent circular reference
+ $visited[] = $table->getName();
+
+ foreach ($table->getExternalLinks() as $link) {
+ if ($link->getAlias() === $target) {
+ $path = array_merge($currentPath, [$link]);
+ }
+ else {
+ $linkTable = $this->getTableByName($link->getTargetTable());
+ if ($linkTable) {
+ $nextStep = array_merge($currentPath, [$link]);
+ $this->findPaths($linkTable, $target, $depth + 1, $path, $nextStep);
+ }
+ }
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Schema;
+
+use Civi\Api4\Entity;
+use Civi\Api4\Event\Events;
+use Civi\Api4\Event\SchemaMapBuildEvent;
+use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable;
+use Civi\Api4\Service\Schema\Joinable\Joinable;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Civi\Api4\Service\Schema\Joinable\OptionValueJoinable;
+use CRM_Core_DAO_AllCoreTables as AllCoreTables;
+use CRM_Utils_Array as UtilsArray;
+
+class SchemaMapBuilder {
+ /**
+ * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+ */
+ protected $dispatcher;
+ /**
+ * @var array
+ */
+ protected $apiEntities;
+
+ /**
+ * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+ */
+ public function __construct(EventDispatcherInterface $dispatcher) {
+ $this->dispatcher = $dispatcher;
+ $this->apiEntities = array_keys((array) Entity::get()->setCheckPermissions(FALSE)->addSelect('name')->execute()->indexBy('name'));
+ }
+
+ /**
+ * @return SchemaMap
+ */
+ public function build() {
+ $map = new SchemaMap();
+ $this->loadTables($map);
+
+ $event = new SchemaMapBuildEvent($map);
+ $this->dispatcher->dispatch(Events::SCHEMA_MAP_BUILD, $event);
+
+ return $map;
+ }
+
+ /**
+ * Add all tables and joins
+ *
+ * @param SchemaMap $map
+ */
+ private function loadTables(SchemaMap $map) {
+ /** @var \CRM_Core_DAO $daoName */
+ foreach (AllCoreTables::get() as $daoName => $data) {
+ $table = new Table($data['table']);
+ foreach ($daoName::fields() as $field => $fieldData) {
+ $this->addJoins($table, $field, $fieldData);
+ }
+ $map->addTable($table);
+ if (in_array($data['name'], $this->apiEntities)) {
+ $this->addCustomFields($map, $table, $data['name']);
+ }
+ }
+
+ $this->addBackReferences($map);
+ }
+
+ /**
+ * @param Table $table
+ * @param string $field
+ * @param array $data
+ */
+ private function addJoins(Table $table, $field, array $data) {
+ $fkClass = UtilsArray::value('FKClassName', $data);
+
+ // can there be multiple methods e.g. pseudoconstant and fkclass
+ if ($fkClass) {
+ $tableName = AllCoreTables::getTableForClass($fkClass);
+ $fkKey = UtilsArray::value('FKKeyColumn', $data, 'id');
+ $alias = str_replace('_id', '', $field);
+ $joinable = new Joinable($tableName, $fkKey, $alias);
+ $joinable->setJoinType($joinable::JOIN_TYPE_MANY_TO_ONE);
+ $table->addTableLink($field, $joinable);
+ }
+ elseif (UtilsArray::value('pseudoconstant', $data)) {
+ $this->addPseudoConstantJoin($table, $field, $data);
+ }
+ }
+
+ /**
+ * @param Table $table
+ * @param string $field
+ * @param array $data
+ */
+ private function addPseudoConstantJoin(Table $table, $field, array $data) {
+ $pseudoConstant = UtilsArray::value('pseudoconstant', $data);
+ $tableName = UtilsArray::value('table', $pseudoConstant);
+ $optionGroupName = UtilsArray::value('optionGroupName', $pseudoConstant);
+ $keyColumn = UtilsArray::value('keyColumn', $pseudoConstant, 'id');
+
+ if ($tableName) {
+ $alias = str_replace('civicrm_', '', $tableName);
+ $joinable = new Joinable($tableName, $keyColumn, $alias);
+ $condition = UtilsArray::value('condition', $pseudoConstant);
+ if ($condition) {
+ $joinable->addCondition($condition);
+ }
+ $table->addTableLink($field, $joinable);
+ }
+ elseif ($optionGroupName) {
+ $keyColumn = UtilsArray::value('keyColumn', $pseudoConstant, 'value');
+ $joinable = new OptionValueJoinable($optionGroupName, NULL, $keyColumn);
+
+ if (!empty($data['serialize'])) {
+ $joinable->setJoinType($joinable::JOIN_TYPE_ONE_TO_MANY);
+ }
+
+ $table->addTableLink($field, $joinable);
+ }
+ }
+
+ /**
+ * Loop through existing links and provide link from the other side
+ *
+ * @param SchemaMap $map
+ */
+ private function addBackReferences(SchemaMap $map) {
+ foreach ($map->getTables() as $table) {
+ foreach ($table->getTableLinks() as $link) {
+ // there are too many possible joins from option value so skip
+ if ($link instanceof OptionValueJoinable) {
+ continue;
+ }
+
+ $target = $map->getTableByName($link->getTargetTable());
+ $tableName = $link->getBaseTable();
+ $plural = str_replace('civicrm_', '', $this->getPlural($tableName));
+ $joinable = new Joinable($tableName, $link->getBaseColumn(), $plural);
+ $joinable->setJoinType($joinable::JOIN_TYPE_ONE_TO_MANY);
+ $target->addTableLink($link->getTargetColumn(), $joinable);
+ }
+ }
+ }
+
+ /**
+ * Simple implementation of pluralization.
+ * Could be replaced with symfony/inflector
+ *
+ * @param string $singular
+ *
+ * @return string
+ */
+ private function getPlural($singular) {
+ $last_letter = substr($singular, -1);
+ switch ($last_letter) {
+ case 'y':
+ return substr($singular, 0, -1) . 'ies';
+
+ case 's':
+ return $singular . 'es';
+
+ default:
+ return $singular . 's';
+ }
+ }
+
+ /**
+ * @param \Civi\Api4\Service\Schema\SchemaMap $map
+ * @param \Civi\Api4\Service\Schema\Table $baseTable
+ * @param string $entity
+ */
+ private function addCustomFields(SchemaMap $map, Table $baseTable, $entity) {
+ // Don't be silly
+ if (!array_key_exists($entity, \CRM_Core_SelectValues::customGroupExtends())) {
+ return;
+ }
+ $queryEntity = (array) $entity;
+ if ($entity == 'Contact') {
+ $queryEntity = ['Contact', 'Individual', 'Organization', 'Household'];
+ }
+ $fieldData = \CRM_Utils_SQL_Select::from('civicrm_custom_field f')
+ ->join('custom_group', 'INNER JOIN civicrm_custom_group g ON g.id = f.custom_group_id')
+ ->select(['g.name as custom_group_name', 'g.table_name', 'g.is_multiple', 'f.name', 'label', 'column_name', 'option_group_id'])
+ ->where('g.extends IN (@entity)', ['@entity' => $queryEntity])
+ ->where('g.is_active')
+ ->where('f.is_active')
+ ->execute();
+
+ $links = [];
+
+ while ($fieldData->fetch()) {
+ $tableName = $fieldData->table_name;
+
+ $customTable = $map->getTableByName($tableName);
+ if (!$customTable) {
+ $customTable = new Table($tableName);
+ }
+
+ if (!empty($fieldData->option_group_id)) {
+ $optionValueJoinable = new OptionValueJoinable($fieldData->option_group_id, $fieldData->label);
+ $customTable->addTableLink($fieldData->column_name, $optionValueJoinable);
+ }
+
+ $map->addTable($customTable);
+
+ $alias = $fieldData->custom_group_name;
+ $links[$alias]['tableName'] = $tableName;
+ $links[$alias]['isMultiple'] = !empty($fieldData->is_multiple);
+ $links[$alias]['columns'][$fieldData->name] = $fieldData->column_name;
+ }
+
+ foreach ($links as $alias => $link) {
+ $joinable = new CustomGroupJoinable($link['tableName'], $alias, $link['isMultiple'], $entity, $link['columns']);
+ $baseTable->addTableLink('id', $joinable);
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Schema;
+
+use Civi\Api4\Service\Schema\Joinable\Joinable;
+
+class Table {
+
+ /**
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * @var \Civi\Api4\Service\Schema\Joinable\Joinable[]
+ * Array of links to other tables
+ */
+ protected $tableLinks = [];
+
+ /**
+ * @param $name
+ */
+ public function __construct($name) {
+ $this->name = $name;
+ }
+
+ /**
+ * @return string
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name) {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * @return \Civi\Api4\Service\Schema\Joinable\Joinable[]
+ */
+ public function getTableLinks() {
+ return $this->tableLinks;
+ }
+
+ /**
+ * @return \Civi\Api4\Service\Schema\Joinable\Joinable[]
+ * Only those links that are not joining the table to itself
+ */
+ public function getExternalLinks() {
+ return array_filter($this->tableLinks, function (Joinable $joinable) {
+ return $joinable->getTargetTable() !== $this->getName();
+ });
+ }
+
+ /**
+ * @param \Civi\Api4\Service\Schema\Joinable\Joinable $linkToRemove
+ */
+ public function removeLink(Joinable $linkToRemove) {
+ foreach ($this->tableLinks as $index => $link) {
+ if ($link === $linkToRemove) {
+ unset($this->tableLinks[$index]);
+ }
+ }
+ }
+
+ /**
+ * @param string $baseColumn
+ * @param \Civi\Api4\Service\Schema\Joinable\Joinable $joinable
+ *
+ * @return $this
+ */
+ public function addTableLink($baseColumn, Joinable $joinable) {
+ $target = $joinable->getTargetTable();
+ $targetCol = $joinable->getTargetColumn();
+ $alias = $joinable->getAlias();
+
+ if (!$this->hasLink($target, $targetCol, $alias)) {
+ if (!$joinable->getBaseTable()) {
+ $joinable->setBaseTable($this->getName());
+ }
+ if (!$joinable->getBaseColumn()) {
+ $joinable->setBaseColumn($baseColumn);
+ }
+ $this->tableLinks[] = $joinable;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param mixed $tableLinks
+ *
+ * @return $this
+ */
+ public function setTableLinks($tableLinks) {
+ $this->tableLinks = $tableLinks;
+
+ return $this;
+ }
+
+ /**
+ * @param $target
+ * @param $targetCol
+ * @param $alias
+ *
+ * @return bool
+ */
+ private function hasLink($target, $targetCol, $alias) {
+ foreach ($this->tableLinks as $link) {
+ if ($link->getTargetTable() === $target
+ && $link->getTargetColumn() === $targetCol
+ && $link->getAlias() === $alias
+ ) {
+ return TRUE;
+ }
+ }
+
+ return FALSE;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec;
+
+class CustomFieldSpec extends FieldSpec {
+ /**
+ * @var int
+ */
+ protected $customFieldId;
+
+ /**
+ * @var int
+ */
+ protected $customGroup;
+
+ /**
+ * @var string
+ */
+ protected $tableName;
+
+ /**
+ * @var string
+ */
+ protected $columnName;
+
+ /**
+ * @inheritDoc
+ */
+ public function setDataType($dataType) {
+ switch ($dataType) {
+ case 'ContactReference':
+ $this->setFkEntity('Contact');
+ $dataType = 'Integer';
+ break;
+
+ case 'File':
+ case 'StateProvince':
+ case 'Country':
+ $this->setFkEntity($dataType);
+ $dataType = 'Integer';
+ break;
+ }
+ return parent::setDataType($dataType);
+ }
+
+ /**
+ * @return int
+ */
+ public function getCustomFieldId() {
+ return $this->customFieldId;
+ }
+
+ /**
+ * @param int $customFieldId
+ *
+ * @return $this
+ */
+ public function setCustomFieldId($customFieldId) {
+ $this->customFieldId = $customFieldId;
+
+ return $this;
+ }
+
+ /**
+ * @return int
+ */
+ public function getCustomGroupName() {
+ return $this->customGroup;
+ }
+
+ /**
+ * @param string $customGroupName
+ *
+ * @return $this
+ */
+ public function setCustomGroupName($customGroupName) {
+ $this->customGroup = $customGroupName;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCustomTableName() {
+ return $this->tableName;
+ }
+
+ /**
+ * @param string $customFieldColumnName
+ *
+ * @return $this
+ */
+ public function setCustomTableName($customFieldColumnName) {
+ $this->tableName = $customFieldColumnName;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCustomFieldColumnName() {
+ return $this->columnName;
+ }
+
+ /**
+ * @param string $customFieldColumnName
+ *
+ * @return $this
+ */
+ public function setCustomFieldColumnName($customFieldColumnName) {
+ $this->columnName = $customFieldColumnName;
+
+ return $this;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec;
+
+use Civi\Api4\Utils\CoreUtil;
+
+class FieldSpec {
+ /**
+ * @var mixed
+ */
+ protected $defaultValue;
+
+ /**
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * @var string
+ */
+ protected $title;
+
+ /**
+ * @var string
+ */
+ protected $entity;
+
+ /**
+ * @var string
+ */
+ protected $description;
+
+ /**
+ * @var bool
+ */
+ protected $required = FALSE;
+
+ /**
+ * @var bool
+ */
+ protected $requiredIf;
+
+ /**
+ * @var array|boolean
+ */
+ protected $options;
+
+ /**
+ * @var string
+ */
+ protected $dataType;
+
+ /**
+ * @var string
+ */
+ protected $inputType;
+
+ /**
+ * @var array
+ */
+ protected $inputAttrs = [];
+
+ /**
+ * @var string
+ */
+ protected $fkEntity;
+
+ /**
+ * @var int
+ */
+ protected $serialize;
+
+ /**
+ * Aliases for the valid data types
+ *
+ * @var array
+ */
+ public static $typeAliases = [
+ 'Int' => 'Integer',
+ 'Link' => 'Url',
+ 'Memo' => 'Text',
+ ];
+
+ /**
+ * @param string $name
+ * @param string $entity
+ * @param string $dataType
+ */
+ public function __construct($name, $entity, $dataType = 'String') {
+ $this->entity = $entity;
+ $this->setName($name);
+ $this->setDataType($dataType);
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getDefaultValue() {
+ return $this->defaultValue;
+ }
+
+ /**
+ * @param mixed $defaultValue
+ *
+ * @return $this
+ */
+ public function setDefaultValue($defaultValue) {
+ $this->defaultValue = $defaultValue;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setName($name) {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getTitle() {
+ return $this->title;
+ }
+
+ /**
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function setTitle($title) {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getEntity() {
+ return $this->entity;
+ }
+
+ /**
+ * @return string
+ */
+ public function getDescription() {
+ return $this->description;
+ }
+
+ /**
+ * @param string $description
+ *
+ * @return $this
+ */
+ public function setDescription($description) {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isRequired() {
+ return $this->required;
+ }
+
+ /**
+ * @param bool $required
+ *
+ * @return $this
+ */
+ public function setRequired($required) {
+ $this->required = $required;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getRequiredIf() {
+ return $this->requiredIf;
+ }
+
+ /**
+ * @param bool $requiredIf
+ *
+ * @return $this
+ */
+ public function setRequiredIf($requiredIf) {
+ $this->requiredIf = $requiredIf;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getDataType() {
+ return $this->dataType;
+ }
+
+ /**
+ * @param $dataType
+ *
+ * @return $this
+ * @throws \Exception
+ */
+ public function setDataType($dataType) {
+ if (array_key_exists($dataType, self::$typeAliases)) {
+ $dataType = self::$typeAliases[$dataType];
+ }
+
+ if (!in_array($dataType, $this->getValidDataTypes())) {
+ throw new \Exception(sprintf('Invalid data type "%s', $dataType));
+ }
+
+ $this->dataType = $dataType;
+
+ return $this;
+ }
+
+ /**
+ * @return int
+ */
+ public function getSerialize() {
+ return $this->serialize;
+ }
+
+ /**
+ * @param int|null $serialize
+ * @return $this
+ */
+ public function setSerialize($serialize) {
+ $this->serialize = $serialize;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getInputType() {
+ return $this->inputType;
+ }
+
+ /**
+ * @param string $inputType
+ * @return $this
+ */
+ public function setInputType($inputType) {
+ $this->inputType = $inputType;
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getInputAttrs() {
+ return $this->inputAttrs;
+ }
+
+ /**
+ * @param array $inputAttrs
+ * @return $this
+ */
+ public function setInputAttrs($inputAttrs) {
+ $this->inputAttrs = $inputAttrs;
+
+ return $this;
+ }
+
+ /**
+ * Add valid types that are not not part of \CRM_Utils_Type::dataTypes
+ *
+ * @return array
+ */
+ private function getValidDataTypes() {
+ $extraTypes = ['Boolean', 'Text', 'Float', 'Url', 'Array'];
+ $extraTypes = array_combine($extraTypes, $extraTypes);
+
+ return array_merge(\CRM_Utils_Type::dataTypes(), $extraTypes);
+ }
+
+ /**
+ * @return array
+ */
+ public function getOptions() {
+ if (!isset($this->options) || $this->options === TRUE) {
+ $fieldName = $this->getName();
+
+ if ($this instanceof CustomFieldSpec) {
+ // buildOptions relies on the custom_* type of field names
+ $fieldName = sprintf('custom_%d', $this->getCustomFieldId());
+ }
+
+ $bao = CoreUtil::getBAOFromApiName($this->getEntity());
+ $options = $bao::buildOptions($fieldName);
+
+ if (!is_array($options) || !$options) {
+ $options = FALSE;
+ }
+
+ $this->setOptions($options);
+ }
+ return $this->options;
+ }
+
+ /**
+ * @param array|bool $options
+ *
+ * @return $this
+ */
+ public function setOptions($options) {
+ $this->options = $options;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getFkEntity() {
+ return $this->fkEntity;
+ }
+
+ /**
+ * @param string $fkEntity
+ *
+ * @return $this
+ */
+ public function setFkEntity($fkEntity) {
+ $this->fkEntity = $fkEntity;
+
+ return $this;
+ }
+
+ /**
+ * @param array $values
+ * @return array
+ */
+ public function toArray($values = []) {
+ $ret = [];
+ foreach (get_object_vars($this) as $key => $val) {
+ $key = strtolower(preg_replace('/(?=[A-Z])/', '_$0', $key));
+ if (!$values || in_array($key, $values)) {
+ $ret[$key] = $val;
+ }
+ }
+ return $ret;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class ACLCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('entity_table')->setDefaultValue('civicrm_acl_role');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'ACL' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class ActionScheduleCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('title')->setRequired(TRUE);
+ $spec->getFieldByName('mapping_id')->setRequired(TRUE);
+ $spec->getFieldByName('entity_value')->setRequired(TRUE);
+ $spec->getFieldByName('start_action_date')->setRequiredIf('empty($values.absolute_date)');
+ $spec->getFieldByName('absolute_date')->setRequiredIf('empty($values.start_action_date)');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'ActionSchedule' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class ActivityCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $sourceContactField = new FieldSpec('source_contact_id', 'Activity', 'Integer');
+ $sourceContactField->setRequired(TRUE);
+ $sourceContactField->setFkEntity('Contact');
+
+ $spec->addFieldSpec($sourceContactField);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Activity' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class AddressCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('contact_id')->setRequired(TRUE);
+ $spec->getFieldByName('location_type_id')->setRequired(TRUE);
+ }
+
+ /**
+ * @param string $entity
+ * @param string $action
+ *
+ * @return bool
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Address' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class CampaignCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('title')->setRequired(TRUE);
+ $spec->getFieldByName('name')->setRequired(FALSE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Campaign' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class ContactCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('contact_type')
+ ->setDefaultValue('Individual');
+
+ $spec->getFieldByName('is_opt_out')->setRequired(FALSE);
+ $spec->getFieldByName('is_deleted')->setRequired(FALSE);
+
+ }
+
+ /**
+ * @param string $entity
+ * @param string $action
+ *
+ * @return bool
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Contact' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class ContactTypeCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('label')->setRequired(TRUE);
+ $spec->getFieldByName('name')->setRequired(TRUE);
+ $spec->getFieldByName('parent_id')->setRequired(TRUE);
+
+ }
+
+ /**
+ * @param string $entity
+ * @param string $action
+ *
+ * @return bool
+ */
+ public function applies($entity, $action) {
+ return $entity === 'ContactType' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class ContributionCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('financial_type_id')->setRequired(TRUE);
+ $spec->getFieldByName('receive_date')->setDefaultValue('now');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Contribution' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class CustomFieldCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $optionField = new FieldSpec('option_values', $spec->getEntity(), 'Array');
+ $optionField->setTitle(ts('Option Values'));
+ $optionField->setDescription('Pass an array of options (value => label) to create this field\'s option values');
+ $spec->addFieldSpec($optionField);
+ $spec->getFieldByName('data_type')->setDefaultValue('String')->setRequired(FALSE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'CustomField' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class CustomGroupCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('extends')->setRequired(TRUE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'CustomGroup' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class CustomValueSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $action = $spec->getAction();
+ if ($action !== 'create') {
+ $idField = new FieldSpec('id', $spec->getEntity(), 'Integer');
+ $idField->setTitle(ts('Custom Value ID'));
+ $spec->addFieldSpec($idField);
+ }
+ $entityField = new FieldSpec('entity_id', $spec->getEntity(), 'Integer');
+ $entityField->setTitle(ts('Entity ID'));
+ $entityField->setRequired($action === 'create');
+ $entityField->setFkEntity('Contact');
+ $spec->addFieldSpec($entityField);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return strstr($entity, 'Custom_');
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class DefaultLocationTypeProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $locationField = $spec->getFieldByName('location_type_id')->setRequired(TRUE);
+ $defaultType = \CRM_Core_BAO_LocationType::getDefault();
+ if ($defaultType) {
+ $locationField->setDefaultValue($defaultType->id);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $action === 'create' && in_array($entity, ['Address', 'Email', 'IM', 'OpenID', 'Phone']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class DomainCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('name')->setRequired(TRUE);
+ $spec->getFieldByName('version')->setRequired(TRUE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Domain' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class EmailCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('contact_id')->setRequired(TRUE);
+ $spec->getFieldByName('email')->setRequired(TRUE);
+ $spec->getFieldByName('on_hold')->setRequired(FALSE);
+ $spec->getFieldByName('is_bulkmail')->setRequired(FALSE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Email' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class EntityTagCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('entity_table')->setRequired(FALSE)->setDefaultValue('civicrm_contact');
+ }
+
+ /**
+ * @param string $entity
+ * @param string $action
+ *
+ * @return bool
+ */
+ public function applies($entity, $action) {
+ return $entity === 'EntityTag' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class EventCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('event_type_id')->setRequiredIf('empty($values.template_id)');
+ $spec->getFieldByName('title')->setRequiredIf('empty($values.is_template)');
+ $spec->getFieldByName('start_date')->setRequiredIf('empty($values.is_template)');
+ $spec->getFieldByName('template_title')->setRequiredIf('!empty($values.is_template)');
+
+ $template_id = new FieldSpec('template_id', 'Event', 'Integer');
+ $template_id
+ ->setTitle('Template Id')
+ ->setDescription('Template on which to base this new event');
+ $spec->addFieldSpec($template_id);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Event' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider\Generic;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+interface SpecProviderInterface {
+
+ /**
+ * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+ *
+ * @return void
+ */
+ public function modifySpec(RequestSpec $spec);
+
+ /**
+ * @param string $entity
+ * @param string $action
+ *
+ * @return bool
+ */
+ public function applies($entity, $action);
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class GetActionDefaultsProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ // Exclude deleted records from api Get by default
+ $isDeletedField = $spec->getFieldByName('is_deleted');
+ if ($isDeletedField) {
+ $isDeletedField->setDefaultValue('0');
+ }
+
+ // Exclude test records from api Get by default
+ $isTestField = $spec->getFieldByName('is_test');
+ if ($isTestField) {
+ $isTestField->setDefaultValue('0');
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $action === 'get';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class GroupCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('title')->setRequired(TRUE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Group' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class MappingCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * This function runs for both Mapping and MappingField entities
+ *
+ * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('name')->setRequired(TRUE);
+ }
+
+ /**
+ * @param string $entity
+ * @param string $action
+ *
+ * @return bool
+ */
+ public function applies($entity, $action) {
+ return strpos($entity, 'Mapping') === 0 && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class NavigationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * This runs for both create and get actions
+ *
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('domain_id')->setRequired(FALSE)->setDefaultValue('current_domain');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Navigation' && in_array($action, ['create', 'get']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class NoteCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('note')->setRequired(TRUE);
+ $spec->getFieldByName('entity_table')->setDefaultValue('civicrm_contact');
+ }
+
+ /**
+ * @param string $entity
+ * @param string $action
+ *
+ * @return bool
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Note' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class OptionValueCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('weight')->setRequired(FALSE);
+ $spec->getFieldByName('value')->setRequired(FALSE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'OptionValue' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class PhoneCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('contact_id')->setRequired(TRUE);
+ $spec->getFieldByName('phone')->setRequired(TRUE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Phone' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class RelationshipTypeCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('name_a_b')->setRequired(TRUE);
+ $spec->getFieldByName('name_b_a')->setRequired(TRUE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'RelationshipType' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class StatusPreferenceCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('domain_id')->setRequired(FALSE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'StatusPreference' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class TagCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('used_for')->setDefaultValue('civicrm_contact');
+ }
+
+ /**
+ * @param string $entity
+ * @param string $action
+ *
+ * @return bool
+ */
+ public function applies($entity, $action) {
+ return $entity === 'Tag' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class UFFieldCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('label')->setRequired(FALSE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'UFField' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class UFMatchCreationSpecProvider implements Generic\SpecProviderInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function modifySpec(RequestSpec $spec) {
+ $spec->getFieldByName('domain_id')->setRequired(FALSE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function applies($entity, $action) {
+ return $entity === 'UFMatch' && $action === 'create';
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec;
+
+class RequestSpec {
+
+ /**
+ * @var string
+ */
+ protected $entity;
+
+ /**
+ * @var string
+ */
+ protected $action;
+
+ /**
+ * @var FieldSpec[]
+ */
+ protected $fields = [];
+
+ /**
+ * @param string $entity
+ * @param string $action
+ */
+ public function __construct($entity, $action) {
+ $this->entity = $entity;
+ $this->action = $action;
+ }
+
+ public function addFieldSpec(FieldSpec $field) {
+ $this->fields[] = $field;
+ }
+
+ /**
+ * @param $name
+ *
+ * @return FieldSpec|null
+ */
+ public function getFieldByName($name) {
+ foreach ($this->fields as $field) {
+ if ($field->getName() === $name) {
+ return $field;
+ }
+ }
+
+ return NULL;
+ }
+
+ /**
+ * @return array
+ * Gets all the field names currently part of the specification
+ */
+ public function getFieldNames() {
+ return array_map(function(FieldSpec $field) {
+ return $field->getName();
+ }, $this->fields);
+ }
+
+ /**
+ * @return array|FieldSpec[]
+ */
+ public function getRequiredFields() {
+ return array_filter($this->fields, function (FieldSpec $field) {
+ return $field->isRequired();
+ });
+ }
+
+ /**
+ * @return array|FieldSpec[]
+ */
+ public function getConditionalRequiredFields() {
+ return array_filter($this->fields, function (FieldSpec $field) {
+ return $field->getRequiredIf();
+ });
+ }
+
+ /**
+ * @param array $fieldNames
+ * Optional array of fields to return
+ * @return FieldSpec[]
+ */
+ public function getFields($fieldNames = NULL) {
+ if (!$fieldNames) {
+ return $this->fields;
+ }
+ $fields = [];
+ foreach ($this->fields as $field) {
+ if (in_array($field->getName(), $fieldNames)) {
+ $fields[] = $field;
+ }
+ }
+ return $fields;
+ }
+
+ /**
+ * @return string
+ */
+ public function getEntity() {
+ return $this->entity;
+ }
+
+ /**
+ * @return string
+ */
+ public function getAction() {
+ return $this->action;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec;
+
+use CRM_Utils_Array as ArrayHelper;
+use CRM_Core_DAO_AllCoreTables as AllCoreTables;
+
+class SpecFormatter {
+
+ /**
+ * @param FieldSpec[] $fields
+ * @param bool $includeFieldOptions
+ *
+ * @return array
+ */
+ public static function specToArray($fields, $includeFieldOptions = FALSE) {
+ $fieldArray = [];
+
+ foreach ($fields as $field) {
+ if ($includeFieldOptions) {
+ $field->getOptions();
+ }
+ $fieldArray[$field->getName()] = $field->toArray();
+ }
+
+ return $fieldArray;
+ }
+
+ /**
+ * @param array $data
+ * @param string $entity
+ *
+ * @return FieldSpec
+ */
+ public static function arrayToField(array $data, $entity) {
+ $dataTypeName = self::getDataType($data);
+
+ if (!empty($data['custom_group_id'])) {
+ $field = new CustomFieldSpec($data['name'], $entity, $dataTypeName);
+ if (strpos($entity, 'Custom_') !== 0) {
+ $field->setName($data['custom_group.name'] . '.' . $data['name']);
+ }
+ else {
+ $field->setCustomTableName($data['custom_group.table_name']);
+ $field->setCustomFieldColumnName($data['column_name']);
+ }
+ $field->setCustomFieldId(ArrayHelper::value('id', $data));
+ $field->setCustomGroupName($data['custom_group.name']);
+ $field->setTitle(ArrayHelper::value('label', $data));
+ $field->setOptions(self::customFieldHasOptions($data));
+ if (\CRM_Core_BAO_CustomField::isSerialized($data)) {
+ $field->setSerialize(\CRM_Core_DAO::SERIALIZE_SEPARATOR_BOOKEND);
+ }
+ }
+ else {
+ $name = ArrayHelper::value('name', $data);
+ $field = new FieldSpec($name, $entity, $dataTypeName);
+ $field->setRequired((bool) ArrayHelper::value('required', $data, FALSE));
+ $field->setTitle(ArrayHelper::value('title', $data));
+ $field->setOptions(!empty($data['pseudoconstant']));
+ $field->setSerialize(ArrayHelper::value('serialize', $data));
+ }
+
+ $field->setDefaultValue(ArrayHelper::value('default', $data));
+ $field->setDescription(ArrayHelper::value('description', $data));
+ self::setInputTypeAndAttrs($field, $data, $dataTypeName);
+
+ $fkAPIName = ArrayHelper::value('FKApiName', $data);
+ $fkClassName = ArrayHelper::value('FKClassName', $data);
+ if ($fkAPIName || $fkClassName) {
+ $field->setFkEntity($fkAPIName ?: AllCoreTables::getBriefName($fkClassName));
+ }
+
+ return $field;
+ }
+
+ /**
+ * Does this custom field have options
+ *
+ * @param array $field
+ * @return bool
+ */
+ private static function customFieldHasOptions($field) {
+ // This will include boolean fields with Yes/No options.
+ if (in_array($field['html_type'], ['Radio', 'CheckBox'])) {
+ return TRUE;
+ }
+ // Do this before the "Select" string search because date fields have a "Select Date" html_type
+ // and contactRef fields have an "Autocomplete-Select" html_type - contacts are an FK not an option list.
+ if (in_array($field['data_type'], ['ContactReference', 'Date'])) {
+ return FALSE;
+ }
+ if (strpos($field['html_type'], 'Select') !== FALSE) {
+ return TRUE;
+ }
+ return !empty($field['option_group_id']);
+ }
+
+ /**
+ * Get the data type from an array. Defaults to 'data_type' with fallback to
+ * mapping for the integer value 'type'
+ *
+ * @param array $data
+ *
+ * @return string
+ */
+ private static function getDataType(array $data) {
+ if (isset($data['data_type'])) {
+ return !empty($data['time_format']) ? 'Timestamp' : $data['data_type'];
+ }
+
+ $dataTypeInt = ArrayHelper::value('type', $data);
+ $dataTypeName = \CRM_Utils_Type::typeToString($dataTypeInt);
+
+ return $dataTypeName;
+ }
+
+ /**
+ * @param \Civi\Api4\Service\Spec\FieldSpec $fieldSpec
+ * @param array $data
+ * @param string $dataTypeName
+ */
+ public static function setInputTypeAndAttrs(FieldSpec &$fieldSpec, $data, $dataTypeName) {
+ $inputType = isset($data['html']['type']) ? $data['html']['type'] : ArrayHelper::value('html_type', $data);
+ $inputAttrs = ArrayHelper::value('html', $data, []);
+ unset($inputAttrs['type']);
+
+ if (!$inputType) {
+ // If no html type is set, guess
+ switch ($dataTypeName) {
+ case 'Int':
+ $inputType = 'Number';
+ $inputAttrs['min'] = 0;
+ break;
+
+ case 'Text':
+ $inputType = ArrayHelper::value('type', $data) === \CRM_Utils_Type::T_LONGTEXT ? 'TextArea' : 'Text';
+ break;
+
+ case 'Timestamp':
+ $inputType = 'Date';
+ $inputAttrs['time'] = TRUE;
+ break;
+
+ case 'Date':
+ $inputAttrs['time'] = FALSE;
+ break;
+
+ case 'Time':
+ $inputType = 'Date';
+ $inputAttrs['time'] = TRUE;
+ $inputAttrs['date'] = FALSE;
+ break;
+
+ default:
+ $map = [
+ 'Email' => 'Email',
+ 'Boolean' => 'Checkbox',
+ ];
+ $inputType = ArrayHelper::value($dataTypeName, $map, 'Text');
+ }
+ }
+ if (strstr($inputType, 'Multi-Select') || ($inputType == 'Select' && !empty($data['serialize']))) {
+ $inputAttrs['multiple'] = TRUE;
+ $inputType = 'Select';
+ }
+ $map = [
+ 'Select State/Province' => 'Select',
+ 'Select Country' => 'Select',
+ 'Select Date' => 'Date',
+ 'Link' => 'Url',
+ ];
+ $inputType = ArrayHelper::value($inputType, $map, $inputType);
+ if ($inputType == 'Date' && !empty($inputAttrs['formatType'])) {
+ self::setLegacyDateFormat($inputAttrs);
+ }
+ // Date/time settings from custom fields
+ if ($inputType == 'Date' && !empty($data['custom_group_id'])) {
+ $inputAttrs['time'] = empty($data['time_format']) ? FALSE : ($data['time_format'] == 1 ? 12 : 24);
+ $inputAttrs['date'] = $data['date_format'];
+ $inputAttrs['start_date_years'] = (int) $data['start_date_years'];
+ $inputAttrs['end_date_years'] = (int) $data['end_date_years'];
+ }
+ if ($inputType == 'Text' && !empty($data['maxlength'])) {
+ $inputAttrs['maxlength'] = (int) $data['maxlength'];
+ }
+ if ($inputType == 'TextArea') {
+ foreach (['rows', 'cols', 'note_rows', 'note_cols'] as $prop) {
+ if (!empty($data[$prop])) {
+ $inputAttrs[str_replace('note_', '', $prop)] = (int) $data[$prop];
+ }
+ }
+ }
+ $fieldSpec
+ ->setInputType($inputType)
+ ->setInputAttrs($inputAttrs);
+ }
+
+ /**
+ * @param array $inputAttrs
+ */
+ private static function setLegacyDateFormat(&$inputAttrs) {
+ if (empty(\Civi::$statics['legacyDatePrefs'][$inputAttrs['formatType']])) {
+ \Civi::$statics['legacyDatePrefs'][$inputAttrs['formatType']] = [];
+ $params = ['name' => $inputAttrs['formatType']];
+ \CRM_Core_DAO::commonRetrieve('CRM_Core_DAO_PreferencesDate', $params, \Civi::$statics['legacyDatePrefs'][$inputAttrs['formatType']]);
+ }
+ $dateFormat = \Civi::$statics['legacyDatePrefs'][$inputAttrs['formatType']];
+ unset($inputAttrs['formatType']);
+ $inputAttrs['time'] = !empty($dateFormat['time_format']);
+ $inputAttrs['date'] = TRUE;
+ $inputAttrs['start_date_years'] = (int) $dateFormat['start'];
+ $inputAttrs['end_date_years'] = (int) $dateFormat['end'];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Service\Spec;
+
+use Civi\Api4\CustomField;
+use Civi\Api4\Service\Spec\Provider\Generic\SpecProviderInterface;
+use Civi\Api4\Utils\CoreUtil;
+
+class SpecGatherer {
+
+ /**
+ * @var \Civi\Api4\Service\Spec\Provider\Generic\SpecProviderInterface[]
+ */
+ protected $specProviders = [];
+
+ /**
+ * A cache of DAOs based on entity
+ *
+ * @var \CRM_Core_DAO[]
+ */
+ protected $DAONames;
+
+ /**
+ * Returns a RequestSpec with all the fields available. Uses spec providers
+ * to add or modify field specifications.
+ * For an example @see CustomFieldSpecProvider.
+ *
+ * @param string $entity
+ * @param string $action
+ * @param $includeCustom
+ *
+ * @return \Civi\Api4\Service\Spec\RequestSpec
+ */
+ public function getSpec($entity, $action, $includeCustom) {
+ $specification = new RequestSpec($entity, $action);
+
+ // Real entities
+ if (strpos($entity, 'Custom_') !== 0) {
+ $this->addDAOFields($entity, $action, $specification);
+ if ($includeCustom && array_key_exists($entity, \CRM_Core_SelectValues::customGroupExtends())) {
+ $this->addCustomFields($entity, $specification);
+ }
+ }
+ // Custom pseudo-entities
+ else {
+ $this->getCustomGroupFields(substr($entity, 7), $specification);
+ }
+
+ // Default value only makes sense for create actions
+ if ($action != 'create') {
+ foreach ($specification->getFields() as $field) {
+ $field->setDefaultValue(NULL);
+ }
+ }
+
+ foreach ($this->specProviders as $provider) {
+ if ($provider->applies($entity, $action)) {
+ $provider->modifySpec($specification);
+ }
+ }
+
+ return $specification;
+ }
+
+ /**
+ * @param \Civi\Api4\Service\Spec\Provider\Generic\SpecProviderInterface $provider
+ */
+ public function addSpecProvider(SpecProviderInterface $provider) {
+ $this->specProviders[] = $provider;
+ }
+
+ /**
+ * @param string $entity
+ * @param string $action
+ * @param \Civi\Api4\Service\Spec\RequestSpec $specification
+ */
+ private function addDAOFields($entity, $action, RequestSpec $specification) {
+ $DAOFields = $this->getDAOFields($entity);
+
+ foreach ($DAOFields as $DAOField) {
+ if ($DAOField['name'] == 'id' && $action == 'create') {
+ continue;
+ }
+ if ($action !== 'create' || isset($DAOField['default'])) {
+ $DAOField['required'] = FALSE;
+ }
+ if ($DAOField['name'] == 'is_active' && empty($DAOField['default'])) {
+ $DAOField['default'] = '1';
+ }
+ $field = SpecFormatter::arrayToField($DAOField, $entity);
+ $specification->addFieldSpec($field);
+ }
+ }
+
+ /**
+ * @param string $entity
+ * @param \Civi\Api4\Service\Spec\RequestSpec $specification
+ */
+ private function addCustomFields($entity, RequestSpec $specification) {
+ $extends = ($entity == 'Contact') ? ['Contact', 'Individual', 'Organization', 'Household'] : [$entity];
+ $customFields = CustomField::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('custom_group.extends', 'IN', $extends)
+ ->setSelect(['custom_group.name', 'custom_group_id', 'name', 'label', 'data_type', 'html_type', 'is_searchable', 'is_search_range', 'weight', 'is_active', 'is_view', 'option_group_id', 'default_value', 'date_format', 'time_format', 'start_date_years', 'end_date_years'])
+ ->execute();
+
+ foreach ($customFields as $fieldArray) {
+ $field = SpecFormatter::arrayToField($fieldArray, $entity);
+ $specification->addFieldSpec($field);
+ }
+ }
+
+ /**
+ * @param string $customGroup
+ * @param \Civi\Api4\Service\Spec\RequestSpec $specification
+ */
+ private function getCustomGroupFields($customGroup, RequestSpec $specification) {
+ $customFields = CustomField::get()
+ ->addWhere('custom_group.name', '=', $customGroup)
+ ->setSelect(['custom_group.name', 'custom_group_id', 'name', 'label', 'data_type', 'html_type', 'is_searchable', 'is_search_range', 'weight', 'is_active', 'is_view', 'option_group_id', 'default_value', 'custom_group.table_name', 'column_name', 'date_format', 'time_format', 'start_date_years', 'end_date_years'])
+ ->execute();
+
+ foreach ($customFields as $fieldArray) {
+ $field = SpecFormatter::arrayToField($fieldArray, 'Custom_' . $customGroup);
+ $specification->addFieldSpec($field);
+ }
+ }
+
+ /**
+ * @param string $entityName
+ *
+ * @return array
+ */
+ private function getDAOFields($entityName) {
+ $bao = CoreUtil::getBAOFromApiName($entityName);
+
+ return $bao::fields();
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * CiviCRM settings api.
+ *
+ * Used to read/write persistent setting data from CiviCRM.
+ *
+ * @package Civi\Api4
+ */
+class Setting extends Generic\AbstractEntity {
+
+ public static function get() {
+ return new Action\Setting\Get(__CLASS__, __FUNCTION__);
+ }
+
+ public static function set() {
+ return new Action\Setting\Set(__CLASS__, __FUNCTION__);
+ }
+
+ public static function revert() {
+ return new Action\Setting\Revert(__CLASS__, __FUNCTION__);
+ }
+
+ public static function getFields() {
+ return new Action\Setting\GetFields(__CLASS__, __FUNCTION__);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * For setting "hush" preferences for system check alerts.
+ *
+ * @package Civi\Api4
+ */
+class StatusPreference extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+use Civi\Api4\Generic\BasicGetFieldsAction;
+
+/**
+ * A collection of system maintenance/diagnostic utilities.
+ *
+ * @package Civi\Api4
+ */
+class System extends Generic\AbstractEntity {
+
+ public static function flush() {
+ return new Action\System\Flush(__CLASS__, __FUNCTION__);
+ }
+
+ public static function check() {
+ return new Action\System\Check(__CLASS__, __FUNCTION__);
+ }
+
+ public static function getFields() {
+ return new BasicGetFieldsAction(__CLASS__, __FUNCTION__, function() {
+ return [];
+ });
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Tag entity.
+ *
+ * Tags in CiviCRM are used for Contacts, Activities, Cases & Attachments.
+ * They are connected to those entities via the EntityTag table.
+ *
+ * @package Civi\Api4
+ */
+class Tag extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * UFField entity - aka profile fields.
+ *
+ * @package Civi\Api4
+ */
+class UFField extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * UFGroup entity - AKA profiles.
+ *
+ * @package Civi\Api4
+ */
+class UFGroup extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * UFJoin entity - links profiles to the components/extensions they are used for.
+ *
+ * @package Civi\Api4
+ */
+class UFJoin extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * UFMatch entity - links civicrm contacts with users created externally
+ *
+ * @package Civi\Api4
+ */
+class UFMatch extends Generic\DAOEntity {
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Utils;
+
+class ActionUtil {
+
+ /**
+ * @param $entityName
+ * @param $actionName
+ * @return \Civi\Api4\Generic\AbstractAction
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ public static function getAction($entityName, $actionName) {
+ // For custom pseudo-entities
+ if (strpos($entityName, 'Custom_') === 0) {
+ return \Civi\Api4\CustomValue::$actionName(substr($entityName, 7));
+ }
+ else {
+ $callable = ["\\Civi\\Api4\\$entityName", $actionName];
+ if (!is_callable($callable)) {
+ throw new \Civi\API\Exception\NotImplementedException("API ($entityName, $actionName) does not exist (join the API team and implement it!)");
+ }
+ return call_user_func($callable);
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Utils;
+
+use CRM_Utils_Array as UtilsArray;
+
+class ArrayInsertionUtil {
+
+ /**
+ * If the values to be inserted contain a key _parent_id they will only be
+ * inserted if the parent node ID matches their ID
+ *
+ * @param $array
+ * The array to insert the value in
+ * @param array $parts
+ * Path to insertion point with structure:
+ * [[ name => is_multiple ], ..]
+ * @param mixed $values
+ * The value to be inserted
+ */
+ public static function insert(&$array, $parts, $values) {
+ $key = key($parts);
+ $isMulti = array_shift($parts);
+ if (!isset($array[$key])) {
+ $array[$key] = $isMulti ? [] : NULL;
+ }
+ if (empty($parts)) {
+ $values = self::filterValues($array, $isMulti, $values);
+ $array[$key] = $values;
+ }
+ else {
+ if ($isMulti) {
+ foreach ($array[$key] as &$subArray) {
+ self::insert($subArray, $parts, $values);
+ }
+ }
+ else {
+ self::insert($array[$key], $parts, $values);
+ }
+ }
+ }
+
+ /**
+ * @param $parentArray
+ * @param $isMulti
+ * @param $values
+ *
+ * @return array|mixed
+ */
+ private static function filterValues($parentArray, $isMulti, $values) {
+ $parentID = UtilsArray::value('id', $parentArray);
+
+ if ($parentID) {
+ $values = array_filter($values, function ($value) use ($parentID) {
+ return UtilsArray::value('_parent_id', $value) == $parentID;
+ });
+ }
+
+ $unsets = ['_parent_id', '_base_id'];
+ array_walk($values, function (&$value) use ($unsets) {
+ foreach ($unsets as $unset) {
+ if (isset($value[$unset])) {
+ unset($value[$unset]);
+ }
+ }
+ });
+
+ if (!$isMulti) {
+ $values = array_shift($values);
+ }
+ return $values;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Utils;
+
+use Civi\Api4\CustomGroup;
+use CRM_Core_DAO_AllCoreTables as AllCoreTables;
+
+require_once 'api/v3/utils.php';
+
+class CoreUtil {
+
+ /**
+ * todo this class should not rely on api3 code
+ *
+ * @param $entityName
+ *
+ * @return \CRM_Core_DAO|string
+ * The BAO name for use in static calls. Return doc block is hacked to allow
+ * auto-completion of static methods
+ */
+ public static function getBAOFromApiName($entityName) {
+ if ($entityName === 'CustomValue' || strpos($entityName, 'Custom_') === 0) {
+ return 'CRM_Contact_BAO_Contact';
+ }
+ return \_civicrm_api3_get_BAO($entityName);
+ }
+
+ /**
+ * Get table name of given Custom group
+ *
+ * @param string $customGroupName
+ *
+ * @return string
+ */
+ public static function getCustomTableByName($customGroupName) {
+ return CustomGroup::get()
+ ->addSelect('table_name')
+ ->addWhere('name', '=', $customGroupName)
+ ->execute()
+ ->first()['table_name'];
+ }
+
+ /**
+ * Given a sql table name, return the name of the api entity.
+ *
+ * @param $tableName
+ * @return string
+ */
+ public static function getApiNameFromTableName($tableName) {
+ return AllCoreTables::getBriefName(AllCoreTables::getClassForTable($tableName));
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Utils;
+
+use CRM_Utils_Array as UtilsArray;
+
+require_once 'api/v3/utils.php';
+
+class FormattingUtil {
+
+ /**
+ * Massage values into the format the BAO expects for a write operation
+ *
+ * @param $params
+ * @param $entity
+ * @param $fields
+ * @throws \API_Exception
+ */
+ public static function formatWriteParams(&$params, $entity, $fields) {
+ foreach ($fields as $name => $field) {
+ if (!empty($params[$name])) {
+ $value =& $params[$name];
+ // Hack for null values -- see comment below
+ if ($value === 'null') {
+ $value = 'Null';
+ }
+ FormattingUtil::formatValue($value, $field, $entity);
+ // Ensure we have an array for serialized fields
+ if (!empty($field['serialize'] && !is_array($value))) {
+ $value = (array) $value;
+ }
+ }
+ /*
+ * Because of the wacky way that database values are saved we need to format
+ * some of the values here. In this strange world the string 'null' is used to
+ * unset values. Hence if we encounter true null we change it to string 'null'.
+ *
+ * If we encounter the string 'null' then we assume the user actually wants to
+ * set the value to string null. However since the string null is reserved for
+ * unsetting values we must change it. Another quirk of the DB_DataObject is
+ * that it allows 'Null' to be set, but any other variation of string 'null'
+ * will be converted to true null, e.g. 'nuLL', 'NUlL' etc. so we change it to
+ * 'Null'.
+ */
+ elseif (array_key_exists($name, $params) && $params[$name] === NULL) {
+ $params[$name] = 'null';
+ }
+ }
+ }
+
+ /**
+ * Transform raw api input to appropriate format for use in a SQL query.
+ *
+ * This is used by read AND write actions (Get, Create, Update, Replace)
+ *
+ * @param $value
+ * @param $fieldSpec
+ * @param string $entity
+ * Ex: 'Contact', 'Domain'
+ * @throws \API_Exception
+ */
+ public static function formatValue(&$value, $fieldSpec, $entity) {
+ if (is_array($value)) {
+ foreach ($value as &$val) {
+ self::formatValue($val, $fieldSpec, $entity);
+ }
+ return;
+ }
+ $fk = UtilsArray::value('fk_entity', $fieldSpec);
+ if ($fieldSpec['name'] == 'id') {
+ $fk = $entity;
+ }
+ $dataType = UtilsArray::value('data_type', $fieldSpec);
+
+ if ($fk === 'Domain' && $value === 'current_domain') {
+ $value = \CRM_Core_Config::domainID();
+ }
+
+ if ($fk === 'Contact' && !is_numeric($value)) {
+ $value = \_civicrm_api3_resolve_contactID($value);
+ if ('unknown-user' === $value) {
+ throw new \API_Exception("\"{$fieldSpec['name']}\" \"{$value}\" cannot be resolved to a contact ID", 2002, ['error_field' => $fieldSpec['name'], "type" => "integer"]);
+ }
+ }
+
+ switch ($dataType) {
+ case 'Timestamp':
+ $value = date('Y-m-d H:i:s', strtotime($value));
+ break;
+
+ case 'Date':
+ $value = date('Ymd', strtotime($value));
+ break;
+ }
+ }
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.7 |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2015 |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM. |
+ | |
+ | CiviCRM is free software; you can copy, modify, and distribute it |
+ | under the terms of the GNU Affero General Public License |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
+ | |
+ | CiviCRM is distributed in the hope that it will be useful, but |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
+ | See the GNU Affero General Public License for more details. |
+ | |
+ | You should have received a copy of the GNU Affero General Public |
+ | License and the CiviCRM Licensing Exception along |
+ | with this program; if not, contact CiviCRM LLC |
+ | at info[AT]civicrm[DOT]org. If you have questions about the |
+ | GNU Affero General Public License or the licensing of CiviCRM, |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Utils;
+
+/**
+ * Just another place to put static functions...
+ */
+class ReflectionUtils {
+
+ /**
+ * @param \Reflector|\ReflectionClass $reflection
+ * @param string $type
+ * If we are not reflecting the class itself, specify "Method", "Property", etc.
+ *
+ * @return array
+ */
+ public static function getCodeDocs($reflection, $type = NULL) {
+ $docs = self::parseDocBlock($reflection->getDocComment());
+
+ // Recurse into parent functions
+ if (isset($docs['inheritDoc']) || isset($docs['inheritdoc'])) {
+ unset($docs['inheritDoc'], $docs['inheritdoc']);
+ $newReflection = NULL;
+ try {
+ if ($type) {
+ $name = $reflection->getName();
+ $reflectionClass = $reflection->getDeclaringClass()->getParentClass();
+ if ($reflectionClass) {
+ $getItem = "get$type";
+ $newReflection = $reflectionClass->$getItem($name);
+ }
+ }
+ else {
+ $newReflection = $reflection->getParentClass();
+ }
+ }
+ catch (\ReflectionException $e) {
+ }
+ if ($newReflection) {
+ // Mix in
+ $additionalDocs = self::getCodeDocs($newReflection, $type);
+ if (!empty($docs['comment']) && !empty($additionalDocs['comment'])) {
+ $docs['comment'] .= "\n\n" . $additionalDocs['comment'];
+ }
+ $docs += $additionalDocs;
+ }
+ }
+ return $docs;
+ }
+
+ /**
+ * @param string $comment
+ * @return array
+ */
+ public static function parseDocBlock($comment) {
+ $info = [];
+ foreach (preg_split("/((\r?\n)|(\r\n?))/", $comment) as $num => $line) {
+ if (!$num || strpos($line, '*/') !== FALSE) {
+ continue;
+ }
+ $line = ltrim(trim($line), '* ');
+ if (strpos($line, '@') === 0) {
+ $words = explode(' ', $line);
+ $key = substr($words[0], 1);
+ if ($key == 'var') {
+ $info['type'] = explode('|', $words[1]);
+ }
+ elseif ($key == 'options') {
+ $val = str_replace(', ', ',', implode(' ', array_slice($words, 1)));
+ $info['options'] = explode(',', $val);
+ }
+ else {
+ // Unrecognized annotation, but we'll duly add it to the info array
+ $val = implode(' ', array_slice($words, 1));
+ $info[$key] = strlen($val) ? $val : TRUE;
+ }
+ }
+ elseif ($num == 1) {
+ $info['description'] = $line;
+ }
+ elseif (!$line) {
+ if (isset($info['comment'])) {
+ $info['comment'] .= "\n";
+ }
+ }
+ else {
+ $info['comment'] = isset($info['comment']) ? "{$info['comment']}\n$line" : $line;
+ }
+ }
+ if (isset($info['comment'])) {
+ $info['comment'] = trim($info['comment']);
+ }
+ return $info;
+ }
+
+ /**
+ * List all traits used by a class and its parents.
+ *
+ * @param object|string $class
+ * @return array
+ */
+ public static function getTraits($class) {
+ $traits = [];
+ // Get traits of this class + parent classes
+ do {
+ $traits = array_merge(class_uses($class), $traits);
+ } while ($class = get_parent_class($class));
+ // Get traits of traits
+ foreach ($traits as $trait => $same) {
+ $traits = array_merge(class_uses($trait), $traits);
+ }
+ return $traits;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Website entity.
+ *
+ * @package Civi\Api4
+ */
+class Website extends Generic\DAOEntity {
+
+}
--- /dev/null
+<container xmlns="http://symfony.com/schema/dic/services"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
+
+ <services>
+
+ <service id="spec_gatherer" class="Civi\Api4\Service\Spec\SpecGatherer"/>
+
+ <service id="schema_map_builder" class="Civi\Api4\Service\Schema\SchemaMapBuilder" public="false">
+ <argument type="service" id="dispatcher" />
+ </service>
+
+ <service id="schema_map" class="Civi\Api4\Service\Schema\SchemaMap">
+ <factory service="schema_map_builder" method="build"/>
+ </service>
+
+ <service id="joiner" class="Civi\Api4\Service\Schema\Joiner">
+ <argument type="service" id="schema_map"/>
+ </service>
+
+ <service id="action_object_provider" class="Civi\Api4\Provider\ActionObjectProvider">
+ <tag name="event_subscriber"/>
+ </service>
+
+ </services>
+</container>
--- /dev/null
+<?php
+// Autoloader data for Api4 angular module.
+return [
+ 'js' => [
+ 'ang/api4.js',
+ 'ang/api4/*.js',
+ 'ang/api4/*/*.js',
+ ],
+ 'css' => [],
+ 'partials' => [],
+ 'requires' => [],
+];
--- /dev/null
+(function(angular, $, _) {
+ // Declare a list of dependencies.
+ angular.module('api4', CRM.angRequires('api4'));
+})(angular, CRM.$, CRM._);
--- /dev/null
+(function(angular, $, _) {
+
+ angular.module('api4').factory('crmApi4', function($q) {
+ var crmApi4 = function(entity, action, params, index) {
+ // JSON serialization in CRM.api4 is not aware of Angular metadata like $$hash, so use angular.toJson()
+ var deferred = $q.defer();
+ var p;
+ var backend = crmApi4.backend || CRM.api4;
+ if (_.isObject(entity)) {
+ // eval content is locally generated.
+ /*jshint -W061 */
+ p = backend(eval('('+angular.toJson(entity)+')'), action);
+ } else {
+ // eval content is locally generated.
+ /*jshint -W061 */
+ p = backend(entity, action, eval('('+angular.toJson(params)+')'), index);
+ }
+ p.then(
+ function(result) {
+ deferred.resolve(result);
+ },
+ function(error) {
+ deferred.reject(error);
+ }
+ );
+ return deferred.promise;
+ };
+ crmApi4.backend = null;
+ crmApi4.val = function(value) {
+ var d = $.Deferred();
+ d.resolve(value);
+ return d.promise();
+ };
+ return crmApi4;
+ });
+
+})(angular, CRM.$, CRM._);
--- /dev/null
+<?php
+// Autoloader data for Api4 explorer.
+return [
+ 'js' => [
+ 'ang/api4Explorer.js',
+ 'ang/api4Explorer/*.js',
+ 'ang/api4Explorer/*/*.js',
+ 'lib/*.js',
+ ],
+ 'css' => [
+ 'css/api4-explorer.css',
+ ],
+ 'partials' => [
+ 'ang/api4Explorer',
+ ],
+ 'basePages' => [],
+ 'requires' => ['crmUi', 'crmUtil', 'ngRoute', 'crmRouteBinder', 'ui.sortable', 'api4', 'ngSanitize'],
+];
--- /dev/null
+(function(angular, $, _) {
+ // Declare a list of dependencies.
+ angular.module('api4Explorer', CRM.angRequires('api4Explorer'));
+})(angular, CRM.$, CRM._);
--- /dev/null
+<input class="form-control" ng-model="chain[1][0]" crm-ui-select="{data: entities, allowClear: true, placeholder: 'None'}" />
+<select class="form-control api4-chain-action" ng-model="chain[1][1]" ng-options="a for a in actions" ></select>
+<input class="form-control api4-chain-params" ng-model="chain[1][2]" placeholder="{{ ts('Params') }}" />
+<input class="form-control api4-chain-index" ng-model="chain[1][3]" placeholder="{{ ts('Index') }}" />
--- /dev/null
+<div id="bootstrap-theme" class="api4-explorer-page">
+ <div crm-ui-debug="availableParams"></div>
+
+ <h1 crm-page-title>
+ {{ ts('CiviCRM API v4') }}{{ entity ? (' (' + entity + '::' + action + ')') : '' }}
+ </h1>
+
+ <!--This warning will show if bootstrap is unavailable. Normally it will be hidden by the bootstrap .collapse class.-->
+ <div class="messages warning no-popup collapse">
+ <p>
+ <i class="crm-i fa-exclamation-triangle"></i>
+ <strong>{{ ts('Bootstrap theme not found.') }}</strong>
+ </p>
+ <p>{{ ts('This screen may not work correctly without a bootstrap-based theme such as Shoreditch installed.') }}</p>
+ </div>
+
+ <div class="api4-explorer-row">
+ <form name="api4-explorer" class="panel panel-default explorer-params-panel">
+ <div class="panel-heading">
+ <div class="form-inline">
+ <input class="collapsible-optgroups form-control" ng-model="entity" ng-disabled="!entities.length" ng-class="{loading: !entities.length}" crm-ui-select="{placeholder: ts('Entity'), data: entities}" />
+ <input class="collapsible-optgroups form-control" ng-model="action" ng-disabled="!entity || !actions.length" ng-class="{loading: entity && !actions.length}" crm-ui-select="{placeholder: ts('Action'), data: actions}" />
+ <input class="form-control api4-index" ng-model="index" ng-mouseenter="help('index', indexHelp)" ng-mouseleave="help()" placeholder="{{ ts('Index') }}" />
+ <button class="btn btn-success pull-right" crm-icon="fa-bolt" ng-disabled="!entity || !action || loading" ng-click="execute()">{{ ts('Execute') }}</button>
+ </div>
+ </div>
+ <div class="panel-body">
+ <div class="api4-input form-inline">
+ <div class="form-control" ng-mouseenter="help(name, param)" ng-mouseleave="help()" ng-class="{'api4-option-selected': params[name]}" ng-repeat="(name, param) in availableParams" ng-if="!isSpecial(name) && param.type[0] === 'bool' && param.default !== null">
+ <input type="checkbox" id="api4-param-{{ name }}" ng-model="params[name]"/>
+ <label for="api4-param-{{ name }}">{{ name }}<span class="crm-marker" ng-if="param.required"> *</span></label>
+ </div>
+ <div class="form-control" ng-mouseenter="help('selectRowCount', availableParams.select)" ng-mouseleave="help()" ng-class="{'api4-option-selected': isSelectRowCount()}" ng-if="availableParams.select">
+ <input type="checkbox" id="api4-param-selectRowCount" ng-checked="isSelectRowCount()" ng-click="selectRowCount()" />
+ <label for="api4-param-selectRowCount">SelectRowCount</label>
+ </div>
+ </div>
+ <div class="api4-input form-inline" ng-mouseenter="help(name, param)" ng-mouseleave="help()" ng-repeat="(name, param) in availableParams" ng-if="!isSpecial(name) && param.type[0] === 'bool' && param.default === null">
+ <label>{{ name }}<span class="crm-marker" ng-if="param.required"> *</span></label>
+ <label class="radio-inline">
+ <input type="radio" ng-model="params[name]" ng-value="true" />true
+ </label>
+ <label class="radio-inline">
+ <input type="radio" ng-model="params[name]" ng-value="false" />false
+ </label>
+ <a href class="crm-hover-button" title="Clear" ng-click="clearParam(name)" ng-show="params[name] !== null"><i class="crm-i fa-times"></i></a>
+ </div>
+ <div class="api4-input form-inline" ng-mouseenter="help('select', availableParams.select)" ng-mouseleave="help()" ng-if="availableParams.select && !isSelectRowCount()">
+ <label for="api4-param-select">select<span class="crm-marker" ng-if="availableParams.select.required"> *</span></label>
+ <input class="collapsible-optgroups form-control" ng-list crm-ui-select="{data: fieldsAndJoins, multiple: true}" id="api4-param-select" ng-model="params.select" style="width: 85%;"/>
+ </div>
+ <div class="api4-input form-inline" ng-mouseenter="help('fields', availableParams.fields)" ng-mouseleave="help()"ng-if="availableParams.fields">
+ <label for="api4-param-fields">fields<span class="crm-marker" ng-if="availableParams.fields.required"> *</span></label>
+ <input class="form-control" ng-list crm-ui-select="{data: fields, multiple: true}" id="api4-param-fields" ng-model="params.fields" style="width: 85%;"/>
+ </div>
+ <div class="api4-input form-inline" ng-mouseenter="help('action', availableParams.action)" ng-mouseleave="help()"ng-if="availableParams.action">
+ <label for="api4-param-action">action<span class="crm-marker" ng-if="availableParams.action.required"> *</span></label>
+ <input class="form-control" crm-ui-select="{data: actions, allowClear: true, placeholder: 'None'}" id="api4-param-action" ng-model="params.action"/>
+ </div>
+ <div class="api4-input form-inline" ng-mouseenter="help(name, param)" ng-mouseleave="help()" ng-repeat="(name, param) in availableParams" ng-if="!isSpecial(name) && (param.type[0] === 'string' || param.type[0] === 'int')">
+ <label for="api4-param-{{ name }}">{{ name }}<span class="crm-marker" ng-if="param.required"> *</span></label>
+ <input class="form-control" ng-if="!param.options" type="{{ param.type[0] === 'int' && param.type.length === 1 ? 'number' : 'text' }}" id="api4-param-{{ name }}" ng-model="params[name]"/>
+ <select class="form-control" ng-if="param.options" ng-options="o for o in param.options" id="api4-param-{{ name }}" ng-model="params[name]"></select>
+ <a href class="crm-hover-button" title="Clear" ng-click="clearParam(name)" ng-show="!!params[name]"><i class="crm-i fa-times"></i></a>
+ </div>
+ <div class="api4-input" ng-mouseenter="help(name, param)" ng-mouseleave="help()" ng-repeat="(name, param) in availableParams" ng-if="!isSpecial(name) && (param.type[0] === 'array' || param.type[0] === 'mixed')">
+ <label for="api4-param-{{ name }}">{{ name }}<span class="crm-marker" ng-if="param.required"> *</span></label>
+ <textarea class="form-control" type="{{ param.type[0] === 'int' && param.type.length === 1 ? 'number' : 'text' }}" id="api4-param-{{ name }}" ng-model="params[name]">
+ </textarea>
+ </div>
+ <fieldset ng-if="availableParams.where" class="api4-where-fieldset" ng-mouseenter="help('where', availableParams.where)" ng-mouseleave="help()" crm-api4-where-clause="{where: params.where, required: availableParams.where.required, op: 'AND', label: 'where', fields: fieldsAndJoins}">
+ </fieldset>
+ <fieldset ng-if="availableParams.values" ng-mouseenter="help('values', availableParams.values)" ng-mouseleave="help()">
+ <legend>values<span class="crm-marker" ng-if="availableParams.values.required"> *</span></legend>
+ <div class="api4-input form-inline" ng-repeat="clause in params.values" ng-mouseenter="help('value: ' + clause[0], fieldHelp(clause[0]))" ng-mouseleave="help('values', availableParams.values)">
+ <input class="collapsible-optgroups form-control" ng-model="clause[0]" crm-ui-select="{formatResult: formatSelect2Item, formatSelection: formatSelect2Item, data: valuesFields, allowClear: true, placeholder: 'Field'}" />
+ <input class="form-control" ng-model="clause[1]" api4-exp-value="{field: clause[0]}" />
+ </div>
+ <div class="api4-input form-inline">
+ <input class="collapsible-optgroups form-control" ng-model="controls.values" crm-ui-select="{formatResult: formatSelect2Item, formatSelection: formatSelect2Item, data: valuesFields}" placeholder="Add value" />
+ </div>
+ </fieldset>
+ <fieldset ng-if="availableParams.orderBy" ng-mouseenter="help('orderBy', availableParams.orderBy)" ng-mouseleave="help()">
+ <legend>orderBy<span class="crm-marker" ng-if="availableParams.orderBy.required"> *</span></legend>
+ <div class="api4-input form-inline" ng-repeat="clause in params.orderBy">
+ <input class="collapsible-optgroups form-control" ng-model="clause[0]" crm-ui-select="{data: fieldsAndJoins, allowClear: true, placeholder: 'Field'}" />
+ <select class="form-control" ng-model="clause[1]">
+ <option value="ASC">ASC</option>
+ <option value="DESC">DESC</option>
+ </select>
+ </div>
+ <div class="api4-input form-inline">
+ <input class="collapsible-optgroups form-control" ng-model="controls.orderBy" crm-ui-select="{data: fieldsAndJoins}" placeholder="Add orderBy" />
+ </div>
+ </fieldset>
+ <fieldset ng-if="availableParams.chain" ng-mouseenter="help('chain', availableParams.chain)" ng-mouseleave="help()">
+ <legend>chain</legend>
+ <div class="api4-input form-inline" ng-repeat="clause in params.chain" api4-exp-chain="clause" entities="entities" main-entity="entity" >
+ </div>
+ <div class="api4-input form-inline">
+ <input class="form-control" ng-model="controls.chain" crm-ui-select="{data: entities}" placeholder="Add chain" />
+ </div>
+ </fieldset>
+ </div>
+ </form>
+ <div class="panel panel-info explorer-help-panel">
+ <div class="panel-heading">
+ <h3 class="panel-title" crm-icon="fa-info-circle">{{ helpTitle }}</h3>
+ </div>
+ <div class="panel-body">
+ <h4>{{ helpContent.description }}</h4>
+ <div ng-if="helpContent.comment">
+ <p ng-repeat='text in helpContent.comment.split("\n\n")'>{{ text }}</p>
+ </div>
+ <p ng-repeat="(key, item) in helpContent" ng-if="key !== 'description' && key !== 'comment'">
+ <strong>{{ key }}:</strong> {{ item }}
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="api4-explorer-row">
+ <div class="panel panel-warning explorer-code-panel">
+ <div class="panel-heading">
+ <h3 class="panel-title" crm-icon="fa-code">{{ ts('Code') }}</h3>
+ </div>
+ <div class="panel-body">
+ <table>
+ <tr ng-repeat="(type, item) in code">
+ <td>{{ type }}</td>
+ <td><pre class="prettyprint" ng-bind-html="item"></pre></td>
+ </tr>
+ </table>
+ </div>
+ </div>
+ <div class="panel explorer-result-panel panel-{{ status }}" >
+ <div class="panel-heading">
+ <h3 class="panel-title">
+ <i class="fa fa-circle-o" ng-if="status === 'default'"></i>
+ <i class="fa fa-check-circle" ng-if="status === 'success'"></i>
+ <i class="fa fa-minus-circle" ng-if="status === 'danger'"></i>
+ <i class="fa fa-spinner fa-pulse" ng-if="status === 'warning'"></i>
+ {{ ts('Result') }}
+ </h3>
+ </div>
+ <div class="panel-body">
+ <pre class="prettyprint" ng-repeat="code in result" ng-bind-html="code"></pre>
+ </div>
+ </div>
+ </div>
+
+
+</div>
--- /dev/null
+(function(angular, $, _, undefined) {
+
+ // Schema metadata
+ var schema = CRM.vars.api4.schema;
+ // FK schema data
+ var links = CRM.vars.api4.links;
+ // Cache list of entities
+ var entities = [];
+ // Cache list of actions
+ var actions = [];
+ // Field options
+ var fieldOptions = {};
+
+
+ angular.module('api4Explorer').config(function($routeProvider) {
+ $routeProvider.when('/explorer/:api4entity?/:api4action?', {
+ controller: 'Api4Explorer',
+ templateUrl: '~/api4Explorer/Explorer.html',
+ reloadOnSearch: false
+ });
+ });
+
+ angular.module('api4Explorer').controller('Api4Explorer', function($scope, $routeParams, $location, $timeout, $http, crmUiHelp, crmApi4) {
+ var ts = $scope.ts = CRM.ts('api4');
+ $scope.entities = entities;
+ $scope.actions = actions;
+ $scope.fields = [];
+ $scope.fieldsAndJoins = [];
+ $scope.availableParams = {};
+ $scope.params = {};
+ $scope.index = '';
+ var getMetaParams = {},
+ objectParams = {orderBy: 'ASC', values: '', chain: ['Entity', '', '{}']},
+ helpTitle = '',
+ helpContent = {};
+ $scope.helpTitle = '';
+ $scope.helpContent = {};
+ $scope.entity = $routeParams.api4entity;
+ $scope.result = [];
+ $scope.status = 'default';
+ $scope.loading = false;
+ $scope.controls = {};
+ $scope.code = {
+ php: '',
+ javascript: '',
+ cli: ''
+ };
+
+ if (!entities.length) {
+ formatForSelect2(schema, entities, 'name', ['description']);
+ }
+
+ $scope.$bindToRoute({
+ expr: 'index',
+ param: 'index',
+ default: ''
+ });
+
+ function ucfirst(str) {
+ return str[0].toUpperCase() + str.slice(1);
+ }
+
+ function lcfirst(str) {
+ return str[0].toLowerCase() + str.slice(1);
+ }
+
+ function pluralize(str) {
+ switch (str[str.length-1]) {
+ case 's':
+ return str + 'es';
+ case 'y':
+ return str.slice(0, -1) + 'ies';
+ default:
+ return str + 's';
+ }
+ }
+
+ // Turn a flat array into a select2 array
+ function arrayToSelect2(array) {
+ var out = [];
+ _.each(array, function(item) {
+ out.push({id: item, text: item});
+ });
+ return out;
+ }
+
+ // Reformat an existing array of objects for compatibility with select2
+ function formatForSelect2(input, container, key, extra, prefix) {
+ _.each(input, function(item) {
+ var id = (prefix || '') + item[key];
+ var formatted = {id: id, text: id};
+ if (extra) {
+ _.merge(formatted, _.pick(item, extra));
+ }
+ container.push(formatted);
+ });
+ return container;
+ }
+
+ function getFieldList(source) {
+ var fields = [],
+ fieldInfo = _.findWhere(getEntity().actions, {name: $scope.action}).fields;
+ formatForSelect2(fieldInfo, fields, 'name', ['description', 'required', 'default_value']);
+ return fields;
+ }
+
+ function addJoins(fieldList) {
+ var fields = _.cloneDeep(fieldList),
+ fks = _.findWhere(links, {entity: $scope.entity}) || {};
+ _.each(fks.links, function(link) {
+ var linkFields = entityFields(link.entity);
+ if (linkFields) {
+ fields.push({
+ text: link.alias,
+ description: 'Join to ' + link.entity,
+ children: formatForSelect2(linkFields, [], 'name', ['description'], link.alias + '.')
+ });
+ }
+ });
+ return fields;
+ }
+
+ $scope.help = function(title, param) {
+ if (!param) {
+ $scope.helpTitle = helpTitle;
+ $scope.helpContent = helpContent;
+ } else {
+ $scope.helpTitle = title;
+ $scope.helpContent = param;
+ }
+ };
+
+ $scope.fieldHelp = function(fieldName) {
+ var field = getField(fieldName, $scope.entity, $scope.action);
+ if (!field) {
+ return;
+ }
+ var info = {
+ description: field.description,
+ type: field.data_type
+ };
+ if (field.default_value) {
+ info.default = field.default_value;
+ }
+ if (field.required_if) {
+ info.required_if = field.required_if;
+ } else if (field.required) {
+ info.required = 'true';
+ }
+ return info;
+ };
+
+ $scope.valuesFields = function() {
+ var fields = _.cloneDeep($scope.fields);
+ // Disable fields that are already in use
+ _.each($scope.params.values || [], function(val) {
+ (_.findWhere(fields, {id: val[0]}) || {}).disabled = true;
+ });
+ return {results: fields};
+ };
+
+ $scope.formatSelect2Item = function(row) {
+ return _.escape(row.text) +
+ (row.required ? '<span class="crm-marker"> *</span>' : '') +
+ (row.description ? '<div class="crm-select2-row-description"><p>' + _.escape(row.description) + '</p></div>' : '');
+ };
+
+ $scope.clearParam = function(name) {
+ $scope.params[name] = $scope.availableParams[name].default;
+ };
+
+ $scope.isSpecial = function(name) {
+ var specialParams = ['select', 'fields', 'action', 'where', 'values', 'orderBy', 'chain'];
+ return _.contains(specialParams, name);
+ };
+
+ $scope.selectRowCount = function() {
+ if ($scope.isSelectRowCount()) {
+ $scope.params.select = [];
+ } else {
+ $scope.params.select = ['row_count'];
+ if ($scope.params.limit == 25) {
+ $scope.params.limit = 0;
+ }
+ }
+ };
+
+ $scope.isSelectRowCount = function() {
+ return $scope.params && $scope.params.select && $scope.params.select.length === 1 && $scope.params.select[0] === 'row_count';
+ };
+
+ function getEntity(entityName) {
+ return _.findWhere(schema, {name: entityName || $scope.entity});
+ }
+
+ // Get all params that have been set
+ function getParams() {
+ var params = {};
+ _.each($scope.params, function(param, key) {
+ if (param != $scope.availableParams[key].default && !(typeof param === 'object' && _.isEmpty(param))) {
+ if (_.contains($scope.availableParams[key].type, 'array') && (typeof objectParams[key] === 'undefined')) {
+ params[key] = parseYaml(_.cloneDeep(param));
+ } else {
+ params[key] = param;
+ }
+ }
+ });
+ _.each(objectParams, function(defaultVal, key) {
+ if (params[key]) {
+ var newParam = {};
+ _.each(params[key], function(item) {
+ newParam[item[0]] = parseYaml(_.cloneDeep(item[1]));
+ });
+ params[key] = newParam;
+ }
+ });
+ return params;
+ }
+
+ function parseYaml(input) {
+ if (typeof input === 'undefined') {
+ return undefined;
+ }
+ if (_.isObject(input) || _.isArray(input)) {
+ _.each(input, function(item, index) {
+ input[index] = parseYaml(item);
+ });
+ return input;
+ }
+ try {
+ var output = (input === '>') ? '>' : jsyaml.safeLoad(input);
+ // We don't want dates parsed to js objects
+ return _.isDate(output) ? input : output;
+ } catch (e) {
+ return input;
+ }
+ }
+
+ function selectAction() {
+ $scope.action = $routeParams.api4action;
+ $scope.fieldsAndJoins = [];
+ if (!actions.length) {
+ formatForSelect2(getEntity().actions, actions, 'name', ['description', 'params']);
+ }
+ if ($scope.action) {
+ var actionInfo = _.findWhere(actions, {id: $scope.action});
+ $scope.fields = getFieldList();
+ if (_.contains(['get', 'update', 'delete', 'replace'], $scope.action)) {
+ $scope.fieldsAndJoins = addJoins($scope.fields);
+ } else {
+ $scope.fieldsAndJoins = $scope.fields;
+ }
+ _.each(actionInfo.params, function (param, name) {
+ var format,
+ defaultVal = _.cloneDeep(param.default);
+ if (param.type) {
+ switch (param.type[0]) {
+ case 'int':
+ case 'bool':
+ format = param.type[0];
+ break;
+
+ case 'array':
+ case 'object':
+ format = 'json';
+ break;
+
+ default:
+ format = 'raw';
+ }
+ if (name == 'limit') {
+ defaultVal = 25;
+ }
+ if (name === 'values') {
+ defaultVal = defaultValues(defaultVal);
+ }
+ $scope.$bindToRoute({
+ expr: 'params["' + name + '"]',
+ param: name,
+ format: format,
+ default: defaultVal,
+ deep: format === 'json'
+ });
+ }
+ if (typeof objectParams[name] !== 'undefined') {
+ $scope.$watch('params.' + name, function(values) {
+ // Remove empty values
+ _.each(values, function(clause, index) {
+ if (!clause || !clause[0]) {
+ $scope.params[name].splice(index, 1);
+ }
+ });
+ }, true);
+ $scope.$watch('controls.' + name, function(value) {
+ var field = value;
+ $timeout(function() {
+ if (field) {
+ var defaultOp = _.cloneDeep(objectParams[name]);
+ if (name === 'chain') {
+ var num = $scope.params.chain.length;
+ defaultOp[0] = field;
+ field = 'name_me_' + num;
+ }
+ $scope.params[name].push([field, defaultOp]);
+ $scope.controls[name] = null;
+ }
+ });
+ });
+ }
+ });
+ $scope.availableParams = actionInfo.params;
+ }
+ writeCode();
+ }
+
+ function defaultValues(defaultVal) {
+ _.each($scope.fields, function(field) {
+ if (field.required) {
+ defaultVal.push([field.id, '']);
+ }
+ });
+ return defaultVal;
+ }
+
+ function stringify(value, trim) {
+ if (typeof value === 'undefined') {
+ return '';
+ }
+ var str = JSON.stringify(value).replace(/,/g, ', ');
+ if (trim) {
+ str = str.slice(1, -1);
+ }
+ return str.trim();
+ }
+
+ function writeCode() {
+ var code = {
+ php: ts('Select an entity and action'),
+ javascript: '',
+ cli: ''
+ },
+ entity = $scope.entity,
+ action = $scope.action,
+ params = getParams(),
+ index = isInt($scope.index) ? +$scope.index : $scope.index,
+ result = 'result';
+ if ($scope.entity && $scope.action) {
+ if (action.slice(0, 3) === 'get') {
+ result = entity.substr(0, 7) === 'Custom_' ? _.camelCase(entity.substr(7)) : entity;
+ result = lcfirst(action.replace(/s$/, '').slice(3) || result);
+ }
+ var results = lcfirst(_.isNumber(index) ? result : pluralize(result)),
+ paramCount = _.size(params),
+ isSelectRowCount = params.select && params.select.length === 1 && params.select[0] === 'row_count',
+ i = 0;
+
+ if (isSelectRowCount) {
+ results = result + 'Count';
+ }
+
+ // Write javascript
+ code.javascript = "CRM.api4('" + entity + "', '" + action + "', {";
+ _.each(params, function(param, key) {
+ code.javascript += "\n " + key + ': ' + stringify(param) +
+ (++i < paramCount ? ',' : '');
+ if (key === 'checkPermissions') {
+ code.javascript += ' // IGNORED: permissions are always enforced from client-side requests';
+ }
+ });
+ code.javascript += "\n}";
+ if (index || index === 0) {
+ code.javascript += ', ' + JSON.stringify(index);
+ }
+ code.javascript += ").then(function(" + results + ") {\n // do something with " + results + " array\n}, function(failure) {\n // handle failure\n});";
+
+ // Write php code
+ if (entity.substr(0, 7) !== 'Custom_') {
+ code.php = '$' + results + " = \\Civi\\Api4\\" + entity + '::' + action + '()';
+ } else {
+ code.php = '$' + results + " = \\Civi\\Api4\\CustomValue::" + action + "('" + entity.substr(7) + "')";
+ }
+ _.each(params, function(param, key) {
+ var val = '';
+ if (typeof objectParams[key] !== 'undefined' && key !== 'chain') {
+ _.each(param, function(item, index) {
+ val = phpFormat(index) + ', ' + phpFormat(item, 4);
+ code.php += "\n ->add" + ucfirst(key).replace(/s$/, '') + '(' + val + ')';
+ });
+ } else if (key === 'where') {
+ _.each(param, function (clause) {
+ if (clause[0] === 'AND' || clause[0] === 'OR' || clause[0] === 'NOT') {
+ code.php += "\n ->addClause(" + phpFormat(clause[0]) + ", " + phpFormat(clause[1]).slice(1, -1) + ')';
+ } else {
+ code.php += "\n ->addWhere(" + phpFormat(clause).slice(1, -1) + ")";
+ }
+ });
+ } else if (key === 'select' && isSelectRowCount) {
+ code.php += "\n ->selectRowCount()";
+ } else {
+ code.php += "\n ->set" + ucfirst(key) + '(' + phpFormat(param, 4) + ')';
+ }
+ });
+ code.php += "\n ->execute()";
+ if (_.isNumber(index)) {
+ code.php += !index ? '\n ->first()' : (index === -1 ? '\n ->last()' : '\n ->itemAt(' + index + ')');
+ } else if (index) {
+ code.php += "\n ->indexBy('" + index + "')";
+ } else if (isSelectRowCount) {
+ code.php += "\n ->count()";
+ }
+ code.php += ";\n";
+ if (!_.isNumber(index) && !isSelectRowCount) {
+ code.php += "foreach ($" + results + ' as $' + ((_.isString(index) && index) ? index + ' => $' : '') + result + ') {\n // do something\n}';
+ }
+
+ // Write cli code
+ code.cli = 'cv api4 ' + entity + '.' + action + " '" + stringify(params) + "'";
+ }
+ _.each(code, function(val, type) {
+ $scope.code[type] = prettyPrintOne(val);
+ });
+ }
+
+ function isInt(value) {
+ if (_.isFinite(value)) {
+ return true;
+ }
+ if (!_.isString(value)) {
+ return false;
+ }
+ return /^-{0,1}\d+$/.test(value);
+ }
+
+ function formatMeta(resp) {
+ var ret = '';
+ _.each(resp, function(val, key) {
+ if (key !== 'values' && !_.isPlainObject(val) && !_.isFunction(val)) {
+ ret += (ret.length ? ', ' : '') + key + ': ' + (_.isArray(val) ? '[' + val + ']' : val);
+ }
+ });
+ return prettyPrintOne(ret);
+ }
+
+ $scope.execute = function() {
+ $scope.status = 'warning';
+ $scope.loading = true;
+ $http.get(CRM.url('civicrm/ajax/api4/' + $scope.entity + '/' + $scope.action, {
+ params: angular.toJson(getParams()),
+ index: $scope.index
+ })).then(function(resp) {
+ $scope.loading = false;
+ $scope.status = 'success';
+ $scope.result = [formatMeta(resp.data), prettyPrintOne(JSON.stringify(resp.data.values, null, 2), 'js', 1)];
+ }, function(resp) {
+ $scope.loading = false;
+ $scope.status = 'danger';
+ $scope.result = [formatMeta(resp), prettyPrintOne(JSON.stringify(resp.data, null, 2))];
+ });
+ };
+
+ /**
+ * Format value to look like php code
+ */
+ function phpFormat(val, indent) {
+ if (typeof val === 'undefined') {
+ return '';
+ }
+ indent = (typeof indent === 'number') ? _.repeat(' ', indent) : (indent || '');
+ var ret = '',
+ baseLine = indent ? indent.slice(0, -2) : '',
+ newLine = indent ? '\n' : '';
+ if ($.isPlainObject(val)) {
+ $.each(val, function(k, v) {
+ ret += (ret ? ', ' : '') + newLine + indent + "'" + k + "' => " + phpFormat(v);
+ });
+ return '[' + ret + newLine + baseLine + ']';
+ }
+ if ($.isArray(val)) {
+ $.each(val, function(k, v) {
+ ret += (ret ? ', ' : '') + newLine + indent + phpFormat(v);
+ });
+ return '[' + ret + newLine + baseLine + ']';
+ }
+ if (_.isString(val) && !_.contains(val, "'")) {
+ return "'" + val + "'";
+ }
+ return JSON.stringify(val).replace(/\$/g, '\\$');
+ }
+
+ function fetchMeta() {
+ crmApi4(getMetaParams)
+ .then(function(data) {
+ if (data.actions) {
+ getEntity().actions = data.actions;
+ selectAction();
+ }
+ });
+ }
+
+ // Help for an entity with no action selected
+ function showEntityHelp(entityName) {
+ var entityInfo = getEntity(entityName);
+ $scope.helpTitle = helpTitle = $scope.entity;
+ $scope.helpContent = helpContent = {
+ description: entityInfo.description,
+ comment: entityInfo.comment
+ };
+ }
+
+ if (!$scope.entity) {
+ $scope.helpTitle = helpTitle = ts('Help');
+ $scope.helpContent = helpContent = {description: ts('Welcome to the api explorer.'), comment: ts('Select an entity to begin.')};
+ } else if (!actions.length && !getEntity().actions) {
+ getMetaParams.actions = [$scope.entity, 'getActions', {chain: {fields: [$scope.entity, 'getFields', {action: '$name'}]}}];
+ fetchMeta();
+ } else {
+ selectAction();
+ }
+
+ if ($scope.entity) {
+ showEntityHelp($scope.entity);
+ }
+
+ // Update route when changing entity
+ $scope.$watch('entity', function(newVal, oldVal) {
+ if (oldVal !== newVal) {
+ // Flush actions cache to re-fetch for new entity
+ actions = [];
+ $location.url('/explorer/' + newVal);
+ }
+ });
+
+ // Update route when changing actions
+ $scope.$watch('action', function(newVal, oldVal) {
+ if ($scope.entity && $routeParams.api4action !== newVal && !_.isUndefined(newVal)) {
+ $location.url('/explorer/' + $scope.entity + '/' + newVal);
+ } else if (newVal) {
+ $scope.helpTitle = helpTitle = $scope.entity + '::' + newVal;
+ $scope.helpContent = helpContent = _.pick(_.findWhere(getEntity().actions, {name: newVal}), ['description', 'comment']);
+ }
+ });
+
+ $scope.indexHelp = {
+ description: ts('(string|int) Index results or select by index.'),
+ comment: ts('Pass a string to index the results by a field value. E.g. index: "name" will return an associative array with names as keys.') + '\n\n' +
+ ts('Pass an integer to return a single result; e.g. index: 0 will return the first result, 1 will return the second, and -1 will return the last.')
+ };
+
+ $scope.$watch('params', writeCode, true);
+ $scope.$watch('index', writeCode);
+ writeCode();
+
+ });
+
+ angular.module('api4Explorer').directive('crmApi4WhereClause', function($timeout) {
+ return {
+ scope: {
+ data: '=crmApi4WhereClause'
+ },
+ templateUrl: '~/api4Explorer/WhereClause.html',
+ link: function (scope, element, attrs) {
+ var ts = scope.ts = CRM.ts('api4');
+ scope.newClause = '';
+ scope.conjunctions = ['AND', 'OR', 'NOT'];
+ scope.operators = CRM.vars.api4.operators;
+
+ scope.addGroup = function(op) {
+ scope.data.where.push([op, []]);
+ };
+
+ scope.removeGroup = function() {
+ scope.data.groupParent.splice(scope.data.groupIndex, 1);
+ };
+
+ scope.onSort = function(event, ui) {
+ $('.api4-where-fieldset').toggleClass('api4-sorting', event.type === 'sortstart');
+ $('.api4-input.form-inline').css('margin-left', '');
+ };
+
+ // Indent clause while dragging between nested groups
+ scope.onSortOver = function(event, ui) {
+ var offset = 0;
+ if (ui.sender) {
+ offset = $(ui.placeholder).offset().left - $(ui.sender).offset().left;
+ }
+ $('.api4-input.form-inline.ui-sortable-helper').css('margin-left', '' + offset + 'px');
+ };
+
+ scope.$watch('newClause', function(value) {
+ var field = value;
+ $timeout(function() {
+ if (field) {
+ scope.data.where.push([field, '=', '']);
+ scope.newClause = null;
+ }
+ });
+ });
+ scope.$watch('data.where', function(values) {
+ // Remove empty values
+ _.each(values, function(clause, index) {
+ if (typeof clause !== 'undefined' && !clause[0]) {
+ values.splice(index, 1);
+ }
+ });
+ }, true);
+ }
+ };
+ });
+
+ angular.module('api4Explorer').directive('api4ExpValue', function($routeParams, crmApi4) {
+ return {
+ scope: {
+ data: '=api4ExpValue'
+ },
+ require: 'ngModel',
+ link: function (scope, element, attrs, ctrl) {
+ var ts = scope.ts = CRM.ts('api4'),
+ multi = _.includes(['IN', 'NOT IN'], scope.data.op),
+ entity = $routeParams.api4entity,
+ action = $routeParams.api4action;
+
+ function destroyWidget() {
+ var $el = $(element);
+ if ($el.is('.crm-form-date-wrapper .crm-hidden-date')) {
+ $el.crmDatepicker('destroy');
+ }
+ if ($el.is('.select2-container + input')) {
+ $el.crmEntityRef('destroy');
+ }
+ $(element).removeData().removeAttr('type').removeAttr('placeholder').show();
+ }
+
+ function makeWidget(field, op) {
+ var $el = $(element),
+ inputType = field.input_type;
+ dataType = field.data_type;
+ if (!op) {
+ op = field.serialize || dataType === 'Array' ? 'IN' : '=';
+ }
+ multi = _.includes(['IN', 'NOT IN'], op);
+ if (op === 'IS NULL' || op === 'IS NOT NULL') {
+ $el.hide();
+ return;
+ }
+ if (inputType === 'Date') {
+ if (_.includes(['=', '!=', '<>', '<', '>=', '<', '<='], op)) {
+ $el.crmDatepicker({time: (field.input_attrs && field.input_attrs.time) || false});
+ }
+ } else if (_.includes(['=', '!=', '<>', 'IN', 'NOT IN'], op) && (field.fk_entity || field.options || dataType === 'Boolean')) {
+ if (field.fk_entity) {
+ $el.crmEntityRef({entity: field.fk_entity, select:{multiple: multi}});
+ } else if (field.options) {
+ $el.addClass('loading').attr('placeholder', ts('- select -')).crmSelect2({multiple: multi, data: [{id: '', text: ''}]});
+ loadFieldOptions(field.entity || entity).then(function(data) {
+ var options = [];
+ _.each(_.findWhere(data, {name: field.name}).options, function(val, key) {
+ options.push({id: key, text: val});
+ });
+ $el.removeClass('loading').select2({data: options, multiple: multi});
+ });
+ } else if (dataType === 'Boolean') {
+ $el.attr('placeholder', ts('- select -')).crmSelect2({allowClear: false, multiple: multi, placeholder: ts('- select -'), data: [
+ {id: '1', text: ts('Yes')},
+ {id: '0', text: ts('No')}
+ ]});
+ }
+ } else if (dataType === 'Integer') {
+ $el.attr('type', 'number');
+ }
+ }
+
+ function loadFieldOptions(entity) {
+ if (!fieldOptions[entity + action]) {
+ fieldOptions[entity + action] = crmApi4(entity, 'getFields', {
+ loadOptions: true,
+ action: action,
+ where: [["options", "!=", false]],
+ select: ["name", "options"]
+ });
+ }
+ return fieldOptions[entity + action];
+ }
+
+ // Copied from ng-list but applied conditionally if field is multi-valued
+ var parseList = function(viewValue) {
+ // If the viewValue is invalid (say required but empty) it will be `undefined`
+ if (_.isUndefined(viewValue)) return;
+
+ if (!multi) {
+ return viewValue;
+ }
+
+ var list = [];
+
+ if (viewValue) {
+ _.each(viewValue.split(','), function(value) {
+ if (value) list.push(_.trim(value));
+ });
+ }
+
+ return list;
+ };
+
+ // Copied from ng-list
+ ctrl.$parsers.push(parseList);
+ ctrl.$formatters.push(function(value) {
+ return _.isArray(value) ? value.join(', ') : value;
+ });
+
+ // Copied from ng-list
+ ctrl.$isEmpty = function(value) {
+ return !value || !value.length;
+ };
+
+ scope.$watchCollection('data', function(data) {
+ destroyWidget();
+ var field = getField(data.field, entity, action);
+ if (field) {
+ makeWidget(field, data.op);
+ }
+ });
+ }
+ };
+ });
+
+
+ angular.module('api4Explorer').directive('api4ExpChain', function(crmApi4) {
+ return {
+ scope: {
+ chain: '=api4ExpChain',
+ mainEntity: '=',
+ entities: '='
+ },
+ templateUrl: '~/api4Explorer/Chain.html',
+ link: function (scope, element, attrs) {
+ var ts = scope.ts = CRM.ts('api4');
+
+ function changeEntity(newEntity, oldEntity) {
+ // When clearing entity remove this chain
+ if (!newEntity) {
+ scope.chain[0] = '';
+ return;
+ }
+ // Reset action && index
+ if (newEntity !== oldEntity) {
+ scope.chain[1][1] = scope.chain[1][2] = '';
+ }
+ if (getEntity(newEntity).actions) {
+ setActions();
+ } else {
+ crmApi4(newEntity, 'getActions', {chain: {fields: [newEntity, 'getFields', {action: '$name'}]}})
+ .then(function(data) {
+ getEntity(data.entity).actions = data;
+ if (data.entity === scope.chain[1][0]) {
+ setActions();
+ }
+ });
+ }
+ }
+
+ function setActions() {
+ scope.actions = [''].concat(_.pluck(getEntity(scope.chain[1][0]).actions, 'name'));
+ }
+
+ // Set default params when choosing action
+ function changeAction(newAction, oldAction) {
+ var link;
+ // Prepopulate links
+ if (newAction && newAction !== oldAction) {
+ // Clear index
+ scope.chain[1][3] = '';
+ // Look for links back to main entity
+ _.each(entityFields(scope.chain[1][0]), function(field) {
+ if (field.fk_entity === scope.mainEntity) {
+ link = [field.name, '$id'];
+ }
+ });
+ // Look for links from main entity
+ if (!link && newAction !== 'create') {
+ _.each(entityFields(scope.mainEntity), function(field) {
+ if (field.fk_entity === scope.chain[1][0]) {
+ link = ['id', '$' + field.name];
+ // Since we're specifying the id, set index to getsingle
+ scope.chain[1][3] = '0';
+ }
+ });
+ }
+ if (link && _.contains(['get', 'update', 'replace', 'delete'], newAction)) {
+ scope.chain[1][2] = '{where: [[' + link[0] + ', =, ' + link[1] + ']]}';
+ }
+ else if (link && _.contains(['create'], newAction)) {
+ scope.chain[1][2] = '{values: {' + link[0] + ': ' + link[1] + '}}';
+ } else {
+ scope.chain[1][2] = '{}';
+ }
+ }
+ }
+
+ scope.$watch("chain[1][0]", changeEntity);
+ scope.$watch("chain[1][1]", changeAction);
+ }
+ };
+ });
+
+ function getEntity(entityName) {
+ return _.findWhere(schema, {name: entityName});
+ }
+
+ function entityFields(entityName, action) {
+ var entity = getEntity(entityName);
+ if (entity && action && entity.actions) {
+ return _.findWhere(entity.actions, {name: action}).fields;
+ }
+ return _.result(entity, 'fields');
+ }
+
+ function getField(fieldName, entity, action) {
+ var fieldNames = fieldName.split('.');
+ return get(entity, fieldNames);
+
+ function get(entity, fieldNames) {
+ if (fieldNames.length === 1) {
+ return _.findWhere(entityFields(entity, action), {name: fieldNames[0]});
+ }
+ var comboName = _.findWhere(entityFields(entity, action), {name: fieldNames[0] + '.' + fieldNames[1]});
+ if (comboName) {
+ return comboName;
+ }
+ var linkName = fieldNames.shift(),
+ entityLinks = _.findWhere(links, {entity: entity}).links,
+ newEntity = _.findWhere(entityLinks, {alias: linkName}).entity;
+ return get(newEntity, fieldNames);
+ }
+ }
+
+ // Collapsible optgroups for select2
+ $(function() {
+ $('body')
+ .on('select2-open', function(e) {
+ if ($(e.target).hasClass('collapsible-optgroups')) {
+ $('#select2-drop')
+ .off('.collapseOptionGroup')
+ .addClass('collapsible-optgroups-enabled')
+ .on('click.collapseOptionGroup', '.select2-result-with-children > .select2-result-label', function() {
+ $(this).parent().toggleClass('optgroup-expanded');
+ });
+ }
+ })
+ .on('select2-close', function() {
+ $('#select2-drop').off('.collapseOptionGroup').removeClass('collapsible-optgroups-enabled');
+ });
+ });
+})(angular, CRM.$, CRM._);
--- /dev/null
+<legend>{{ data.label || data.op + ' group' }}<span class="crm-marker" ng-if="data.required"> *</span></legend>
+<div class="btn-group btn-group-xs" ng-if="data.groupParent">
+ <button class="btn btn-danger-outline" ng-click="removeGroup()" title="{{ ts('Remove group') }}">
+ <i class="crm-i fa-trash"></i>
+ </button>
+</div>
+<div class="api4-where-group-sortable" ng-model="data.where" ui-sortable="{axis: 'y', connectWith: '.api4-where-group-sortable', containment: '.api4-where-fieldset', over: onSortOver, start: onSort, stop: onSort}">
+ <div class="api4-input form-inline clearfix" ng-repeat="(index, clause) in data.where">
+ <div class="api4-clause-badge" title="{{ ts('Drag to reposition') }}">
+ <span class="badge badge-info">
+ <span ng-if="!index && !data.groupParent">Where</span>
+ <span ng-if="index || data.groupParent">{{ data.op }}</span>
+ <i class="crm-i fa-arrows"></i>
+ </span>
+ </div>
+ <div ng-if="clause[0] !== 'AND' && clause[0] !== 'OR' && clause[0] !== 'NOT'" class="api4-input-group">
+ <input class="collapsible-optgroups form-control" ng-model="clause[0]" crm-ui-select="{data: data.fields, allowClear: true, placeholder: 'Field'}" />
+ <select class="form-control api4-operator" ng-model="clause[1]" ng-options="o for o in operators" ></select>
+ <input class="form-control" ng-model="clause[2]" api4-exp-value="{field: clause[0], op: clause[1]}" />
+ </div>
+ <fieldset class="clearfix" ng-if="clause[0] === 'AND' || clause[0] === 'OR' || clause[0] === 'NOT'" crm-api4-where-clause="{where: clause[1], op: clause[0], fields: data.fields, operators: data.operators, groupParent: data.where, groupIndex: index}">
+ </fieldset>
+ </div>
+</div>
+<div class="api4-input form-inline">
+ <div class="api4-clause-badge">
+ <div class="btn-group btn-group-xs" title="{{ data.groupParent ? ts('Add a subgroup of clauses') : ts('Add a group of clauses') }}">
+ <button type="button" class="btn btn-info dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ {{ data.op }} <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu api4-add-where-group-menu">
+ <li ng-repeat="con in conjunctions" ng-if="data.op !== con">
+ <a href ng-click="addGroup(con)">{{ con }}</a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ <input class="collapsible-optgroups form-control" ng-model="newClause" title="Add a single clause" crm-ui-select="{data: data.fields, placeholder: 'Add clause'}" />
+</div>
\ No newline at end of file
--- /dev/null
+/* Style rules for Api4 Explorer */
+
+#bootstrap-theme.api4-explorer-page .panel-heading {
+ height: 50px;
+}
+#bootstrap-theme.api4-explorer-page .panel-body {
+ min-height: calc( 100% - 50px);
+}
+#bootstrap-theme.api4-explorer-page .explorer-params-panel .panel-heading {
+ padding-top: 12px;
+}
+#bootstrap-theme.api4-explorer-page .explorer-params-panel .panel-heading button {
+ position: relative;
+ top: -5px;
+}
+#bootstrap-theme .explorer-params-panel .panel-heading .form-inline > .select2-container {
+ max-width: 25% !important;
+}
+#bootstrap-theme.api4-explorer-page .api4-explorer-row {
+ display: flex;
+}
+#bootstrap-theme.api4-explorer-page > div > .panel {
+ flex: 1;
+ margin: 10px;
+ min-height: 400px;
+}
+#bootstrap-theme.api4-explorer-page > div > form.panel {
+ flex: 2;
+}
+/* Fix weird shorditch style */
+#bootstrap-theme.api4-explorer-page .api4-explorer-row .panel .panel-heading {
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+ margin-bottom: 0;
+}
+#bootstrap-theme.api4-explorer-page .explorer-code-panel table td:first-child {
+ width: 5em;
+}
+
+#bootstrap-theme.api4-explorer-page .explorer-params-panel > .panel-body > div.api4-input {
+ margin-bottom: 10px;
+}
+
+#bootstrap-theme.api4-explorer-page .api4-input.form-inline > label {
+ margin-right: 12px;
+}
+
+#bootstrap-theme.api4-explorer-page .explorer-help-panel .panel-body {
+ word-break: break-word;
+}
+
+#bootstrap-theme.api4-explorer-page form label {
+ text-transform: capitalize;
+}
+
+#bootstrap-theme.api4-explorer-page fieldset {
+ padding: 6px;
+ border: 1px solid lightgrey;
+ margin-bottom: 10px;
+ position: relative;
+}
+
+#bootstrap-theme.api4-explorer-page fieldset legend {
+ background-color: white;
+ font-size: 13px;
+ margin: 0;
+ width: auto;
+ border: 0 none;
+ padding: 2px 5px;
+ text-transform: capitalize;
+}
+#bootstrap-theme.api4-explorer-page fieldset > .btn-group {
+ position: absolute;
+ right: 0;
+ top: 11px;
+}
+#bootstrap-theme.api4-explorer-page fieldset > .btn-group .btn {
+ border: 0 none;
+}
+
+#bootstrap-theme.api4-explorer-page fieldset div.api4-input {
+ margin-bottom: 10px;
+}
+
+#bootstrap-theme.api4-explorer-page fieldset div.api4-input.ui-sortable-helper {
+ background-color: rgba(255, 255, 255, .9);
+}
+
+#bootstrap-theme.api4-explorer-page fieldset div.api4-input.ui-sortable-helper {
+ background-color: rgba(255, 255, 255, .9);
+}
+
+#bootstrap-theme.api4-explorer-page div.api4-input.form-inline .form-control {
+ margin-right: 6px;
+}
+
+#bootstrap-theme.api4-explorer-page div.api4-input.form-inline .form-control:not(.api4-option-selected) {
+ transition: none;
+ box-shadow: none;
+ -webkit-box-shadow: none;
+ -moz-box-shadow: none;
+}
+
+#bootstrap-theme.api4-explorer-page div.api4-input.form-inline .form-control label {
+ font-weight: normal;
+ position: relative;
+ top: -2px;
+}
+
+#bootstrap-theme.api4-explorer-page .api4-where-fieldset fieldset {
+ float: right;
+ width: calc(100% - 58px);
+ margin-top: -8px;
+}
+
+#bootstrap-theme.api4-explorer-page .api4-where-fieldset.api4-sorting fieldset .api4-where-group-sortable {
+ min-height: 3.5em;
+}
+
+#bootstrap-theme.api4-explorer-page .api4-input-group {
+ display: inline-block;
+}
+
+#bootstrap-theme.api4-explorer-page .api4-clause-badge {
+ width: 55px;
+ display: inline-block;
+ cursor: move;
+}
+#bootstrap-theme.api4-explorer-page .api4-clause-badge .badge {
+ opacity: .5;
+ position: relative;
+}
+#bootstrap-theme.api4-explorer-page .api4-clause-badge .caret {
+ margin: 0;
+}
+#bootstrap-theme.api4-explorer-page .api4-clause-badge .crm-i {
+ display: none;
+ padding: 0 6px;
+}
+#bootstrap-theme.api4-explorer-page .ui-sortable-helper .api4-clause-badge .badge span {
+ display: none;
+}
+#bootstrap-theme.api4-explorer-page .ui-sortable-helper .api4-clause-badge .crm-i {
+ display: inline-block;
+}
+
+#bootstrap-theme.api4-explorer-page .api4-operator,
+#bootstrap-theme.api4-explorer-page .api4-chain-index,
+#bootstrap-theme.api4-explorer-page .api4-index,
+#bootstrap-theme.api4-explorer-page .api4-chain-action {
+ width: 70px;
+}
+#bootstrap-theme.api4-explorer-page .api4-chain-params {
+ width: calc( 100% - 346px);
+}
+
+#bootstrap-theme.api4-explorer-page .api4-add-where-group-menu {
+ min-width: 80px;
+ background-color: rgba(186, 225, 251, 0.94);
+}
+#bootstrap-theme.api4-explorer-page .api4-add-where-group-menu a {
+ padding: 5px 10px;
+}
+
+/* Collapsible optgroups for select2 */
+div.select2-drop.collapsible-optgroups-enabled .select2-result-with-children:not(.optgroup-expanded) > .select2-result-sub > li.select2-result {
+ display: none;
+}
+div.select2-drop.collapsible-optgroups-enabled .select2-result-with-children > .select2-result-label:before {
+ font-family: FontAwesome;
+ content: "\f0da";
+ display: inline-block;
+ padding-right: 3px;
+ vertical-align: middle;
+ font-weight: normal;
+}
+div.select2-drop.collapsible-optgroups-enabled .select2-result-with-children.optgroup-expanded > .select2-result-label:before {
+ content: "\f0d7";
+}
+
+/**
+ * Shims so the UI isn't completely broken when a Bootstrap theme is not installed
+ */
+#bootstrap-theme.api4-explorer-page * {
+ box-sizing: border-box;
+}
+.api4-explorer-page.panel {
+ border: 1px solid grey;
+ background-color: white;
+}
+.api4-explorer-page.panel-heading {
+ padding: 10px 20px;
+ color: #464354;
+ background-color: #f3f6f7;
+}
--- /dev/null
+// Loads a copy of shoreditch's bootstrap if bootstrap is missing
+CRM.$(function($) {
+ if (!$.isFunction($.fn.dropdown)) {
+ CRM.loadScript(CRM.vars.api4.basePath + 'lib/shoreditch/dropdown.js');
+ $('head').append('<link type="text/css" rel="stylesheet" href="' + CRM.vars.api4.basePath + 'lib/shoreditch/bootstrap.css" />');
+ }
+});
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use api\v4\UnitTestCase;
+use api\v4\Traits\TableDropperTrait;
+
+abstract class BaseCustomValueTest extends UnitTestCase {
+
+ use \api\v4\Traits\OptionCleanupTrait {
+ setUp as setUpOptionCleanup;
+ }
+ use TableDropperTrait;
+
+ /**
+ * Set up baseline for testing
+ */
+ public function setUp() {
+ $this->setUpOptionCleanup();
+ $cleanup_params = [
+ 'tablesToTruncate' => [
+ 'civicrm_custom_group',
+ 'civicrm_custom_field',
+ ],
+ ];
+
+ $this->dropByPrefix('civicrm_value_mycontact');
+ $this->cleanup($cleanup_params);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use api\v4\UnitTestCase;
+use Civi\Api4\MockBasicEntity;
+
+/**
+ * @group headless
+ */
+class BasicActionsTest extends UnitTestCase {
+
+ public function testCrud() {
+ MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
+
+ $id1 = MockBasicEntity::create()->addValue('foo', 'one')->execute()->first()['id'];
+
+ $result = MockBasicEntity::get()->execute();
+ $this->assertCount(1, $result);
+
+ $id2 = MockBasicEntity::create()->addValue('foo', 'two')->execute()->first()['id'];
+
+ $result = MockBasicEntity::get()->selectRowCount()->execute();
+ $this->assertEquals(2, $result->count());
+
+ MockBasicEntity::update()->addWhere('id', '=', $id2)->addValue('foo', 'new')->execute();
+
+ $result = MockBasicEntity::get()->addOrderBy('id', 'DESC')->setLimit(1)->execute();
+ $this->assertCount(1, $result);
+ $this->assertEquals('new', $result->first()['foo']);
+
+ $result = MockBasicEntity::save()
+ ->addRecord(['id' => $id1, 'foo' => 'one updated'])
+ ->addRecord(['id' => $id2])
+ ->addRecord(['foo' => 'three'])
+ ->addDefault('color', 'pink')
+ ->setReload(TRUE)
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertEquals('new', $result[$id2]['foo']);
+ $this->assertEquals('three', $result->last()['foo']);
+ $this->assertCount(3, $result);
+ foreach ($result as $item) {
+ $this->assertEquals('pink', $item['color']);
+ }
+
+ $this->assertEquals('one updated', MockBasicEntity::get()->addWhere('id', '=', $id1)->execute()->first()['foo']);
+
+ MockBasicEntity::delete()->addWhere('id', '=', $id2);
+ $result = MockBasicEntity::get()->execute();
+ $this->assertEquals('one updated', $result->first()['foo']);
+ }
+
+ public function testReplace() {
+ MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
+
+ $objects = [
+ ['group' => 'one', 'color' => 'red'],
+ ['group' => 'one', 'color' => 'blue'],
+ ['group' => 'one', 'color' => 'green'],
+ ['group' => 'two', 'color' => 'orange'],
+ ];
+
+ foreach ($objects as &$object) {
+ $object['id'] = MockBasicEntity::create()->setValues($object)->execute()->first()['id'];
+ }
+
+ // Keep red, change blue, delete green, and add yellow
+ $replacements = [
+ ['color' => 'red', 'id' => $objects[0]['id']],
+ ['color' => 'not blue', 'id' => $objects[1]['id']],
+ ['color' => 'yellow'],
+ ];
+
+ MockBasicEntity::replace()->addWhere('group', '=', 'one')->setRecords($replacements)->execute();
+
+ $newObjects = MockBasicEntity::get()->addOrderBy('id', 'DESC')->execute()->indexBy('id');
+
+ $this->assertCount(4, $newObjects);
+
+ $this->assertEquals('yellow', $newObjects->first()['color']);
+
+ $this->assertEquals('not blue', $newObjects[$objects[1]['id']]['color']);
+
+ // Ensure group two hasn't been altered
+ $this->assertEquals('orange', $newObjects[$objects[3]['id']]['color']);
+ $this->assertEquals('two', $newObjects[$objects[3]['id']]['group']);
+ }
+
+ public function testBatchFrobnicate() {
+ MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
+
+ $objects = [
+ ['group' => 'one', 'color' => 'red', 'number' => 10],
+ ['group' => 'one', 'color' => 'blue', 'number' => 20],
+ ['group' => 'one', 'color' => 'green', 'number' => 30],
+ ['group' => 'two', 'color' => 'blue', 'number' => 40],
+ ];
+ foreach ($objects as &$object) {
+ $object['id'] = MockBasicEntity::create()->setValues($object)->execute()->first()['id'];
+ }
+
+ $result = MockBasicEntity::batchFrobnicate()->addWhere('color', '=', 'blue')->execute();
+ $this->assertEquals(2, count($result));
+ $this->assertEquals([400, 1600], \CRM_Utils_Array::collect('frobnication', (array) $result));
+ }
+
+ public function testGetFields() {
+ $getFields = MockBasicEntity::getFields()->execute()->indexBy('name');
+
+ $this->assertCount(6, $getFields);
+ $this->assertEquals('Id', $getFields['id']['title']);
+ // Ensure default data type is "String" when not specified
+ $this->assertEquals('String', $getFields['color']['data_type']);
+
+ // Getfields should default to loadOptions = false and reduce them to bool
+ $this->assertTrue($getFields['group']['options']);
+ $this->assertFalse($getFields['id']['options']);
+
+ // Now load options
+ $getFields = MockBasicEntity::getFields()
+ ->addWhere('name', '=', 'group')
+ ->setLoadOptions(TRUE)
+ ->execute()->indexBy('name');
+
+ $this->assertCount(1, $getFields);
+ $this->assertArrayHasKey('one', $getFields['group']['options']);
+ }
+
+ public function testItemsToGet() {
+ $get = MockBasicEntity::get()
+ ->addWhere('color', 'NOT IN', ['yellow'])
+ ->addWhere('color', 'IN', ['red', 'blue'])
+ ->addWhere('color', '!=', 'green')
+ ->addWhere('group', '=', 'one')
+ ->addWhere('size', 'LIKE', 'big')
+ ->addWhere('shape', 'LIKE', '%a');
+
+ $itemsToGet = new \ReflectionMethod($get, '_itemsToGet');
+ $itemsToGet->setAccessible(TRUE);
+
+ $this->assertEquals(['red', 'blue'], $itemsToGet->invoke($get, 'color'));
+ $this->assertEquals(['one'], $itemsToGet->invoke($get, 'group'));
+ $this->assertEquals(['big'], $itemsToGet->invoke($get, 'size'));
+ $this->assertEmpty($itemsToGet->invoke($get, 'shape'));
+ $this->assertEmpty($itemsToGet->invoke($get, 'weight'));
+ }
+
+ public function testFieldsToGet() {
+ $get = MockBasicEntity::get()
+ ->addWhere('color', '!=', 'green');
+
+ $isFieldSelected = new \ReflectionMethod($get, '_isFieldSelected');
+ $isFieldSelected->setAccessible(TRUE);
+
+ // If no "select" is set, should always return true
+ $this->assertTrue($isFieldSelected->invoke($get, 'color'));
+ $this->assertTrue($isFieldSelected->invoke($get, 'shape'));
+ $this->assertTrue($isFieldSelected->invoke($get, 'size'));
+
+ // With a non-empty "select" fieldsToSelect() will return fields needed to evaluate each clause.
+ $get->addSelect('id');
+ $this->assertTrue($isFieldSelected->invoke($get, 'color'));
+ $this->assertTrue($isFieldSelected->invoke($get, 'id'));
+ $this->assertFalse($isFieldSelected->invoke($get, 'shape'));
+ $this->assertFalse($isFieldSelected->invoke($get, 'size'));
+ $this->assertFalse($isFieldSelected->invoke($get, 'weight'));
+ $this->assertFalse($isFieldSelected->invoke($get, 'group'));
+
+ $get->addClause('OR', ['shape', '=', 'round'], ['AND', [['size', '=', 'big'], ['weight', '!=', 'small']]]);
+ $this->assertTrue($isFieldSelected->invoke($get, 'color'));
+ $this->assertTrue($isFieldSelected->invoke($get, 'id'));
+ $this->assertTrue($isFieldSelected->invoke($get, 'shape'));
+ $this->assertTrue($isFieldSelected->invoke($get, 'size'));
+ $this->assertTrue($isFieldSelected->invoke($get, 'weight'));
+ $this->assertFalse($isFieldSelected->invoke($get, 'group'));
+
+ $get->addOrderBy('group');
+ $this->assertTrue($isFieldSelected->invoke($get, 'group'));
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+
+/**
+ * @group headless
+ */
+class BasicCustomFieldTest extends BaseCustomValueTest {
+
+ public function testWithSingleField() {
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $contactId = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Johann')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('MyContactFields.FavColor', 'Red')
+ ->execute()
+ ->first()['id'];
+
+ $contact = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('first_name')
+ ->addSelect('MyContactFields.FavColor')
+ ->addWhere('id', '=', $contactId)
+ ->addWhere('MyContactFields.FavColor', '=', 'Red')
+ ->execute()
+ ->first();
+
+ $this->assertEquals('Red', $contact['MyContactFields.FavColor']);
+
+ Contact::update()
+ ->addWhere('id', '=', $contactId)
+ ->addValue('MyContactFields.FavColor', 'Blue')
+ ->execute();
+
+ $contact = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('MyContactFields.FavColor')
+ ->addWhere('id', '=', $contactId)
+ ->execute()
+ ->first();
+
+ $this->assertEquals('Blue', $contact['MyContactFields.FavColor']);
+ }
+
+ public function testWithTwoFields() {
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavFood')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $contactId1 = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Johann')
+ ->addValue('last_name', 'Tester')
+ ->addValue('MyContactFields.FavColor', 'Red')
+ ->addValue('MyContactFields.FavFood', 'Cherry')
+ ->execute()
+ ->first()['id'];
+
+ $contactId2 = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'MaryLou')
+ ->addValue('last_name', 'Tester')
+ ->addValue('MyContactFields.FavColor', 'Purple')
+ ->addValue('MyContactFields.FavFood', 'Grapes')
+ ->execute()
+ ->first()['id'];
+
+ $contact = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('first_name')
+ ->addSelect('MyContactFields.FavColor')
+ ->addSelect('MyContactFields.FavFood')
+ ->addWhere('id', '=', $contactId1)
+ ->addWhere('MyContactFields.FavColor', '=', 'Red')
+ ->addWhere('MyContactFields.FavFood', '=', 'Cherry')
+ ->execute()
+ ->first();
+
+ $this->assertArrayHasKey('MyContactFields.FavColor', $contact);
+ $this->assertEquals('Red', $contact['MyContactFields.FavColor']);
+
+ Contact::update()
+ ->addWhere('id', '=', $contactId1)
+ ->addValue('MyContactFields.FavColor', 'Blue')
+ ->execute();
+
+ $contact = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('MyContactFields.FavColor')
+ ->addWhere('id', '=', $contactId1)
+ ->execute()
+ ->first();
+
+ $this->assertEquals('Blue', $contact['MyContactFields.FavColor']);
+
+ $search = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addClause('OR', ['MyContactFields.FavColor', '=', 'Blue'], ['MyContactFields.FavFood', '=', 'Grapes'])
+ ->addSelect('id')
+ ->addOrderBy('id')
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertEquals([$contactId1, $contactId2], array_keys((array) $search));
+
+ $search = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addClause('NOT', ['MyContactFields.FavColor', '=', 'Purple'], ['MyContactFields.FavFood', '=', 'Grapes'])
+ ->addSelect('id')
+ ->addOrderBy('id')
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertNotContains($contactId2, array_keys((array) $search));
+
+ $search = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addClause('NOT', ['MyContactFields.FavColor', '=', 'Purple'], ['MyContactFields.FavFood', '=', 'Grapes'])
+ ->addSelect('id')
+ ->addOrderBy('id')
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertContains($contactId1, array_keys((array) $search));
+ $this->assertNotContains($contactId2, array_keys((array) $search));
+
+ $search = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->setWhere([['NOT', ['OR', [['MyContactFields.FavColor', '=', 'Blue'], ['MyContactFields.FavFood', '=', 'Grapes']]]]])
+ ->addSelect('id')
+ ->addOrderBy('id')
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertNotContains($contactId1, array_keys((array) $search));
+ $this->assertNotContains($contactId2, array_keys((array) $search));
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ChainTest extends UnitTestCase {
+
+ public function testGetActionsWithFields() {
+ $actions = \Civi\Api4\Activity::getActions()
+ ->addChain('fields', \Civi\Api4\Activity::getFields()->setAction('$name'), 'name')
+ ->execute()
+ ->indexBy('name');
+
+ $this->assertEquals('Array', $actions['getActions']['fields']['params']['data_type']);
+ }
+
+ public function testGetEntityWithActions() {
+ $entities = \Civi\Api4\Entity::get()
+ ->addSelect('name')
+ ->setChain([
+ 'actions' => ['$name', 'getActions', ['select' => ['name']], 'name'],
+ ])
+ ->execute()
+ ->indexBy('name');
+
+ $this->assertArrayHasKey('replace', $entities['Contact']['actions']);
+ $this->assertArrayHasKey('getLinks', $entities['Entity']['actions']);
+ $this->assertArrayNotHasKey('replace', $entities['Entity']['actions']);
+ }
+
+ public function testContactCreateWithGroup() {
+ $firstName = uniqid('cwtf');
+ $lastName = uniqid('cwtl');
+
+ $contact = \Civi\Api4\Contact::create()
+ ->addValue('first_name', $firstName)
+ ->addValue('last_name', $lastName)
+ ->addChain('group', \Civi\Api4\Group::create()->addValue('title', '$display_name'), 0)
+ ->addChain('add_to_group', \Civi\Api4\GroupContact::create()->addValue('contact_id', '$id')->addValue('group_id', '$group.id'), 0)
+ ->addChain('check_group', \Civi\Api4\GroupContact::get()->addWhere('group_id', '=', '$group.id'))
+ ->execute()
+ ->first();
+
+ $this->assertCount(1, $contact['check_group']);
+ $this->assertEquals($contact['id'], $contact['check_group'][0]['contact_id']);
+ $this->assertEquals($contact['group']['id'], $contact['check_group'][0]['group_id']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use api\v4\UnitTestCase;
+use Civi\Api4\Activity;
+
+/**
+ * @group headless
+ *
+ * This class tests a series of complex query situations described in the
+ * initial APIv4 specification
+ */
+class ComplexQueryTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_activity',
+ 'civicrm_activity_contact',
+ ];
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('DefaultDataSet');
+
+ return parent::setUpHeadless();
+ }
+
+ /**
+ * Fetch all phone call activities
+ * Expects at least one activity loaded from the data set.
+ */
+ public function testGetAllHousingSupportActivities() {
+ $results = Activity::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('activity_type.name', '=', 'Phone Call')
+ ->execute();
+
+ $this->assertGreaterThan(0, count($results));
+ }
+
+ /**
+ * Fetch all activities with a blue tag; and return all tags on the activities
+ */
+ public function testGetAllTagsForBlueTaggedActivities() {
+
+ }
+
+ /**
+ * Fetch contacts named 'Bob' and all of their blue activities
+ */
+ public function testGetAllBlueActivitiesForBobs() {
+
+ }
+
+ /**
+ * Get all contacts in a zipcode and return their Home or Work email addresses
+ */
+ public function testGetHomeOrWorkEmailsForContactsWithZipcode() {
+
+ }
+
+ /**
+ * Fetch all activities where Bob is the assignee or source
+ */
+ public function testGetActivitiesWithBobAsAssigneeOrSource() {
+
+ }
+
+ /**
+ * Get all contacts which
+ * (a) have address in zipcode 94117 or 94118 or in city "San Francisco","LA"
+ * and
+ * (b) are not deceased and
+ * (c) have a custom-field "most_important_issue=Environment".
+ */
+ public function testAWholeLotOfConditions() {
+
+ }
+
+ /**
+ * Get participants who attended CiviCon 2012 but not CiviCon 2013.
+ * Return their name and email.
+ */
+ public function testGettingNameAndEmailOfAttendeesOfCiviCon2012Only() {
+
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\Email;
+
+/**
+ * @group headless
+ */
+class ContactApiKeyTest extends \api\v4\UnitTestCase {
+
+ public function testGetApiKey() {
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'add contacts', 'edit api keys', 'view all contacts', 'edit all contacts'];
+ $key = uniqid();
+
+ $contact = Contact::create()
+ ->addValue('first_name', 'Api')
+ ->addValue('last_name', 'Key0')
+ ->addValue('api_key', $key)
+ ->addChain('email', Email::create()
+ ->addValue('contact_id', '$id')
+ ->addValue('email', 'test@key.get'),
+ 0
+ )
+ ->execute()
+ ->first();
+
+ // With sufficient permission we should see the key
+ $result = Contact::get()
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+ $this->assertEquals($key, $result['api_key']);
+
+ // Can also be fetched via join
+ $email = Email::get()
+ ->addSelect('contact.api_key')
+ ->addWhere('id', '=', $contact['email']['id'])
+ ->execute()->first();
+ $this->assertEquals($key, $email['contact.api_key']);
+
+ // Remove permission and we should not see the key
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM'];
+ $result = Contact::get()
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+ $this->assertTrue(empty($result['api_key']));
+
+ // Also not available via join
+ $email = Email::get()
+ ->addSelect('contact.api_key')
+ ->addWhere('id', '=', $contact['email']['id'])
+ ->execute()->first();
+ $this->assertTrue(empty($email['contact.api_key']));
+
+ $result = Contact::get()
+ ->addWhere('id', '=', $contact['id'])
+ ->execute()
+ ->first();
+ $this->assertTrue(empty($result['api_key']));
+ }
+
+ public function testCreateWithInsufficientPermissions() {
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'add contacts'];
+ $key = uniqid();
+
+ $error = '';
+ try {
+ Contact::create()
+ ->addValue('first_name', 'Api')
+ ->addValue('last_name', 'Key1')
+ ->addValue('api_key', $key)
+ ->execute()
+ ->first();
+ }
+ catch (\Exception $e) {
+ $error = $e->getMessage();
+ }
+ $this->assertContains('key', $error);
+ }
+
+ public function testUpdateApiKey() {
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'edit all contacts'];
+ $key = uniqid();
+
+ $contact = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Api')
+ ->addValue('last_name', 'Key2')
+ ->addValue('api_key', $key)
+ ->execute()
+ ->first();
+
+ $error = '';
+ try {
+ // Try to update the key without permissions; nothing should happen
+ Contact::update()
+ ->addWhere('id', '=', $contact['id'])
+ ->addValue('api_key', "NotAllowed")
+ ->execute();
+ }
+ catch (\Exception $e) {
+ $error = $e->getMessage();
+ }
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+
+ $this->assertContains('key', $error);
+
+ // Assert key is still the same
+ $this->assertEquals($result['api_key'], $key);
+
+ // Now we can update the key
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'administer CiviCRM', 'edit all contacts'];
+
+ Contact::update()
+ ->addWhere('id', '=', $contact['id'])
+ ->addValue('api_key', "IGotThePower!")
+ ->execute();
+
+ $result = Contact::get()
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+
+ // Assert key was updated
+ $this->assertEquals($result['api_key'], "IGotThePower!");
+ }
+
+ public function testUpdateOwnApiKey() {
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'edit own api keys', 'edit all contacts'];
+ $key = uniqid();
+
+ $contact = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Api')
+ ->addValue('last_name', 'Key3')
+ ->addValue('api_key', $key)
+ ->execute()
+ ->first();
+
+ $error = '';
+ try {
+ // Try to update the key without permissions; nothing should happen
+ Contact::update()
+ ->addWhere('id', '=', $contact['id'])
+ ->addValue('api_key', "NotAllowed")
+ ->execute();
+ }
+ catch (\Exception $e) {
+ $error = $e->getMessage();
+ }
+
+ $this->assertContains('key', $error);
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+
+ // Assert key is still the same
+ $this->assertEquals($result['api_key'], $key);
+
+ // Now we can update the key
+ \CRM_Core_Session::singleton()->set('userID', $contact['id']);
+
+ Contact::update()
+ ->addWhere('id', '=', $contact['id'])
+ ->addValue('api_key', "MyId!")
+ ->execute();
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('api_key')
+ ->execute()
+ ->first();
+
+ // Assert key was updated
+ $this->assertEquals($result['api_key'], "MyId!");
+ }
+
+ public function testApiKeyWithGetFields() {
+ // With sufficient permissions the field should exist
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'edit api keys'];
+ $this->assertArrayHasKey('api_key', \civicrm_api4('Contact', 'getFields', [], 'name'));
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'administer CiviCRM'];
+ $this->assertArrayHasKey('api_key', \civicrm_api4('Contact', 'getFields', [], 'name'));
+
+ // Field hidden from non-privileged users...
+ \CRM_Core_Config::singleton()->userPermissionClass->permissions = ['access CiviCRM', 'edit own api keys'];
+ $this->assertArrayNotHasKey('api_key', \civicrm_api4('Contact', 'getFields', [], 'name'));
+
+ // ...unless you disable 'checkPermissions'
+ $this->assertArrayHasKey('api_key', \civicrm_api4('Contact', 'getFields', ['checkPermissions' => FALSE], 'name'));
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class ContactChecksumTest extends \api\v4\UnitTestCase {
+
+ public function testGetChecksum() {
+ $contact = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Check')
+ ->addValue('last_name', 'Sum')
+ ->addChain('cs', Contact::getChecksum()->setContactId('$id')->setTtl(500), 0)
+ ->execute()
+ ->first();
+
+ $result = Contact::validateChecksum()
+ ->setContactId($contact['id'])
+ ->setChecksum($contact['cs']['checksum'])
+ ->execute()
+ ->first();
+
+ $this->assertTrue($result['valid']);
+ }
+
+ public function testValidateChecksum() {
+ $cid = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Checker')
+ ->addValue('last_name', 'Sum')
+ ->execute()
+ ->first()['id'];
+
+ $goodCs = \CRM_Contact_BAO_Contact_Utils::generateChecksum($cid, NULL, 500);
+ $badCs = \CRM_Contact_BAO_Contact_Utils::generateChecksum($cid, strtotime('now - 1 week'), 1);
+
+ $result1 = Contact::validateChecksum()
+ ->setContactId($cid)
+ ->setChecksum($goodCs)
+ ->execute()
+ ->first();
+ $this->assertTrue($result1['valid']);
+
+ $result2 = Contact::validateChecksum()
+ ->setContactId($cid)
+ ->setChecksum($badCs)
+ ->execute()
+ ->first();
+ $this->assertFalse($result2['valid']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class ContactGetTest extends \api\v4\UnitTestCase {
+
+ public function testGetDeletedContacts() {
+ $last_name = uniqid('deleteContactTest');
+
+ $bob = Contact::create()
+ ->setValues(['first_name' => 'Bob', 'last_name' => $last_name])
+ ->execute()->first();
+
+ $jan = Contact::create()
+ ->setValues(['first_name' => 'Jan', 'last_name' => $last_name])
+ ->execute()->first();
+
+ $del = Contact::create()
+ ->setValues(['first_name' => 'Del', 'last_name' => $last_name, 'is_deleted' => 1])
+ ->execute()->first();
+
+ // Deleted contacts are not fetched by default
+ $this->assertCount(2, Contact::get()->addWhere('last_name', '=', $last_name)->selectRowCount()->execute());
+
+ // You can search for them specifically
+ $contacts = Contact::get()->addWhere('last_name', '=', $last_name)->addWhere('is_deleted', '=', 1)->addSelect('id')->execute();
+ $this->assertEquals($del['id'], $contacts->first()['id']);
+
+ // Or by id
+ $this->assertCount(3, Contact::get()->addWhere('id', 'IN', [$bob['id'], $jan['id'], $del['id']])->selectRowCount()->execute());
+
+ // Putting is_deleted anywhere in the where clause will disable the default
+ $contacts = Contact::get()->addClause('OR', ['last_name', '=', $last_name], ['is_deleted', '=', 0])->addSelect('id')->execute();
+ $this->assertContains($del['id'], $contacts->column('id'));
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Api4\OptionGroup;
+use Civi\Api4\OptionValue;
+
+/**
+ * @group headless
+ */
+class CreateCustomValueTest extends BaseCustomValueTest {
+
+ public function testGetWithCustomData() {
+ $optionValues = ['r' => 'Red', 'g' => 'Green', 'b' => 'Blue'];
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'Color')
+ ->addValue('option_values', $optionValues)
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $customField = CustomField::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('label', '=', 'Color')
+ ->execute()
+ ->first();
+
+ $this->assertNotNull($customField['option_group_id']);
+ $optionGroupId = $customField['option_group_id'];
+
+ $optionGroup = OptionGroup::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $optionGroupId)
+ ->execute()
+ ->first();
+
+ $this->assertEquals('Color', $optionGroup['title']);
+
+ $createdOptionValues = OptionValue::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('option_group_id', '=', $optionGroupId)
+ ->execute()
+ ->getArrayCopy();
+
+ $values = array_column($createdOptionValues, 'value');
+ $labels = array_column($createdOptionValues, 'label');
+ $createdOptionValues = array_combine($values, $labels);
+
+ $this->assertEquals($optionValues, $createdOptionValues);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class CreateWithOptionGroupTest extends BaseCustomValueTest {
+
+ /**
+ * Remove the custom tables
+ */
+ public function setUp() {
+ $this->dropByPrefix('civicrm_value_financial');
+ $this->dropByPrefix('civicrm_value_favorite');
+ parent::setUp();
+ }
+
+ public function testGetWithCustomData() {
+ $group = uniqid('fava');
+ $colorField = uniqid('colora');
+ $foodField = uniqid('fooda');
+
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', $group)
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $colorField)
+ ->addValue('name', $colorField)
+ ->addValue('option_values', ['r' => 'Red', 'g' => 'Green', 'b' => 'Blue'])
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $foodField)
+ ->addValue('name', $foodField)
+ ->addValue('option_values', ['1' => 'Corn', '2' => 'Potatoes', '3' => 'Cheese'])
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'FinancialStuff')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'Salary')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Number')
+ ->addValue('data_type', 'Money')
+ ->execute();
+
+ Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Jerome')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue("$group.$colorField", 'r')
+ ->addValue("$group.$foodField", '1')
+ ->addValue('FinancialStuff.Salary', 50000)
+ ->execute();
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('first_name')
+ ->addSelect("$group.$colorField.label")
+ ->addSelect("$group.$foodField.label")
+ ->addSelect('FinancialStuff.Salary')
+ ->addWhere("$group.$foodField.label", 'IN', ['Corn', 'Potatoes'])
+ ->addWhere('FinancialStuff.Salary', '>', '10000')
+ ->execute()
+ ->first();
+
+ $this->assertEquals('Red', $result["$group.$colorField.label"]);
+ $this->assertEquals('Corn', $result["$group.$foodField.label"]);
+ $this->assertEquals(50000, $result['FinancialStuff.Salary']);
+ }
+
+ public function testWithCustomDataForMultipleContacts() {
+ $group = uniqid('favb');
+ $colorField = uniqid('colorb');
+ $foodField = uniqid('foodb');
+
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', $group)
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $colorField)
+ ->addValue('name', $colorField)
+ ->addValue('option_values', ['r' => 'Red', 'g' => 'Green', 'b' => 'Blue'])
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $foodField)
+ ->addValue('name', $foodField)
+ ->addValue('option_values', ['1' => 'Corn', '2' => 'Potatoes', '3' => 'Cheese'])
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'FinancialStuff')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'Salary')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Number')
+ ->addValue('data_type', 'Money')
+ ->execute();
+
+ Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Red')
+ ->addValue('last_name', 'Corn')
+ ->addValue('contact_type', 'Individual')
+ ->addValue("$group.$colorField", 'r')
+ ->addValue("$group.$foodField", '1')
+ ->addValue('FinancialStuff.Salary', 10000)
+ ->execute();
+
+ Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Blue')
+ ->addValue('last_name', 'Cheese')
+ ->addValue('contact_type', 'Individual')
+ ->addValue("$group.$colorField", 'b')
+ ->addValue("$group.$foodField", '3')
+ ->addValue('FinancialStuff.Salary', 500000)
+ ->execute();
+
+ $result = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('first_name')
+ ->addSelect('last_name')
+ ->addSelect("$group.$colorField.label")
+ ->addSelect("$group.$foodField.label")
+ ->addSelect('FinancialStuff.Salary')
+ ->addWhere("$group.$foodField.label", 'IN', ['Corn', 'Cheese'])
+ ->execute();
+
+ $blueCheese = NULL;
+ foreach ($result as $contact) {
+ if ($contact['first_name'] === 'Blue') {
+ $blueCheese = $contact;
+ }
+ }
+
+ $this->assertEquals('Blue', $blueCheese["$group.$colorField.label"]);
+ $this->assertEquals('Cheese', $blueCheese["$group.$foodField.label"]);
+ $this->assertEquals(500000, $blueCheese['FinancialStuff.Salary']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Relationship;
+use api\v4\UnitTestCase;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class CurrentFilterTest extends UnitTestCase {
+
+ public function testCurrentRelationship() {
+ $cid1 = Contact::create()->addValue('first_name', 'Bob1')->execute()->first()['id'];
+ $cid2 = Contact::create()->addValue('first_name', 'Bob2')->execute()->first()['id'];
+
+ $current = Relationship::create()->setValues([
+ 'relationship_type_id' => 1,
+ 'contact_id_a' => $cid1,
+ 'contact_id_b' => $cid2,
+ 'end_date' => 'now + 1 week',
+ ])->execute()->first();
+ $indefinite = Relationship::create()->setValues([
+ 'relationship_type_id' => 2,
+ 'contact_id_a' => $cid1,
+ 'contact_id_b' => $cid2,
+ ])->execute()->first();
+ $expiring = Relationship::create()->setValues([
+ 'relationship_type_id' => 3,
+ 'contact_id_a' => $cid1,
+ 'contact_id_b' => $cid2,
+ 'end_date' => 'now',
+ ])->execute()->first();
+ $past = Relationship::create()->setValues([
+ 'relationship_type_id' => 3,
+ 'contact_id_a' => $cid1,
+ 'contact_id_b' => $cid2,
+ 'end_date' => 'now - 1 week',
+ ])->execute()->first();
+ $inactive = Relationship::create()->setValues([
+ 'relationship_type_id' => 4,
+ 'contact_id_a' => $cid1,
+ 'contact_id_b' => $cid2,
+ 'is_active' => 0,
+ ])->execute()->first();
+
+ $getCurrent = (array) Relationship::get()->setCurrent(TRUE)->execute()->indexBy('id');
+ $notCurrent = (array) Relationship::get()->setCurrent(FALSE)->execute()->indexBy('id');
+ $getAll = (array) Relationship::get()->execute()->indexBy('id');
+
+ $this->assertArrayHasKey($current['id'], $getAll);
+ $this->assertArrayHasKey($indefinite['id'], $getAll);
+ $this->assertArrayHasKey($expiring['id'], $getAll);
+ $this->assertArrayHasKey($past['id'], $getAll);
+ $this->assertArrayHasKey($inactive['id'], $getAll);
+
+ $this->assertArrayHasKey($current['id'], $getCurrent);
+ $this->assertArrayHasKey($indefinite['id'], $getCurrent);
+ $this->assertArrayHasKey($expiring['id'], $getCurrent);
+ $this->assertArrayNotHasKey($past['id'], $getCurrent);
+ $this->assertArrayNotHasKey($inactive['id'], $getCurrent);
+
+ $this->assertArrayNotHasKey($current['id'], $notCurrent);
+ $this->assertArrayNotHasKey($indefinite['id'], $notCurrent);
+ $this->assertArrayNotHasKey($expiring['id'], $notCurrent);
+ $this->assertArrayHasKey($past['id'], $notCurrent);
+ $this->assertArrayHasKey($inactive['id'], $notCurrent);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use api\v4\Traits\QueryCounterTrait;
+
+/**
+ * @group headless
+ */
+class CustomValuePerformanceTest extends BaseCustomValueTest {
+
+ use QueryCounterTrait;
+
+ public function testQueryCount() {
+
+ $this->markTestIncomplete();
+
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('title', 'MyContactFields')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('options', ['r' => 'Red', 'g' => 'Green', 'b' => 'Blue'])
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavAnimal')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavLetter')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavFood')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $this->beginQueryCount();
+
+ Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Red')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('MyContactFields.FavColor', 'r')
+ ->addValue('MyContactFields.FavAnimal', 'Sheep')
+ ->addValue('MyContactFields.FavLetter', 'z')
+ ->addValue('MyContactFields.FavFood', 'Coconuts')
+ ->execute();
+
+ Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('display_name')
+ ->addSelect('MyContactFields.FavColor.label')
+ ->addSelect('MyContactFields.FavColor.weight')
+ ->addSelect('MyContactFields.FavColor.is_default')
+ ->addSelect('MyContactFields.FavAnimal')
+ ->addSelect('MyContactFields.FavLetter')
+ ->addWhere('MyContactFields.FavColor', '=', 'r')
+ ->addWhere('MyContactFields.FavFood', '=', 'Coconuts')
+ ->addWhere('MyContactFields.FavAnimal', '=', 'Sheep')
+ ->addWhere('MyContactFields.FavLetter', '=', 'z')
+ ->execute()
+ ->first();
+
+ // FIXME: This count is artificially high due to the line
+ // $this->entity = Tables::getBriefName(Tables::getClassForTable($targetTable));
+ // In class Joinable. TODO: Investigate why.
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Api4\CustomValue;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class CustomValueTest extends BaseCustomValueTest {
+
+ protected $contactID;
+
+ /**
+ * Test CustomValue::GetFields/Get/Create/Update/Replace/Delete
+ */
+ public function testCRUD() {
+ $optionValues = ['r' => 'Red', 'g' => 'Green', 'b' => 'Blue'];
+
+ $group = uniqid('groupc');
+ $colorField = uniqid('colorc');
+ $textField = uniqid('txt');
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', $group)
+ ->addValue('extends', 'Contact')
+ ->addValue('is_multiple', TRUE)
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $colorField)
+ ->addValue('options', $optionValues)
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', $textField)
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $this->contactID = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Johann')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first()['id'];
+
+ // Retrieve and check the fields of CustomValue = Custom_$group
+ $fields = CustomValue::getFields($group)->execute();
+ $expectedResult = [
+ [
+ 'custom_field_id' => 1,
+ 'custom_group' => $group,
+ 'name' => $colorField,
+ 'title' => $colorField,
+ 'entity' => "Custom_$group",
+ 'data_type' => 'String',
+ 'fk_entity' => NULL,
+ ],
+ [
+ 'custom_field_id' => 2,
+ 'custom_group' => $group,
+ 'name' => $textField,
+ 'title' => $textField,
+ 'entity' => "Custom_$group",
+ 'data_type' => 'String',
+ 'fk_entity' => NULL,
+ ],
+ [
+ 'name' => 'id',
+ 'title' => ts('Custom Value ID'),
+ 'entity' => "Custom_$group",
+ 'data_type' => 'Integer',
+ 'fk_entity' => NULL,
+ ],
+ [
+ 'name' => 'entity_id',
+ 'title' => ts('Entity ID'),
+ 'entity' => "Custom_$group",
+ 'data_type' => 'Integer',
+ 'fk_entity' => 'Contact',
+ ],
+ ];
+
+ foreach ($expectedResult as $key => $field) {
+ foreach ($field as $attr => $value) {
+ $this->assertEquals($expectedResult[$key][$attr], $fields[$key][$attr]);
+ }
+ }
+
+ // CASE 1: Test CustomValue::create
+ // Create two records for a single contact and using CustomValue::get ensure that two records are created
+ CustomValue::create($group)
+ ->addValue($colorField, 'Green')
+ ->addValue("entity_id", $this->contactID)
+ ->execute();
+ CustomValue::create($group)
+ ->addValue($colorField, 'Red')
+ ->addValue("entity_id", $this->contactID)
+ ->execute();
+ // fetch custom values using API4 CustomValue::get
+ $result = CustomValue::get($group)->execute();
+
+ // check if two custom values are created
+ $this->assertEquals(2, count($result));
+ $expectedResult = [
+ [
+ 'id' => 1,
+ $colorField => 'Green',
+ 'entity_id' => $this->contactID,
+ ],
+ [
+ 'id' => 2,
+ $colorField => 'Red',
+ 'entity_id' => $this->contactID,
+ ],
+ ];
+ // match the data
+ foreach ($expectedResult as $key => $field) {
+ foreach ($field as $attr => $value) {
+ $this->assertEquals($expectedResult[$key][$attr], $result[$key][$attr]);
+ }
+ }
+
+ // CASE 2: Test CustomValue::update
+ // Update a records whose id is 1 and change the custom field (name = Color) value to 'White' from 'Green'
+ CustomValue::update($group)
+ ->addWhere("id", "=", 1)
+ ->addValue($colorField, 'White')
+ ->execute();
+
+ // ensure that the value is changed for id = 1
+ $color = CustomValue::get($group)
+ ->addWhere("id", "=", 1)
+ ->execute()
+ ->first()[$colorField];
+ $this->assertEquals('White', $color);
+
+ // CASE 3: Test CustomValue::replace
+ // create a second contact which will be used to replace the custom values, created earlier
+ $secondContactID = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Adam')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first()['id'];
+ // Replace all the records which was created earlier with entity_id = first contact
+ // with custom record [$colorField => 'Rainbow', 'entity_id' => $secondContactID]
+ CustomValue::replace($group)
+ ->setRecords([[$colorField => 'Rainbow', 'entity_id' => $secondContactID]])
+ ->addWhere('entity_id', '=', $this->contactID)
+ ->execute();
+
+ // Check the two records created earlier is replaced by new contact
+ $result = CustomValue::get($group)->execute();
+ $this->assertEquals(1, count($result));
+
+ $expectedResult = [
+ [
+ 'id' => 3,
+ $colorField => 'Rainbow',
+ 'entity_id' => $secondContactID,
+ ],
+ ];
+ foreach ($expectedResult as $key => $field) {
+ foreach ($field as $attr => $value) {
+ $this->assertEquals($expectedResult[$key][$attr], $result[$key][$attr]);
+ }
+ }
+
+ // CASE 4: Test CustomValue::delete
+ // There is only record left whose id = 3, delete that record on basis of criteria id = 3
+ CustomValue::delete($group)->addWhere("id", "=", 3)->execute();
+ $result = CustomValue::get($group)->execute();
+ // check that there are no custom values present
+ $this->assertEquals(0, count($result));
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\Relationship;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class DateTest extends UnitTestCase {
+
+ public function testRelationshipDate() {
+ $c1 = Contact::create()
+ ->addValue('first_name', 'c')
+ ->addValue('last_name', 'one')
+ ->execute()
+ ->first()['id'];
+ $c2 = Contact::create()
+ ->addValue('first_name', 'c')
+ ->addValue('last_name', 'two')
+ ->execute()
+ ->first()['id'];
+ $r = Relationship::create()
+ ->addValue('contact_id_a', $c1)
+ ->addValue('contact_id_b', $c2)
+ ->addValue('relationship_type_id', 1)
+ ->addValue('start_date', 'now')
+ ->addValue('end_date', 'now + 1 week')
+ ->execute()
+ ->first()['id'];
+ $result = Relationship::get()
+ ->addWhere('start_date', '=', 'now')
+ ->addWhere('end_date', '>', 'now + 1 day')
+ ->execute()
+ ->indexBy('id');
+ $this->assertArrayHasKey($r, $result);
+ $result = Relationship::get()
+ ->addWhere('start_date', '<', 'now')
+ ->execute()
+ ->indexBy('id');
+ $this->assertArrayNotHasKey($r, $result);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\MockBasicEntity;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class EvaluateConditionTest extends UnitTestCase {
+
+ public function testEvaluateCondition() {
+ $action = MockBasicEntity::get();
+ $reflection = new \ReflectionClass($action);
+ $method = $reflection->getMethod('evaluateCondition');
+ $method->setAccessible(TRUE);
+
+ $data = [
+ 'nada' => 0,
+ 'uno' => 1,
+ 'dos' => 2,
+ 'apple' => 'red',
+ 'banana' => 'yellow',
+ 'values' => ['one' => 1, 'two' => 2, 'three' => 3],
+ ];
+
+ $this->assertFalse($method->invoke($action, '$uno > $dos', $data));
+ $this->assertTrue($method->invoke($action, '$uno < $dos', $data));
+ $this->assertTrue($method->invoke($action, '$apple == "red" && $banana != "red"', $data));
+ $this->assertFalse($method->invoke($action, '$apple == "red" && $banana != "yellow"', $data));
+ $this->assertTrue($method->invoke($action, '$values.one == $uno', $data));
+ $this->assertTrue($method->invoke($action, '$values.one + $dos == $values.three', $data));
+ $this->assertTrue($method->invoke($action, 'empty($nada)', $data));
+ $this->assertFalse($method->invoke($action, 'empty($values)', $data));
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+
+/**
+ * @group headless
+ */
+class ExtendFromIndividualTest extends BaseCustomValueTest {
+
+ public function testGetWithNonStandardExtends() {
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ // not Contact
+ ->addValue('extends', 'Individual')
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $contactId = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Johann')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('MyContactFields.FavColor', 'Red')
+ ->execute()
+ ->first()['id'];
+
+ $contact = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('display_name')
+ ->addSelect('MyContactFields.FavColor')
+ ->addWhere('id', '=', $contactId)
+ ->execute()
+ ->first();
+
+ $this->assertEquals('Red', $contact['MyContactFields.FavColor']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use api\v4\UnitTestCase;
+use Civi\Api4\Activity;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class FkJoinTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_activity',
+ 'civicrm_phone',
+ 'civicrm_activity_contact',
+ ];
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('DefaultDataSet');
+
+ return parent::setUpHeadless();
+ }
+
+ /**
+ * Fetch all phone call activities. Expects a single activity
+ * loaded from the data set.
+ */
+ public function testThreeLevelJoin() {
+ $results = Activity::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('activity_type.name', '=', 'Phone Call')
+ ->execute();
+
+ $this->assertCount(1, $results);
+ }
+
+ public function testActivityContactJoin() {
+ $results = Activity::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('assignees.id')
+ ->addSelect('assignees.first_name')
+ ->addSelect('assignees.display_name')
+ ->addWhere('assignees.first_name', '=', 'Phoney')
+ ->execute();
+
+ $firstResult = $results->first();
+
+ $this->assertCount(1, $results);
+ $this->assertTrue(is_array($firstResult['assignees']));
+
+ $firstAssignee = array_shift($firstResult['assignees']);
+ $this->assertEquals($firstAssignee['first_name'], 'Phoney');
+ }
+
+ public function testContactPhonesJoin() {
+ $testContact = $this->getReference('test_contact_1');
+ $testPhone = $this->getReference('test_phone_1');
+
+ $results = Contact::get()
+ ->setCheckPermissions(FALSE)
+ ->addSelect('phones.phone')
+ ->addWhere('id', '=', $testContact['id'])
+ ->addWhere('phones.location_type.name', '=', 'Home')
+ ->execute()
+ ->first();
+
+ $this->assertArrayHasKey('phones', $results);
+ $this->assertCount(1, $results['phones']);
+ $firstPhone = array_shift($results['phones']);
+ $this->assertEquals($testPhone['phone'], $firstPhone['phone']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use api\v4\UnitTestCase;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class GetExtraFieldsTest extends UnitTestCase {
+
+ public function testBAOFieldsWillBeReturned() {
+ $returnedFields = Contact::getFields()
+ ->execute()
+ ->getArrayCopy();
+
+ $baseFields = \CRM_Contact_BAO_Contact::fields();
+ $baseFieldNames = array_column($baseFields, 'name');
+ $returnedFieldNames = array_column($returnedFields, 'name');
+ $notReturned = array_diff($baseFieldNames, $returnedFieldNames);
+
+ $this->assertEmpty($notReturned);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use api\v4\UnitTestCase;
+use Civi\Api4\MockArrayEntity;
+
+/**
+ * @group headless
+ */
+class GetFromArrayTest extends UnitTestCase {
+
+ public function testArrayGetWithLimit() {
+ $result = MockArrayEntity::get()
+ ->setOffset(2)
+ ->setLimit(2)
+ ->execute();
+ $this->assertEquals(3, $result[0]['field1']);
+ $this->assertEquals(4, $result[1]['field1']);
+ $this->assertEquals(2, count($result));
+ }
+
+ public function testArrayGetWithSort() {
+ $result = MockArrayEntity::get()
+ ->addOrderBy('field1', 'DESC')
+ ->execute();
+ $this->assertEquals([5, 4, 3, 2, 1], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addOrderBy('field5', 'DESC')
+ ->addOrderBy('field2', 'ASC')
+ ->execute();
+ $this->assertEquals([3, 2, 5, 4, 1], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addOrderBy('field3', 'ASC')
+ ->addOrderBy('field2', 'ASC')
+ ->execute();
+ $this->assertEquals([3, 1, 2, 5, 4], array_column((array) $result, 'field1'));
+ }
+
+ public function testArrayGetWithSelect() {
+ $result = MockArrayEntity::get()
+ ->addSelect('field1')
+ ->addSelect('field3')
+ ->setLimit(4)
+ ->execute();
+ $this->assertEquals([
+ [
+ 'field1' => 1,
+ 'field3' => NULL,
+ ],
+ [
+ 'field1' => 2,
+ 'field3' => 0,
+ ],
+ [
+ 'field1' => 3,
+ ],
+ [
+ 'field1' => 4,
+ 'field3' => 1,
+ ],
+ ], (array) $result);
+ }
+
+ public function testArrayGetWithWhere() {
+ $result = MockArrayEntity::get()
+ ->addWhere('field2', '=', 'yack')
+ ->execute();
+ $this->assertEquals([2], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field5', '!=', 'banana')
+ ->addWhere('field3', 'IS NOT NULL')
+ ->execute();
+ $this->assertEquals([4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field1', '>=', '4')
+ ->execute();
+ $this->assertEquals([4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field1', '<', '2')
+ ->execute();
+ $this->assertEquals([1], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field2', 'LIKE', '%ra%')
+ ->execute();
+ $this->assertEquals([1, 3], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field3', 'IS NULL')
+ ->execute();
+ $this->assertEquals([1, 3], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field3', '=', '0')
+ ->execute();
+ $this->assertEquals([2], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field2', 'LIKE', '%ra')
+ ->execute();
+ $this->assertEquals([1], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field2', 'LIKE', 'ra')
+ ->execute();
+ $this->assertEquals(0, count($result));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field2', 'NOT LIKE', '%ra%')
+ ->execute();
+ $this->assertEquals([2, 4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field6', '=', '0')
+ ->execute();
+ $this->assertEquals([3, 4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field6', '=', 0)
+ ->execute();
+ $this->assertEquals([3, 4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field1', 'BETWEEN', [3, 5])
+ ->execute();
+ $this->assertEquals([3, 4, 5], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addWhere('field1', 'NOT BETWEEN', [3, 4])
+ ->execute();
+ $this->assertEquals([1, 2, 5], array_column((array) $result, 'field1'));
+ }
+
+ public function testArrayGetWithNestedWhereClauses() {
+ $result = MockArrayEntity::get()
+ ->addClause('OR', ['field2', 'LIKE', '%ra'], ['field2', 'LIKE', 'x ray'])
+ ->execute();
+ $this->assertEquals([1, 3], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addClause('OR', ['field2', '=', 'zebra'], ['field2', '=', 'yack'])
+ ->addClause('OR', ['field5', '!=', 'apple'], ['field3', 'IS NULL'])
+ ->execute();
+ $this->assertEquals([1, 2], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addClause('NOT', ['field2', '!=', 'yack'])
+ ->execute();
+ $this->assertEquals([2], array_column((array) $result, 'field1'));
+
+ $result = MockArrayEntity::get()
+ ->addClause('OR', ['field1', '=', 2], ['AND', [['field5', '=', 'apple'], ['field3', '=', 1]]])
+ ->execute();
+ $this->assertEquals([2, 4, 5], array_column((array) $result, 'field1'));
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class IndexTest extends UnitTestCase {
+
+ public function testIndex() {
+ // Results indexed by name
+ $resultByName = civicrm_api4('Activity', 'getActions', [], 'name');
+ $this->assertInstanceOf('Civi\Api4\Generic\Result', $resultByName);
+ $this->assertEquals('get', $resultByName['get']['name']);
+
+ // Get result at index 0
+ $firstResult = civicrm_api4('Activity', 'getActions', [], 0);
+ $this->assertInstanceOf('Civi\Api4\Generic\Result', $firstResult);
+ $this->assertArrayHasKey('name', $firstResult);
+
+ $this->assertEquals($resultByName->first(), (array) $firstResult);
+ }
+
+ public function testBadIndexInt() {
+ $error = '';
+ try {
+ civicrm_api4('Activity', 'getActions', [], 99);
+ }
+ catch (\API_Exception $e) {
+ $error = $e->getMessage();
+ }
+ $this->assertContains('not found', $error);
+ }
+
+ public function testBadIndexString() {
+ $error = '';
+ try {
+ civicrm_api4('Activity', 'getActions', [], 'xyz');
+ }
+ catch (\API_Exception $e) {
+ $error = $e->getMessage();
+ }
+ $this->assertContains('not found', $error);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Contact;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class NullValueTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $format = '{contact.first_name}{ }{contact.last_name}';
+ \Civi::settings()->set('display_name_format', $format);
+ return parent::setUpHeadless();
+ }
+
+ public function testStringNull() {
+ $contact = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Joseph')
+ ->addValue('last_name', 'null')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first();
+
+ $this->assertSame('Null', $contact['last_name']);
+ $this->assertSame('Joseph Null', $contact['display_name']);
+ }
+
+ public function testSettingToNull() {
+ $contact = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'ILoveMy')
+ ->addValue('last_name', 'LastName')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first();
+
+ $this->assertSame('ILoveMy LastName', $contact['display_name']);
+ $contactId = $contact['id'];
+
+ $contact = Contact::update()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contactId)
+ ->addValue('last_name', NULL)
+ ->execute()
+ ->first();
+
+ $this->assertSame(NULL, $contact['last_name']);
+ $this->assertSame('ILoveMy', $contact['display_name']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Api4\CustomValue;
+use Civi\Api4\Email;
+use api\v4\Traits\TableDropperTrait;
+use api\v4\UnitTestCase;
+use Civi\Api4\Contact;
+
+/**
+ * @group headless
+ */
+class ReplaceTest extends UnitTestCase {
+ use TableDropperTrait;
+
+ /**
+ * Set up baseline for testing
+ */
+ public function setUp() {
+ $tablesToTruncate = [
+ 'civicrm_custom_group',
+ 'civicrm_custom_field',
+ 'civicrm_email',
+ ];
+ $this->dropByPrefix('civicrm_value_replacetest');
+ $this->cleanup(['tablesToTruncate' => $tablesToTruncate]);
+ parent::setUp();
+ }
+
+ public function testEmailReplace() {
+ $cid1 = Contact::create()
+ ->addValue('first_name', 'Lotsa')
+ ->addValue('last_name', 'Emails')
+ ->execute()
+ ->first()['id'];
+ $cid2 = Contact::create()
+ ->addValue('first_name', 'Notso')
+ ->addValue('last_name', 'Many')
+ ->execute()
+ ->first()['id'];
+ $e0 = Email::create()
+ ->setValues(['contact_id' => $cid2, 'email' => 'nosomany@example.com', 'location_type_id' => 1])
+ ->execute()
+ ->first()['id'];
+ $e1 = Email::create()
+ ->setValues(['contact_id' => $cid1, 'email' => 'first@example.com', 'location_type_id' => 1])
+ ->execute()
+ ->first()['id'];
+ $e2 = Email::create()
+ ->setValues(['contact_id' => $cid1, 'email' => 'second@example.com', 'location_type_id' => 1])
+ ->execute()
+ ->first()['id'];
+ $replacement = [
+ ['email' => 'firstedited@example.com', 'id' => $e1],
+ ['contact_id' => $cid1, 'email' => 'third@example.com', 'location_type_id' => 1],
+ ];
+ $replaced = Email::replace()
+ ->setRecords($replacement)
+ ->addWhere('contact_id', '=', $cid1)
+ ->execute();
+ // Should have saved 2 records
+ $this->assertEquals(2, $replaced->count());
+ // Should have deleted email2
+ $this->assertEquals([['id' => $e2]], $replaced->deleted);
+ // Verify contact now has the new email records
+ $results = Email::get()
+ ->addWhere('contact_id', '=', $cid1)
+ ->execute()
+ ->indexBy('id');
+ $this->assertEquals('firstedited@example.com', $results[$e1]['email']);
+ $this->assertEquals(2, $results->count());
+ $this->assertArrayNotHasKey($e2, (array) $results);
+ $this->assertArrayNotHasKey($e0, (array) $results);
+ unset($results[$e1]);
+ foreach ($results as $result) {
+ $this->assertEquals('third@example.com', $result['email']);
+ }
+ // Validate our other contact's email did not get deleted
+ $c2email = Email::get()
+ ->addWhere('contact_id', '=', $cid2)
+ ->execute()
+ ->first();
+ $this->assertEquals('nosomany@example.com', $c2email['email']);
+ }
+
+ public function testCustomValueReplace() {
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'replaceTest')
+ ->addValue('extends', 'Contact')
+ ->addValue('is_multiple', TRUE)
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->addValue('label', 'Custom1')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'String')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'Custom2')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'String')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $cid1 = Contact::create()
+ ->addValue('first_name', 'Lotsa')
+ ->addValue('last_name', 'Data')
+ ->execute()
+ ->first()['id'];
+ $cid2 = Contact::create()
+ ->addValue('first_name', 'Notso')
+ ->addValue('last_name', 'Much')
+ ->execute()
+ ->first()['id'];
+
+ // Contact 2 gets one row
+ CustomValue::create('replaceTest')
+ ->setCheckPermissions(FALSE)
+ ->addValue('Custom1', "2 1")
+ ->addValue('Custom2', "2 1")
+ ->addValue('entity_id', $cid2)
+ ->execute();
+
+ // Create 3 rows for contact 1
+ foreach ([1, 2, 3] as $i) {
+ CustomValue::create('replaceTest')
+ ->setCheckPermissions(FALSE)
+ ->addValue('Custom1', "1 $i")
+ ->addValue('Custom2', "1 $i")
+ ->addValue('entity_id', $cid1)
+ ->execute();
+ }
+
+ $cid1Records = CustomValue::get('replaceTest')
+ ->setCheckPermissions(FALSE)
+ ->addWhere('entity_id', '=', $cid1)
+ ->execute();
+
+ $this->assertCount(3, $cid1Records);
+ $this->assertCount(1, CustomValue::get('replaceTest')->setCheckPermissions(FALSE)->addWhere('entity_id', '=', $cid2)->execute());
+
+ $result = CustomValue::replace('replaceTest')
+ ->addWhere('entity_id', '=', $cid1)
+ ->addRecord(['Custom1' => 'new one', 'Custom2' => 'new two'])
+ ->addRecord(['id' => $cid1Records[0]['id'], 'Custom1' => 'changed one', 'Custom2' => 'changed two'])
+ ->execute();
+
+ $this->assertCount(2, $result);
+ $this->assertCount(2, $result->deleted);
+
+ $newRecords = CustomValue::get('replaceTest')
+ ->setCheckPermissions(FALSE)
+ ->addWhere('entity_id', '=', $cid1)
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertEquals('new one', $newRecords->last()['Custom1']);
+ $this->assertEquals('new two', $newRecords->last()['Custom2']);
+ $this->assertEquals('changed one', $newRecords[$cid1Records[0]['id']]['Custom1']);
+ $this->assertEquals('changed two', $newRecords[$cid1Records[0]['id']]['Custom2']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Event;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class RequiredFieldTest extends UnitTestCase {
+
+ public function testRequired() {
+ $msg = '';
+ try {
+ Event::create()->execute();
+ }
+ catch (\API_Exception $e) {
+ $msg = $e->getMessage();
+ }
+ $this->assertEquals('Mandatory values missing from Api4 Event::create: title, event_type_id, start_date', $msg);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Contact;
+use api\v4\UnitTestCase;
+
+/**
+ * Class UpdateContactTest
+ * @package api\v4\Action
+ * @group headless
+ */
+class UpdateContactTest extends UnitTestCase {
+
+ public function testUpdateWithIdInWhere() {
+ $contactId = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Johann')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first()['id'];
+
+ $contact = Contact::update()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contactId)
+ ->addValue('first_name', 'Testy')
+ ->execute()
+ ->first();
+ $this->assertEquals('Testy', $contact['first_name']);
+ $this->assertEquals('Tester', $contact['last_name']);
+ }
+
+ public function testUpdateWithIdInValues() {
+ $contactId = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Bobby')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->execute()
+ ->first()['id'];
+
+ $contact = Contact::update()
+ ->setCheckPermissions(FALSE)
+ ->addValue('id', $contactId)
+ ->addValue('first_name', 'Billy')
+ ->execute();
+ $this->assertCount(1, $contact);
+ $this->assertEquals($contactId, $contact[0]['id']);
+ $this->assertEquals('Billy', $contact[0]['first_name']);
+ $this->assertEquals('Tester', $contact[0]['last_name']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Action;
+
+use Civi\Api4\Contact;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use CRM_Core_BAO_CustomValueTable as CustomValueTable;
+
+/**
+ * @group headless
+ */
+class UpdateCustomValueTest extends BaseCustomValueTest {
+
+ public function testGetWithCustomData() {
+
+ $customGroup = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'MyContactFields')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first();
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroup['id'])
+ ->addValue('html_type', 'Text')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $contactId = Contact::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('first_name', 'Red')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('MyContactFields.FavColor', 'Red')
+ ->execute()
+ ->first()['id'];
+
+ Contact::update()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $contactId)
+ ->addValue('first_name', 'Red')
+ ->addValue('last_name', 'Tester')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('MyContactFields.FavColor', 'Blue')
+ ->execute();
+
+ $result = CustomValueTable::getEntityValues($contactId, 'Contact');
+
+ $this->assertEquals(1, count($result));
+ $this->assertContains('Blue', $result);
+ }
+
+}
--- /dev/null
+<?php
+// vim: set si ai expandtab tabstop=4 shiftwidth=4 softtabstop=4:
+
+/**
+ * File for the api_v4_AllTests class
+ *
+ * (PHP 5)
+ *
+ * @author Walt Haas <walt@dharmatech.org> (801) 534-1262
+ * @copyright Copyright CiviCRM LLC (C) 2009
+ * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html
+ * GNU Affero General Public License version 3
+ * @version $Id: AllTests.php 40328 2012-05-11 23:06:13Z allen $
+ * @package CiviCRM
+ *
+ * This file is part of CiviCRM
+ *
+ * CiviCRM is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation; either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * CiviCRM is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ */
+
+/**
+ * Class containing the APIv4 test suite
+ *
+ * @package CiviCRM
+ */
+class api_v4_AllTests extends CiviTestSuite {
+ private static $instance = NULL;
+
+ /**
+ */
+ private static function getInstance() {
+ if (is_null(self::$instance)) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Build test suite dynamically.
+ */
+ public static function suite() {
+ $inst = self::getInstance();
+ return $inst->implSuite(__FILE__);
+ }
+
+}
+// class AllTests
+
+// -- set Emacs parameters --
+// Local variables:
+// mode: php;
+// tab-width: 4
+// c-basic-offset: 4
+// c-hanging-comment-ender-p: nil
+// indent-tabs-mode: nil
+// End:
--- /dev/null
+{
+ "Contact": [
+ {
+ "first_name": "Janice",
+ "last_name": "Voss",
+ "contact_type": "Individual",
+ "@ref": "test_contact_1"
+ }
+ ],
+ "CustomGroup": [
+ {
+ "name": "MyFavoriteThings",
+ "extends": "Contact"
+ }
+ ],
+ "Event": [
+ {
+ "start_date": "20401010000000",
+ "title": "The Singularity",
+ "event_type_id": "major_historical_event"
+ }
+ ],
+ "Group": [
+ {
+ "name": "the_group",
+ "title": "The Group"
+ }
+ ],
+ "Mapping": [
+ {
+ "name": "the_mapping",
+ "mapping_type_id": "1"
+ }
+ ],
+ "Activity": [
+ {
+ "subject": "Test A Phone Activity",
+ "activity_type": "Phone Call",
+ "source_contact_id": "@ref test_contact_1.id"
+ }
+ ]
+}
--- /dev/null
+{
+ "Contact": [
+ {
+ "first_name": "Phoney",
+ "last_name": "Contact",
+ "contact_type": "Individual",
+ "@ref": "test_contact_1"
+ },
+ {
+ "first_name": "Second",
+ "last_name": "Test",
+ "contact_type": "Individual",
+ "@ref": "test_contact_2"
+ }
+ ],
+ "Activity": [
+ {
+ "subject": "Test Phone Activity",
+ "activity_type": "Phone Call",
+ "source_contact_id": "@ref test_contact_1.id"
+ },
+ {
+ "subject": "Another Activity",
+ "activity_type": "Meeting",
+ "source_contact_id": "@ref test_contact_1.id",
+ "assignee_contact_id": [
+ "@ref test_contact_1.id",
+ "@ref test_contact_2.id"
+ ]
+ }
+ ],
+ "Phone": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "phone": "+35355439483",
+ "location_type_id": "1",
+ "@ref": "test_phone_1"
+ },
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "phone": "+3538733439483",
+ "location_type_id": "2"
+ }
+ ]
+}
--- /dev/null
+{
+ "Contact": [
+ {
+ "first_name": "First",
+ "last_name": "Contact",
+ "contact_type": "Individual",
+ "@ref": "test_contact_1"
+ },
+ {
+ "first_name": "Second",
+ "last_name": "Contact",
+ "contact_type": "Individual",
+ "@ref": "test_contact_2"
+ }
+ ],
+ "Email": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "email": "test_contact_one_home@fakedomain.com",
+ "location_type_id": 1,
+ "@ref": "test_email_1"
+ },
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "email": "test_contact_one_work@fakedomain.com",
+ "location_type_id": 2,
+ "@ref": "test_email_2"
+ },
+ {
+ "contact_id": "@ref test_contact_2.id",
+ "email": "test_contact_two_home@fakedomain.com",
+ "location_type_id": 1,
+ "@ref": "test_email_3"
+ },
+ {
+ "contact_id": "@ref test_contact_2.id",
+ "email": "test_contact_two_work@fakedomain.com",
+ "location_type_id": 2,
+ "@ref": "test_email_4"
+ }
+ ]
+}
--- /dev/null
+{
+ "Contact": [
+ {
+ "first_name": "Single",
+ "last_name": "Contact",
+ "contact_type": "Individual",
+ "preferred_communication_method": "1",
+ "@ref": "test_contact_1"
+ }
+ ],
+ "Activity": [
+ {
+ "subject": "Won A Nobel Prize",
+ "activity_type": "Meeting",
+ "source_contact_id": "@ref test_contact_1.id",
+ "@ref": "test_activity_1"
+ },
+ {
+ "subject": "Cleaned The House",
+ "activity_type": "Meeting",
+ "source_contact_id": "@ref test_contact_1.id",
+ "assignee_contact_id": [
+ "@ref test_contact_1.id"
+ ],
+ "@ref": "test_activity_2"
+ }
+ ],
+ "Phone": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "phone": "+1111111111111",
+ "location_type_id": 1
+ },
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "phone": "+2222222222222",
+ "location_type_id": 2
+ }
+ ],
+ "Email": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "email": "test_contact_home@fakedomain.com",
+ "location_type_id": 1
+ },
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "email": "test_contact_work@fakedomain.com",
+ "location_type_id": 2
+ }
+ ],
+ "Address": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "street_address": "123 Sesame St.",
+ "location_type_id": 1
+ }
+ ],
+ "Website": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "url": "http://test.com",
+ "website_id": 1
+ }
+ ],
+ "OpenID": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "openid": "123",
+ "allowed_to_login": 1,
+ "location_type_id": 1
+ }
+ ],
+ "IM": [
+ {
+ "contact_id": "@ref test_contact_1.id",
+ "name": "123",
+ "location_type_id": 1
+ }
+ ]
+}
--- /dev/null
+<?php
+
+namespace api\v4\Entity;
+
+use Civi\Api4\Entity;
+use api\v4\Traits\TableDropperTrait;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ConformanceTest extends UnitTestCase {
+
+ use TableDropperTrait;
+ use \api\v4\Traits\OptionCleanupTrait {
+ setUp as setUpOptionCleanup;
+ }
+
+ /**
+ * @var \api\v4\Service\TestCreationParameterProvider
+ */
+ protected $creationParamProvider;
+
+ /**
+ * Set up baseline for testing
+ */
+ public function setUp() {
+ $tablesToTruncate = [
+ 'civicrm_custom_group',
+ 'civicrm_custom_field',
+ 'civicrm_group',
+ 'civicrm_event',
+ 'civicrm_participant',
+ ];
+ $this->dropByPrefix('civicrm_value_myfavorite');
+ $this->cleanup(['tablesToTruncate' => $tablesToTruncate]);
+ $this->setUpOptionCleanup();
+ $this->loadDataSet('ConformanceTest');
+ $this->creationParamProvider = \Civi::container()->get('test.param_provider');
+ parent::setUp();
+ // calculateTaxAmount() for contribution triggers a deprecation notice
+ \PHPUnit_Framework_Error_Deprecated::$enabled = FALSE;
+ }
+
+ public function getEntities() {
+ return Entity::get()->setCheckPermissions(FALSE)->execute()->column('name');
+ }
+
+ /**
+ * Fixme: This should use getEntities as a dataProvider but that fails for some reason
+ */
+ public function testConformance() {
+ $entities = $this->getEntities();
+ $this->assertNotEmpty($entities);
+
+ foreach ($entities as $data) {
+ $entity = $data;
+ $entityClass = 'Civi\Api4\\' . $entity;
+
+ $actions = $this->checkActions($entityClass);
+
+ // Go no further if it's not a CRUD entity
+ if (array_diff(['get', 'create', 'update', 'delete'], array_keys($actions))) {
+ continue;
+ }
+
+ $this->checkFields($entityClass, $entity);
+ $id = $this->checkCreation($entity, $entityClass);
+ $this->checkGet($entityClass, $id, $entity);
+ $this->checkGetCount($entityClass, $id, $entity);
+ $this->checkUpdateFailsFromCreate($entityClass, $id);
+ $this->checkWrongParamType($entityClass);
+ $this->checkDeleteWithNoId($entityClass);
+ $this->checkDeletion($entityClass, $id);
+ $this->checkPostDelete($entityClass, $id, $entity);
+ }
+ }
+
+ /**
+ * @param string $entityClass
+ * @param $entity
+ */
+ protected function checkFields($entityClass, $entity) {
+ $fields = $entityClass::getFields()
+ ->setCheckPermissions(FALSE)
+ ->setIncludeCustom(FALSE)
+ ->execute()
+ ->indexBy('name');
+
+ $errMsg = sprintf('%s is missing required ID field', $entity);
+ $subset = ['data_type' => 'Integer'];
+
+ $this->assertArraySubset($subset, $fields['id'], $errMsg);
+ }
+
+ /**
+ * @param string $entityClass
+ */
+ protected function checkActions($entityClass) {
+ $actions = $entityClass::getActions()
+ ->setCheckPermissions(FALSE)
+ ->execute()
+ ->indexBy('name');
+
+ $this->assertNotEmpty($actions);
+ return (array) $actions;
+ }
+
+ /**
+ * @param string $entity
+ * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
+ *
+ * @return mixed
+ */
+ protected function checkCreation($entity, $entityClass) {
+ $requiredParams = $this->creationParamProvider->getRequired($entity);
+ $createResult = $entityClass::create()
+ ->setValues($requiredParams)
+ ->setCheckPermissions(FALSE)
+ ->execute()
+ ->first();
+
+ $this->assertArrayHasKey('id', $createResult, "create missing ID");
+ $id = $createResult['id'];
+
+ $this->assertGreaterThanOrEqual(1, $id, "$entity ID not positive");
+
+ return $id;
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
+ * @param int $id
+ */
+ protected function checkUpdateFailsFromCreate($entityClass, $id) {
+ $exceptionThrown = '';
+ try {
+ $entityClass::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('id', $id)
+ ->execute();
+ }
+ catch (\API_Exception $e) {
+ $exceptionThrown = $e->getMessage();
+ }
+ $this->assertContains('id', $exceptionThrown);
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
+ * @param int $id
+ * @param string $entity
+ */
+ protected function checkGet($entityClass, $id, $entity) {
+ $getResult = $entityClass::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $id)
+ ->execute();
+
+ $errMsg = sprintf('Failed to fetch a %s after creation', $entity);
+ $this->assertEquals($id, $getResult->first()['id'], $errMsg);
+ $this->assertEquals(1, $getResult->count(), $errMsg);
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
+ * @param int $id
+ * @param string $entity
+ */
+ protected function checkGetCount($entityClass, $id, $entity) {
+ $getResult = $entityClass::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $id)
+ ->selectRowCount()
+ ->execute();
+ $errMsg = sprintf('%s getCount failed', $entity);
+ $this->assertEquals(1, $getResult->count(), $errMsg);
+
+ $getResult = $entityClass::get()
+ ->setCheckPermissions(FALSE)
+ ->selectRowCount()
+ ->execute();
+ $errMsg = sprintf('%s getCount failed', $entity);
+ $this->assertGreaterThanOrEqual(1, $getResult->count(), $errMsg);
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
+ */
+ protected function checkDeleteWithNoId($entityClass) {
+ $exceptionThrown = '';
+ try {
+ $entityClass::delete()
+ ->execute();
+ }
+ catch (\API_Exception $e) {
+ $exceptionThrown = $e->getMessage();
+ }
+ $this->assertContains('required', $exceptionThrown);
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
+ */
+ protected function checkWrongParamType($entityClass) {
+ $exceptionThrown = '';
+ try {
+ $entityClass::get()
+ ->setCheckPermissions('nada')
+ ->execute();
+ }
+ catch (\API_Exception $e) {
+ $exceptionThrown = $e->getMessage();
+ }
+ $this->assertContains('checkPermissions', $exceptionThrown);
+ $this->assertContains('type', $exceptionThrown);
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
+ * @param int $id
+ */
+ protected function checkDeletion($entityClass, $id) {
+ $deleteResult = $entityClass::delete()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $id)
+ ->execute();
+
+ // should get back an array of deleted id
+ $this->assertEquals([['id' => $id]], (array) $deleteResult);
+ }
+
+ /**
+ * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
+ * @param int $id
+ * @param string $entity
+ */
+ protected function checkPostDelete($entityClass, $id, $entity) {
+ $getDeletedResult = $entityClass::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('id', '=', $id)
+ ->execute();
+
+ $errMsg = sprintf('Entity "%s" was not deleted', $entity);
+ $this->assertEquals(0, count($getDeletedResult), $errMsg);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Entity;
+
+use Civi\Api4\Contact;
+use Civi\Api4\OptionValue;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ContactJoinTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_address',
+ 'civicrm_email',
+ 'civicrm_phone',
+ 'civicrm_openid',
+ 'civicrm_im',
+ 'civicrm_website',
+ 'civicrm_activity',
+ 'civicrm_activity_contact',
+ ];
+
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('SingleContact');
+
+ return parent::setUpHeadless();
+ }
+
+ public function testContactJoin() {
+
+ $contact = $this->getReference('test_contact_1');
+ $entitiesToTest = ['Address', 'OpenID', 'IM', 'Website', 'Email', 'Phone'];
+
+ foreach ($entitiesToTest as $entity) {
+ $results = civicrm_api4($entity, 'get', [
+ 'where' => [['contact_id', '=', $contact['id']]],
+ 'select' => ['contact.display_name', 'contact.id'],
+ ]);
+ foreach ($results as $result) {
+ $this->assertEquals($contact['id'], $result['contact.id']);
+ $this->assertEquals($contact['display_name'], $result['contact.display_name']);
+ }
+ }
+ }
+
+ public function testJoinToPCMWillReturnArray() {
+ $contact = Contact::create()->setValues([
+ 'preferred_communication_method' => [1, 2, 3],
+ 'contact_type' => 'Individual',
+ 'first_name' => 'Test',
+ 'last_name' => 'PCM',
+ ])->execute()->first();
+
+ $fetchedContact = Contact::get()
+ ->addWhere('id', '=', $contact['id'])
+ ->addSelect('preferred_communication_method')
+ ->execute()
+ ->first();
+
+ $this->assertCount(3, $fetchedContact["preferred_communication_method"]);
+ }
+
+ public function testJoinToPCMOptionValueWillShowLabel() {
+ $options = OptionValue::get()
+ ->addWhere('option_group.name', '=', 'preferred_communication_method')
+ ->execute()
+ ->getArrayCopy();
+
+ $optionValues = array_column($options, 'value');
+ $labels = array_column($options, 'label');
+
+ $contact = Contact::create()->setValues([
+ 'preferred_communication_method' => $optionValues,
+ 'contact_type' => 'Individual',
+ 'first_name' => 'Test',
+ 'last_name' => 'PCM',
+ ])->execute()->first();
+
+ $contact2 = Contact::create()->setValues([
+ 'preferred_communication_method' => $optionValues,
+ 'contact_type' => 'Individual',
+ 'first_name' => 'Test',
+ 'last_name' => 'PCM2',
+ ])->execute()->first();
+
+ $contactIds = array_column([$contact, $contact2], 'id');
+
+ $fetchedContact = Contact::get()
+ ->addWhere('id', 'IN', $contactIds)
+ ->addSelect('preferred_communication_method.label')
+ ->execute()
+ ->first();
+
+ $preferredMethod = $fetchedContact['preferred_communication_method'];
+ $returnedLabels = array_column($preferredMethod, 'label');
+
+ $this->assertEquals($labels, $returnedLabels);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Entity;
+
+use Civi\Api4\Entity;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class EntityTest extends UnitTestCase {
+
+ public function testEntityGet() {
+ $result = Entity::get()
+ ->setCheckPermissions(FALSE)
+ ->execute()
+ ->indexBy('name');
+ $this->assertArrayHasKey('Entity', $result,
+ "Entity::get missing itself");
+ $this->assertArrayHasKey('Participant', $result,
+ "Entity::get missing Participant");
+ }
+
+ public function testEntity() {
+ $result = Entity::getActions()
+ ->setCheckPermissions(FALSE)
+ ->execute()
+ ->indexBy('name');
+ $this->assertNotContains(
+ 'create',
+ array_keys((array) $result),
+ "Entity entity has more than basic actions");
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Entity;
+
+use Civi\Api4\Participant;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ParticipantTest extends UnitTestCase {
+
+ public function setUp() {
+ parent::setUp();
+ $cleanup_params = [
+ 'tablesToTruncate' => [
+ 'civicrm_event',
+ 'civicrm_participant',
+ ],
+ ];
+ $this->cleanup($cleanup_params);
+ }
+
+ public function testGetActions() {
+ $result = Participant::getActions()
+ ->setCheckPermissions(FALSE)
+ ->execute()
+ ->indexBy('name');
+
+ $getParams = $result['get']['params'];
+ $whereDescription = 'Criteria for selecting items.';
+
+ $this->assertEquals(TRUE, $getParams['checkPermissions']['default']);
+ $this->assertEquals($whereDescription, $getParams['where']['description']);
+ }
+
+ public function testGet() {
+ $rows = $this->getRowCount('civicrm_participant');
+ if ($rows > 0) {
+ $this->markTestSkipped('Participant table must be empty');
+ }
+
+ // With no records:
+ $result = Participant::get()->setCheckPermissions(FALSE)->execute();
+ $this->assertEquals(0, $result->count(), "count of empty get is not 0");
+
+ // Check that the $result knows what the inputs were
+ $this->assertEquals('Participant', $result->entity);
+ $this->assertEquals('get', $result->action);
+ $this->assertEquals(4, $result->version);
+
+ // Create some test related records before proceeding
+ $participantCount = 20;
+ $contactCount = 7;
+ $eventCount = 5;
+
+ // All events will either have this number or one less because of the
+ // rotating participation creation method.
+ $expectedFirstEventCount = ceil($participantCount / $eventCount);
+
+ $dummy = [
+ 'contacts' => $this->createEntity([
+ 'type' => 'Individual',
+ 'count' => $contactCount,
+ 'seq' => 1,
+ ]),
+ 'events' => $this->createEntity([
+ 'type' => 'Event',
+ 'count' => $eventCount,
+ 'seq' => 1,
+ ]),
+ 'sources' => ['Paddington', 'Springfield', 'Central'],
+ ];
+
+ // - create dummy participants record
+ for ($i = 0; $i < $participantCount; $i++) {
+ $dummy['participants'][$i] = $this->sample([
+ 'type' => 'Participant',
+ 'overrides' => [
+ 'event_id' => $dummy['events'][$i % $eventCount]['id'],
+ 'contact_id' => $dummy['contacts'][$i % $contactCount]['id'],
+ // 3 = number of sources
+ 'source' => $dummy['sources'][$i % 3],
+ ],
+ ])['sample_params'];
+
+ Participant::create()
+ ->setValues($dummy['participants'][$i])
+ ->setCheckPermissions(FALSE)
+ ->execute();
+ }
+ $sqlCount = $this->getRowCount('civicrm_participant');
+ $this->assertEquals($participantCount, $sqlCount, "Unexpected count");
+
+ $firstEventId = $dummy['events'][0]['id'];
+ $secondEventId = $dummy['events'][1]['id'];
+ $firstContactId = $dummy['contacts'][0]['id'];
+
+ $firstOnlyResult = Participant::get()
+ ->setCheckPermissions(FALSE)
+ ->addClause('AND', ['event_id', '=', $firstEventId])
+ ->execute();
+
+ $this->assertEquals($expectedFirstEventCount, count($firstOnlyResult),
+ "count of first event is not $expectedFirstEventCount");
+
+ // get first two events using different methods
+ $firstTwo = Participant::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('event_id', 'IN', [$firstEventId, $secondEventId])
+ ->execute();
+
+ $firstResult = $result->first();
+
+ // verify counts
+ // count should either twice the first event count or one less
+ $this->assertLessThanOrEqual(
+ $expectedFirstEventCount * 2,
+ count($firstTwo),
+ "count is too high"
+ );
+
+ $this->assertGreaterThanOrEqual(
+ $expectedFirstEventCount * 2 - 1,
+ count($firstTwo),
+ "count is too low"
+ );
+
+ $firstParticipantResult = Participant::get()
+ ->setCheckPermissions(FALSE)
+ ->addWhere('event_id', '=', $firstEventId)
+ ->addWhere('contact_id', '=', $firstContactId)
+ ->execute();
+
+ $this->assertEquals(1, count($firstParticipantResult), "more than one registration");
+
+ $firstParticipantId = $firstParticipantResult->first()['id'];
+
+ // get a result which excludes $first_participant
+ $otherParticipantResult = Participant::get()
+ ->setCheckPermissions(FALSE)
+ ->setSelect(['id'])
+ ->addClause('NOT', [
+ ['event_id', '=', $firstEventId],
+ ['contact_id', '=', $firstContactId],
+ ])
+ ->execute()
+ ->indexBy('id');
+
+ // check alternate syntax for NOT
+ $otherParticipantResult2 = Participant::get()
+ ->setCheckPermissions(FALSE)
+ ->setSelect(['id'])
+ ->addClause('NOT', 'AND', [
+ ['event_id', '=', $firstEventId],
+ ['contact_id', '=', $firstContactId],
+ ])
+ ->execute()
+ ->indexBy('id');
+
+ $this->assertEquals($otherParticipantResult, $otherParticipantResult2);
+
+ $this->assertEquals($participantCount - 1,
+ count($otherParticipantResult),
+ "failed to exclude a single record on complex criteria");
+ // check the record we have excluded is the right one:
+
+ $this->assertFalse(
+ $otherParticipantResult->offsetExists($firstParticipantId),
+ 'excluded wrong record');
+
+ // retrieve a participant record and update some records
+ $patchRecord = [
+ 'source' => "not " . $firstResult['source'],
+ ];
+
+ Participant::update()
+ ->addWhere('event_id', '=', $firstEventId)
+ ->setCheckPermissions(FALSE)
+ ->setLimit(20)
+ ->setValues($patchRecord)
+ ->setCheckPermissions(FALSE)
+ ->execute();
+
+ // - delete some records
+ $secondEventId = $dummy['events'][1]['id'];
+ $deleteResult = Participant::delete()
+ ->addWhere('event_id', '=', $secondEventId)
+ ->setCheckPermissions(FALSE)
+ ->execute();
+ $expectedDeletes = [2, 7, 12, 17];
+ $this->assertEquals($expectedDeletes, array_column((array) $deleteResult, 'id'),
+ "didn't delete every second record as expected");
+
+ $sqlCount = $this->getRowCount('civicrm_participant');
+ $this->assertEquals(
+ $participantCount - count($expectedDeletes),
+ $sqlCount,
+ "records not gone from database after delete");
+
+ // Try creating is_test participants
+ foreach ($dummy['contacts'] as $contact) {
+ Participant::create()
+ ->addValue('is_test', 1)
+ ->addValue('contact_id', $contact['id'])
+ ->addValue('event_id', $secondEventId)
+ ->execute();
+ }
+
+ // By default is_test participants are hidden
+ $this->assertCount(0, Participant::get()->selectRowCount()->addWhere('event_id', '=', $secondEventId)->execute());
+
+ // Test records show up if you add is_test to the query
+ $testParticipants = Participant::get()->addWhere('event_id', '=', $secondEventId)->addWhere('is_test', '=', 1)->addSelect('id')->execute();
+ $this->assertCount($contactCount, $testParticipants);
+
+ // Or if you search by id
+ $this->assertCount(1, Participant::get()->selectRowCount()->addWhere('id', '=', $testParticipants->first()['id'])->execute());
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Entity;
+
+use Civi\Api4\Route;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class RouteTest extends UnitTestCase {
+
+ public function testGet() {
+ $result = Route::get()->addWhere('path', '=', 'civicrm/admin')->execute();
+ $this->assertEquals(1, $result->count());
+
+ $result = Route::get()->addWhere('path', 'LIKE', 'civicrm/admin/%')->execute();
+ $this->assertGreaterThan(10, $result->count());
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Entity;
+
+use Civi\Api4\Setting;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class SettingTest extends UnitTestCase {
+
+ public function testSettingASetting() {
+ $setting = Setting::set()->addValue('menubar_position', 'above-crm-container')->setCheckPermissions(FALSE)->execute()->first();
+ $this->assertEquals('above-crm-container', $setting['value']);
+ $setting = Setting::get()->addSelect('menubar_position')->setCheckPermissions(FALSE)->execute()->first();
+ $this->assertEquals('above-crm-container', $setting['value']);
+
+ $setting = Setting::revert()->addSelect('menubar_position')->setCheckPermissions(FALSE)->execute()->indexBy('name')->column('value');
+ $this->assertEquals(['menubar_position' => 'over-cms-menu'], $setting);
+ $setting = civicrm_api4('Setting', 'get', ['select' => ['menubar_position'], 'checkPermissions' => FALSE], 0);
+ $this->assertEquals('over-cms-menu', $setting['value']);
+ }
+
+ public function testInvalidSetting() {
+ $message = '';
+ try {
+ Setting::set()->addValue('not_a_real_setting!', 'hello')->setCheckPermissions(FALSE)->execute();
+ }
+ catch (\API_Exception $e) {
+ $message = $e->getMessage();
+ }
+ $this->assertContains('setting', $message);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4\Action\MockArrayEntity;
+
+/**
+ * This class demonstrates how the getRecords method of Basic\Get can be overridden.
+ */
+class Get extends \Civi\Api4\Generic\BasicGetAction {
+
+ public function getRecords() {
+ return [
+ [
+ 'field1' => 1,
+ 'field2' => 'zebra',
+ 'field3' => NULL,
+ 'field4' => [1, 2, 3],
+ 'field5' => 'apple',
+ ],
+ [
+ 'field1' => 2,
+ 'field2' => 'yack',
+ 'field3' => 0,
+ 'field4' => [2, 3, 4],
+ 'field5' => 'banana',
+ 'field6' => '',
+ ],
+ [
+ 'field1' => 3,
+ 'field2' => 'x ray',
+ 'field4' => [3, 4, 5],
+ 'field5' => 'banana',
+ 'field6' => 0,
+ ],
+ [
+ 'field1' => 4,
+ 'field2' => 'wildebeest',
+ 'field3' => 1,
+ 'field4' => [4, 5, 6],
+ 'field5' => 'apple',
+ 'field6' => '0',
+ ],
+ [
+ 'field1' => 5,
+ 'field2' => 'vole',
+ 'field3' => 1,
+ 'field4' => [4, 5, 6],
+ 'field5' => 'apple',
+ 'field6' => 0,
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+use Civi\Api4\Generic\BasicGetFieldsAction;
+
+/**
+ * MockArrayEntity entity.
+ *
+ * @method Generic\BasicGetAction get()
+ *
+ * @package Civi\Api4
+ */
+class MockArrayEntity extends Generic\AbstractEntity {
+
+ public static function getFields() {
+ return new BasicGetFieldsAction(static::class, __FUNCTION__, function() {
+ return [];
+ });
+ }
+
+}
--- /dev/null
+<?php
+
+namespace Civi\Api4;
+
+use api\v4\Mock\MockEntityDataStorage;
+
+/**
+ * MockBasicEntity entity.
+ *
+ * @package Civi\Api4
+ */
+class MockBasicEntity extends Generic\AbstractEntity {
+
+ /**
+ * @return Generic\BasicGetFieldsAction
+ */
+ public static function getFields() {
+ return new Generic\BasicGetFieldsAction(static::class, __FUNCTION__, function() {
+ return [
+ [
+ 'name' => 'id',
+ 'type' => 'Integer',
+ ],
+ [
+ 'name' => 'group',
+ 'options' => [
+ 'one' => 'One',
+ 'two' => 'Two',
+ ],
+ ],
+ [
+ 'name' => 'color',
+ ],
+ [
+ 'name' => 'shape',
+ ],
+ [
+ 'name' => 'size',
+ ],
+ [
+ 'name' => 'weight',
+ ],
+ ];
+ });
+ }
+
+ /**
+ * @return Generic\BasicGetAction
+ */
+ public static function get() {
+ return new Generic\BasicGetAction('MockBasicEntity', __FUNCTION__, [MockEntityDataStorage::CLASS, 'get']);
+ }
+
+ /**
+ * @return Generic\BasicCreateAction
+ */
+ public static function create() {
+ return new Generic\BasicCreateAction(static::class, __FUNCTION__, [MockEntityDataStorage::CLASS, 'write']);
+ }
+
+ /**
+ * @return Generic\BasicSaveAction
+ */
+ public static function save() {
+ return new Generic\BasicSaveAction(self::getEntityName(), __FUNCTION__, 'id', [MockEntityDataStorage::CLASS, 'write']);
+ }
+
+ /**
+ * @return Generic\BasicUpdateAction
+ */
+ public static function update() {
+ return new Generic\BasicUpdateAction(self::getEntityName(), __FUNCTION__, 'id', [MockEntityDataStorage::CLASS, 'write']);
+ }
+
+ /**
+ * @return Generic\BasicBatchAction
+ */
+ public static function delete() {
+ return new Generic\BasicBatchAction('MockBasicEntity', __FUNCTION__, 'id', [MockEntityDataStorage::CLASS, 'delete']);
+ }
+
+ /**
+ * @return Generic\BasicBatchAction
+ */
+ public static function batchFrobnicate() {
+ return new Generic\BasicBatchAction('MockBasicEntity', __FUNCTION__, ['id', 'number'], function ($item) {
+ return [
+ 'id' => $item['id'],
+ 'frobnication' => $item['number'] * $item['number'],
+ ];
+ });
+ }
+
+ /**
+ * @return Generic\BasicReplaceAction
+ */
+ public static function replace() {
+ return new Generic\BasicReplaceAction('MockBasicEntity', __FUNCTION__);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Mock;
+
+/**
+ * Simple data backend for mock basic api.
+ */
+class MockEntityDataStorage {
+
+ private static $data = [];
+
+ private static $nextId = 1;
+
+ public static function get() {
+ return self::$data;
+ }
+
+ public static function write($record) {
+ if (empty($record['id'])) {
+ $record['id'] = self::$nextId++;
+ self::$data[$record['id']] = $record;
+ }
+ else {
+ self::$data[$record['id']] = $record + self::$data[$record['id']];
+ }
+ return $record;
+ }
+
+ public static function delete($record) {
+ unset(self::$data[$record['id']]);
+ return $record;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Mock;
+
+/**
+ * Class TestV4ReflectionBase
+ *
+ * This is the base class.
+ *
+ * @internal
+ */
+class MockV4ReflectionBase {
+ /**
+ * This is the foo property.
+ *
+ * In general, you can do nothing with it.
+ *
+ * @var array
+ */
+ public $foo = [];
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Mock;
+
+/**
+ * @inheritDoc
+ */
+class MockV4ReflectionChild extends MockV4ReflectionBase {
+ /**
+ * @var array
+ *
+ * In the child class, foo has been barred.
+ */
+ public $foo = ['bar' => 1];
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Mock;
+
+/**
+ * Grandchild class
+ *
+ * This is an extended description.
+ *
+ * There is a line break in this description.
+ *
+ * @inheritdoc
+ */
+class MockV4ReflectionGrandchild extends MockV4ReflectionChild {
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Query;
+
+use Civi\Api4\Query\Api4SelectQuery;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class Api4SelectQueryComplexJoinTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_address',
+ 'civicrm_email',
+ 'civicrm_phone',
+ 'civicrm_openid',
+ 'civicrm_im',
+ 'civicrm_website',
+ 'civicrm_activity',
+ 'civicrm_activity_contact',
+ ];
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('SingleContact');
+ return parent::setUpHeadless();
+ }
+
+ public function testWithComplexRelatedEntitySelect() {
+ $query = new Api4SelectQuery('Contact', FALSE, civicrm_api4('Contact', 'getFields', ['includeCustom' => FALSE, 'checkPermissions' => FALSE, 'action' => 'get'], 'name'));
+ $query->select[] = 'id';
+ $query->select[] = 'display_name';
+ $query->select[] = 'phones.phone';
+ $query->select[] = 'emails.email';
+ $query->select[] = 'emails.location_type.name';
+ $query->select[] = 'created_activities.contact_id';
+ $query->select[] = 'created_activities.activity.subject';
+ $query->select[] = 'created_activities.activity.activity_type.name';
+ $query->where[] = ['first_name', '=', 'Single'];
+ $query->where[] = ['id', '=', $this->getReference('test_contact_1')['id']];
+ $results = $query->run();
+
+ $testActivities = [
+ $this->getReference('test_activity_1'),
+ $this->getReference('test_activity_2'),
+ ];
+ $activitySubjects = array_column($testActivities, 'subject');
+
+ $this->assertCount(1, $results);
+ $firstResult = array_shift($results);
+ $this->assertArrayHasKey('created_activities', $firstResult);
+ $firstCreatedActivity = array_shift($firstResult['created_activities']);
+ $this->assertArrayHasKey('activity', $firstCreatedActivity);
+ $firstActivity = $firstCreatedActivity['activity'];
+ $this->assertContains($firstActivity['subject'], $activitySubjects);
+ $this->assertArrayHasKey('activity_type', $firstActivity);
+ $activityType = $firstActivity['activity_type'];
+ $this->assertArrayHasKey('name', $activityType);
+ }
+
+ public function testWithSelectOfOrphanDeepValues() {
+ $query = new Api4SelectQuery('Contact', FALSE, civicrm_api4('Contact', 'getFields', ['includeCustom' => FALSE, 'checkPermissions' => FALSE, 'action' => 'get'], 'name'));
+ $query->select[] = 'id';
+ $query->select[] = 'first_name';
+ // emails not selected
+ $query->select[] = 'emails.location_type.name';
+ $results = $query->run();
+ $firstResult = array_shift($results);
+
+ $this->assertEmpty($firstResult['emails']);
+ }
+
+ public function testOrderDoesNotMatter() {
+ $query = new Api4SelectQuery('Contact', FALSE, civicrm_api4('Contact', 'getFields', ['includeCustom' => FALSE, 'checkPermissions' => FALSE, 'action' => 'get'], 'name'));
+ $query->select[] = 'id';
+ $query->select[] = 'first_name';
+ // before emails selection
+ $query->select[] = 'emails.location_type.name';
+ $query->select[] = 'emails.email';
+ $query->where[] = ['emails.email', 'IS NOT NULL'];
+ $results = $query->run();
+ $firstResult = array_shift($results);
+
+ $this->assertNotEmpty($firstResult['emails'][0]['location_type']['name']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Query;
+
+use Civi\Api4\Query\Api4SelectQuery;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class Api4SelectQueryTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_address',
+ 'civicrm_email',
+ 'civicrm_phone',
+ 'civicrm_openid',
+ 'civicrm_im',
+ 'civicrm_website',
+ 'civicrm_activity',
+ 'civicrm_activity_contact',
+ ];
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('DefaultDataSet');
+ $displayNameFormat = '{contact.first_name}{ }{contact.last_name}';
+ \Civi::settings()->set('display_name_format', $displayNameFormat);
+
+ return parent::setUpHeadless();
+ }
+
+ public function testWithSingleWhereJoin() {
+ $phoneNum = $this->getReference('test_phone_1')['phone'];
+
+ $query = new Api4SelectQuery('Contact', FALSE, civicrm_api4('Contact', 'getFields', ['includeCustom' => FALSE, 'checkPermissions' => FALSE, 'action' => 'get'], 'name'));
+ $query->where[] = ['phones.phone', '=', $phoneNum];
+ $results = $query->run();
+
+ $this->assertCount(1, $results);
+ }
+
+ public function testOneToManyJoin() {
+ $phoneNum = $this->getReference('test_phone_1')['phone'];
+
+ $query = new Api4SelectQuery('Contact', FALSE, civicrm_api4('Contact', 'getFields', ['includeCustom' => FALSE, 'checkPermissions' => FALSE, 'action' => 'get'], 'name'));
+ $query->select[] = 'id';
+ $query->select[] = 'first_name';
+ $query->select[] = 'phones.phone';
+ $query->where[] = ['phones.phone', '=', $phoneNum];
+ $results = $query->run();
+
+ $this->assertCount(1, $results);
+ $firstResult = array_shift($results);
+ $this->assertArrayHasKey('phones', $firstResult);
+ $firstPhone = array_shift($firstResult['phones']);
+ $this->assertEquals($phoneNum, $firstPhone['phone']);
+ }
+
+ public function testManyToOneJoin() {
+ $phoneNum = $this->getReference('test_phone_1')['phone'];
+ $contact = $this->getReference('test_contact_1');
+
+ $query = new Api4SelectQuery('Phone', FALSE, civicrm_api4('Phone', 'getFields', ['includeCustom' => FALSE, 'checkPermissions' => FALSE, 'action' => 'get'], 'name'));
+ $query->select[] = 'id';
+ $query->select[] = 'phone';
+ $query->select[] = 'contact.display_name';
+ $query->select[] = 'contact.first_name';
+ $query->where[] = ['phone', '=', $phoneNum];
+ $results = $query->run();
+
+ $this->assertCount(1, $results);
+ $firstResult = array_shift($results);
+ $this->assertEquals($contact['display_name'], $firstResult['contact.display_name']);
+ }
+
+ public function testOneToManyMultipleJoin() {
+ $query = new Api4SelectQuery('Contact', FALSE, civicrm_api4('Contact', 'getFields', ['includeCustom' => FALSE, 'checkPermissions' => FALSE, 'action' => 'get'], 'name'));
+ $query->select[] = 'id';
+ $query->select[] = 'first_name';
+ $query->select[] = 'phones.phone';
+ $query->where[] = ['first_name', '=', 'Phoney'];
+ $results = $query->run();
+ $result = array_pop($results);
+
+ $this->assertEquals('Phoney', $result['first_name']);
+ $this->assertCount(2, $result['phones']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Query;
+
+use Civi\Api4\Contact;
+use api\v4\UnitTestCase;
+
+/**
+ * Class OneToOneJoinTest
+ * @package api\v4\Query
+ * @group headless
+ */
+class OneToOneJoinTest extends UnitTestCase {
+
+ public function testOneToOneJoin() {
+ $armenianContact = Contact::create()
+ ->addValue('first_name', 'Contact')
+ ->addValue('last_name', 'One')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('preferred_language', 'hy_AM')
+ ->execute()
+ ->first();
+
+ $basqueContact = Contact::create()
+ ->addValue('first_name', 'Contact')
+ ->addValue('last_name', 'Two')
+ ->addValue('contact_type', 'Individual')
+ ->addValue('preferred_language', 'eu_ES')
+ ->execute()
+ ->first();
+
+ $contacts = Contact::get()
+ ->addWhere('id', 'IN', [$armenianContact['id'], $basqueContact['id']])
+ ->addSelect('preferred_language.label')
+ ->addSelect('last_name')
+ ->execute()
+ ->indexBy('last_name')
+ ->getArrayCopy();
+
+ $this->assertEquals($contacts['One']['preferred_language.label'], 'Armenian');
+ $this->assertEquals($contacts['Two']['preferred_language.label'], 'Basque');
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Query;
+
+use Civi\Api4\Query\Api4SelectQuery;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class OptionValueJoinTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $relatedTables = [
+ 'civicrm_address',
+ 'civicrm_email',
+ 'civicrm_phone',
+ 'civicrm_openid',
+ 'civicrm_im',
+ 'civicrm_website',
+ 'civicrm_activity',
+ 'civicrm_activity_contact',
+ ];
+
+ $this->cleanup(['tablesToTruncate' => $relatedTables]);
+ $this->loadDataSet('SingleContact');
+
+ return parent::setUpHeadless();
+ }
+
+ public function testCommunicationMethodJoin() {
+ $query = new Api4SelectQuery('Contact', FALSE, civicrm_api4('Contact', 'getFields', ['includeCustom' => FALSE, 'checkPermissions' => FALSE, 'action' => 'get'], 'name'));
+ $query->select[] = 'first_name';
+ $query->select[] = 'preferred_communication_method.label';
+ $query->where[] = ['preferred_communication_method', 'IS NOT NULL'];
+ $results = $query->run();
+ $first = array_shift($results);
+ $firstPreferredMethod = array_shift($first['preferred_communication_method']);
+
+ $this->assertEquals(
+ 'Phone',
+ $firstPreferredMethod['label']
+ );
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Query;
+
+use Civi\Api4\Contact;
+use Civi\Api4\Email;
+use api\v4\UnitTestCase;
+
+/**
+ * Class SelectQueryMultiJoinTest
+ * @package api\v4\Query
+ * @group headless
+ */
+class SelectQueryMultiJoinTest extends UnitTestCase {
+
+ public function setUpHeadless() {
+ $this->cleanup(['tablesToTruncate' => ['civicrm_contact', 'civicrm_email']]);
+ $this->loadDataSet('MultiContactMultiEmail');
+ return parent::setUpHeadless();
+ }
+
+ public function testOneToManySelect() {
+ $results = Contact::get()
+ ->addSelect('emails.email')
+ ->execute()
+ ->indexBy('id')
+ ->getArrayCopy();
+
+ $firstContactId = $this->getReference('test_contact_1')['id'];
+ $secondContactId = $this->getReference('test_contact_2')['id'];
+
+ $firstContact = $results[$firstContactId];
+ $secondContact = $results[$secondContactId];
+ $firstContactEmails = array_column($firstContact['emails'], 'email');
+ $secondContactEmails = array_column($secondContact['emails'], 'email');
+
+ $expectedFirstEmails = [
+ 'test_contact_one_home@fakedomain.com',
+ 'test_contact_one_work@fakedomain.com',
+ ];
+ $expectedSecondEmails = [
+ 'test_contact_two_home@fakedomain.com',
+ 'test_contact_two_work@fakedomain.com',
+ ];
+
+ $this->assertEquals($expectedFirstEmails, $firstContactEmails);
+ $this->assertEquals($expectedSecondEmails, $secondContactEmails);
+ }
+
+ public function testManyToOneSelect() {
+ $results = Email::get()
+ ->addSelect('contact.display_name')
+ ->execute()
+ ->indexBy('id')
+ ->getArrayCopy();
+
+ $firstEmail = $this->getReference('test_email_1');
+ $secondEmail = $this->getReference('test_email_2');
+ $thirdEmail = $this->getReference('test_email_3');
+ $fourthEmail = $this->getReference('test_email_4');
+ $firstContactEmailIds = [$firstEmail['id'], $secondEmail['id']];
+ $secondContactEmailIds = [$thirdEmail['id'], $fourthEmail['id']];
+
+ foreach ($results as $id => $email) {
+ $displayName = $email['contact.display_name'];
+ if (in_array($id, $firstContactEmailIds)) {
+ $this->assertEquals('First Contact', $displayName);
+ }
+ elseif (in_array($id, $secondContactEmailIds)) {
+ $this->assertEquals('Second Contact', $displayName);
+ }
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Service\Schema;
+
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class SchemaMapRealTableTest extends UnitTestCase {
+
+ public function testAutoloadWillPopulateTablesByDefault() {
+ $map = \Civi::container()->get('schema_map');
+ $this->assertNotEmpty($map->getTables());
+ }
+
+ public function testSimplePathWillExist() {
+ $map = \Civi::container()->get('schema_map');
+ $path = $map->getPath('civicrm_contact', 'emails');
+ $this->assertCount(1, $path);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Service\Schema;
+
+use Civi\Api4\Service\Schema\Joinable\Joinable;
+use Civi\Api4\Service\Schema\SchemaMap;
+use Civi\Api4\Service\Schema\Table;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class SchemaMapperTest extends UnitTestCase {
+
+ public function testWillHaveNoPathWithNoTables() {
+ $map = new SchemaMap();
+ $this->assertEmpty($map->getPath('foo', 'bar'));
+ }
+
+ public function testWillHavePathWithSingleJump() {
+ $phoneTable = new Table('civicrm_phone');
+ $locationTable = new Table('civicrm_location_type');
+ $link = new Joinable('civicrm_location_type', 'id', 'location');
+ $phoneTable->addTableLink('location_type_id', $link);
+
+ $map = new SchemaMap();
+ $map->addTables([$phoneTable, $locationTable]);
+
+ $this->assertNotEmpty($map->getPath('civicrm_phone', 'location'));
+ }
+
+ public function testWillHavePathWithDoubleJump() {
+ $activity = new Table('activity');
+ $activityContact = new Table('activity_contact');
+ $middleLink = new Joinable('activity_contact', 'activity_id');
+ $contactLink = new Joinable('contact', 'id');
+ $activity->addTableLink('id', $middleLink);
+ $activityContact->addTableLink('contact_id', $contactLink);
+
+ $map = new SchemaMap();
+ $map->addTables([$activity, $activityContact]);
+
+ $this->assertNotEmpty($map->getPath('activity', 'contact'));
+ }
+
+ public function testPathWithTripleJoin() {
+ $first = new Table('first');
+ $second = new Table('second');
+ $third = new Table('third');
+ $first->addTableLink('id', new Joinable('second', 'id'));
+ $second->addTableLink('id', new Joinable('third', 'id'));
+ $third->addTableLink('id', new Joinable('fourth', 'id'));
+
+ $map = new SchemaMap();
+ $map->addTables([$first, $second, $third]);
+
+ $this->assertNotEmpty($map->getPath('first', 'fourth'));
+ }
+
+ public function testCircularReferenceWillNotBreakIt() {
+ $contactTable = new Table('contact');
+ $carTable = new Table('car');
+ $carLink = new Joinable('car', 'id');
+ $ownerLink = new Joinable('contact', 'id');
+ $contactTable->addTableLink('car_id', $carLink);
+ $carTable->addTableLink('owner_id', $ownerLink);
+
+ $map = new SchemaMap();
+ $map->addTables([$contactTable, $carTable]);
+
+ $this->assertEmpty($map->getPath('contact', 'foo'));
+ }
+
+ public function testCannotGoOverJoinLimit() {
+ $first = new Table('first');
+ $second = new Table('second');
+ $third = new Table('third');
+ $fourth = new Table('fourth');
+ $first->addTableLink('id', new Joinable('second', 'id'));
+ $second->addTableLink('id', new Joinable('third', 'id'));
+ $third->addTableLink('id', new Joinable('fourth', 'id'));
+ $fourth->addTableLink('id', new Joinable('fifth', 'id'));
+
+ $map = new SchemaMap();
+ $map->addTables([$first, $second, $third, $fourth]);
+
+ $this->assertEmpty($map->getPath('first', 'fifth'));
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Service;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\SpecGatherer;
+
+class TestCreationParameterProvider {
+
+ /**
+ * @var \Civi\Api4\Service\Spec\SpecGatherer
+ */
+ protected $gatherer;
+
+ /**
+ * @param \Civi\Api4\Service\Spec\SpecGatherer $gatherer
+ */
+ public function __construct(SpecGatherer $gatherer) {
+ $this->gatherer = $gatherer;
+ }
+
+ /**
+ * @param $entity
+ *
+ * @return array
+ */
+ public function getRequired($entity) {
+ $createSpec = $this->gatherer->getSpec($entity, 'create', FALSE);
+ $requiredFields = array_merge($createSpec->getRequiredFields(), $createSpec->getConditionalRequiredFields());
+
+ if ($entity === 'Contact') {
+ $requiredFields[] = $createSpec->getFieldByName('first_name');
+ $requiredFields[] = $createSpec->getFieldByName('last_name');
+ }
+
+ $requiredParams = [];
+ foreach ($requiredFields as $requiredField) {
+ $value = $this->getRequiredValue($requiredField);
+ $requiredParams[$requiredField->getName()] = $value;
+ }
+
+ unset($requiredParams['id']);
+
+ return $requiredParams;
+ }
+
+ /**
+ * Attempt to get a value using field option, defaults, FKEntity, or a random
+ * value based on the data type.
+ *
+ * @param \Civi\Api4\Service\Spec\FieldSpec $field
+ *
+ * @return mixed
+ * @throws \Exception
+ */
+ private function getRequiredValue(FieldSpec $field) {
+
+ if ($field->getOptions()) {
+ return $this->getOption($field);
+ }
+ elseif ($field->getDefaultValue()) {
+ return $field->getDefaultValue();
+ }
+ elseif ($field->getFkEntity()) {
+ return $this->getFkID($field, $field->getFkEntity());
+ }
+ elseif (in_array($field->getName(), ['entity_id', 'contact_id'])) {
+ return $this->getFkID($field, 'Contact');
+ }
+
+ $randomValue = $this->getRandomValue($field->getDataType());
+
+ if ($randomValue) {
+ return $randomValue;
+ }
+
+ throw new \Exception('Could not provide default value');
+ }
+
+ /**
+ * @param \Civi\Api4\Service\Spec\FieldSpec $field
+ *
+ * @return mixed
+ */
+ private function getOption(FieldSpec $field) {
+ $options = $field->getOptions();
+ return array_rand($options);
+ }
+
+ /**
+ * @param \Civi\Api4\Service\Spec\FieldSpec $field
+ * @param string $fkEntity
+ *
+ * @return mixed
+ * @throws \Exception
+ */
+ private function getFkID(FieldSpec $field, $fkEntity) {
+ $params = ['checkPermissions' => FALSE];
+ // Be predictable about what type of contact we select
+ if ($fkEntity === 'Contact') {
+ $params['where'] = [['contact_type', '=', 'Individual']];
+ }
+ $entityList = civicrm_api4($fkEntity, 'get', $params);
+ if ($entityList->count() < 1) {
+ $msg = sprintf('At least one %s is required in test', $fkEntity);
+ throw new \Exception($msg);
+ }
+
+ return $entityList->last()['id'];
+ }
+
+ /**
+ * @param $dataType
+ *
+ * @return int|null|string
+ */
+ private function getRandomValue($dataType) {
+ switch ($dataType) {
+ case 'Boolean':
+ return TRUE;
+
+ case 'Integer':
+ return rand(1, 2000);
+
+ case 'String':
+ return \CRM_Utils_String::createRandom(10, implode('', range('a', 'z')));
+
+ case 'Text':
+ return \CRM_Utils_String::createRandom(100, implode('', range('a', 'z')));
+
+ case 'Money':
+ return sprintf('%d.%2d', rand(0, 2000), rand(10, 99));
+
+ case 'Date':
+ return '20100102';
+
+ case 'Timestamp':
+ return 'now';
+ }
+
+ return NULL;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Spec;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class RequestSpecTest extends UnitTestCase {
+
+ public function testRequiredFieldFetching() {
+ $spec = new RequestSpec('Contact', 'get');
+ $requiredField = new FieldSpec('name', 'Contact');
+ $requiredField->setRequired(TRUE);
+ $nonRequiredField = new FieldSpec('age', 'Contact', 'Integer');
+ $nonRequiredField->setRequired(FALSE);
+ $spec->addFieldSpec($requiredField);
+ $spec->addFieldSpec($nonRequiredField);
+
+ $requiredFields = $spec->getRequiredFields();
+
+ $this->assertCount(1, $requiredFields);
+ $this->assertEquals('name', array_shift($requiredFields)->getName());
+ }
+
+ public function testGettingFieldNames() {
+ $spec = new RequestSpec('Contact', 'get');
+ $nameField = new FieldSpec('name', 'Contact');
+ $ageField = new FieldSpec('age', 'Contact', 'Integer');
+ $spec->addFieldSpec($nameField);
+ $spec->addFieldSpec($ageField);
+
+ $fieldNames = $spec->getFieldNames();
+
+ $this->assertCount(2, $fieldNames);
+ $this->assertEquals(['name', 'age'], $fieldNames);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Spec;
+
+use Civi\Api4\Service\Spec\CustomFieldSpec;
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+use Civi\Api4\Service\Spec\SpecFormatter;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class SpecFormatterTest extends UnitTestCase {
+
+ public function testSpecToArray() {
+ $spec = new RequestSpec('Contact', 'get');
+ $fieldName = 'last_name';
+ $field = new FieldSpec($fieldName, 'Contact');
+ $spec->addFieldSpec($field);
+ $arraySpec = SpecFormatter::specToArray($spec->getFields());
+
+ $this->assertEquals('String', $arraySpec[$fieldName]['data_type']);
+ }
+
+ /**
+ * @dataProvider arrayFieldSpecProvider
+ *
+ * @param array $fieldData
+ * @param string $expectedName
+ * @param string $expectedType
+ */
+ public function testArrayToField($fieldData, $expectedName, $expectedType) {
+ $field = SpecFormatter::arrayToField($fieldData, 'TestEntity');
+
+ $this->assertEquals($expectedName, $field->getName());
+ $this->assertEquals($expectedType, $field->getDataType());
+ }
+
+ public function testCustomFieldWillBeReturned() {
+ $customGroupId = 1432;
+ $customFieldId = 3333;
+ $name = 'MyFancyField';
+
+ $data = [
+ 'custom_group_id' => $customGroupId,
+ 'custom_group.name' => 'my_group',
+ 'id' => $customFieldId,
+ 'name' => $name,
+ 'data_type' => 'String',
+ 'html_type' => 'Multi-Select',
+ ];
+
+ /** @var \Civi\Api4\Service\Spec\CustomFieldSpec $field */
+ $field = SpecFormatter::arrayToField($data, 'TestEntity');
+
+ $this->assertInstanceOf(CustomFieldSpec::class, $field);
+ $this->assertEquals('my_group', $field->getCustomGroupName());
+ $this->assertEquals($customFieldId, $field->getCustomFieldId());
+ $this->assertEquals(\CRM_Core_DAO::SERIALIZE_SEPARATOR_BOOKEND, $field->getSerialize());
+ $this->assertEquals('Select', $field->getInputType());
+ $this->assertTrue($field->getInputAttrs()['multiple']);
+ }
+
+ /**
+ * @return array
+ */
+ public function arrayFieldSpecProvider() {
+ return [
+ [
+ [
+ 'name' => 'Foo',
+ 'title' => 'Bar',
+ 'type' => \CRM_Utils_Type::T_STRING,
+ ],
+ 'Foo',
+ 'String',
+ ],
+ [
+ [
+ 'name' => 'MyField',
+ 'title' => 'Bar',
+ 'type' => \CRM_Utils_Type::T_STRING,
+ // this should take precedence
+ 'data_type' => 'Boolean',
+ ],
+ 'MyField',
+ 'Boolean',
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Spec;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\Provider\Generic\SpecProviderInterface;
+use Civi\Api4\Service\Spec\SpecGatherer;
+use api\v4\Traits\OptionCleanupTrait;
+use api\v4\UnitTestCase;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use api\v4\Traits\TableDropperTrait;
+use Prophecy\Argument;
+
+/**
+ * @group headless
+ */
+class SpecGathererTest extends UnitTestCase {
+
+ use TableDropperTrait;
+ use OptionCleanupTrait;
+
+ public function setUpHeadless() {
+ $this->dropByPrefix('civicrm_value_favorite');
+ $this->cleanup([
+ 'tablesToTruncate' => [
+ 'civicrm_custom_group',
+ 'civicrm_custom_field',
+ ],
+ ]);
+ return parent::setUpHeadless();
+ }
+
+ public function testBasicFieldsGathering() {
+ $gatherer = new SpecGatherer();
+ $specs = $gatherer->getSpec('Contact', 'get', FALSE);
+ $contactDAO = _civicrm_api3_get_DAO('Contact');
+ $contactFields = $contactDAO::fields();
+ $specFieldNames = $specs->getFieldNames();
+ $contactFieldNames = array_column($contactFields, 'name');
+
+ $this->assertEmpty(array_diff_key($contactFieldNames, $specFieldNames));
+ }
+
+ public function testWithSpecProvider() {
+ $gather = new SpecGatherer();
+
+ $provider = $this->prophesize(SpecProviderInterface::class);
+ $provider->applies('Contact', 'create')->willReturn(TRUE);
+ $provider->modifySpec(Argument::any())->will(function ($args) {
+ /** @var \Civi\Api4\Service\Spec\RequestSpec $spec */
+ $spec = $args[0];
+ $spec->addFieldSpec(new FieldSpec('foo', 'Contact'));
+ });
+ $gather->addSpecProvider($provider->reveal());
+
+ $spec = $gather->getSpec('Contact', 'create', FALSE);
+ $fieldNames = $spec->getFieldNames();
+
+ $this->assertContains('foo', $fieldNames);
+ }
+
+ public function testPseudoConstantOptionsWillBeAdded() {
+ $customGroupId = CustomGroup::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('name', 'FavoriteThings')
+ ->addValue('extends', 'Contact')
+ ->execute()
+ ->first()['id'];
+
+ $options = ['r' => 'Red', 'g' => 'Green', 'p' => 'Pink'];
+
+ CustomField::create()
+ ->setCheckPermissions(FALSE)
+ ->addValue('label', 'FavColor')
+ ->addValue('custom_group_id', $customGroupId)
+ ->addValue('option_values', $options)
+ ->addValue('html_type', 'Select')
+ ->addValue('data_type', 'String')
+ ->execute();
+
+ $gatherer = new SpecGatherer();
+ $spec = $gatherer->getSpec('Contact', 'get', TRUE);
+
+ $regularField = $spec->getFieldByName('contact_type');
+ $this->assertNotEmpty($regularField->getOptions());
+ $this->assertContains('Individual', $regularField->getOptions());
+
+ $customField = $spec->getFieldByName('FavoriteThings.FavColor');
+ $this->assertNotEmpty($customField->getOptions());
+ $this->assertContains('Green', $customField->getOptions());
+ $this->assertEquals('Pink', $customField->getOptions()['p']);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Traits;
+
+trait OptionCleanupTrait {
+
+ protected $optionGroupMaxId;
+ protected $optionValueMaxId;
+
+ public function setUp() {
+ $this->optionGroupMaxId = \CRM_Core_DAO::singleValueQuery('SELECT MAX(id) FROM civicrm_option_group');
+ $this->optionValueMaxId = \CRM_Core_DAO::singleValueQuery('SELECT MAX(id) FROM civicrm_option_value');
+ }
+
+ public function tearDown() {
+ if ($this->optionValueMaxId) {
+ \CRM_Core_DAO::executeQuery('DELETE FROM civicrm_option_value WHERE id > ' . $this->optionValueMaxId);
+ }
+ if ($this->optionGroupMaxId) {
+ \CRM_Core_DAO::executeQuery('DELETE FROM civicrm_option_group WHERE id > ' . $this->optionGroupMaxId);
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Traits;
+
+use CRM_Utils_Array as ArrayHelper;
+
+trait QueryCounterTrait {
+
+ /**
+ * @var int
+ */
+ protected $startCount = 0;
+
+ /**
+ * Start the query counter
+ */
+ protected function beginQueryCount() {
+ $this->startCount = $this->getCurrentGlobalQueryCount();
+ }
+
+ /**
+ * @return int
+ * The number of queries since the counter was started
+ */
+ protected function getQueryCount() {
+ return $this->getCurrentGlobalQueryCount() - $this->startCount;
+ }
+
+ /**
+ * @return int
+ * @throws \Exception
+ */
+ private function getCurrentGlobalQueryCount() {
+ global $_DB_DATAOBJECT;
+
+ if (!$_DB_DATAOBJECT) {
+ throw new \Exception('Database object not set so cannot count queries');
+ }
+
+ return ArrayHelper::value('RESULTSEQ', $_DB_DATAOBJECT, 0);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Traits;
+
+trait TableDropperTrait {
+
+ /**
+ * @param $prefix
+ */
+ protected function dropByPrefix($prefix) {
+ $sql = "SELECT CONCAT( 'DROP TABLE ', GROUP_CONCAT(table_name) , ';' ) " .
+ "AS statement FROM information_schema.tables " .
+ "WHERE table_name LIKE '%s%%' AND table_schema = DATABASE();";
+ $sql = sprintf($sql, $prefix);
+ $dropTableQuery = \CRM_Core_DAO::executeQuery($sql);
+ $dropTableQuery->fetch();
+ $dropTableQuery = $dropTableQuery->statement;
+
+ if ($dropTableQuery) {
+ \CRM_Core_DAO::executeQuery($dropTableQuery);
+ }
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Traits;
+
+/**
+ * This probably should be a separate class
+ */
+trait TestDataLoaderTrait {
+
+ /**
+ * @var array
+ * References to entities used for loading test data
+ */
+ protected $references;
+
+ /**
+ * Creates entities from a JSON data set
+ *
+ * @param $path
+ */
+ protected function loadDataSet($path) {
+ if (!file_exists($path)) {
+ $path = __DIR__ . '/../DataSets/' . $path . '.json';
+ }
+
+ $dataSet = json_decode(file_get_contents($path), TRUE);
+ foreach ($dataSet as $entityName => $entities) {
+ foreach ($entities as $entityValues) {
+
+ $entityValues = $this->replaceReferences($entityValues);
+
+ $params = ['values' => $entityValues, 'checkPermissions' => FALSE];
+ $result = civicrm_api4($entityName, 'create', $params);
+ if (isset($entityValues['@ref'])) {
+ $this->references[$entityValues['@ref']] = $result->first();
+ }
+ }
+ }
+ }
+
+ /**
+ * @param $name
+ *
+ * @return null|mixed
+ */
+ protected function getReference($name) {
+ return isset($this->references[$name]) ? $this->references[$name] : NULL;
+ }
+
+ /**
+ * @param array $entityValues
+ *
+ * @return array
+ */
+ private function replaceReferences($entityValues) {
+ foreach ($entityValues as $name => $value) {
+ if (is_array($value)) {
+ $entityValues[$name] = $this->replaceReferences($value);
+ }
+ elseif (substr($value, 0, 4) === '@ref') {
+ $referenceName = substr($value, 5);
+ list ($reference, $property) = explode('.', $referenceName);
+ $entityValues[$name] = $this->references[$reference][$property];
+ }
+ }
+ return $entityValues;
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4;
+
+use api\v4\Traits\TestDataLoaderTrait;
+use Civi\Test\HeadlessInterface;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class UnitTestCase extends \PHPUnit_Framework_TestCase implements HeadlessInterface, TransactionalInterface {
+
+ use TestDataLoaderTrait;
+
+ /**
+ * @see CiviUnitTestCase
+ *
+ * @param string $name
+ * @param array $data
+ * @param string $dataName
+ */
+ public function __construct($name = NULL, array $data = [], $dataName = '') {
+ parent::__construct($name, $data, $dataName);
+ error_reporting(E_ALL & ~E_NOTICE);
+ }
+
+ public function setUpHeadless() {
+ return \Civi\Test::headless()->installMe(__DIR__)->apply();
+ }
+
+ /**
+ * Tears down the fixture, for example, closes a network connection.
+ *
+ * This method is called after a test is executed.
+ */
+ public function tearDown() {
+ parent::tearDown();
+ }
+
+ /**
+ * Quick clean by emptying tables created for the test.
+ *
+ * @param array $params
+ */
+ public function cleanup($params) {
+ $params += [
+ 'tablesToTruncate' => [],
+ ];
+ \CRM_Core_DAO::executeQuery("SET FOREIGN_KEY_CHECKS = 0;");
+ foreach ($params['tablesToTruncate'] as $table) {
+ \Civi::log()->info('truncating: ' . $table);
+ $sql = "TRUNCATE TABLE $table";
+ \CRM_Core_DAO::executeQuery($sql);
+ }
+ \CRM_Core_DAO::executeQuery("SET FOREIGN_KEY_CHECKS = 1;");
+ }
+
+ /**
+ * Quick record counter
+ *
+ * @param string $table_name
+ * @returns int record count
+ */
+ public function getRowCount($table_name) {
+ $sql = "SELECT count(id) FROM $table_name";
+ return (int) \CRM_Core_DAO::singleValueQuery($sql);
+ }
+
+ /**
+ * Create sample entities (using V3 for now).
+ *
+ * @param array $params
+ * (type, seq, overrides, count)
+ * @return array
+ * (either single, or array of array if count >1)
+ * @throws \CiviCRM_API3_Exception
+ * @throws \Exception
+ */
+ public static function createEntity($params) {
+ $params += [
+ 'count' => 1,
+ 'seq' => 0,
+ ];
+ $entities = [];
+ $entity = NULL;
+ for ($i = 0; $i < $params['count']; $i++) {
+ $params['seq']++;
+ $data = self::sample($params);
+ $api_params = ['sequential' => 1] + $data['sample_params'];
+ $result = civicrm_api3($data['entity'], 'create', $api_params);
+ if ($result['is_error']) {
+ throw new \Exception("creating $data[entity] failed");
+ }
+ $entity = $result['values'][0];
+ if (!($entity['id'] > 0)) {
+ throw new \Exception("created entity is malformed");
+ }
+ $entities[] = $entity;
+ }
+ return $params['count'] == 1 ? $entity : $entities;
+ }
+
+ /**
+ * Helper function for creating sample entities.
+ *
+ * Depending on the supplied sequence integer, plucks values from the dummy data.
+ * Constructs a foreign entity when an ID is required but isn't supplied in the overrides.
+ *
+ * Inspired by CiviUnitTestCase::
+ * @todo - extract this function to own class and share with CiviUnitTestCase?
+ * @param array $params
+ * - type: string roughly matching entity type
+ * - seq: (optional) int sequence number for the values of this type
+ * - overrides: (optional) array of fill in parameters
+ *
+ * @return array
+ * - entity: string API entity type (usually the type supplied except for contact subtypes)
+ * - sample_params: array API sample_params properties of sample entity
+ */
+ public static function sample($params) {
+ $params += [
+ 'seq' => 0,
+ 'overrides' => [],
+ ];
+ $type = $params['type'];
+ // sample data - if field is array then chosed based on `seq`
+ $sample_params = [];
+ if (in_array($type, ['Individual', 'Organization', 'Household'])) {
+ $sample_params['contact_type'] = $type;
+ $entity = 'Contact';
+ }
+ else {
+ $entity = $type;
+ }
+ // use the seq to pluck a set of params out
+ foreach (self::sampleData($type) as $key => $value) {
+ if (is_array($value)) {
+ $sample_params[$key] = $value[$params['seq'] % count($value)];
+ }
+ else {
+ $sample_params[$key] = $value;
+ }
+ }
+ if ($type == 'Individual') {
+ $sample_params['email'] = strtolower(
+ $sample_params['first_name'] . '_' . $sample_params['last_name'] . '@civicrm.org'
+ );
+ $sample_params['prefix_id'] = 3;
+ $sample_params['suffix_id'] = 3;
+ }
+ if (!count($sample_params)) {
+ throw new \Exception("unknown sample type: $type");
+ }
+ $sample_params = $params['overrides'] + $sample_params;
+ // make foreign enitiies if they haven't been supplied
+ foreach ($sample_params as $key => $value) {
+ if (substr($value, 0, 6) === 'dummy.') {
+ $foreign_entity = self::createEntity([
+ 'type' => substr($value, 6),
+ 'seq' => $params['seq'],
+ ]);
+ $sample_params[$key] = $foreign_entity['id'];
+ }
+ }
+ return compact("entity", "sample_params");
+ }
+
+ /**
+ * Provider of sample data.
+ *
+ * @return array
+ * Array values represent a set of allowable items.
+ * Strings in the form "dummy.Entity" require creating a foreign entity first.
+ */
+ public static function sampleData($type) {
+ $data = [
+ 'Individual' => [
+ // The number of values in each list need to be coprime numbers to not have duplicates
+ 'first_name' => ['Anthony', 'Joe', 'Terrence', 'Lucie', 'Albert', 'Bill', 'Kim'],
+ 'middle_name' => ['J.', 'M.', 'P', 'L.', 'K.', 'A.', 'B.', 'C.', 'D', 'E.', 'Z.'],
+ 'last_name' => ['Anderson', 'Miller', 'Smith', 'Collins', 'Peterson'],
+ 'contact_type' => 'Individual',
+ ],
+ 'Organization' => [
+ 'organization_name' => [
+ 'Unit Test Organization',
+ 'Acme',
+ 'Roberts and Sons',
+ 'Cryo Space Labs',
+ 'Sharper Pens',
+ ],
+ ],
+ 'Household' => [
+ 'household_name' => ['Unit Test household'],
+ ],
+ 'Event' => [
+ 'title' => 'Annual CiviCRM meet',
+ 'summary' => 'If you have any CiviCRM related issues or want to track where CiviCRM is heading, Sign up now',
+ 'description' => 'This event is intended to give brief idea about progess of CiviCRM and giving solutions to common user issues',
+ 'event_type_id' => 1,
+ 'is_public' => 1,
+ 'start_date' => 20081021,
+ 'end_date' => 20081023,
+ 'is_online_registration' => 1,
+ 'registration_start_date' => 20080601,
+ 'registration_end_date' => 20081015,
+ 'max_participants' => 100,
+ 'event_full_text' => 'Sorry! We are already full',
+ 'is_monetary' => 0,
+ 'is_active' => 1,
+ 'is_show_location' => 0,
+ ],
+ 'Participant' => [
+ 'event_id' => 'dummy.Event',
+ 'contact_id' => 'dummy.Individual',
+ 'status_id' => 2,
+ 'role_id' => 1,
+ 'register_date' => 20070219,
+ 'source' => 'Wimbeldon',
+ 'event_level' => 'Payment',
+ ],
+ 'Contribution' => [
+ 'contact_id' => 'dummy.Individual',
+ // donation, 2 = member, 3 = campaign contribution, 4=event
+ 'financial_type_id' => 1,
+ 'total_amount' => 7.3,
+ ],
+ 'Activity' => [
+ //'activity_type_id' => 1,
+ 'subject' => 'unit testing',
+ 'source_contact_id' => 'dummy.Individual',
+ ],
+ ];
+ if ($type == 'Contact') {
+ $type = 'Individual';
+ }
+ return $data[$type];
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Utils;
+
+use Civi\Api4\Utils\ArrayInsertionUtil;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ArrayInsertionServiceTest extends UnitTestCase {
+
+ public function testInsertWillWork() {
+ $arr = [];
+ $path = ['foo' => FALSE, 'bar' => FALSE];
+ $inserter = new ArrayInsertionUtil();
+ $inserter::insert($arr, $path, ['LALA']);
+
+ $expected = [
+ 'foo' => [
+ 'bar' => 'LALA',
+ ],
+ ];
+
+ $this->assertEquals($expected, $arr);
+ }
+
+ public function testInsertionOfContactEmailLocation() {
+ $contacts = [
+ [
+ 'id' => 1,
+ 'first_name' => 'Jim',
+ ],
+ [
+ 'id' => 2,
+ 'first_name' => 'Karen',
+ ],
+ ];
+ $emails = [
+ [
+ 'email' => 'jim@jim.com',
+ 'id' => 2,
+ '_parent_id' => 1,
+ ],
+ ];
+ $locationTypes = [
+ [
+ 'name' => 'Home',
+ 'id' => 3,
+ '_parent_id' => 2,
+ ],
+ ];
+
+ $emailPath = ['emails' => TRUE];
+ $locationPath = ['emails' => TRUE, 'location' => FALSE];
+ $inserter = new ArrayInsertionUtil();
+
+ foreach ($contacts as &$contact) {
+ $inserter::insert($contact, $emailPath, $emails);
+ $inserter::insert($contact, $locationPath, $locationTypes);
+ }
+
+ $locationType = $contacts[0]['emails'][0]['location']['name'];
+ $this->assertEquals('Home', $locationType);
+ }
+
+}
--- /dev/null
+<?php
+
+namespace api\v4\Utils;
+
+use Civi\Api4\Utils\ReflectionUtils;
+use api\v4\Mock\MockV4ReflectionGrandchild;
+use api\v4\UnitTestCase;
+
+/**
+ * @group headless
+ */
+class ReflectionUtilsTest extends UnitTestCase {
+
+ /**
+ * Test that class annotations are returned across @inheritDoc
+ */
+ public function testGetDocBlockForClass() {
+ $grandChild = new MockV4ReflectionGrandchild();
+ $reflection = new \ReflectionClass($grandChild);
+ $doc = ReflectionUtils::getCodeDocs($reflection);
+
+ $this->assertEquals(TRUE, $doc['internal']);
+ $this->assertEquals('Grandchild class', $doc['description']);
+
+ $expectedComment = 'This is an extended description.
+
+There is a line break in this description.
+
+This is the base class.';
+
+ $this->assertEquals($expectedComment, $doc['comment']);
+ }
+
+ /**
+ * Test that property annotations are returned across @inheritDoc
+ */
+ public function testGetDocBlockForProperty() {
+ $grandChild = new MockV4ReflectionGrandchild();
+ $reflection = new \ReflectionClass($grandChild);
+ $doc = ReflectionUtils::getCodeDocs($reflection->getProperty('foo'), 'Property');
+
+ $this->assertEquals('This is the foo property.', $doc['description']);
+ $this->assertEquals("In the child class, foo has been barred.\n\nIn general, you can do nothing with it.", $doc['comment']);
+ }
+
+}
--- /dev/null
+<container xmlns="http://symfony.com/schema/dic/services"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
+
+ <services>
+ <service id="test.param_provider" class="api\v4\Service\TestCreationParameterProvider">
+ <argument type="service" id="spec_gatherer"/>
+ </service>
+ </services>
+</container>