api4 - Import CRM/, Civi/, templates/, ang/, css/, js/, xml/menu
authorCiviCRM <info@civicrm.org>
Sun, 15 Sep 2019 03:48:45 +0000 (23:48 -0400)
committerColeman Watts <coleman@civicrm.org>
Tue, 17 Sep 2019 03:11:09 +0000 (23:11 -0400)
255 files changed:
CRM/Api4/Page/AJAX.php [new file with mode: 0644]
CRM/Api4/Page/Api4Explorer.php [new file with mode: 0644]
CRM/Api4/Services.php [new file with mode: 0644]
CRM/Core/xml/Menu/Api4.xml [new file with mode: 0644]
Civi/Api4/ACL.php [new file with mode: 0644]
Civi/Api4/Action/Address/AddressSaveTrait.php [new file with mode: 0644]
Civi/Api4/Action/Address/Create.php [new file with mode: 0644]
Civi/Api4/Action/Address/Save.php [new file with mode: 0644]
Civi/Api4/Action/Address/Update.php [new file with mode: 0644]
Civi/Api4/Action/Campaign/Get.php [new file with mode: 0644]
Civi/Api4/Action/Contact/GetChecksum.php [new file with mode: 0644]
Civi/Api4/Action/Contact/GetFields.php [new file with mode: 0644]
Civi/Api4/Action/Contact/ValidateChecksum.php [new file with mode: 0644]
Civi/Api4/Action/CustomValue/Create.php [new file with mode: 0644]
Civi/Api4/Action/CustomValue/Delete.php [new file with mode: 0644]
Civi/Api4/Action/CustomValue/Get.php [new file with mode: 0644]
Civi/Api4/Action/CustomValue/GetActions.php [new file with mode: 0644]
Civi/Api4/Action/CustomValue/GetFields.php [new file with mode: 0644]
Civi/Api4/Action/CustomValue/Replace.php [new file with mode: 0644]
Civi/Api4/Action/CustomValue/Save.php [new file with mode: 0644]
Civi/Api4/Action/CustomValue/Update.php [new file with mode: 0644]
Civi/Api4/Action/Domain/Get.php [new file with mode: 0644]
Civi/Api4/Action/Entity/Get.php [new file with mode: 0644]
Civi/Api4/Action/Entity/GetLinks.php [new file with mode: 0644]
Civi/Api4/Action/Event/Get.php [new file with mode: 0644]
Civi/Api4/Action/GetActions.php [new file with mode: 0644]
Civi/Api4/Action/GroupContact/Create.php [new file with mode: 0644]
Civi/Api4/Action/GroupContact/GroupContactSaveTrait.php [new file with mode: 0644]
Civi/Api4/Action/GroupContact/Save.php [new file with mode: 0644]
Civi/Api4/Action/GroupContact/Update.php [new file with mode: 0644]
Civi/Api4/Action/Relationship/Get.php [new file with mode: 0644]
Civi/Api4/Action/Setting/AbstractSettingAction.php [new file with mode: 0644]
Civi/Api4/Action/Setting/Get.php [new file with mode: 0644]
Civi/Api4/Action/Setting/GetFields.php [new file with mode: 0644]
Civi/Api4/Action/Setting/Revert.php [new file with mode: 0644]
Civi/Api4/Action/Setting/Set.php [new file with mode: 0644]
Civi/Api4/Action/System/Check.php [new file with mode: 0644]
Civi/Api4/Action/System/Flush.php [new file with mode: 0644]
Civi/Api4/ActionSchedule.php [new file with mode: 0644]
Civi/Api4/Activity.php [new file with mode: 0644]
Civi/Api4/ActivityContact.php [new file with mode: 0644]
Civi/Api4/Address.php [new file with mode: 0644]
Civi/Api4/Campaign.php [new file with mode: 0644]
Civi/Api4/Contact.php [new file with mode: 0644]
Civi/Api4/ContactType.php [new file with mode: 0644]
Civi/Api4/Contribution.php [new file with mode: 0644]
Civi/Api4/ContributionPage.php [new file with mode: 0644]
Civi/Api4/CustomField.php [new file with mode: 0644]
Civi/Api4/CustomGroup.php [new file with mode: 0644]
Civi/Api4/CustomValue.php [new file with mode: 0644]
Civi/Api4/Domain.php [new file with mode: 0644]
Civi/Api4/Email.php [new file with mode: 0644]
Civi/Api4/Entity.php [new file with mode: 0644]
Civi/Api4/EntityTag.php [new file with mode: 0644]
Civi/Api4/Event.php [new file with mode: 0644]
Civi/Api4/Event/Events.php [new file with mode: 0644]
Civi/Api4/Event/GetSpecEvent.php [new file with mode: 0644]
Civi/Api4/Event/PostSelectQueryEvent.php [new file with mode: 0644]
Civi/Api4/Event/SchemaMapBuildEvent.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/ActivityPreCreationSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/ActivitySchemaMapSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/ContactPreSaveSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/ContactSchemaMapSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/ContributionPreSaveSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/CustomFieldPreSaveSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/CustomGroupPreCreationSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/Generic/AbstractPrepareSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/Generic/PreCreationSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/Generic/PreSaveSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/IsCurrentSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/OptionValuePreCreationSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/PermissionCheckSubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/PostSelectQuerySubscriber.php [new file with mode: 0644]
Civi/Api4/Event/Subscriber/ValidateFieldsSubscriber.php [new file with mode: 0644]
Civi/Api4/Generic/AbstractAction.php [new file with mode: 0644]
Civi/Api4/Generic/AbstractBatchAction.php [new file with mode: 0644]
Civi/Api4/Generic/AbstractCreateAction.php [new file with mode: 0644]
Civi/Api4/Generic/AbstractEntity.php [new file with mode: 0644]
Civi/Api4/Generic/AbstractGetAction.php [new file with mode: 0644]
Civi/Api4/Generic/AbstractQueryAction.php [new file with mode: 0644]
Civi/Api4/Generic/AbstractSaveAction.php [new file with mode: 0644]
Civi/Api4/Generic/AbstractUpdateAction.php [new file with mode: 0644]
Civi/Api4/Generic/BasicBatchAction.php [new file with mode: 0644]
Civi/Api4/Generic/BasicCreateAction.php [new file with mode: 0644]
Civi/Api4/Generic/BasicGetAction.php [new file with mode: 0644]
Civi/Api4/Generic/BasicGetFieldsAction.php [new file with mode: 0644]
Civi/Api4/Generic/BasicReplaceAction.php [new file with mode: 0644]
Civi/Api4/Generic/BasicSaveAction.php [new file with mode: 0644]
Civi/Api4/Generic/BasicUpdateAction.php [new file with mode: 0644]
Civi/Api4/Generic/DAOCreateAction.php [new file with mode: 0644]
Civi/Api4/Generic/DAODeleteAction.php [new file with mode: 0644]
Civi/Api4/Generic/DAOEntity.php [new file with mode: 0644]
Civi/Api4/Generic/DAOGetAction.php [new file with mode: 0644]
Civi/Api4/Generic/DAOGetFieldsAction.php [new file with mode: 0644]
Civi/Api4/Generic/DAOSaveAction.php [new file with mode: 0644]
Civi/Api4/Generic/DAOUpdateAction.php [new file with mode: 0644]
Civi/Api4/Generic/Result.php [new file with mode: 0644]
Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php [new file with mode: 0644]
Civi/Api4/Generic/Traits/CustomValueActionTrait.php [new file with mode: 0644]
Civi/Api4/Generic/Traits/DAOActionTrait.php [new file with mode: 0644]
Civi/Api4/Generic/Traits/IsCurrentTrait.php [new file with mode: 0644]
Civi/Api4/Group.php [new file with mode: 0644]
Civi/Api4/GroupContact.php [new file with mode: 0644]
Civi/Api4/GroupNesting.php [new file with mode: 0644]
Civi/Api4/GroupOrganization.php [new file with mode: 0644]
Civi/Api4/IM.php [new file with mode: 0644]
Civi/Api4/LocationType.php [new file with mode: 0644]
Civi/Api4/MailSettings.php [new file with mode: 0644]
Civi/Api4/Mapping.php [new file with mode: 0644]
Civi/Api4/MappingField.php [new file with mode: 0644]
Civi/Api4/Navigation.php [new file with mode: 0644]
Civi/Api4/Note.php [new file with mode: 0644]
Civi/Api4/OpenID.php [new file with mode: 0644]
Civi/Api4/OptionGroup.php [new file with mode: 0644]
Civi/Api4/OptionValue.php [new file with mode: 0644]
Civi/Api4/Participant.php [new file with mode: 0644]
Civi/Api4/Phone.php [new file with mode: 0644]
Civi/Api4/Provider/ActionObjectProvider.php [new file with mode: 0644]
Civi/Api4/Query/Api4SelectQuery.php [new file with mode: 0644]
Civi/Api4/Relationship.php [new file with mode: 0644]
Civi/Api4/RelationshipType.php [new file with mode: 0644]
Civi/Api4/Result/ReplaceResult.php [new file with mode: 0644]
Civi/Api4/Route.php [new file with mode: 0644]
Civi/Api4/Service/Schema/Joinable/ActivityToActivityContactAssigneesJoinable.php [new file with mode: 0644]
Civi/Api4/Service/Schema/Joinable/BridgeJoinable.php [new file with mode: 0644]
Civi/Api4/Service/Schema/Joinable/CustomGroupJoinable.php [new file with mode: 0644]
Civi/Api4/Service/Schema/Joinable/Joinable.php [new file with mode: 0644]
Civi/Api4/Service/Schema/Joinable/OptionValueJoinable.php [new file with mode: 0644]
Civi/Api4/Service/Schema/Joiner.php [new file with mode: 0644]
Civi/Api4/Service/Schema/SchemaMap.php [new file with mode: 0644]
Civi/Api4/Service/Schema/SchemaMapBuilder.php [new file with mode: 0644]
Civi/Api4/Service/Schema/Table.php [new file with mode: 0644]
Civi/Api4/Service/Spec/CustomFieldSpec.php [new file with mode: 0644]
Civi/Api4/Service/Spec/FieldSpec.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/ACLCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/ActionScheduleCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/ActivityCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/AddressCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/CampaignCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/ContactCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/ContactTypeCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/ContributionCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/CustomFieldCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/CustomGroupCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/CustomValueSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/DefaultLocationTypeProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/DomainCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/EmailCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/EntityTagCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/EventCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/Generic/SpecProviderInterface.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/GetActionDefaultsProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/GroupCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/MappingCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/NavigationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/NoteCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/OptionValueCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/PhoneCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/RelationshipTypeCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/StatusPreferenceCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/TagCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/UFFieldCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/Provider/UFMatchCreationSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/RequestSpec.php [new file with mode: 0644]
Civi/Api4/Service/Spec/SpecFormatter.php [new file with mode: 0644]
Civi/Api4/Service/Spec/SpecGatherer.php [new file with mode: 0644]
Civi/Api4/Setting.php [new file with mode: 0644]
Civi/Api4/StatusPreference.php [new file with mode: 0644]
Civi/Api4/System.php [new file with mode: 0644]
Civi/Api4/Tag.php [new file with mode: 0644]
Civi/Api4/UFField.php [new file with mode: 0644]
Civi/Api4/UFGroup.php [new file with mode: 0644]
Civi/Api4/UFJoin.php [new file with mode: 0644]
Civi/Api4/UFMatch.php [new file with mode: 0644]
Civi/Api4/Utils/ActionUtil.php [new file with mode: 0644]
Civi/Api4/Utils/ArrayInsertionUtil.php [new file with mode: 0644]
Civi/Api4/Utils/CoreUtil.php [new file with mode: 0644]
Civi/Api4/Utils/FormattingUtil.php [new file with mode: 0644]
Civi/Api4/Utils/ReflectionUtils.php [new file with mode: 0644]
Civi/Api4/Website.php [new file with mode: 0644]
Civi/Api4/services.xml [new file with mode: 0644]
ang/api4.ang.php [new file with mode: 0644]
ang/api4.js [new file with mode: 0644]
ang/api4/crmApi4.js [new file with mode: 0644]
ang/api4Explorer.ang.php [new file with mode: 0644]
ang/api4Explorer.js [new file with mode: 0644]
ang/api4Explorer/Chain.html [new file with mode: 0644]
ang/api4Explorer/Explorer.html [new file with mode: 0644]
ang/api4Explorer/Explorer.js [new file with mode: 0644]
ang/api4Explorer/WhereClause.html [new file with mode: 0644]
css/api4-explorer.css [new file with mode: 0644]
js/load-bootstrap.js [new file with mode: 0644]
templates/CRM/Api4/Page/Api4Explorer.tpl [new file with mode: 0644]
tests/phpunit/api/v4/Action/BaseCustomValueTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/BasicActionsTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/BasicCustomFieldTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/ChainTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/ComplexQueryTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/ContactApiKeyTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/ContactChecksumTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/ContactGetTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/CreateCustomValueTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/CreateWithOptionGroupTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/CurrentFilterTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/CustomValuePerformanceTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/CustomValueTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/DateTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/EvaluateConditionTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/ExtendFromIndividualTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/FkJoinTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/GetExtraFieldsTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/GetFromArrayTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/IndexTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/NullValueTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/ReplaceTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/RequiredFieldTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/UpdateContactTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/UpdateCustomValueTest.php [new file with mode: 0644]
tests/phpunit/api/v4/AllTests.php [new file with mode: 0644]
tests/phpunit/api/v4/DataSets/ConformanceTest.json [new file with mode: 0644]
tests/phpunit/api/v4/DataSets/DefaultDataSet.json [new file with mode: 0644]
tests/phpunit/api/v4/DataSets/MultiContactMultiEmail.json [new file with mode: 0644]
tests/phpunit/api/v4/DataSets/SingleContact.json [new file with mode: 0644]
tests/phpunit/api/v4/Entity/ConformanceTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Entity/ContactJoinTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Entity/EntityTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Entity/ParticipantTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Entity/RouteTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Entity/SettingTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php [new file with mode: 0644]
tests/phpunit/api/v4/Mock/Api4/MockArrayEntity.php [new file with mode: 0644]
tests/phpunit/api/v4/Mock/Api4/MockBasicEntity.php [new file with mode: 0644]
tests/phpunit/api/v4/Mock/MockEntityDataStorage.php [new file with mode: 0644]
tests/phpunit/api/v4/Mock/MockV4ReflectionBase.php [new file with mode: 0644]
tests/phpunit/api/v4/Mock/MockV4ReflectionChild.php [new file with mode: 0644]
tests/phpunit/api/v4/Mock/MockV4ReflectionGrandchild.php [new file with mode: 0644]
tests/phpunit/api/v4/Query/Api4SelectQueryComplexJoinTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Query/Api4SelectQueryTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Query/OneToOneJoinTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Query/OptionValueJoinTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Query/SelectQueryMultiJoinTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Service/Schema/SchemaMapRealTableTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Service/Schema/SchemaMapperTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Service/TestCreationParameterProvider.php [new file with mode: 0644]
tests/phpunit/api/v4/Spec/RequestSpecTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Spec/SpecFormatterTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Spec/SpecGathererTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Traits/OptionCleanupTrait.php [new file with mode: 0644]
tests/phpunit/api/v4/Traits/QueryCounterTrait.php [new file with mode: 0644]
tests/phpunit/api/v4/Traits/TableDropperTrait.php [new file with mode: 0644]
tests/phpunit/api/v4/Traits/TestDataLoaderTrait.php [new file with mode: 0644]
tests/phpunit/api/v4/UnitTestCase.php [new file with mode: 0644]
tests/phpunit/api/v4/Utils/ArrayInsertionServiceTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Utils/ReflectionUtilsTest.php [new file with mode: 0644]
tests/phpunit/api/v4/services.xml [new file with mode: 0644]

