From 19b53e5b6c7ff859e40d1750def795e5968f45b1 Mon Sep 17 00:00:00 2001 From: CiviCRM Date: Sat, 14 Sep 2019 23:48:45 -0400 Subject: [PATCH] api4 - Import CRM/, Civi/, templates/, ang/, css/, js/, xml/menu --- CRM/Api4/Page/AJAX.php | 72 ++ CRM/Api4/Page/Api4Explorer.php | 28 + CRM/Api4/Services.php | 75 ++ CRM/Core/xml/Menu/Api4.xml | 14 + Civi/Api4/ACL.php | 19 + Civi/Api4/Action/Address/AddressSaveTrait.php | 50 + Civi/Api4/Action/Address/Create.php | 11 + Civi/Api4/Action/Address/Save.php | 11 + Civi/Api4/Action/Address/Update.php | 11 + Civi/Api4/Action/Campaign/Get.php | 12 + Civi/Api4/Action/Contact/GetChecksum.php | 45 + Civi/Api4/Action/Contact/GetFields.php | 19 + Civi/Api4/Action/Contact/ValidateChecksum.php | 42 + Civi/Api4/Action/CustomValue/Create.php | 11 + Civi/Api4/Action/CustomValue/Delete.php | 11 + Civi/Api4/Action/CustomValue/Get.php | 11 + Civi/Api4/Action/CustomValue/GetActions.php | 11 + Civi/Api4/Action/CustomValue/GetFields.php | 33 + Civi/Api4/Action/CustomValue/Replace.php | 11 + Civi/Api4/Action/CustomValue/Save.php | 11 + Civi/Api4/Action/CustomValue/Update.php | 11 + Civi/Api4/Action/Domain/Get.php | 27 + Civi/Api4/Action/Entity/Get.php | 102 +++ Civi/Api4/Action/Entity/GetLinks.php | 51 ++ Civi/Api4/Action/Event/Get.php | 12 + Civi/Api4/Action/GetActions.php | 111 +++ Civi/Api4/Action/GroupContact/Create.php | 11 + .../GroupContact/GroupContactSaveTrait.php | 40 + Civi/Api4/Action/GroupContact/Save.php | 11 + Civi/Api4/Action/GroupContact/Update.php | 11 + Civi/Api4/Action/Relationship/Get.php | 12 + .../Action/Setting/AbstractSettingAction.php | 80 ++ Civi/Api4/Action/Setting/Get.php | 55 ++ Civi/Api4/Action/Setting/GetFields.php | 84 ++ Civi/Api4/Action/Setting/Revert.php | 46 + Civi/Api4/Action/Setting/Set.php | 44 + Civi/Api4/Action/System/Check.php | 83 ++ Civi/Api4/Action/System/Flush.php | 32 + Civi/Api4/ActionSchedule.php | 18 + Civi/Api4/Activity.php | 20 + Civi/Api4/ActivityContact.php | 16 + Civi/Api4/Address.php | 39 + Civi/Api4/Campaign.php | 19 + Civi/Api4/Contact.php | 29 + Civi/Api4/ContactType.php | 18 + Civi/Api4/Contribution.php | 12 + Civi/Api4/ContributionPage.php | 12 + Civi/Api4/CustomField.php | 12 + Civi/Api4/CustomGroup.php | 12 + Civi/Api4/CustomValue.php | 87 ++ Civi/Api4/Domain.php | 16 + Civi/Api4/Email.php | 16 + Civi/Api4/Entity.php | 48 + Civi/Api4/EntityTag.php | 12 + Civi/Api4/Event.php | 19 + Civi/Api4/Event/Events.php | 25 + Civi/Api4/Event/GetSpecEvent.php | 35 + Civi/Api4/Event/PostSelectQueryEvent.php | 64 ++ Civi/Api4/Event/SchemaMapBuildEvent.php | 39 + .../ActivityPreCreationSubscriber.php | 41 + .../ActivitySchemaMapSubscriber.php | 40 + .../Subscriber/ContactPreSaveSubscriber.php | 25 + .../Subscriber/ContactSchemaMapSubscriber.php | 54 ++ .../ContributionPreSaveSubscriber.php | 18 + .../CustomFieldPreSaveSubscriber.php | 38 + .../CustomGroupPreCreationSubscriber.php | 30 + .../Generic/AbstractPrepareSubscriber.php | 25 + .../Generic/PreCreationSubscriber.php | 51 ++ .../Subscriber/Generic/PreSaveSubscriber.php | 54 ++ .../Event/Subscriber/IsCurrentSubscriber.php | 39 + .../OptionValuePreCreationSubscriber.php | 50 + .../Subscriber/PermissionCheckSubscriber.php | 66 ++ .../Subscriber/PostSelectQuerySubscriber.php | 331 +++++++ .../Subscriber/ValidateFieldsSubscriber.php | 100 ++ Civi/Api4/Generic/AbstractAction.php | 446 +++++++++ Civi/Api4/Generic/AbstractBatchAction.php | 64 ++ Civi/Api4/Generic/AbstractCreateAction.php | 42 + Civi/Api4/Generic/AbstractEntity.php | 89 ++ Civi/Api4/Generic/AbstractGetAction.php | 119 +++ Civi/Api4/Generic/AbstractQueryAction.php | 144 +++ Civi/Api4/Generic/AbstractSaveAction.php | 90 ++ Civi/Api4/Generic/AbstractUpdateAction.php | 45 + Civi/Api4/Generic/BasicBatchAction.php | 73 ++ Civi/Api4/Generic/BasicCreateAction.php | 64 ++ Civi/Api4/Generic/BasicGetAction.php | 84 ++ Civi/Api4/Generic/BasicGetFieldsAction.php | 156 ++++ Civi/Api4/Generic/BasicReplaceAction.php | 114 +++ Civi/Api4/Generic/BasicSaveAction.php | 79 ++ Civi/Api4/Generic/BasicUpdateAction.php | 73 ++ Civi/Api4/Generic/DAOCreateAction.php | 36 + Civi/Api4/Generic/DAODeleteAction.php | 75 ++ Civi/Api4/Generic/DAOEntity.php | 59 ++ Civi/Api4/Generic/DAOGetAction.php | 20 + Civi/Api4/Generic/DAOGetFieldsAction.php | 52 ++ Civi/Api4/Generic/DAOSaveAction.php | 32 + Civi/Api4/Generic/DAOUpdateAction.php | 62 ++ Civi/Api4/Generic/Result.php | 131 +++ .../Generic/Traits/ArrayQueryActionTrait.php | 201 +++++ .../Generic/Traits/CustomValueActionTrait.php | 92 ++ Civi/Api4/Generic/Traits/DAOActionTrait.php | 255 ++++++ Civi/Api4/Generic/Traits/IsCurrentTrait.php | 40 + Civi/Api4/Group.php | 12 + Civi/Api4/GroupContact.php | 36 + Civi/Api4/GroupNesting.php | 11 + Civi/Api4/GroupOrganization.php | 11 + Civi/Api4/IM.php | 12 + Civi/Api4/LocationType.php | 12 + Civi/Api4/MailSettings.php | 12 + Civi/Api4/Mapping.php | 14 + Civi/Api4/MappingField.php | 14 + Civi/Api4/Navigation.php | 12 + Civi/Api4/Note.php | 12 + Civi/Api4/OpenID.php | 12 + Civi/Api4/OptionGroup.php | 12 + Civi/Api4/OptionValue.php | 12 + Civi/Api4/Participant.php | 12 + Civi/Api4/Phone.php | 16 + Civi/Api4/Provider/ActionObjectProvider.php | 160 ++++ Civi/Api4/Query/Api4SelectQuery.php | 580 ++++++++++++ Civi/Api4/Relationship.php | 19 + Civi/Api4/RelationshipType.php | 12 + Civi/Api4/Result/ReplaceResult.php | 10 + Civi/Api4/Route.php | 76 ++ ...vityToActivityContactAssigneesJoinable.php | 40 + .../Schema/Joinable/BridgeJoinable.php | 23 + .../Schema/Joinable/CustomGroupJoinable.php | 73 ++ .../Api4/Service/Schema/Joinable/Joinable.php | 277 ++++++ .../Schema/Joinable/OptionValueJoinable.php | 61 ++ Civi/Api4/Service/Schema/Joiner.php | 97 ++ Civi/Api4/Service/Schema/SchemaMap.php | 139 +++ Civi/Api4/Service/Schema/SchemaMapBuilder.php | 217 +++++ Civi/Api4/Service/Schema/Table.php | 128 +++ Civi/Api4/Service/Spec/CustomFieldSpec.php | 118 +++ Civi/Api4/Service/Spec/FieldSpec.php | 367 ++++++++ .../Spec/Provider/ACLCreationSpecProvider.php | 23 + .../ActionScheduleCreationSpecProvider.php | 27 + .../Provider/ActivityCreationSpecProvider.php | 28 + .../Provider/AddressCreationSpecProvider.php | 27 + .../Provider/CampaignCreationSpecProvider.php | 24 + .../Provider/ContactCreationSpecProvider.php | 31 + .../ContactTypeCreationSpecProvider.php | 29 + .../ContributionCreationSpecProvider.php | 24 + .../CustomFieldCreationSpecProvider.php | 28 + .../CustomGroupCreationSpecProvider.php | 23 + .../Spec/Provider/CustomValueSpecProvider.php | 34 + .../Provider/DefaultLocationTypeProvider.php | 27 + .../Provider/DomainCreationSpecProvider.php | 24 + .../Provider/EmailCreationSpecProvider.php | 26 + .../EntityTagCreationSpecProvider.php | 26 + .../Provider/EventCreationSpecProvider.php | 33 + .../Generic/SpecProviderInterface.php | 24 + .../Provider/GetActionDefaultsProvider.php | 33 + .../Provider/GroupCreationSpecProvider.php | 23 + .../Provider/MappingCreationSpecProvider.php | 28 + .../Spec/Provider/NavigationSpecProvider.php | 25 + .../Provider/NoteCreationSpecProvider.php | 27 + .../OptionValueCreationSpecProvider.php | 24 + .../Provider/PhoneCreationSpecProvider.php | 24 + .../RelationshipTypeCreationSpecProvider.php | 24 + .../StatusPreferenceCreationSpecProvider.php | 23 + .../Spec/Provider/TagCreationSpecProvider.php | 26 + .../Provider/UFFieldCreationSpecProvider.php | 23 + .../Provider/UFMatchCreationSpecProvider.php | 23 + Civi/Api4/Service/Spec/RequestSpec.php | 110 +++ Civi/Api4/Service/Spec/SpecFormatter.php | 216 +++++ Civi/Api4/Service/Spec/SpecGatherer.php | 140 +++ Civi/Api4/Setting.php | 30 + Civi/Api4/StatusPreference.php | 12 + Civi/Api4/System.php | 28 + Civi/Api4/Tag.php | 15 + Civi/Api4/UFField.php | 12 + Civi/Api4/UFGroup.php | 12 + Civi/Api4/UFJoin.php | 12 + Civi/Api4/UFMatch.php | 12 + Civi/Api4/Utils/ActionUtil.php | 27 + Civi/Api4/Utils/ArrayInsertionUtil.php | 74 ++ Civi/Api4/Utils/CoreUtil.php | 53 ++ Civi/Api4/Utils/FormattingUtil.php | 97 ++ Civi/Api4/Utils/ReflectionUtils.php | 140 +++ Civi/Api4/Website.php | 12 + Civi/Api4/services.xml | 26 + ang/api4.ang.php | 12 + ang/api4.js | 4 + ang/api4/crmApi4.js | 37 + ang/api4Explorer.ang.php | 18 + ang/api4Explorer.js | 4 + ang/api4Explorer/Chain.html | 4 + ang/api4Explorer/Explorer.html | 152 ++++ ang/api4Explorer/Explorer.js | 853 ++++++++++++++++++ ang/api4Explorer/WhereClause.html | 39 + css/api4-explorer.css | 195 ++++ js/load-bootstrap.js | 7 + templates/CRM/Api4/Page/Api4Explorer.tpl | 0 .../api/v4/Action/BaseCustomValueTest.php | 31 + .../api/v4/Action/BasicActionsTest.php | 183 ++++ .../api/v4/Action/BasicCustomFieldTest.php | 180 ++++ tests/phpunit/api/v4/Action/ChainTest.php | 53 ++ .../api/v4/Action/ComplexQueryTest.php | 87 ++ .../api/v4/Action/ContactApiKeyTest.php | 210 +++++ .../api/v4/Action/ContactChecksumTest.php | 56 ++ .../phpunit/api/v4/Action/ContactGetTest.php | 42 + .../api/v4/Action/CreateCustomValueTest.php | 64 ++ .../v4/Action/CreateWithOptionGroupTest.php | 185 ++++ .../api/v4/Action/CurrentFilterTest.php | 71 ++ .../v4/Action/CustomValuePerformanceTest.php | 95 ++ .../phpunit/api/v4/Action/CustomValueTest.php | 192 ++++ tests/phpunit/api/v4/Action/DateTest.php | 46 + .../api/v4/Action/EvaluateConditionTest.php | 38 + .../v4/Action/ExtendFromIndividualTest.php | 52 ++ tests/phpunit/api/v4/Action/FkJoinTest.php | 75 ++ .../api/v4/Action/GetExtraFieldsTest.php | 26 + .../api/v4/Action/GetFromArrayTest.php | 163 ++++ tests/phpunit/api/v4/Action/IndexTest.php | 48 + tests/phpunit/api/v4/Action/NullValueTest.php | 55 ++ tests/phpunit/api/v4/Action/ReplaceTest.php | 171 ++++ .../api/v4/Action/RequiredFieldTest.php | 24 + .../api/v4/Action/UpdateContactTest.php | 54 ++ .../api/v4/Action/UpdateCustomValueTest.php | 56 ++ tests/phpunit/api/v4/AllTests.php | 68 ++ .../api/v4/DataSets/ConformanceTest.json | 42 + .../api/v4/DataSets/DefaultDataSet.json | 45 + .../v4/DataSets/MultiContactMultiEmail.json | 42 + .../api/v4/DataSets/SingleContact.json | 81 ++ .../phpunit/api/v4/Entity/ConformanceTest.php | 248 +++++ .../phpunit/api/v4/Entity/ContactJoinTest.php | 103 +++ tests/phpunit/api/v4/Entity/EntityTest.php | 35 + .../phpunit/api/v4/Entity/ParticipantTest.php | 221 +++++ tests/phpunit/api/v4/Entity/RouteTest.php | 21 + tests/phpunit/api/v4/Entity/SettingTest.php | 36 + .../Mock/Api4/Action/MockArrayEntity/Get.php | 53 ++ .../api/v4/Mock/Api4/MockArrayEntity.php | 22 + .../api/v4/Mock/Api4/MockBasicEntity.php | 101 +++ .../api/v4/Mock/MockEntityDataStorage.php | 34 + .../api/v4/Mock/MockV4ReflectionBase.php | 22 + .../api/v4/Mock/MockV4ReflectionChild.php | 16 + .../v4/Mock/MockV4ReflectionGrandchild.php | 16 + .../Query/Api4SelectQueryComplexJoinTest.php | 87 ++ .../api/v4/Query/Api4SelectQueryTest.php | 89 ++ .../phpunit/api/v4/Query/OneToOneJoinTest.php | 44 + .../api/v4/Query/OptionValueJoinTest.php | 46 + .../api/v4/Query/SelectQueryMultiJoinTest.php | 75 ++ .../Service/Schema/SchemaMapRealTableTest.php | 23 + .../v4/Service/Schema/SchemaMapperTest.php | 90 ++ .../Service/TestCreationParameterProvider.php | 144 +++ tests/phpunit/api/v4/Spec/RequestSpecTest.php | 42 + .../phpunit/api/v4/Spec/SpecFormatterTest.php | 93 ++ .../phpunit/api/v4/Spec/SpecGathererTest.php | 95 ++ .../api/v4/Traits/OptionCleanupTrait.php | 24 + .../api/v4/Traits/QueryCounterTrait.php | 43 + .../api/v4/Traits/TableDropperTrait.php | 24 + .../api/v4/Traits/TestDataLoaderTrait.php | 69 ++ tests/phpunit/api/v4/UnitTestCase.php | 241 +++++ .../v4/Utils/ArrayInsertionServiceTest.php | 67 ++ .../api/v4/Utils/ReflectionUtilsTest.php | 46 + tests/phpunit/api/v4/services.xml | 10 + 255 files changed, 16146 insertions(+) create mode 100644 CRM/Api4/Page/AJAX.php create mode 100644 CRM/Api4/Page/Api4Explorer.php create mode 100644 CRM/Api4/Services.php create mode 100644 CRM/Core/xml/Menu/Api4.xml create mode 100644 Civi/Api4/ACL.php create mode 100644 Civi/Api4/Action/Address/AddressSaveTrait.php create mode 100644 Civi/Api4/Action/Address/Create.php create mode 100644 Civi/Api4/Action/Address/Save.php create mode 100644 Civi/Api4/Action/Address/Update.php create mode 100644 Civi/Api4/Action/Campaign/Get.php create mode 100644 Civi/Api4/Action/Contact/GetChecksum.php create mode 100644 Civi/Api4/Action/Contact/GetFields.php create mode 100644 Civi/Api4/Action/Contact/ValidateChecksum.php create mode 100644 Civi/Api4/Action/CustomValue/Create.php create mode 100644 Civi/Api4/Action/CustomValue/Delete.php create mode 100644 Civi/Api4/Action/CustomValue/Get.php create mode 100644 Civi/Api4/Action/CustomValue/GetActions.php create mode 100644 Civi/Api4/Action/CustomValue/GetFields.php create mode 100644 Civi/Api4/Action/CustomValue/Replace.php create mode 100644 Civi/Api4/Action/CustomValue/Save.php create mode 100644 Civi/Api4/Action/CustomValue/Update.php create mode 100644 Civi/Api4/Action/Domain/Get.php create mode 100644 Civi/Api4/Action/Entity/Get.php create mode 100644 Civi/Api4/Action/Entity/GetLinks.php create mode 100644 Civi/Api4/Action/Event/Get.php create mode 100644 Civi/Api4/Action/GetActions.php create mode 100644 Civi/Api4/Action/GroupContact/Create.php create mode 100644 Civi/Api4/Action/GroupContact/GroupContactSaveTrait.php create mode 100644 Civi/Api4/Action/GroupContact/Save.php create mode 100644 Civi/Api4/Action/GroupContact/Update.php create mode 100644 Civi/Api4/Action/Relationship/Get.php create mode 100644 Civi/Api4/Action/Setting/AbstractSettingAction.php create mode 100644 Civi/Api4/Action/Setting/Get.php create mode 100644 Civi/Api4/Action/Setting/GetFields.php create mode 100644 Civi/Api4/Action/Setting/Revert.php create mode 100644 Civi/Api4/Action/Setting/Set.php create mode 100644 Civi/Api4/Action/System/Check.php create mode 100644 Civi/Api4/Action/System/Flush.php create mode 100644 Civi/Api4/ActionSchedule.php create mode 100644 Civi/Api4/Activity.php create mode 100644 Civi/Api4/ActivityContact.php create mode 100644 Civi/Api4/Address.php create mode 100644 Civi/Api4/Campaign.php create mode 100644 Civi/Api4/Contact.php create mode 100644 Civi/Api4/ContactType.php create mode 100644 Civi/Api4/Contribution.php create mode 100644 Civi/Api4/ContributionPage.php create mode 100644 Civi/Api4/CustomField.php create mode 100644 Civi/Api4/CustomGroup.php create mode 100644 Civi/Api4/CustomValue.php create mode 100644 Civi/Api4/Domain.php create mode 100644 Civi/Api4/Email.php create mode 100644 Civi/Api4/Entity.php create mode 100644 Civi/Api4/EntityTag.php create mode 100644 Civi/Api4/Event.php create mode 100644 Civi/Api4/Event/Events.php create mode 100644 Civi/Api4/Event/GetSpecEvent.php create mode 100644 Civi/Api4/Event/PostSelectQueryEvent.php create mode 100644 Civi/Api4/Event/SchemaMapBuildEvent.php create mode 100644 Civi/Api4/Event/Subscriber/ActivityPreCreationSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/ActivitySchemaMapSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/ContactPreSaveSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/ContactSchemaMapSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/ContributionPreSaveSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/CustomFieldPreSaveSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/CustomGroupPreCreationSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/Generic/AbstractPrepareSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/Generic/PreCreationSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/Generic/PreSaveSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/IsCurrentSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/OptionValuePreCreationSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/PermissionCheckSubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/PostSelectQuerySubscriber.php create mode 100644 Civi/Api4/Event/Subscriber/ValidateFieldsSubscriber.php create mode 100644 Civi/Api4/Generic/AbstractAction.php create mode 100644 Civi/Api4/Generic/AbstractBatchAction.php create mode 100644 Civi/Api4/Generic/AbstractCreateAction.php create mode 100644 Civi/Api4/Generic/AbstractEntity.php create mode 100644 Civi/Api4/Generic/AbstractGetAction.php create mode 100644 Civi/Api4/Generic/AbstractQueryAction.php create mode 100644 Civi/Api4/Generic/AbstractSaveAction.php create mode 100644 Civi/Api4/Generic/AbstractUpdateAction.php create mode 100644 Civi/Api4/Generic/BasicBatchAction.php create mode 100644 Civi/Api4/Generic/BasicCreateAction.php create mode 100644 Civi/Api4/Generic/BasicGetAction.php create mode 100644 Civi/Api4/Generic/BasicGetFieldsAction.php create mode 100644 Civi/Api4/Generic/BasicReplaceAction.php create mode 100644 Civi/Api4/Generic/BasicSaveAction.php create mode 100644 Civi/Api4/Generic/BasicUpdateAction.php create mode 100644 Civi/Api4/Generic/DAOCreateAction.php create mode 100644 Civi/Api4/Generic/DAODeleteAction.php create mode 100644 Civi/Api4/Generic/DAOEntity.php create mode 100644 Civi/Api4/Generic/DAOGetAction.php create mode 100644 Civi/Api4/Generic/DAOGetFieldsAction.php create mode 100644 Civi/Api4/Generic/DAOSaveAction.php create mode 100644 Civi/Api4/Generic/DAOUpdateAction.php create mode 100644 Civi/Api4/Generic/Result.php create mode 100644 Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php create mode 100644 Civi/Api4/Generic/Traits/CustomValueActionTrait.php create mode 100644 Civi/Api4/Generic/Traits/DAOActionTrait.php create mode 100644 Civi/Api4/Generic/Traits/IsCurrentTrait.php create mode 100644 Civi/Api4/Group.php create mode 100644 Civi/Api4/GroupContact.php create mode 100644 Civi/Api4/GroupNesting.php create mode 100644 Civi/Api4/GroupOrganization.php create mode 100644 Civi/Api4/IM.php create mode 100644 Civi/Api4/LocationType.php create mode 100644 Civi/Api4/MailSettings.php create mode 100644 Civi/Api4/Mapping.php create mode 100644 Civi/Api4/MappingField.php create mode 100644 Civi/Api4/Navigation.php create mode 100644 Civi/Api4/Note.php create mode 100644 Civi/Api4/OpenID.php create mode 100644 Civi/Api4/OptionGroup.php create mode 100644 Civi/Api4/OptionValue.php create mode 100644 Civi/Api4/Participant.php create mode 100644 Civi/Api4/Phone.php create mode 100644 Civi/Api4/Provider/ActionObjectProvider.php create mode 100644 Civi/Api4/Query/Api4SelectQuery.php create mode 100644 Civi/Api4/Relationship.php create mode 100644 Civi/Api4/RelationshipType.php create mode 100644 Civi/Api4/Result/ReplaceResult.php create mode 100644 Civi/Api4/Route.php create mode 100644 Civi/Api4/Service/Schema/Joinable/ActivityToActivityContactAssigneesJoinable.php create mode 100644 Civi/Api4/Service/Schema/Joinable/BridgeJoinable.php create mode 100644 Civi/Api4/Service/Schema/Joinable/CustomGroupJoinable.php create mode 100644 Civi/Api4/Service/Schema/Joinable/Joinable.php create mode 100644 Civi/Api4/Service/Schema/Joinable/OptionValueJoinable.php create mode 100644 Civi/Api4/Service/Schema/Joiner.php create mode 100644 Civi/Api4/Service/Schema/SchemaMap.php create mode 100644 Civi/Api4/Service/Schema/SchemaMapBuilder.php create mode 100644 Civi/Api4/Service/Schema/Table.php create mode 100644 Civi/Api4/Service/Spec/CustomFieldSpec.php create mode 100644 Civi/Api4/Service/Spec/FieldSpec.php create mode 100644 Civi/Api4/Service/Spec/Provider/ACLCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/ActionScheduleCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/ActivityCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/AddressCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/CampaignCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/ContactCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/ContactTypeCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/ContributionCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/CustomFieldCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/CustomGroupCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/CustomValueSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/DefaultLocationTypeProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/DomainCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/EmailCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/EntityTagCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/EventCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/Generic/SpecProviderInterface.php create mode 100644 Civi/Api4/Service/Spec/Provider/GetActionDefaultsProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/GroupCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/MappingCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/NavigationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/NoteCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/OptionValueCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/PhoneCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/RelationshipTypeCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/StatusPreferenceCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/TagCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/UFFieldCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/Provider/UFMatchCreationSpecProvider.php create mode 100644 Civi/Api4/Service/Spec/RequestSpec.php create mode 100644 Civi/Api4/Service/Spec/SpecFormatter.php create mode 100644 Civi/Api4/Service/Spec/SpecGatherer.php create mode 100644 Civi/Api4/Setting.php create mode 100644 Civi/Api4/StatusPreference.php create mode 100644 Civi/Api4/System.php create mode 100644 Civi/Api4/Tag.php create mode 100644 Civi/Api4/UFField.php create mode 100644 Civi/Api4/UFGroup.php create mode 100644 Civi/Api4/UFJoin.php create mode 100644 Civi/Api4/UFMatch.php create mode 100644 Civi/Api4/Utils/ActionUtil.php create mode 100644 Civi/Api4/Utils/ArrayInsertionUtil.php create mode 100644 Civi/Api4/Utils/CoreUtil.php create mode 100644 Civi/Api4/Utils/FormattingUtil.php create mode 100644 Civi/Api4/Utils/ReflectionUtils.php create mode 100644 Civi/Api4/Website.php create mode 100644 Civi/Api4/services.xml create mode 100644 ang/api4.ang.php create mode 100644 ang/api4.js create mode 100644 ang/api4/crmApi4.js create mode 100644 ang/api4Explorer.ang.php create mode 100644 ang/api4Explorer.js create mode 100644 ang/api4Explorer/Chain.html create mode 100644 ang/api4Explorer/Explorer.html create mode 100644 ang/api4Explorer/Explorer.js create mode 100644 ang/api4Explorer/WhereClause.html create mode 100644 css/api4-explorer.css create mode 100644 js/load-bootstrap.js create mode 100644 templates/CRM/Api4/Page/Api4Explorer.tpl create mode 100644 tests/phpunit/api/v4/Action/BaseCustomValueTest.php create mode 100644 tests/phpunit/api/v4/Action/BasicActionsTest.php create mode 100644 tests/phpunit/api/v4/Action/BasicCustomFieldTest.php create mode 100644 tests/phpunit/api/v4/Action/ChainTest.php create mode 100644 tests/phpunit/api/v4/Action/ComplexQueryTest.php create mode 100644 tests/phpunit/api/v4/Action/ContactApiKeyTest.php create mode 100644 tests/phpunit/api/v4/Action/ContactChecksumTest.php create mode 100644 tests/phpunit/api/v4/Action/ContactGetTest.php create mode 100644 tests/phpunit/api/v4/Action/CreateCustomValueTest.php create mode 100644 tests/phpunit/api/v4/Action/CreateWithOptionGroupTest.php create mode 100644 tests/phpunit/api/v4/Action/CurrentFilterTest.php create mode 100644 tests/phpunit/api/v4/Action/CustomValuePerformanceTest.php create mode 100644 tests/phpunit/api/v4/Action/CustomValueTest.php create mode 100644 tests/phpunit/api/v4/Action/DateTest.php create mode 100644 tests/phpunit/api/v4/Action/EvaluateConditionTest.php create mode 100644 tests/phpunit/api/v4/Action/ExtendFromIndividualTest.php create mode 100644 tests/phpunit/api/v4/Action/FkJoinTest.php create mode 100644 tests/phpunit/api/v4/Action/GetExtraFieldsTest.php create mode 100644 tests/phpunit/api/v4/Action/GetFromArrayTest.php create mode 100644 tests/phpunit/api/v4/Action/IndexTest.php create mode 100644 tests/phpunit/api/v4/Action/NullValueTest.php create mode 100644 tests/phpunit/api/v4/Action/ReplaceTest.php create mode 100644 tests/phpunit/api/v4/Action/RequiredFieldTest.php create mode 100644 tests/phpunit/api/v4/Action/UpdateContactTest.php create mode 100644 tests/phpunit/api/v4/Action/UpdateCustomValueTest.php create mode 100644 tests/phpunit/api/v4/AllTests.php create mode 100644 tests/phpunit/api/v4/DataSets/ConformanceTest.json create mode 100644 tests/phpunit/api/v4/DataSets/DefaultDataSet.json create mode 100644 tests/phpunit/api/v4/DataSets/MultiContactMultiEmail.json create mode 100644 tests/phpunit/api/v4/DataSets/SingleContact.json create mode 100644 tests/phpunit/api/v4/Entity/ConformanceTest.php create mode 100644 tests/phpunit/api/v4/Entity/ContactJoinTest.php create mode 100644 tests/phpunit/api/v4/Entity/EntityTest.php create mode 100644 tests/phpunit/api/v4/Entity/ParticipantTest.php create mode 100644 tests/phpunit/api/v4/Entity/RouteTest.php create mode 100644 tests/phpunit/api/v4/Entity/SettingTest.php create mode 100644 tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php create mode 100644 tests/phpunit/api/v4/Mock/Api4/MockArrayEntity.php create mode 100644 tests/phpunit/api/v4/Mock/Api4/MockBasicEntity.php create mode 100644 tests/phpunit/api/v4/Mock/MockEntityDataStorage.php create mode 100644 tests/phpunit/api/v4/Mock/MockV4ReflectionBase.php create mode 100644 tests/phpunit/api/v4/Mock/MockV4ReflectionChild.php create mode 100644 tests/phpunit/api/v4/Mock/MockV4ReflectionGrandchild.php create mode 100644 tests/phpunit/api/v4/Query/Api4SelectQueryComplexJoinTest.php create mode 100644 tests/phpunit/api/v4/Query/Api4SelectQueryTest.php create mode 100644 tests/phpunit/api/v4/Query/OneToOneJoinTest.php create mode 100644 tests/phpunit/api/v4/Query/OptionValueJoinTest.php create mode 100644 tests/phpunit/api/v4/Query/SelectQueryMultiJoinTest.php create mode 100644 tests/phpunit/api/v4/Service/Schema/SchemaMapRealTableTest.php create mode 100644 tests/phpunit/api/v4/Service/Schema/SchemaMapperTest.php create mode 100644 tests/phpunit/api/v4/Service/TestCreationParameterProvider.php create mode 100644 tests/phpunit/api/v4/Spec/RequestSpecTest.php create mode 100644 tests/phpunit/api/v4/Spec/SpecFormatterTest.php create mode 100644 tests/phpunit/api/v4/Spec/SpecGathererTest.php create mode 100644 tests/phpunit/api/v4/Traits/OptionCleanupTrait.php create mode 100644 tests/phpunit/api/v4/Traits/QueryCounterTrait.php create mode 100644 tests/phpunit/api/v4/Traits/TableDropperTrait.php create mode 100644 tests/phpunit/api/v4/Traits/TestDataLoaderTrait.php create mode 100644 tests/phpunit/api/v4/UnitTestCase.php create mode 100644 tests/phpunit/api/v4/Utils/ArrayInsertionServiceTest.php create mode 100644 tests/phpunit/api/v4/Utils/ReflectionUtilsTest.php create mode 100644 tests/phpunit/api/v4/services.xml diff --git a/CRM/Api4/Page/AJAX.php b/CRM/Api4/Page/AJAX.php new file mode 100644 index 0000000000..e8346ca032 --- /dev/null +++ b/CRM/Api4/Page/AJAX.php @@ -0,0 +1,72 @@ +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 index 0000000000..f06452548c --- /dev/null +++ b/CRM/Api4/Page/Api4Explorer.php @@ -0,0 +1,28 @@ + \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 index 0000000000..ddd889a390 --- /dev/null +++ b/CRM/Api4/Services.php @@ -0,0 +1,75 @@ +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 index 0000000000..1973f23437 --- /dev/null +++ b/CRM/Core/xml/Menu/Api4.xml @@ -0,0 +1,14 @@ + + + + civicrm/ajax/api4 + CRM_Api4_Page_AJAX + access CiviCRM + + + civicrm/api4 + CRM_Api4_Page_Api4Explorer + CiviCRM + access CiviCRM + + diff --git a/Civi/Api4/ACL.php b/Civi/Api4/ACL.php new file mode 100644 index 0000000000..754a0499b8 --- /dev/null +++ b/Civi/Api4/ACL.php @@ -0,0 +1,19 @@ +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 index 0000000000..43278d4126 --- /dev/null +++ b/Civi/Api4/Action/Address/Create.php @@ -0,0 +1,11 @@ +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 index 0000000000..1d6ddf66ed --- /dev/null +++ b/Civi/Api4/Action/Contact/GetFields.php @@ -0,0 +1,19 @@ +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 index 0000000000..07819c7872 --- /dev/null +++ b/Civi/Api4/Action/Contact/ValidateChecksum.php @@ -0,0 +1,42 @@ + \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 index 0000000000..7d059b2466 --- /dev/null +++ b/Civi/Api4/Action/CustomValue/Create.php @@ -0,0 +1,11 @@ +_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 index 0000000000..457be9caf3 --- /dev/null +++ b/Civi/Api4/Action/CustomValue/Replace.php @@ -0,0 +1,11 @@ +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 index 0000000000..1d4678cd3c --- /dev/null +++ b/Civi/Api4/Action/Entity/Get.php @@ -0,0 +1,102 @@ +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 index 0000000000..bfd3c3b406 --- /dev/null +++ b/Civi/Api4/Action/Entity/GetLinks.php @@ -0,0 +1,51 @@ +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 index 0000000000..15cac2ff7a --- /dev/null +++ b/Civi/Api4/Action/Event/Get.php @@ -0,0 +1,12 @@ +_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 index 0000000000..89dd8695ae --- /dev/null +++ b/Civi/Api4/Action/GroupContact/Create.php @@ -0,0 +1,11 @@ +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 index 0000000000..28ef046720 --- /dev/null +++ b/Civi/Api4/Action/GroupContact/Save.php @@ -0,0 +1,11 @@ +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 index 0000000000..31d5e8b2f2 --- /dev/null +++ b/Civi/Api4/Action/Setting/Get.php @@ -0,0 +1,55 @@ +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 index 0000000000..5864a46879 --- /dev/null +++ b/Civi/Api4/Action/Setting/GetFields.php @@ -0,0 +1,84 @@ +_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 index 0000000000..65540abfd7 --- /dev/null +++ b/Civi/Api4/Action/Setting/Revert.php @@ -0,0 +1,46 @@ +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 index 0000000000..ad9804ab5a --- /dev/null +++ b/Civi/Api4/Action/Setting/Set.php @@ -0,0 +1,44 @@ +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 index 0000000000..1910f61b0d --- /dev/null +++ b/Civi/Api4/Action/System/Check.php @@ -0,0 +1,83 @@ +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 index 0000000000..0a39138576 --- /dev/null +++ b/Civi/Api4/Action/System/Flush.php @@ -0,0 +1,32 @@ +triggers, $this->session); + } + +} diff --git a/Civi/Api4/ActionSchedule.php b/Civi/Api4/ActionSchedule.php new file mode 100644 index 0000000000..a8235f6407 --- /dev/null +++ b/Civi/Api4/ActionSchedule.php @@ -0,0 +1,18 @@ + '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 index 0000000000..6274e0c8fd --- /dev/null +++ b/Civi/Api4/EntityTag.php @@ -0,0 +1,12 @@ +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 index 0000000000..15456d7ffe --- /dev/null +++ b/Civi/Api4/Event/PostSelectQueryEvent.php @@ -0,0 +1,64 @@ +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 index 0000000000..7698eb2df1 --- /dev/null +++ b/Civi/Api4/Event/SchemaMapBuildEvent.php @@ -0,0 +1,39 @@ +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 index 0000000000..adfd17c8d0 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/ActivityPreCreationSubscriber.php @@ -0,0 +1,41 @@ +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 index 0000000000..4e05c44de3 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/ActivitySchemaMapSubscriber.php @@ -0,0 +1,40 @@ + '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 index 0000000000..21d2627aad --- /dev/null +++ b/Civi/Api4/Event/Subscriber/ContactPreSaveSubscriber.php @@ -0,0 +1,25 @@ +getEntityName() === 'Contact'; + } + +} diff --git a/Civi/Api4/Event/Subscriber/ContactSchemaMapSubscriber.php b/Civi/Api4/Event/Subscriber/ContactSchemaMapSubscriber.php new file mode 100644 index 0000000000..f2c82a07c2 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/ContactSchemaMapSubscriber.php @@ -0,0 +1,54 @@ + '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 index 0000000000..dfc723a495 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/ContributionPreSaveSubscriber.php @@ -0,0 +1,18 @@ +getEntityName() === 'Contribution'; + } + +} diff --git a/Civi/Api4/Event/Subscriber/CustomFieldPreSaveSubscriber.php b/Civi/Api4/Event/Subscriber/CustomFieldPreSaveSubscriber.php new file mode 100644 index 0000000000..0a4ce707a9 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/CustomFieldPreSaveSubscriber.php @@ -0,0 +1,38 @@ + $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 index 0000000000..e83c91f79e --- /dev/null +++ b/Civi/Api4/Event/Subscriber/CustomGroupPreCreationSubscriber.php @@ -0,0 +1,30 @@ +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 index 0000000000..833e886fd0 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/Generic/AbstractPrepareSubscriber.php @@ -0,0 +1,25 @@ + '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 index 0000000000..66f5366df7 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/Generic/PreCreationSubscriber.php @@ -0,0 +1,51 @@ +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 index 0000000000..a36ba1fa11 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/Generic/PreSaveSubscriber.php @@ -0,0 +1,54 @@ +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 index 0000000000..4f1d349461 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/IsCurrentSubscriber.php @@ -0,0 +1,39 @@ +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 index 0000000000..2695cd4c1d --- /dev/null +++ b/Civi/Api4/Event/Subscriber/OptionValuePreCreationSubscriber.php @@ -0,0 +1,50 @@ +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 index 0000000000..b63fdc553d --- /dev/null +++ b/Civi/Api4/Event/Subscriber/PermissionCheckSubscriber.php @@ -0,0 +1,66 @@ + [ + ['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 index 0000000000..1fed061974 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/PostSelectQuerySubscriber.php @@ -0,0 +1,331 @@ + '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 index 0000000000..2e7faab588 --- /dev/null +++ b/Civi/Api4/Event/Subscriber/ValidateFieldsSubscriber.php @@ -0,0 +1,100 @@ +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 index 0000000000..cd316f6210 --- /dev/null +++ b/Civi/Api4/Generic/AbstractAction.php @@ -0,0 +1,446 @@ +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 index 0000000000..2be3b8d799 --- /dev/null +++ b/Civi/Api4/Generic/AbstractBatchAction.php @@ -0,0 +1,64 @@ +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 index 0000000000..43f43b601b --- /dev/null +++ b/Civi/Api4/Generic/AbstractCreateAction.php @@ -0,0 +1,42 @@ + 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 index 0000000000..ef380a52ad --- /dev/null +++ b/Civi/Api4/Generic/AbstractEntity.php @@ -0,0 +1,89 @@ +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 index 0000000000..82fd42fe28 --- /dev/null +++ b/Civi/Api4/Generic/AbstractQueryAction.php @@ -0,0 +1,144 @@ +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 index 0000000000..117a1fe77c --- /dev/null +++ b/Civi/Api4/Generic/AbstractSaveAction.php @@ -0,0 +1,90 @@ +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 index 0000000000..47e2e53c55 --- /dev/null +++ b/Civi/Api4/Generic/AbstractUpdateAction.php @@ -0,0 +1,45 @@ + 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 index 0000000000..49b76a5a56 --- /dev/null +++ b/Civi/Api4/Generic/BasicBatchAction.php @@ -0,0 +1,73 @@ + 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 index 0000000000..ddd238f42a --- /dev/null +++ b/Civi/Api4/Generic/BasicCreateAction.php @@ -0,0 +1,64 @@ + 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 index 0000000000..033c702ca9 --- /dev/null +++ b/Civi/Api4/Generic/BasicGetAction.php @@ -0,0 +1,84 @@ + 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 index 0000000000..198dc930f8 --- /dev/null +++ b/Civi/Api4/Generic/BasicGetFieldsAction.php @@ -0,0 +1,156 @@ +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 index 0000000000..da4bac1a83 --- /dev/null +++ b/Civi/Api4/Generic/BasicReplaceAction.php @@ -0,0 +1,114 @@ +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 index 0000000000..c03d5af349 --- /dev/null +++ b/Civi/Api4/Generic/BasicSaveAction.php @@ -0,0 +1,79 @@ + 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 index 0000000000..796944e3f4 --- /dev/null +++ b/Civi/Api4/Generic/BasicUpdateAction.php @@ -0,0 +1,73 @@ + 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 index 0000000000..091eacf036 --- /dev/null +++ b/Civi/Api4/Generic/DAOCreateAction.php @@ -0,0 +1,36 @@ +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 index 0000000000..00b32fe217 --- /dev/null +++ b/Civi/Api4/Generic/DAODeleteAction.php @@ -0,0 +1,75 @@ +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 index 0000000000..0f2b14160e --- /dev/null +++ b/Civi/Api4/Generic/DAOEntity.php @@ -0,0 +1,59 @@ +setDefaultWhereClause(); + $result->exchangeArray($this->getObjects()); + } + +} diff --git a/Civi/Api4/Generic/DAOGetFieldsAction.php b/Civi/Api4/Generic/DAOGetFieldsAction.php new file mode 100644 index 0000000000..9f351d075f --- /dev/null +++ b/Civi/Api4/Generic/DAOGetFieldsAction.php @@ -0,0 +1,52 @@ +_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 index 0000000000..4169ab3103 --- /dev/null +++ b/Civi/Api4/Generic/DAOSaveAction.php @@ -0,0 +1,32 @@ +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 index 0000000000..28684ad9e5 --- /dev/null +++ b/Civi/Api4/Generic/DAOUpdateAction.php @@ -0,0 +1,62 @@ +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 index 0000000000..9930ce813f --- /dev/null +++ b/Civi/Api4/Generic/Result.php @@ -0,0 +1,131 @@ +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 index 0000000000..4435d1f49f --- /dev/null +++ b/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php @@ -0,0 +1,201 @@ +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 index 0000000000..dc59ca77b0 --- /dev/null +++ b/Civi/Api4/Generic/Traits/CustomValueActionTrait.php @@ -0,0 +1,92 @@ +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 index 0000000000..0d79389c2e --- /dev/null +++ b/Civi/Api4/Generic/Traits/DAOActionTrait.php @@ -0,0 +1,255 @@ +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 index 0000000000..97a008ac09 --- /dev/null +++ b/Civi/Api4/Generic/Traits/IsCurrentTrait.php @@ -0,0 +1,40 @@ += 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 index 0000000000..b82fa98281 --- /dev/null +++ b/Civi/Api4/Group.php @@ -0,0 +1,12 @@ + [ + ['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 index 0000000000..f7026ba349 --- /dev/null +++ b/Civi/Api4/Query/Api4SelectQuery.php @@ -0,0 +1,580 @@ +=', '>', '<', '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 [, ] + */ + 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 index 0000000000..49f0519959 --- /dev/null +++ b/Civi/Api4/Relationship.php @@ -0,0 +1,19 @@ + $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 index 0000000000..191f4389b8 --- /dev/null +++ b/Civi/Api4/Service/Schema/Joinable/ActivityToActivityContactAssigneesJoinable.php @@ -0,0 +1,40 @@ +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 index 0000000000..370c589855 --- /dev/null +++ b/Civi/Api4/Service/Schema/Joinable/BridgeJoinable.php @@ -0,0 +1,23 @@ +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 index 0000000000..4069b9aff0 --- /dev/null +++ b/Civi/Api4/Service/Schema/Joinable/CustomGroupJoinable.php @@ -0,0 +1,73 @@ +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 index 0000000000..f2f29916bf --- /dev/null +++ b/Civi/Api4/Service/Schema/Joinable/Joinable.php @@ -0,0 +1,277 @@ +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 index 0000000000..96f6548812 --- /dev/null +++ b/Civi/Api4/Service/Schema/Joinable/OptionValueJoinable.php @@ -0,0 +1,61 @@ +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 index 0000000000..87677f1390 --- /dev/null +++ b/Civi/Api4/Service/Schema/Joiner.php @@ -0,0 +1,97 @@ +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 index 0000000000..ed3743d9e5 --- /dev/null +++ b/Civi/Api4/Service/Schema/SchemaMap.php @@ -0,0 +1,139 @@ +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 index 0000000000..e1e62a7156 --- /dev/null +++ b/Civi/Api4/Service/Schema/SchemaMapBuilder.php @@ -0,0 +1,217 @@ +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 index 0000000000..a6e706bebb --- /dev/null +++ b/Civi/Api4/Service/Schema/Table.php @@ -0,0 +1,128 @@ +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 index 0000000000..2c68934426 --- /dev/null +++ b/Civi/Api4/Service/Spec/CustomFieldSpec.php @@ -0,0 +1,118 @@ +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 index 0000000000..685a7c7df6 --- /dev/null +++ b/Civi/Api4/Service/Spec/FieldSpec.php @@ -0,0 +1,367 @@ + '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 index 0000000000..cbc126cb59 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/ACLCreationSpecProvider.php @@ -0,0 +1,23 @@ +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 index 0000000000..e05b7d5925 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/ActionScheduleCreationSpecProvider.php @@ -0,0 +1,27 @@ +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 index 0000000000..52eb75a2a0 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/ActivityCreationSpecProvider.php @@ -0,0 +1,28 @@ +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 index 0000000000..ecb6b271b9 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/AddressCreationSpecProvider.php @@ -0,0 +1,27 @@ +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 index 0000000000..db0f48f7d6 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/CampaignCreationSpecProvider.php @@ -0,0 +1,24 @@ +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 index 0000000000..9f826280e7 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/ContactCreationSpecProvider.php @@ -0,0 +1,31 @@ +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 index 0000000000..88c115edf2 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/ContactTypeCreationSpecProvider.php @@ -0,0 +1,29 @@ +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 index 0000000000..42d2a7a5da --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/ContributionCreationSpecProvider.php @@ -0,0 +1,24 @@ +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 index 0000000000..4d3aba5799 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/CustomFieldCreationSpecProvider.php @@ -0,0 +1,28 @@ +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 index 0000000000..aa3002d95c --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/CustomGroupCreationSpecProvider.php @@ -0,0 +1,23 @@ +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 index 0000000000..97b58ee34a --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/CustomValueSpecProvider.php @@ -0,0 +1,34 @@ +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 index 0000000000..54ee12061d --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/DefaultLocationTypeProvider.php @@ -0,0 +1,27 @@ +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 index 0000000000..890bd7e9d4 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/DomainCreationSpecProvider.php @@ -0,0 +1,24 @@ +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 index 0000000000..aa6db7c350 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/EmailCreationSpecProvider.php @@ -0,0 +1,26 @@ +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 index 0000000000..47df3af9f4 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/EntityTagCreationSpecProvider.php @@ -0,0 +1,26 @@ +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 index 0000000000..109a3a98d8 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/EventCreationSpecProvider.php @@ -0,0 +1,33 @@ +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 index 0000000000..4b7a9887c6 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/Generic/SpecProviderInterface.php @@ -0,0 +1,24 @@ +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 index 0000000000..b549fa05c9 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/GroupCreationSpecProvider.php @@ -0,0 +1,23 @@ +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 index 0000000000..2139c5e4f3 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/MappingCreationSpecProvider.php @@ -0,0 +1,28 @@ +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 index 0000000000..1a9188dc8d --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/NavigationSpecProvider.php @@ -0,0 +1,25 @@ +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 index 0000000000..20592543eb --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/NoteCreationSpecProvider.php @@ -0,0 +1,27 @@ +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 index 0000000000..8e996ecb1a --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/OptionValueCreationSpecProvider.php @@ -0,0 +1,24 @@ +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 index 0000000000..a69177278e --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/PhoneCreationSpecProvider.php @@ -0,0 +1,24 @@ +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 index 0000000000..b2d60a5915 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/RelationshipTypeCreationSpecProvider.php @@ -0,0 +1,24 @@ +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 index 0000000000..66fa7ad820 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/StatusPreferenceCreationSpecProvider.php @@ -0,0 +1,23 @@ +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 index 0000000000..f4fe05af0f --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/TagCreationSpecProvider.php @@ -0,0 +1,26 @@ +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 index 0000000000..e7cf393cbb --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/UFFieldCreationSpecProvider.php @@ -0,0 +1,23 @@ +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 index 0000000000..06b9bb25cc --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/UFMatchCreationSpecProvider.php @@ -0,0 +1,23 @@ +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 index 0000000000..9437d930a7 --- /dev/null +++ b/Civi/Api4/Service/Spec/RequestSpec.php @@ -0,0 +1,110 @@ +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 index 0000000000..0ab4c6585c --- /dev/null +++ b/Civi/Api4/Service/Spec/SpecFormatter.php @@ -0,0 +1,216 @@ +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 index 0000000000..ca5da91a59 --- /dev/null +++ b/Civi/Api4/Service/Spec/SpecGatherer.php @@ -0,0 +1,140 @@ +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 index 0000000000..abd76793cd --- /dev/null +++ b/Civi/Api4/Setting.php @@ -0,0 +1,30 @@ + 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 index 0000000000..b4ed48e82c --- /dev/null +++ b/Civi/Api4/Utils/CoreUtil.php @@ -0,0 +1,53 @@ +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 index 0000000000..6a69e29ac3 --- /dev/null +++ b/Civi/Api4/Utils/FormattingUtil.php @@ -0,0 +1,97 @@ + $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 index 0000000000..535f9523c5 --- /dev/null +++ b/Civi/Api4/Utils/ReflectionUtils.php @@ -0,0 +1,140 @@ +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 index 0000000000..fb890a0fd4 --- /dev/null +++ b/Civi/Api4/Website.php @@ -0,0 +1,12 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ang/api4.ang.php b/ang/api4.ang.php new file mode 100644 index 0000000000..f7d69b39a3 --- /dev/null +++ b/ang/api4.ang.php @@ -0,0 +1,12 @@ + [ + '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 index 0000000000..d1116fc45a --- /dev/null +++ b/ang/api4.js @@ -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 index 0000000000..743b35910b --- /dev/null +++ b/ang/api4/crmApi4.js @@ -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 index 0000000000..176d740d89 --- /dev/null +++ b/ang/api4Explorer.ang.php @@ -0,0 +1,18 @@ + [ + '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 index 0000000000..85e10c4679 --- /dev/null +++ b/ang/api4Explorer.js @@ -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 index 0000000000..257efdeccf --- /dev/null +++ b/ang/api4Explorer/Chain.html @@ -0,0 +1,4 @@ + + + + diff --git a/ang/api4Explorer/Explorer.html b/ang/api4Explorer/Explorer.html new file mode 100644 index 0000000000..816262c1dd --- /dev/null +++ b/ang/api4Explorer/Explorer.html @@ -0,0 +1,152 @@ +
+
+ +

+ {{ ts('CiviCRM API v4') }}{{ entity ? (' (' + entity + '::' + action + ')') : '' }} +

+ + +
+

+ + {{ ts('Bootstrap theme not found.') }} +

+

{{ ts('This screen may not work correctly without a bootstrap-based theme such as Shoreditch installed.') }}

+
+ +
+
+
+
+ + + + +
+
+
+
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+
+
+ values * +
+ + +
+
+ +
+
+
+ orderBy * +
+ + +
+
+ +
+
+
+ chain +
+
+
+ +
+
+
+
+
+
+

{{ helpTitle }}

+
+
+

{{ helpContent.description }}

+
+

{{ text }}

+
+

+ {{ key }}: {{ item }} +

+
+
+
+
+
+
+

{{ ts('Code') }}

+
+
+ + + + + +
{{ type }}
+
+
+
+
+

+ + + + + {{ ts('Result') }} +

+
+
+

+        
+
+
+ + +
diff --git a/ang/api4Explorer/Explorer.js b/ang/api4Explorer/Explorer.js new file mode 100644 index 0000000000..e08796dbe1 --- /dev/null +++ b/ang/api4Explorer/Explorer.js @@ -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 ? ' *' : '') + + (row.description ? '

' + _.escape(row.description) + '

' : ''); + }; + + $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 index 0000000000..d36480f9de --- /dev/null +++ b/ang/api4Explorer/WhereClause.html @@ -0,0 +1,39 @@ +{{ data.label || data.op + ' group' }} * +
+ +
+
+
+
+ + Where + {{ data.op }} + + +
+
+ + + +
+
+
+
+
+
+
+
+ + +
+
+ +
\ No newline at end of file diff --git a/css/api4-explorer.css b/css/api4-explorer.css new file mode 100644 index 0000000000..a5be9c8a06 --- /dev/null +++ b/css/api4-explorer.css @@ -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 index 0000000000..aff280a07b --- /dev/null +++ b/js/load-bootstrap.js @@ -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(''); + } +}); \ 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 index 0000000000..e69de29bb2 diff --git a/tests/phpunit/api/v4/Action/BaseCustomValueTest.php b/tests/phpunit/api/v4/Action/BaseCustomValueTest.php new file mode 100644 index 0000000000..fd4dc717c8 --- /dev/null +++ b/tests/phpunit/api/v4/Action/BaseCustomValueTest.php @@ -0,0 +1,31 @@ +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 index 0000000000..18e76e5589 --- /dev/null +++ b/tests/phpunit/api/v4/Action/BasicActionsTest.php @@ -0,0 +1,183 @@ +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 index 0000000000..b115d5f664 --- /dev/null +++ b/tests/phpunit/api/v4/Action/BasicCustomFieldTest.php @@ -0,0 +1,180 @@ +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 index 0000000000..cd896a8293 --- /dev/null +++ b/tests/phpunit/api/v4/Action/ChainTest.php @@ -0,0 +1,53 @@ +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 index 0000000000..51fb272d2f --- /dev/null +++ b/tests/phpunit/api/v4/Action/ComplexQueryTest.php @@ -0,0 +1,87 @@ +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 index 0000000000..c5885e2fd7 --- /dev/null +++ b/tests/phpunit/api/v4/Action/ContactApiKeyTest.php @@ -0,0 +1,210 @@ +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 index 0000000000..abf65cada2 --- /dev/null +++ b/tests/phpunit/api/v4/Action/ContactChecksumTest.php @@ -0,0 +1,56 @@ +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 index 0000000000..e023361dc5 --- /dev/null +++ b/tests/phpunit/api/v4/Action/ContactGetTest.php @@ -0,0 +1,42 @@ +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 index 0000000000..9cb119846d --- /dev/null +++ b/tests/phpunit/api/v4/Action/CreateCustomValueTest.php @@ -0,0 +1,64 @@ + '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 index 0000000000..6a2a3568ca --- /dev/null +++ b/tests/phpunit/api/v4/Action/CreateWithOptionGroupTest.php @@ -0,0 +1,185 @@ +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 index 0000000000..4034dbed81 --- /dev/null +++ b/tests/phpunit/api/v4/Action/CurrentFilterTest.php @@ -0,0 +1,71 @@ +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 index 0000000000..1990f6c8e9 --- /dev/null +++ b/tests/phpunit/api/v4/Action/CustomValuePerformanceTest.php @@ -0,0 +1,95 @@ +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 index 0000000000..72d127508a --- /dev/null +++ b/tests/phpunit/api/v4/Action/CustomValueTest.php @@ -0,0 +1,192 @@ + '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 index 0000000000..507d80edd0 --- /dev/null +++ b/tests/phpunit/api/v4/Action/DateTest.php @@ -0,0 +1,46 @@ +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 index 0000000000..31f7441899 --- /dev/null +++ b/tests/phpunit/api/v4/Action/EvaluateConditionTest.php @@ -0,0 +1,38 @@ +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 index 0000000000..1d9136427e --- /dev/null +++ b/tests/phpunit/api/v4/Action/ExtendFromIndividualTest.php @@ -0,0 +1,52 @@ +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 index 0000000000..85bf67e916 --- /dev/null +++ b/tests/phpunit/api/v4/Action/FkJoinTest.php @@ -0,0 +1,75 @@ +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 index 0000000000..837d7d87e0 --- /dev/null +++ b/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php @@ -0,0 +1,26 @@ +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 index 0000000000..06a0c17ca2 --- /dev/null +++ b/tests/phpunit/api/v4/Action/GetFromArrayTest.php @@ -0,0 +1,163 @@ +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 index 0000000000..9e33ea4e21 --- /dev/null +++ b/tests/phpunit/api/v4/Action/IndexTest.php @@ -0,0 +1,48 @@ +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 index 0000000000..c8b6516f42 --- /dev/null +++ b/tests/phpunit/api/v4/Action/NullValueTest.php @@ -0,0 +1,55 @@ +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 index 0000000000..701f4e2172 --- /dev/null +++ b/tests/phpunit/api/v4/Action/ReplaceTest.php @@ -0,0 +1,171 @@ +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 index 0000000000..2374b335ca --- /dev/null +++ b/tests/phpunit/api/v4/Action/RequiredFieldTest.php @@ -0,0 +1,24 @@ +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 index 0000000000..b1bffbbbf5 --- /dev/null +++ b/tests/phpunit/api/v4/Action/UpdateContactTest.php @@ -0,0 +1,54 @@ +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 index 0000000000..23b6d2a46d --- /dev/null +++ b/tests/phpunit/api/v4/Action/UpdateCustomValueTest.php @@ -0,0 +1,56 @@ +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 index 0000000000..aea9bf33ba --- /dev/null +++ b/tests/phpunit/api/v4/AllTests.php @@ -0,0 +1,68 @@ + (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 + * . + */ + +/** + * 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 index 0000000000..1028c5b6f8 --- /dev/null +++ b/tests/phpunit/api/v4/DataSets/ConformanceTest.json @@ -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 index 0000000000..7d4a91bc77 --- /dev/null +++ b/tests/phpunit/api/v4/DataSets/DefaultDataSet.json @@ -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 index 0000000000..ce3fbcaf92 --- /dev/null +++ b/tests/phpunit/api/v4/DataSets/MultiContactMultiEmail.json @@ -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 index 0000000000..73e7369e6b --- /dev/null +++ b/tests/phpunit/api/v4/DataSets/SingleContact.json @@ -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 index 0000000000..a038cbf82f --- /dev/null +++ b/tests/phpunit/api/v4/Entity/ConformanceTest.php @@ -0,0 +1,248 @@ +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 index 0000000000..268520afe6 --- /dev/null +++ b/tests/phpunit/api/v4/Entity/ContactJoinTest.php @@ -0,0 +1,103 @@ +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 index 0000000000..7e0a392fdb --- /dev/null +++ b/tests/phpunit/api/v4/Entity/EntityTest.php @@ -0,0 +1,35 @@ +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 index 0000000000..7822ae9f5b --- /dev/null +++ b/tests/phpunit/api/v4/Entity/ParticipantTest.php @@ -0,0 +1,221 @@ + [ + '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 index 0000000000..d096029fde --- /dev/null +++ b/tests/phpunit/api/v4/Entity/RouteTest.php @@ -0,0 +1,21 @@ +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 index 0000000000..4443228366 --- /dev/null +++ b/tests/phpunit/api/v4/Entity/SettingTest.php @@ -0,0 +1,36 @@ +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 index 0000000000..f276baf68a --- /dev/null +++ b/tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php @@ -0,0 +1,53 @@ + 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 index 0000000000..b6e3fac5ea --- /dev/null +++ b/tests/phpunit/api/v4/Mock/Api4/MockArrayEntity.php @@ -0,0 +1,22 @@ + '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 index 0000000000..1f2ad10015 --- /dev/null +++ b/tests/phpunit/api/v4/Mock/MockEntityDataStorage.php @@ -0,0 +1,34 @@ + 1]; + +} diff --git a/tests/phpunit/api/v4/Mock/MockV4ReflectionGrandchild.php b/tests/phpunit/api/v4/Mock/MockV4ReflectionGrandchild.php new file mode 100644 index 0000000000..12cec53d14 --- /dev/null +++ b/tests/phpunit/api/v4/Mock/MockV4ReflectionGrandchild.php @@ -0,0 +1,16 @@ +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 index 0000000000..a866558c04 --- /dev/null +++ b/tests/phpunit/api/v4/Query/Api4SelectQueryTest.php @@ -0,0 +1,89 @@ +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 index 0000000000..0fa0c949e2 --- /dev/null +++ b/tests/phpunit/api/v4/Query/OneToOneJoinTest.php @@ -0,0 +1,44 @@ +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 index 0000000000..eb6bfa4d48 --- /dev/null +++ b/tests/phpunit/api/v4/Query/OptionValueJoinTest.php @@ -0,0 +1,46 @@ +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 index 0000000000..8d11e295b0 --- /dev/null +++ b/tests/phpunit/api/v4/Query/SelectQueryMultiJoinTest.php @@ -0,0 +1,75 @@ +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 index 0000000000..fb5710fe34 --- /dev/null +++ b/tests/phpunit/api/v4/Service/Schema/SchemaMapRealTableTest.php @@ -0,0 +1,23 @@ +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 index 0000000000..0c47982d5a --- /dev/null +++ b/tests/phpunit/api/v4/Service/Schema/SchemaMapperTest.php @@ -0,0 +1,90 @@ +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 index 0000000000..477c43f9bd --- /dev/null +++ b/tests/phpunit/api/v4/Service/TestCreationParameterProvider.php @@ -0,0 +1,144 @@ +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 index 0000000000..81db002346 --- /dev/null +++ b/tests/phpunit/api/v4/Spec/RequestSpecTest.php @@ -0,0 +1,42 @@ +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 index 0000000000..d54ea28a91 --- /dev/null +++ b/tests/phpunit/api/v4/Spec/SpecFormatterTest.php @@ -0,0 +1,93 @@ +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 index 0000000000..c030952f5b --- /dev/null +++ b/tests/phpunit/api/v4/Spec/SpecGathererTest.php @@ -0,0 +1,95 @@ +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 index 0000000000..a848516249 --- /dev/null +++ b/tests/phpunit/api/v4/Traits/OptionCleanupTrait.php @@ -0,0 +1,24 @@ +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 index 0000000000..988d8c2adf --- /dev/null +++ b/tests/phpunit/api/v4/Traits/QueryCounterTrait.php @@ -0,0 +1,43 @@ +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 index 0000000000..a623e5ac62 --- /dev/null +++ b/tests/phpunit/api/v4/Traits/TableDropperTrait.php @@ -0,0 +1,24 @@ +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 index 0000000000..b03ad8c4b6 --- /dev/null +++ b/tests/phpunit/api/v4/Traits/TestDataLoaderTrait.php @@ -0,0 +1,69 @@ + $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 index 0000000000..49f462831a --- /dev/null +++ b/tests/phpunit/api/v4/UnitTestCase.php @@ -0,0 +1,241 @@ +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 index 0000000000..81d7878052 --- /dev/null +++ b/tests/phpunit/api/v4/Utils/ArrayInsertionServiceTest.php @@ -0,0 +1,67 @@ + 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 index 0000000000..57b5aad877 --- /dev/null +++ b/tests/phpunit/api/v4/Utils/ReflectionUtilsTest.php @@ -0,0 +1,46 @@ +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 index 0000000000..3310b03e93 --- /dev/null +++ b/tests/phpunit/api/v4/services.xml @@ -0,0 +1,10 @@ + + + + + + + + -- 2.25.1