diff --git a/CRM/Api4/Page/AJAX.php b/CRM/Api4/Page/AJAX.php
new file mode 100644 (file)
index 0000000..e8346ca
--- /dev/null
@@ -0,0 +1,72 @@
+<?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;
+  }
+
+}
diff --git a/CRM/Api4/Page/Api4Explorer.php b/CRM/Api4/Page/Api4Explorer.php
new file mode 100644 (file)
index 0000000..f064525
--- /dev/null
@@ -0,0 +1,28 @@
+<?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();
+  }
+
+}
diff --git a/CRM/Api4/Services.php b/CRM/Api4/Services.php
new file mode 100644 (file)
index 0000000..ddd889a
--- /dev/null
@@ -0,0 +1,75 @@
+<?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);
+        }
+      }
+    }
+  }
+
+}
diff --git a/CRM/Core/xml/Menu/Api4.xml b/CRM/Core/xml/Menu/Api4.xml
new file mode 100644 (file)
index 0000000..1973f23
--- /dev/null
@@ -0,0 +1,14 @@
+<?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>
diff --git a/Civi/Api4/ACL.php b/Civi/Api4/ACL.php
new file mode 100644 (file)
index 0000000..754a049
--- /dev/null
@@ -0,0 +1,19 @@
+<?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 {
+
+}
diff --git a/Civi/Api4/Action/Address/AddressSaveTrait.php b/Civi/Api4/Action/Address/AddressSaveTrait.php
new file mode 100644 (file)
index 0000000..640d67e
--- /dev/null
@@ -0,0 +1,50 @@
+<?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);
+  }
+
+}
diff --git a/Civi/Api4/Action/Address/Create.php b/Civi/Api4/Action/Address/Create.php
new file mode 100644 (file)
index 0000000..43278d4
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Civi\Api4\Action\Address;
+
+/**
+ * @inheritDoc
+ */
+class Create extends \Civi\Api4\Generic\DAOCreateAction {
+  use AddressSaveTrait;
+
+}
diff --git a/Civi/Api4/Action/Address/Save.php b/Civi/Api4/Action/Address/Save.php
new file mode 100644 (file)
index 0000000..d2b1e65
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Civi\Api4\Action\Address;
+
+/**
+ * @inheritDoc
+ */
+class Save extends \Civi\Api4\Generic\DAOSaveAction {
+  use AddressSaveTrait;
+
+}
diff --git a/Civi/Api4/Action/Address/Update.php b/Civi/Api4/Action/Address/Update.php
new file mode 100644 (file)
index 0000000..214c2ac
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Civi\Api4\Action\Address;
+
+/**
+ * @inheritDoc
+ */
+class Update extends \Civi\Api4\Generic\DAOUpdateAction {
+  use AddressSaveTrait;
+
+}
diff --git a/Civi/Api4/Action/Campaign/Get.php b/Civi/Api4/Action/Campaign/Get.php
new file mode 100644 (file)
index 0000000..4b81acc
--- /dev/null
@@ -0,0 +1,12 @@
+<?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;
+
+}
diff --git a/Civi/Api4/Action/Contact/GetChecksum.php b/Civi/Api4/Action/Contact/GetChecksum.php
new file mode 100644 (file)
index 0000000..daa81df
--- /dev/null
@@ -0,0 +1,45 @@
+<?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),
+    ];
+  }
+
+}
diff --git a/Civi/Api4/Action/Contact/GetFields.php b/Civi/Api4/Action/Contact/GetFields.php
new file mode 100644 (file)
index 0000000..1d6ddf6
--- /dev/null
@@ -0,0 +1,19 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Action/Contact/ValidateChecksum.php b/Civi/Api4/Action/Contact/ValidateChecksum.php
new file mode 100644 (file)
index 0000000..07819c7
--- /dev/null
@@ -0,0 +1,42 @@
+<?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),
+    ];
+  }
+
+}
diff --git a/Civi/Api4/Action/CustomValue/Create.php b/Civi/Api4/Action/CustomValue/Create.php
new file mode 100644 (file)
index 0000000..7d059b2
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+/**
+ * @inheritDoc
+ */
+class Create extends \Civi\Api4\Generic\DAOCreateAction {
+  use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+}
diff --git a/Civi/Api4/Action/CustomValue/Delete.php b/Civi/Api4/Action/CustomValue/Delete.php
new file mode 100644 (file)
index 0000000..7c52174
--- /dev/null
@@ -0,0 +1,11 @@
+<?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;
+
+}
diff --git a/Civi/Api4/Action/CustomValue/Get.php b/Civi/Api4/Action/CustomValue/Get.php
new file mode 100644 (file)
index 0000000..47f3f51
--- /dev/null
@@ -0,0 +1,11 @@
+<?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;
+
+}
diff --git a/Civi/Api4/Action/CustomValue/GetActions.php b/Civi/Api4/Action/CustomValue/GetActions.php
new file mode 100644 (file)
index 0000000..8af9088
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+/**
+ * @inheritDoc
+ */
+class GetActions extends \Civi\Api4\Action\GetActions {
+  use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+}
diff --git a/Civi/Api4/Action/CustomValue/GetFields.php b/Civi/Api4/Action/CustomValue/GetFields.php
new file mode 100644 (file)
index 0000000..398ee36
--- /dev/null
@@ -0,0 +1,33 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Action/CustomValue/Replace.php b/Civi/Api4/Action/CustomValue/Replace.php
new file mode 100644 (file)
index 0000000..457be9c
--- /dev/null
@@ -0,0 +1,11 @@
+<?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;
+
+}
diff --git a/Civi/Api4/Action/CustomValue/Save.php b/Civi/Api4/Action/CustomValue/Save.php
new file mode 100644 (file)
index 0000000..b298284
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Civi\Api4\Action\CustomValue;
+
+/**
+ * @inheritDoc
+ */
+class Save extends \Civi\Api4\Generic\DAOSaveAction {
+  use \Civi\Api4\Generic\Traits\CustomValueActionTrait;
+
+}
diff --git a/Civi/Api4/Action/CustomValue/Update.php b/Civi/Api4/Action/CustomValue/Update.php
new file mode 100644 (file)
index 0000000..14f66f2
--- /dev/null
@@ -0,0 +1,11 @@
+<?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;
+
+}
diff --git a/Civi/Api4/Action/Domain/Get.php b/Civi/Api4/Action/Domain/Get.php
new file mode 100644 (file)
index 0000000..9d67a80
--- /dev/null
@@ -0,0 +1,27 @@
+<?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();
+  }
+
+}
diff --git a/Civi/Api4/Action/Entity/Get.php b/Civi/Api4/Action/Entity/Get.php
new file mode 100644 (file)
index 0000000..1d4678c
--- /dev/null
@@ -0,0 +1,102 @@
+<?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']);
+  }
+
+}
diff --git a/Civi/Api4/Action/Entity/GetLinks.php b/Civi/Api4/Action/Entity/GetLinks.php
new file mode 100644 (file)
index 0000000..bfd3c3b
--- /dev/null
@@ -0,0 +1,51 @@
+<?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',
+      ],
+    ];
+  }
+
+}
diff --git a/Civi/Api4/Action/Event/Get.php b/Civi/Api4/Action/Event/Get.php
new file mode 100644 (file)
index 0000000..15cac2f
--- /dev/null
@@ -0,0 +1,12 @@
+<?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;
+
+}
diff --git a/Civi/Api4/Action/GetActions.php b/Civi/Api4/Action/GetActions.php
new file mode 100644 (file)
index 0000000..67da925
--- /dev/null
@@ -0,0 +1,111 @@
+<?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',
+      ],
+    ];
+  }
+
+}
diff --git a/Civi/Api4/Action/GroupContact/Create.php b/Civi/Api4/Action/GroupContact/Create.php
new file mode 100644 (file)
index 0000000..89dd869
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Civi\Api4\Action\GroupContact;
+
+/**
+ * @inheritDoc
+ */
+class Create extends \Civi\Api4\Generic\DAOCreateAction {
+  use GroupContactSaveTrait;
+
+}
diff --git a/Civi/Api4/Action/GroupContact/GroupContactSaveTrait.php b/Civi/Api4/Action/GroupContact/GroupContactSaveTrait.php
new file mode 100644 (file)
index 0000000..7142259
--- /dev/null
@@ -0,0 +1,40 @@
+<?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);
+  }
+
+}
diff --git a/Civi/Api4/Action/GroupContact/Save.php b/Civi/Api4/Action/GroupContact/Save.php
new file mode 100644 (file)
index 0000000..28ef046
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Civi\Api4\Action\GroupContact;
+
+/**
+ * @inheritDoc
+ */
+class Save extends \Civi\Api4\Generic\DAOSaveAction {
+  use GroupContactSaveTrait;
+
+}
diff --git a/Civi/Api4/Action/GroupContact/Update.php b/Civi/Api4/Action/GroupContact/Update.php
new file mode 100644 (file)
index 0000000..f9b0933
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Civi\Api4\Action\GroupContact;
+
+/**
+ * @inheritDoc
+ */
+class Update extends \Civi\Api4\Generic\DAOUpdateAction {
+  use GroupContactSaveTrait;
+
+}
diff --git a/Civi/Api4/Action/Relationship/Get.php b/Civi/Api4/Action/Relationship/Get.php
new file mode 100644 (file)
index 0000000..f94dad5
--- /dev/null
@@ -0,0 +1,12 @@
+<?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;
+
+}
diff --git a/Civi/Api4/Action/Setting/AbstractSettingAction.php b/Civi/Api4/Action/Setting/AbstractSettingAction.php
new file mode 100644 (file)
index 0000000..a0481e1
--- /dev/null
@@ -0,0 +1,80 @@
+<?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()];
+    }
+  }
+
+}
diff --git a/Civi/Api4/Action/Setting/Get.php b/Civi/Api4/Action/Setting/Get.php
new file mode 100644 (file)
index 0000000..31d5e8b
--- /dev/null
@@ -0,0 +1,55 @@
+<?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']);
+      }
+    }
+  }
+
+}
diff --git a/Civi/Api4/Action/Setting/GetFields.php b/Civi/Api4/Action/Setting/GetFields.php
new file mode 100644 (file)
index 0000000..5864a46
--- /dev/null
@@ -0,0 +1,84 @@
+<?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',
+      ],
+    ];
+  }
+
+}
diff --git a/Civi/Api4/Action/Setting/Revert.php b/Civi/Api4/Action/Setting/Revert.php
new file mode 100644 (file)
index 0000000..65540ab
--- /dev/null
@@ -0,0 +1,46 @@
+<?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']);
+      }
+    }
+  }
+
+}
diff --git a/Civi/Api4/Action/Setting/Set.php b/Civi/Api4/Action/Setting/Set.php
new file mode 100644 (file)
index 0000000..ad9804a
--- /dev/null
@@ -0,0 +1,44 @@
+<?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,
+      ];
+    }
+  }
+
+}
diff --git a/Civi/Api4/Action/System/Check.php b/Civi/Api4/Action/System/Check.php
new file mode 100644 (file)
index 0000000..1910f61
--- /dev/null
@@ -0,0 +1,83 @@
+<?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',
+      ],
+    ];
+  }
+
+}
diff --git a/Civi/Api4/Action/System/Flush.php b/Civi/Api4/Action/System/Flush.php
new file mode 100644 (file)
index 0000000..0a39138
--- /dev/null
@@ -0,0 +1,32 @@
+<?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);
+  }
+
+}
diff --git a/Civi/Api4/ActionSchedule.php b/Civi/Api4/ActionSchedule.php
new file mode 100644 (file)
index 0000000..a8235f6
--- /dev/null
@@ -0,0 +1,18 @@
+<?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 {
+
+}
diff --git a/Civi/Api4/Activity.php b/Civi/Api4/Activity.php
new file mode 100644 (file)
index 0000000..23ee4c1
--- /dev/null
@@ -0,0 +1,20 @@
+<?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 {
+
+}
diff --git a/Civi/Api4/ActivityContact.php b/Civi/Api4/ActivityContact.php
new file mode 100644 (file)
index 0000000..7ef438a
--- /dev/null
@@ -0,0 +1,16 @@
+<?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 {
+
+}
diff --git a/Civi/Api4/Address.php b/Civi/Api4/Address.php
new file mode 100644 (file)
index 0000000..be8df42
--- /dev/null
@@ -0,0 +1,39 @@
+<?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__);
+  }
+
+}
diff --git a/Civi/Api4/Campaign.php b/Civi/Api4/Campaign.php
new file mode 100644 (file)
index 0000000..ee85821
--- /dev/null
@@ -0,0 +1,19 @@
+<?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__);
+  }
+
+}
diff --git a/Civi/Api4/Contact.php b/Civi/Api4/Contact.php
new file mode 100644 (file)
index 0000000..7282ba7
--- /dev/null
@@ -0,0 +1,29 @@
+<?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__);
+  }
+
+}
diff --git a/Civi/Api4/ContactType.php b/Civi/Api4/ContactType.php
new file mode 100644 (file)
index 0000000..8ce6c8d
--- /dev/null
@@ -0,0 +1,18 @@
+<?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 {
+
+}
diff --git a/Civi/Api4/Contribution.php b/Civi/Api4/Contribution.php
new file mode 100644 (file)
index 0000000..1386cae
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Contribution entity.
+ *
+ * @package Civi\Api4
+ */
+class Contribution extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/ContributionPage.php b/Civi/Api4/ContributionPage.php
new file mode 100644 (file)
index 0000000..51c9232
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * ContributionPage entity.
+ *
+ * @package Civi\Api4
+ */
+class ContributionPage extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/CustomField.php b/Civi/Api4/CustomField.php
new file mode 100644 (file)
index 0000000..245c9f4
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * CustomField entity.
+ *
+ * @package Civi\Api4
+ */
+class CustomField extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/CustomGroup.php b/Civi/Api4/CustomGroup.php
new file mode 100644 (file)
index 0000000..780ccd2
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * CustomGroup entity.
+ *
+ * @package Civi\Api4
+ */
+class CustomGroup extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/CustomValue.php b/Civi/Api4/CustomValue.php
new file mode 100644 (file)
index 0000000..037470e
--- /dev/null
@@ -0,0 +1,87 @@
+<?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'];
+  }
+
+}
diff --git a/Civi/Api4/Domain.php b/Civi/Api4/Domain.php
new file mode 100644 (file)
index 0000000..9aebbf4
--- /dev/null
@@ -0,0 +1,16 @@
+<?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__);
+  }
+
+}
diff --git a/Civi/Api4/Email.php b/Civi/Api4/Email.php
new file mode 100644 (file)
index 0000000..cb743e3
--- /dev/null
@@ -0,0 +1,16 @@
+<?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 {
+
+}
diff --git a/Civi/Api4/Entity.php b/Civi/Api4/Entity.php
new file mode 100644 (file)
index 0000000..008e818
--- /dev/null
@@ -0,0 +1,48 @@
+<?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'],
+    ];
+  }
+
+}
diff --git a/Civi/Api4/EntityTag.php b/Civi/Api4/EntityTag.php
new file mode 100644 (file)
index 0000000..6274e0c
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * EntityTag - links tags to contacts, activities, etc.
+ *
+ * @package Civi\Api4
+ */
+class EntityTag extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/Event.php b/Civi/Api4/Event.php
new file mode 100644 (file)
index 0000000..70555af
--- /dev/null
@@ -0,0 +1,19 @@
+<?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__);
+  }
+
+}
diff --git a/Civi/Api4/Event/Events.php b/Civi/Api4/Event/Events.php
new file mode 100644 (file)
index 0000000..0bf4a99
--- /dev/null
@@ -0,0 +1,25 @@
+<?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';
+
+}
diff --git a/Civi/Api4/Event/GetSpecEvent.php b/Civi/Api4/Event/GetSpecEvent.php
new file mode 100644 (file)
index 0000000..d85f416
--- /dev/null
@@ -0,0 +1,35 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Event/PostSelectQueryEvent.php b/Civi/Api4/Event/PostSelectQueryEvent.php
new file mode 100644 (file)
index 0000000..15456d7
--- /dev/null
@@ -0,0 +1,64 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Event/SchemaMapBuildEvent.php b/Civi/Api4/Event/SchemaMapBuildEvent.php
new file mode 100644 (file)
index 0000000..7698eb2
--- /dev/null
@@ -0,0 +1,39 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/ActivityPreCreationSubscriber.php b/Civi/Api4/Event/Subscriber/ActivityPreCreationSubscriber.php
new file mode 100644 (file)
index 0000000..adfd17c
--- /dev/null
@@ -0,0 +1,41 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/ActivitySchemaMapSubscriber.php b/Civi/Api4/Event/Subscriber/ActivitySchemaMapSubscriber.php
new file mode 100644 (file)
index 0000000..4e05c44
--- /dev/null
@@ -0,0 +1,40 @@
+<?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);
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/ContactPreSaveSubscriber.php b/Civi/Api4/Event/Subscriber/ContactPreSaveSubscriber.php
new file mode 100644 (file)
index 0000000..21d2627
--- /dev/null
@@ -0,0 +1,25 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/ContactSchemaMapSubscriber.php b/Civi/Api4/Event/Subscriber/ContactSchemaMapSubscriber.php
new file mode 100644 (file)
index 0000000..f2c82a0
--- /dev/null
@@ -0,0 +1,54 @@
+<?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;
+      }
+    }
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/ContributionPreSaveSubscriber.php b/Civi/Api4/Event/Subscriber/ContributionPreSaveSubscriber.php
new file mode 100644 (file)
index 0000000..dfc723a
--- /dev/null
@@ -0,0 +1,18 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/CustomFieldPreSaveSubscriber.php b/Civi/Api4/Event/Subscriber/CustomFieldPreSaveSubscriber.php
new file mode 100644 (file)
index 0000000..0a4ce70
--- /dev/null
@@ -0,0 +1,38 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/CustomGroupPreCreationSubscriber.php b/Civi/Api4/Event/Subscriber/CustomGroupPreCreationSubscriber.php
new file mode 100644 (file)
index 0000000..e83c91f
--- /dev/null
@@ -0,0 +1,30 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/Generic/AbstractPrepareSubscriber.php b/Civi/Api4/Event/Subscriber/Generic/AbstractPrepareSubscriber.php
new file mode 100644 (file)
index 0000000..833e886
--- /dev/null
@@ -0,0 +1,25 @@
+<?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);
+
+}
diff --git a/Civi/Api4/Event/Subscriber/Generic/PreCreationSubscriber.php b/Civi/Api4/Event/Subscriber/Generic/PreCreationSubscriber.php
new file mode 100644 (file)
index 0000000..66f5366
--- /dev/null
@@ -0,0 +1,51 @@
+<?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) {
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/Generic/PreSaveSubscriber.php b/Civi/Api4/Event/Subscriber/Generic/PreSaveSubscriber.php
new file mode 100644 (file)
index 0000000..a36ba1f
--- /dev/null
@@ -0,0 +1,54 @@
+<?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);
+
+}
diff --git a/Civi/Api4/Event/Subscriber/IsCurrentSubscriber.php b/Civi/Api4/Event/Subscriber/IsCurrentSubscriber.php
new file mode 100644 (file)
index 0000000..4f1d349
--- /dev/null
@@ -0,0 +1,39 @@
+<?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);
+      }
+    }
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/OptionValuePreCreationSubscriber.php b/Civi/Api4/Event/Subscriber/OptionValuePreCreationSubscriber.php
new file mode 100644 (file)
index 0000000..2695cd4
--- /dev/null
@@ -0,0 +1,50 @@
+<?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']);
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/PermissionCheckSubscriber.php b/Civi/Api4/Event/Subscriber/PermissionCheckSubscriber.php
new file mode 100644 (file)
index 0000000..b63fdc5
--- /dev/null
@@ -0,0 +1,66 @@
+<?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();
+      }
+    }
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/PostSelectQuerySubscriber.php b/Civi/Api4/Event/Subscriber/PostSelectQuerySubscriber.php
new file mode 100644 (file)
index 0000000..1fed061
--- /dev/null
@@ -0,0 +1,331 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Event/Subscriber/ValidateFieldsSubscriber.php b/Civi/Api4/Event/Subscriber/ValidateFieldsSubscriber.php
new file mode 100644 (file)
index 0000000..2e7faab
--- /dev/null
@@ -0,0 +1,100 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/AbstractAction.php b/Civi/Api4/Generic/AbstractAction.php
new file mode 100644 (file)
index 0000000..cd316f6
--- /dev/null
@@ -0,0 +1,446 @@
+<?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));
+  }
+
+}
diff --git a/Civi/Api4/Generic/AbstractBatchAction.php b/Civi/Api4/Generic/AbstractBatchAction.php
new file mode 100644 (file)
index 0000000..2be3b8d
--- /dev/null
@@ -0,0 +1,64 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/AbstractCreateAction.php b/Civi/Api4/Generic/AbstractCreateAction.php
new file mode 100644 (file)
index 0000000..43f43b6
--- /dev/null
@@ -0,0 +1,42 @@
+<?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]);
+    }
+  }
+
+}
diff --git a/Civi/Api4/Generic/AbstractEntity.php b/Civi/Api4/Generic/AbstractEntity.php
new file mode 100644 (file)
index 0000000..ef380a5
--- /dev/null
@@ -0,0 +1,89 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/AbstractGetAction.php b/Civi/Api4/Generic/AbstractGetAction.php
new file mode 100644 (file)
index 0000000..842836f
--- /dev/null
@@ -0,0 +1,119 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/AbstractQueryAction.php b/Civi/Api4/Generic/AbstractQueryAction.php
new file mode 100644 (file)
index 0000000..82fd42f
--- /dev/null
@@ -0,0 +1,144 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/AbstractSaveAction.php b/Civi/Api4/Generic/AbstractSaveAction.php
new file mode 100644 (file)
index 0000000..117a1fe
--- /dev/null
@@ -0,0 +1,90 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/AbstractUpdateAction.php b/Civi/Api4/Generic/AbstractUpdateAction.php
new file mode 100644 (file)
index 0000000..47e2e53
--- /dev/null
@@ -0,0 +1,45 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/BasicBatchAction.php b/Civi/Api4/Generic/BasicBatchAction.php
new file mode 100644 (file)
index 0000000..49b76a5
--- /dev/null
@@ -0,0 +1,73 @@
+<?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());
+  }
+
+}
diff --git a/Civi/Api4/Generic/BasicCreateAction.php b/Civi/Api4/Generic/BasicCreateAction.php
new file mode 100644 (file)
index 0000000..ddd238f
--- /dev/null
@@ -0,0 +1,64 @@
+<?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());
+  }
+
+}
diff --git a/Civi/Api4/Generic/BasicGetAction.php b/Civi/Api4/Generic/BasicGetAction.php
new file mode 100644 (file)
index 0000000..033c702
--- /dev/null
@@ -0,0 +1,84 @@
+<?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());
+  }
+
+}
diff --git a/Civi/Api4/Generic/BasicGetFieldsAction.php b/Civi/Api4/Generic/BasicGetFieldsAction.php
new file mode 100644 (file)
index 0000000..198dc93
--- /dev/null
@@ -0,0 +1,156 @@
+<?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',
+      ],
+    ];
+  }
+
+}
diff --git a/Civi/Api4/Generic/BasicReplaceAction.php b/Civi/Api4/Generic/BasicReplaceAction.php
new file mode 100644 (file)
index 0000000..da4bac1
--- /dev/null
@@ -0,0 +1,114 @@
+<?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(),
+      ]);
+    }
+  }
+
+}
diff --git a/Civi/Api4/Generic/BasicSaveAction.php b/Civi/Api4/Generic/BasicSaveAction.php
new file mode 100644 (file)
index 0000000..c03d5af
--- /dev/null
@@ -0,0 +1,79 @@
+<?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());
+  }
+
+}
diff --git a/Civi/Api4/Generic/BasicUpdateAction.php b/Civi/Api4/Generic/BasicUpdateAction.php
new file mode 100644 (file)
index 0000000..796944e
--- /dev/null
@@ -0,0 +1,73 @@
+<?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());
+  }
+
+}
diff --git a/Civi/Api4/Generic/DAOCreateAction.php b/Civi/Api4/Generic/DAOCreateAction.php
new file mode 100644 (file)
index 0000000..091eacf
--- /dev/null
@@ -0,0 +1,36 @@
+<?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();
+  }
+
+}
diff --git a/Civi/Api4/Generic/DAODeleteAction.php b/Civi/Api4/Generic/DAODeleteAction.php
new file mode 100644 (file)
index 0000000..00b32fe
--- /dev/null
@@ -0,0 +1,75 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/DAOEntity.php b/Civi/Api4/Generic/DAOEntity.php
new file mode 100644 (file)
index 0000000..0f2b141
--- /dev/null
@@ -0,0 +1,59 @@
+<?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__);
+  }
+
+}
diff --git a/Civi/Api4/Generic/DAOGetAction.php b/Civi/Api4/Generic/DAOGetAction.php
new file mode 100644 (file)
index 0000000..2420034
--- /dev/null
@@ -0,0 +1,20 @@
+<?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());
+  }
+
+}
diff --git a/Civi/Api4/Generic/DAOGetFieldsAction.php b/Civi/Api4/Generic/DAOGetFieldsAction.php
new file mode 100644 (file)
index 0000000..9f351d0
--- /dev/null
@@ -0,0 +1,52 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/DAOSaveAction.php b/Civi/Api4/Generic/DAOSaveAction.php
new file mode 100644 (file)
index 0000000..4169ab3
--- /dev/null
@@ -0,0 +1,32 @@
+<?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);
+  }
+
+}
diff --git a/Civi/Api4/Generic/DAOUpdateAction.php b/Civi/Api4/Generic/DAOUpdateAction.php
new file mode 100644 (file)
index 0000000..28684ad
--- /dev/null
@@ -0,0 +1,62 @@
+<?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));
+  }
+
+}
diff --git a/Civi/Api4/Generic/Result.php b/Civi/Api4/Generic/Result.php
new file mode 100644 (file)
index 0000000..9930ce8
--- /dev/null
@@ -0,0 +1,131 @@
+<?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);
+  }
+
+}
diff --git a/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php b/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php
new file mode 100644 (file)
index 0000000..4435d1f
--- /dev/null
@@ -0,0 +1,201 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/Traits/CustomValueActionTrait.php b/Civi/Api4/Generic/Traits/CustomValueActionTrait.php
new file mode 100644 (file)
index 0000000..dc59ca7
--- /dev/null
@@ -0,0 +1,92 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Generic/Traits/DAOActionTrait.php b/Civi/Api4/Generic/Traits/DAOActionTrait.php
new file mode 100644 (file)
index 0000000..0d79389
--- /dev/null
@@ -0,0 +1,255 @@
+<?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);
+    }
+  }
+
+}
diff --git a/Civi/Api4/Generic/Traits/IsCurrentTrait.php b/Civi/Api4/Generic/Traits/IsCurrentTrait.php
new file mode 100644 (file)
index 0000000..97a008a
--- /dev/null
@@ -0,0 +1,40 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Group.php b/Civi/Api4/Group.php
new file mode 100644 (file)
index 0000000..b82fa98
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Group entity.
+ *
+ * @package Civi\Api4
+ */
+class Group extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/GroupContact.php b/Civi/Api4/GroupContact.php
new file mode 100644 (file)
index 0000000..e2664c2
--- /dev/null
@@ -0,0 +1,36 @@
+<?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__);
+  }
+
+}
diff --git a/Civi/Api4/GroupNesting.php b/Civi/Api4/GroupNesting.php
new file mode 100644 (file)
index 0000000..df3286b
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+namespace Civi\Api4;
+
+/**
+ * GroupNesting entity.
+ *
+ * @package Civi\Api4
+ */
+class GroupNesting extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/GroupOrganization.php b/Civi/Api4/GroupOrganization.php
new file mode 100644 (file)
index 0000000..0263a5b
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+namespace Civi\Api4;
+
+/**
+ * GroupOrganization entity.
+ *
+ * @package Civi\Api4
+ */
+class GroupOrganization extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/IM.php b/Civi/Api4/IM.php
new file mode 100644 (file)
index 0000000..514f39a
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * IM entity.
+ *
+ * @package Civi\Api4
+ */
+class IM extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/LocationType.php b/Civi/Api4/LocationType.php
new file mode 100644 (file)
index 0000000..3bea60b
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * LocationType entity.
+ *
+ * @package Civi\Api4
+ */
+class LocationType extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/MailSettings.php b/Civi/Api4/MailSettings.php
new file mode 100644 (file)
index 0000000..c03f0df
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * MailSettings entity.
+ *
+ * @package Civi\Api4
+ */
+class MailSettings extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/Mapping.php b/Civi/Api4/Mapping.php
new file mode 100644 (file)
index 0000000..910ae1a
--- /dev/null
@@ -0,0 +1,14 @@
+<?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 {
+
+}
diff --git a/Civi/Api4/MappingField.php b/Civi/Api4/MappingField.php
new file mode 100644 (file)
index 0000000..b1a1be1
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * MappingField entity.
+ *
+ * This represents one field in a Mapping collection.
+ *
+ * @package Civi\Api4
+ */
+class MappingField extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/Navigation.php b/Civi/Api4/Navigation.php
new file mode 100644 (file)
index 0000000..8c0f79d
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Navigation entity.
+ *
+ * @package Civi\Api4
+ */
+class Navigation extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/Note.php b/Civi/Api4/Note.php
new file mode 100644 (file)
index 0000000..55f6b7e
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Note entity.
+ *
+ * @package Civi\Api4
+ */
+class Note extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/OpenID.php b/Civi/Api4/OpenID.php
new file mode 100644 (file)
index 0000000..a0c146a
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * OpenID entity.
+ *
+ * @package Civi\Api4
+ */
+class OpenID extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/OptionGroup.php b/Civi/Api4/OptionGroup.php
new file mode 100644 (file)
index 0000000..4821348
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * OptionGroup entity.
+ *
+ * @package Civi\Api4
+ */
+class OptionGroup extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/OptionValue.php b/Civi/Api4/OptionValue.php
new file mode 100644 (file)
index 0000000..16e9706
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * OptionValue entity.
+ *
+ * @package Civi\Api4
+ */
+class OptionValue extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/Participant.php b/Civi/Api4/Participant.php
new file mode 100644 (file)
index 0000000..d0fce78
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Participant entity, stores the participation record of a contact in an event.
+ *
+ * @package Civi\Api4
+ */
+class Participant extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/Phone.php b/Civi/Api4/Phone.php
new file mode 100644 (file)
index 0000000..a02cd7c
--- /dev/null
@@ -0,0 +1,16 @@
+<?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 {
+
+}
diff --git a/Civi/Api4/Provider/ActionObjectProvider.php b/Civi/Api4/Provider/ActionObjectProvider.php
new file mode 100644 (file)
index 0000000..0169800
--- /dev/null
@@ -0,0 +1,160 @@
+<?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 [];
+  }
+
+}
diff --git a/Civi/Api4/Query/Api4SelectQuery.php b/Civi/Api4/Query/Api4SelectQuery.php
new file mode 100644 (file)
index 0000000..f7026ba
--- /dev/null
@@ -0,0 +1,580 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Relationship.php b/Civi/Api4/Relationship.php
new file mode 100644 (file)
index 0000000..49f0519
--- /dev/null
@@ -0,0 +1,19 @@
+<?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__);
+  }
+
+}
diff --git a/Civi/Api4/RelationshipType.php b/Civi/Api4/RelationshipType.php
new file mode 100644 (file)
index 0000000..1cd335c
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * RelationshipType entity.
+ *
+ * @package Civi\Api4
+ */
+class RelationshipType extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/Result/ReplaceResult.php b/Civi/Api4/Result/ReplaceResult.php
new file mode 100644 (file)
index 0000000..a11b76c
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+namespace Civi\Api4\Result;
+
+class ReplaceResult extends \Civi\Api4\Generic\Result {
+  /**
+   * @var array
+   */
+  public $deleted = [];
+
+}
diff --git a/Civi/Api4/Route.php b/Civi/Api4/Route.php
new file mode 100644 (file)
index 0000000..dda3a27
--- /dev/null
@@ -0,0 +1,76 @@
+<?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"],
+    ];
+  }
+
+}
diff --git a/Civi/Api4/Service/Schema/Joinable/ActivityToActivityContactAssigneesJoinable.php b/Civi/Api4/Service/Schema/Joinable/ActivityToActivityContactAssigneesJoinable.php
new file mode 100644 (file)
index 0000000..191f438
--- /dev/null
@@ -0,0 +1,40 @@
+<?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);
+  }
+
+}
diff --git a/Civi/Api4/Service/Schema/Joinable/BridgeJoinable.php b/Civi/Api4/Service/Schema/Joinable/BridgeJoinable.php
new file mode 100644 (file)
index 0000000..370c589
--- /dev/null
@@ -0,0 +1,23 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Service/Schema/Joinable/CustomGroupJoinable.php b/Civi/Api4/Service/Schema/Joinable/CustomGroupJoinable.php
new file mode 100644 (file)
index 0000000..4069b9a
--- /dev/null
@@ -0,0 +1,73 @@
+<?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];
+  }
+
+}
diff --git a/Civi/Api4/Service/Schema/Joinable/Joinable.php b/Civi/Api4/Service/Schema/Joinable/Joinable.php
new file mode 100644 (file)
index 0000000..f2f2991
--- /dev/null
@@ -0,0 +1,277 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Service/Schema/Joinable/OptionValueJoinable.php b/Civi/Api4/Service/Schema/Joinable/OptionValueJoinable.php
new file mode 100644 (file)
index 0000000..96f6548
--- /dev/null
@@ -0,0 +1,61 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Service/Schema/Joiner.php b/Civi/Api4/Service/Schema/Joiner.php
new file mode 100644 (file)
index 0000000..87677f1
--- /dev/null
@@ -0,0 +1,97 @@
+<?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];
+  }
+
+}
diff --git a/Civi/Api4/Service/Schema/SchemaMap.php b/Civi/Api4/Service/Schema/SchemaMap.php
new file mode 100644 (file)
index 0000000..ed3743d
--- /dev/null
@@ -0,0 +1,139 @@
+<?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);
+        }
+      }
+    }
+  }
+
+}
diff --git a/Civi/Api4/Service/Schema/SchemaMapBuilder.php b/Civi/Api4/Service/Schema/SchemaMapBuilder.php
new file mode 100644 (file)
index 0000000..e1e62a7
--- /dev/null
@@ -0,0 +1,217 @@
+<?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);
+    }
+  }
+
+}
diff --git a/Civi/Api4/Service/Schema/Table.php b/Civi/Api4/Service/Schema/Table.php
new file mode 100644 (file)
index 0000000..a6e706b
--- /dev/null
@@ -0,0 +1,128 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/CustomFieldSpec.php b/Civi/Api4/Service/Spec/CustomFieldSpec.php
new file mode 100644 (file)
index 0000000..2c68934
--- /dev/null
@@ -0,0 +1,118 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/FieldSpec.php b/Civi/Api4/Service/Spec/FieldSpec.php
new file mode 100644 (file)
index 0000000..685a7c7
--- /dev/null
@@ -0,0 +1,367 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/ACLCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/ACLCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..cbc126c
--- /dev/null
@@ -0,0 +1,23 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/ActionScheduleCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/ActionScheduleCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..e05b7d5
--- /dev/null
@@ -0,0 +1,27 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/ActivityCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/ActivityCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..52eb75a
--- /dev/null
@@ -0,0 +1,28 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/AddressCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/AddressCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..ecb6b27
--- /dev/null
@@ -0,0 +1,27 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/CampaignCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/CampaignCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..db0f48f
--- /dev/null
@@ -0,0 +1,24 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/ContactCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/ContactCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..9f82628
--- /dev/null
@@ -0,0 +1,31 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/ContactTypeCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/ContactTypeCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..88c115e
--- /dev/null
@@ -0,0 +1,29 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/ContributionCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/ContributionCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..42d2a7a
--- /dev/null
@@ -0,0 +1,24 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/CustomFieldCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/CustomFieldCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..4d3aba5
--- /dev/null
@@ -0,0 +1,28 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/CustomGroupCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/CustomGroupCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..aa3002d
--- /dev/null
@@ -0,0 +1,23 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/CustomValueSpecProvider.php b/Civi/Api4/Service/Spec/Provider/CustomValueSpecProvider.php
new file mode 100644 (file)
index 0000000..97b58ee
--- /dev/null
@@ -0,0 +1,34 @@
+<?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_');
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/DefaultLocationTypeProvider.php b/Civi/Api4/Service/Spec/Provider/DefaultLocationTypeProvider.php
new file mode 100644 (file)
index 0000000..54ee120
--- /dev/null
@@ -0,0 +1,27 @@
+<?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']);
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/DomainCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/DomainCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..890bd7e
--- /dev/null
@@ -0,0 +1,24 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/EmailCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/EmailCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..aa6db7c
--- /dev/null
@@ -0,0 +1,26 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/EntityTagCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/EntityTagCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..47df3af
--- /dev/null
@@ -0,0 +1,26 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/EventCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/EventCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..109a3a9
--- /dev/null
@@ -0,0 +1,33 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/Generic/SpecProviderInterface.php b/Civi/Api4/Service/Spec/Provider/Generic/SpecProviderInterface.php
new file mode 100644 (file)
index 0000000..4b7a988
--- /dev/null
@@ -0,0 +1,24 @@
+<?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);
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/GetActionDefaultsProvider.php b/Civi/Api4/Service/Spec/Provider/GetActionDefaultsProvider.php
new file mode 100644 (file)
index 0000000..5f67131
--- /dev/null
@@ -0,0 +1,33 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/GroupCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/GroupCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..b549fa0
--- /dev/null
@@ -0,0 +1,23 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/MappingCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/MappingCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..2139c5e
--- /dev/null
@@ -0,0 +1,28 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/NavigationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/NavigationSpecProvider.php
new file mode 100644 (file)
index 0000000..1a9188d
--- /dev/null
@@ -0,0 +1,25 @@
+<?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']);
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/NoteCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/NoteCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..2059254
--- /dev/null
@@ -0,0 +1,27 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/OptionValueCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/OptionValueCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..8e996ec
--- /dev/null
@@ -0,0 +1,24 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/PhoneCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/PhoneCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..a691772
--- /dev/null
@@ -0,0 +1,24 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/RelationshipTypeCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/RelationshipTypeCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..b2d60a5
--- /dev/null
@@ -0,0 +1,24 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/StatusPreferenceCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/StatusPreferenceCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..66fa7ad
--- /dev/null
@@ -0,0 +1,23 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/TagCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/TagCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..f4fe05a
--- /dev/null
@@ -0,0 +1,26 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/UFFieldCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/UFFieldCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..e7cf393
--- /dev/null
@@ -0,0 +1,23 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/Provider/UFMatchCreationSpecProvider.php b/Civi/Api4/Service/Spec/Provider/UFMatchCreationSpecProvider.php
new file mode 100644 (file)
index 0000000..06b9bb2
--- /dev/null
@@ -0,0 +1,23 @@
+<?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';
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/RequestSpec.php b/Civi/Api4/Service/Spec/RequestSpec.php
new file mode 100644 (file)
index 0000000..9437d93
--- /dev/null
@@ -0,0 +1,110 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/SpecFormatter.php b/Civi/Api4/Service/Spec/SpecFormatter.php
new file mode 100644 (file)
index 0000000..0ab4c65
--- /dev/null
@@ -0,0 +1,216 @@
+<?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'];
+  }
+
+}
diff --git a/Civi/Api4/Service/Spec/SpecGatherer.php b/Civi/Api4/Service/Spec/SpecGatherer.php
new file mode 100644 (file)
index 0000000..ca5da91
--- /dev/null
@@ -0,0 +1,140 @@
+<?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();
+  }
+
+}
diff --git a/Civi/Api4/Setting.php b/Civi/Api4/Setting.php
new file mode 100644 (file)
index 0000000..abd7679
--- /dev/null
@@ -0,0 +1,30 @@
+<?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__);
+  }
+
+}
diff --git a/Civi/Api4/StatusPreference.php b/Civi/Api4/StatusPreference.php
new file mode 100644 (file)
index 0000000..c89ff97
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * For setting "hush" preferences for system check alerts.
+ *
+ * @package Civi\Api4
+ */
+class StatusPreference extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/System.php b/Civi/Api4/System.php
new file mode 100644 (file)
index 0000000..fbbc37a
--- /dev/null
@@ -0,0 +1,28 @@
+<?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 [];
+    });
+  }
+
+}
diff --git a/Civi/Api4/Tag.php b/Civi/Api4/Tag.php
new file mode 100644 (file)
index 0000000..a56679d
--- /dev/null
@@ -0,0 +1,15 @@
+<?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 {
+
+}
diff --git a/Civi/Api4/UFField.php b/Civi/Api4/UFField.php
new file mode 100644 (file)
index 0000000..736554c
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * UFField entity - aka profile fields.
+ *
+ * @package Civi\Api4
+ */
+class UFField extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/UFGroup.php b/Civi/Api4/UFGroup.php
new file mode 100644 (file)
index 0000000..aeea02c
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * UFGroup entity - AKA profiles.
+ *
+ * @package Civi\Api4
+ */
+class UFGroup extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/UFJoin.php b/Civi/Api4/UFJoin.php
new file mode 100644 (file)
index 0000000..4d68fc7
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * UFJoin entity - links profiles to the components/extensions they are used for.
+ *
+ * @package Civi\Api4
+ */
+class UFJoin extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/UFMatch.php b/Civi/Api4/UFMatch.php
new file mode 100644 (file)
index 0000000..0840c44
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * UFMatch entity - links civicrm contacts with users created externally
+ *
+ * @package Civi\Api4
+ */
+class UFMatch extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/Utils/ActionUtil.php b/Civi/Api4/Utils/ActionUtil.php
new file mode 100644 (file)
index 0000000..628bc6f
--- /dev/null
@@ -0,0 +1,27 @@
+<?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);
+    }
+  }
+
+}
diff --git a/Civi/Api4/Utils/ArrayInsertionUtil.php b/Civi/Api4/Utils/ArrayInsertionUtil.php
new file mode 100644 (file)
index 0000000..301e3f6
--- /dev/null
@@ -0,0 +1,74 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Utils/CoreUtil.php b/Civi/Api4/Utils/CoreUtil.php
new file mode 100644 (file)
index 0000000..b4ed48e
--- /dev/null
@@ -0,0 +1,53 @@
+<?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));
+  }
+
+}
diff --git a/Civi/Api4/Utils/FormattingUtil.php b/Civi/Api4/Utils/FormattingUtil.php
new file mode 100644 (file)
index 0000000..6a69e29
--- /dev/null
@@ -0,0 +1,97 @@
+<?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;
+    }
+  }
+
+}
diff --git a/Civi/Api4/Utils/ReflectionUtils.php b/Civi/Api4/Utils/ReflectionUtils.php
new file mode 100644 (file)
index 0000000..535f952
--- /dev/null
@@ -0,0 +1,140 @@
+<?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;
+  }
+
+}
diff --git a/Civi/Api4/Website.php b/Civi/Api4/Website.php
new file mode 100644 (file)
index 0000000..fb890a0
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Website entity.
+ *
+ * @package Civi\Api4
+ */
+class Website extends Generic\DAOEntity {
+
+}
diff --git a/Civi/Api4/services.xml b/Civi/Api4/services.xml
new file mode 100644 (file)
index 0000000..3d8a2fc
--- /dev/null
@@ -0,0 +1,26 @@
+<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>
diff --git a/ang/api4.ang.php b/ang/api4.ang.php
new file mode 100644 (file)
index 0000000..f7d69b3
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+// Autoloader data for Api4 angular module.
+return [
+  'js' => [
+    'ang/api4.js',
+    'ang/api4/*.js',
+    'ang/api4/*/*.js',
+  ],
+  'css' => [],
+  'partials' => [],
+  'requires' => [],
+];
diff --git a/ang/api4.js b/ang/api4.js
new file mode 100644 (file)
index 0000000..d1116fc
--- /dev/null
@@ -0,0 +1,4 @@
+(function(angular, $, _) {
+  // Declare a list of dependencies.
+  angular.module('api4', CRM.angRequires('api4'));
+})(angular, CRM.$, CRM._);
diff --git a/ang/api4/crmApi4.js b/ang/api4/crmApi4.js
new file mode 100644 (file)
index 0000000..743b359
--- /dev/null
@@ -0,0 +1,37 @@
+(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._);
diff --git a/ang/api4Explorer.ang.php b/ang/api4Explorer.ang.php
new file mode 100644 (file)
index 0000000..176d740
--- /dev/null
@@ -0,0 +1,18 @@
+<?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'],
+];
diff --git a/ang/api4Explorer.js b/ang/api4Explorer.js
new file mode 100644 (file)
index 0000000..85e10c4
--- /dev/null
@@ -0,0 +1,4 @@
+(function(angular, $, _) {
+  // Declare a list of dependencies.
+  angular.module('api4Explorer', CRM.angRequires('api4Explorer'));
+})(angular, CRM.$, CRM._);
diff --git a/ang/api4Explorer/Chain.html b/ang/api4Explorer/Chain.html
new file mode 100644 (file)
index 0000000..257efde
--- /dev/null
@@ -0,0 +1,4 @@
+<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') }}" />
diff --git a/ang/api4Explorer/Explorer.html b/ang/api4Explorer/Explorer.html
new file mode 100644 (file)
index 0000000..816262c
--- /dev/null
@@ -0,0 +1,152 @@
+<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>
diff --git a/ang/api4Explorer/Explorer.js b/ang/api4Explorer/Explorer.js
new file mode 100644 (file)
index 0000000..e08796d
--- /dev/null
@@ -0,0 +1,853 @@
+(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._);
diff --git a/ang/api4Explorer/WhereClause.html b/ang/api4Explorer/WhereClause.html
new file mode 100644 (file)
index 0000000..d36480f
--- /dev/null
@@ -0,0 +1,39 @@
+<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
diff --git a/css/api4-explorer.css b/css/api4-explorer.css
new file mode 100644 (file)
index 0000000..a5be9c8
--- /dev/null
@@ -0,0 +1,195 @@
+/* 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;
+}
diff --git a/js/load-bootstrap.js b/js/load-bootstrap.js
new file mode 100644 (file)
index 0000000..aff280a
--- /dev/null
@@ -0,0 +1,7 @@
+// 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
diff --git a/templates/CRM/Api4/Page/Api4Explorer.tpl b/templates/CRM/Api4/Page/Api4Explorer.tpl
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/phpunit/api/v4/Action/BaseCustomValueTest.php b/tests/phpunit/api/v4/Action/BaseCustomValueTest.php
new file mode 100644 (file)
index 0000000..fd4dc71
--- /dev/null
@@ -0,0 +1,31 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/BasicActionsTest.php b/tests/phpunit/api/v4/Action/BasicActionsTest.php
new file mode 100644 (file)
index 0000000..18e76e5
--- /dev/null
@@ -0,0 +1,183 @@
+<?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'));
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/BasicCustomFieldTest.php b/tests/phpunit/api/v4/Action/BasicCustomFieldTest.php
new file mode 100644 (file)
index 0000000..b115d5f
--- /dev/null
@@ -0,0 +1,180 @@
+<?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));
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/ChainTest.php b/tests/phpunit/api/v4/Action/ChainTest.php
new file mode 100644 (file)
index 0000000..cd896a8
--- /dev/null
@@ -0,0 +1,53 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/ComplexQueryTest.php b/tests/phpunit/api/v4/Action/ComplexQueryTest.php
new file mode 100644 (file)
index 0000000..51fb272
--- /dev/null
@@ -0,0 +1,87 @@
+<?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() {
+
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/ContactApiKeyTest.php b/tests/phpunit/api/v4/Action/ContactApiKeyTest.php
new file mode 100644 (file)
index 0000000..c5885e2
--- /dev/null
@@ -0,0 +1,210 @@
+<?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'));
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/ContactChecksumTest.php b/tests/phpunit/api/v4/Action/ContactChecksumTest.php
new file mode 100644 (file)
index 0000000..abf65ca
--- /dev/null
@@ -0,0 +1,56 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/ContactGetTest.php b/tests/phpunit/api/v4/Action/ContactGetTest.php
new file mode 100644 (file)
index 0000000..e023361
--- /dev/null
@@ -0,0 +1,42 @@
+<?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'));
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/CreateCustomValueTest.php b/tests/phpunit/api/v4/Action/CreateCustomValueTest.php
new file mode 100644 (file)
index 0000000..9cb1198
--- /dev/null
@@ -0,0 +1,64 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/CreateWithOptionGroupTest.php b/tests/phpunit/api/v4/Action/CreateWithOptionGroupTest.php
new file mode 100644 (file)
index 0000000..6a2a356
--- /dev/null
@@ -0,0 +1,185 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/CurrentFilterTest.php b/tests/phpunit/api/v4/Action/CurrentFilterTest.php
new file mode 100644 (file)
index 0000000..4034dbe
--- /dev/null
@@ -0,0 +1,71 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/CustomValuePerformanceTest.php b/tests/phpunit/api/v4/Action/CustomValuePerformanceTest.php
new file mode 100644 (file)
index 0000000..1990f6c
--- /dev/null
@@ -0,0 +1,95 @@
+<?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.
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/CustomValueTest.php b/tests/phpunit/api/v4/Action/CustomValueTest.php
new file mode 100644 (file)
index 0000000..72d1275
--- /dev/null
@@ -0,0 +1,192 @@
+<?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));
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/DateTest.php b/tests/phpunit/api/v4/Action/DateTest.php
new file mode 100644 (file)
index 0000000..507d80e
--- /dev/null
@@ -0,0 +1,46 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/EvaluateConditionTest.php b/tests/phpunit/api/v4/Action/EvaluateConditionTest.php
new file mode 100644 (file)
index 0000000..31f7441
--- /dev/null
@@ -0,0 +1,38 @@
+<?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));
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/ExtendFromIndividualTest.php b/tests/phpunit/api/v4/Action/ExtendFromIndividualTest.php
new file mode 100644 (file)
index 0000000..1d91364
--- /dev/null
@@ -0,0 +1,52 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/FkJoinTest.php b/tests/phpunit/api/v4/Action/FkJoinTest.php
new file mode 100644 (file)
index 0000000..85bf67e
--- /dev/null
@@ -0,0 +1,75 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php b/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php
new file mode 100644 (file)
index 0000000..837d7d8
--- /dev/null
@@ -0,0 +1,26 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/GetFromArrayTest.php b/tests/phpunit/api/v4/Action/GetFromArrayTest.php
new file mode 100644 (file)
index 0000000..06a0c17
--- /dev/null
@@ -0,0 +1,163 @@
+<?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'));
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/IndexTest.php b/tests/phpunit/api/v4/Action/IndexTest.php
new file mode 100644 (file)
index 0000000..9e33ea4
--- /dev/null
@@ -0,0 +1,48 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/NullValueTest.php b/tests/phpunit/api/v4/Action/NullValueTest.php
new file mode 100644 (file)
index 0000000..c8b6516
--- /dev/null
@@ -0,0 +1,55 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/ReplaceTest.php b/tests/phpunit/api/v4/Action/ReplaceTest.php
new file mode 100644 (file)
index 0000000..701f4e2
--- /dev/null
@@ -0,0 +1,171 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/RequiredFieldTest.php b/tests/phpunit/api/v4/Action/RequiredFieldTest.php
new file mode 100644 (file)
index 0000000..2374b33
--- /dev/null
@@ -0,0 +1,24 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/UpdateContactTest.php b/tests/phpunit/api/v4/Action/UpdateContactTest.php
new file mode 100644 (file)
index 0000000..b1bffbb
--- /dev/null
@@ -0,0 +1,54 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Action/UpdateCustomValueTest.php b/tests/phpunit/api/v4/Action/UpdateCustomValueTest.php
new file mode 100644 (file)
index 0000000..23b6d2a
--- /dev/null
@@ -0,0 +1,56 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/AllTests.php b/tests/phpunit/api/v4/AllTests.php
new file mode 100644 (file)
index 0000000..aea9bf3
--- /dev/null
@@ -0,0 +1,68 @@
+<?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:
diff --git a/tests/phpunit/api/v4/DataSets/ConformanceTest.json b/tests/phpunit/api/v4/DataSets/ConformanceTest.json
new file mode 100644 (file)
index 0000000..1028c5b
--- /dev/null
@@ -0,0 +1,42 @@
+{
+  "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"
+    }
+  ]
+}
diff --git a/tests/phpunit/api/v4/DataSets/DefaultDataSet.json b/tests/phpunit/api/v4/DataSets/DefaultDataSet.json
new file mode 100644 (file)
index 0000000..7d4a91b
--- /dev/null
@@ -0,0 +1,45 @@
+{
+  "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"
+    }
+  ]
+}
diff --git a/tests/phpunit/api/v4/DataSets/MultiContactMultiEmail.json b/tests/phpunit/api/v4/DataSets/MultiContactMultiEmail.json
new file mode 100644 (file)
index 0000000..ce3fbca
--- /dev/null
@@ -0,0 +1,42 @@
+{
+  "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"
+    }
+  ]
+}
diff --git a/tests/phpunit/api/v4/DataSets/SingleContact.json b/tests/phpunit/api/v4/DataSets/SingleContact.json
new file mode 100644 (file)
index 0000000..73e7369
--- /dev/null
@@ -0,0 +1,81 @@
+{
+  "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
+    }
+  ]
+}
diff --git a/tests/phpunit/api/v4/Entity/ConformanceTest.php b/tests/phpunit/api/v4/Entity/ConformanceTest.php
new file mode 100644 (file)
index 0000000..a038cbf
--- /dev/null
@@ -0,0 +1,248 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Entity/ContactJoinTest.php b/tests/phpunit/api/v4/Entity/ContactJoinTest.php
new file mode 100644 (file)
index 0000000..268520a
--- /dev/null
@@ -0,0 +1,103 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Entity/EntityTest.php b/tests/phpunit/api/v4/Entity/EntityTest.php
new file mode 100644 (file)
index 0000000..7e0a392
--- /dev/null
@@ -0,0 +1,35 @@
+<?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");
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Entity/ParticipantTest.php b/tests/phpunit/api/v4/Entity/ParticipantTest.php
new file mode 100644 (file)
index 0000000..7822ae9
--- /dev/null
@@ -0,0 +1,221 @@
+<?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());
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Entity/RouteTest.php b/tests/phpunit/api/v4/Entity/RouteTest.php
new file mode 100644 (file)
index 0000000..d096029
--- /dev/null
@@ -0,0 +1,21 @@
+<?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());
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Entity/SettingTest.php b/tests/phpunit/api/v4/Entity/SettingTest.php
new file mode 100644 (file)
index 0000000..4443228
--- /dev/null
@@ -0,0 +1,36 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php b/tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php
new file mode 100644 (file)
index 0000000..f276baf
--- /dev/null
@@ -0,0 +1,53 @@
+<?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,
+      ],
+    ];
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Mock/Api4/MockArrayEntity.php b/tests/phpunit/api/v4/Mock/Api4/MockArrayEntity.php
new file mode 100644 (file)
index 0000000..b6e3fac
--- /dev/null
@@ -0,0 +1,22 @@
+<?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 [];
+    });
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Mock/Api4/MockBasicEntity.php b/tests/phpunit/api/v4/Mock/Api4/MockBasicEntity.php
new file mode 100644 (file)
index 0000000..cc81035
--- /dev/null
@@ -0,0 +1,101 @@
+<?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__);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Mock/MockEntityDataStorage.php b/tests/phpunit/api/v4/Mock/MockEntityDataStorage.php
new file mode 100644 (file)
index 0000000..1f2ad10
--- /dev/null
@@ -0,0 +1,34 @@
+<?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;
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Mock/MockV4ReflectionBase.php b/tests/phpunit/api/v4/Mock/MockV4ReflectionBase.php
new file mode 100644 (file)
index 0000000..c014f35
--- /dev/null
@@ -0,0 +1,22 @@
+<?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 = [];
+
+}
diff --git a/tests/phpunit/api/v4/Mock/MockV4ReflectionChild.php b/tests/phpunit/api/v4/Mock/MockV4ReflectionChild.php
new file mode 100644 (file)
index 0000000..0aa5777
--- /dev/null
@@ -0,0 +1,16 @@
+<?php
+
+namespace api\v4\Mock;
+
+/**
+ * @inheritDoc
+ */
+class MockV4ReflectionChild extends MockV4ReflectionBase {
+  /**
+   * @var array
+   *
+   * In the child class, foo has been barred.
+   */
+  public $foo = ['bar' => 1];
+
+}
diff --git a/tests/phpunit/api/v4/Mock/MockV4ReflectionGrandchild.php b/tests/phpunit/api/v4/Mock/MockV4ReflectionGrandchild.php
new file mode 100644 (file)
index 0000000..12cec53
--- /dev/null
@@ -0,0 +1,16 @@
+<?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 {
+
+}
diff --git a/tests/phpunit/api/v4/Query/Api4SelectQueryComplexJoinTest.php b/tests/phpunit/api/v4/Query/Api4SelectQueryComplexJoinTest.php
new file mode 100644 (file)
index 0000000..129db65
--- /dev/null
@@ -0,0 +1,87 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Query/Api4SelectQueryTest.php b/tests/phpunit/api/v4/Query/Api4SelectQueryTest.php
new file mode 100644 (file)
index 0000000..a866558
--- /dev/null
@@ -0,0 +1,89 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Query/OneToOneJoinTest.php b/tests/phpunit/api/v4/Query/OneToOneJoinTest.php
new file mode 100644 (file)
index 0000000..0fa0c94
--- /dev/null
@@ -0,0 +1,44 @@
+<?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');
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Query/OptionValueJoinTest.php b/tests/phpunit/api/v4/Query/OptionValueJoinTest.php
new file mode 100644 (file)
index 0000000..eb6bfa4
--- /dev/null
@@ -0,0 +1,46 @@
+<?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']
+    );
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Query/SelectQueryMultiJoinTest.php b/tests/phpunit/api/v4/Query/SelectQueryMultiJoinTest.php
new file mode 100644 (file)
index 0000000..8d11e29
--- /dev/null
@@ -0,0 +1,75 @@
+<?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);
+      }
+    }
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Service/Schema/SchemaMapRealTableTest.php b/tests/phpunit/api/v4/Service/Schema/SchemaMapRealTableTest.php
new file mode 100644 (file)
index 0000000..fb5710f
--- /dev/null
@@ -0,0 +1,23 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Service/Schema/SchemaMapperTest.php b/tests/phpunit/api/v4/Service/Schema/SchemaMapperTest.php
new file mode 100644 (file)
index 0000000..0c47982
--- /dev/null
@@ -0,0 +1,90 @@
+<?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'));
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Service/TestCreationParameterProvider.php b/tests/phpunit/api/v4/Service/TestCreationParameterProvider.php
new file mode 100644 (file)
index 0000000..477c43f
--- /dev/null
@@ -0,0 +1,144 @@
+<?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;
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Spec/RequestSpecTest.php b/tests/phpunit/api/v4/Spec/RequestSpecTest.php
new file mode 100644 (file)
index 0000000..81db002
--- /dev/null
@@ -0,0 +1,42 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Spec/SpecFormatterTest.php b/tests/phpunit/api/v4/Spec/SpecFormatterTest.php
new file mode 100644 (file)
index 0000000..d54ea28
--- /dev/null
@@ -0,0 +1,93 @@
+<?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',
+      ],
+    ];
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Spec/SpecGathererTest.php b/tests/phpunit/api/v4/Spec/SpecGathererTest.php
new file mode 100644 (file)
index 0000000..c030952
--- /dev/null
@@ -0,0 +1,95 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Traits/OptionCleanupTrait.php b/tests/phpunit/api/v4/Traits/OptionCleanupTrait.php
new file mode 100644 (file)
index 0000000..a848516
--- /dev/null
@@ -0,0 +1,24 @@
+<?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);
+    }
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Traits/QueryCounterTrait.php b/tests/phpunit/api/v4/Traits/QueryCounterTrait.php
new file mode 100644 (file)
index 0000000..988d8c2
--- /dev/null
@@ -0,0 +1,43 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Traits/TableDropperTrait.php b/tests/phpunit/api/v4/Traits/TableDropperTrait.php
new file mode 100644 (file)
index 0000000..a623e5a
--- /dev/null
@@ -0,0 +1,24 @@
+<?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);
+    }
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Traits/TestDataLoaderTrait.php b/tests/phpunit/api/v4/Traits/TestDataLoaderTrait.php
new file mode 100644 (file)
index 0000000..b03ad8c
--- /dev/null
@@ -0,0 +1,69 @@
+<?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;
+  }
+
+}
diff --git a/tests/phpunit/api/v4/UnitTestCase.php b/tests/phpunit/api/v4/UnitTestCase.php
new file mode 100644 (file)
index 0000000..49f4628
--- /dev/null
@@ -0,0 +1,241 @@
+<?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];
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Utils/ArrayInsertionServiceTest.php b/tests/phpunit/api/v4/Utils/ArrayInsertionServiceTest.php
new file mode 100644 (file)
index 0000000..81d7878
--- /dev/null
@@ -0,0 +1,67 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Utils/ReflectionUtilsTest.php b/tests/phpunit/api/v4/Utils/ReflectionUtilsTest.php
new file mode 100644 (file)
index 0000000..57b5aad
--- /dev/null
@@ -0,0 +1,46 @@
+<?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']);
+  }
+
+}
diff --git a/tests/phpunit/api/v4/services.xml b/tests/phpunit/api/v4/services.xml
new file mode 100644 (file)
index 0000000..3310b03
--- /dev/null
@@ -0,0 +1,10 @@
+<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>