From c8074a93613820d1880ad325d2bf83caa9d19887 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 30 Jan 2015 00:40:22 -0800 Subject: [PATCH 1/1] CRM-15578 - Add Civi\Core\Resolver. Fix loading of Angular content after updating components. After enabling or disabling components, the Angular content may have changed, so we must call CRM_Core_Resources::singleton()->resetCacheCode. This commit does so in a few pieces: * It updates the admin UI so that it uses the API for saving changes to "enable_components". (This resolves a split where UI+API were different.) * It updates the "enable_components" setting so that changes trigger a cache-code reset. * It updates the settings framework so that callbacks can reference non-static functions. (The objects must be registered in "Container".) * It exposes some of our singletons (eg CRM_Core_Resources) as objects in "Container". * It adds a helper function (Civi\Core\Resolver) which can be used to convert references from data files (*.setting.php, *.xml, *.yml, etc) into objects/callables. * It updates various places that use callbacks from data files to go through Resolver. See also: http://forum.civicrm.org/index.php/topic,35579.0.html --- CRM/Admin/Form/Setting.php | 10 +- CRM/Admin/Form/Setting/Component.php | 1 - CRM/Core/BAO/Setting.php | 11 +- CRM/Core/Invoke.php | 15 +- CRM/Core/Menu.php | 3 + CRM/Core/PseudoConstant.php | 5 +- Civi/Core/Container.php | 15 ++ Civi/Core/Resolver.php | 215 +++++++++++++++++++++++ settings/Core.setting.php | 5 +- tests/phpunit/Civi/Core/ResolverTest.php | 171 ++++++++++++++++++ 10 files changed, 425 insertions(+), 26 deletions(-) create mode 100644 Civi/Core/Resolver.php create mode 100644 tests/phpunit/Civi/Core/ResolverTest.php diff --git a/CRM/Admin/Form/Setting.php b/CRM/Admin/Form/Setting.php index 15c197977d..af3a49aa5d 100644 --- a/CRM/Admin/Form/Setting.php +++ b/CRM/Admin/Form/Setting.php @@ -211,12 +211,10 @@ class CRM_Admin_Form_Setting extends CRM_Core_Form { // save components to be enabled if (array_key_exists('enableComponents', $params)) { - CRM_Core_BAO_Setting::setItem($params['enableComponents'], - CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'enable_components'); - - // unset params by emptying the values, so while retrieving we can detect and load from settings table - // instead of config-backend for backward compatibility. We could use unset() in later releases. - $params['enableComponents'] = $params['enableComponentIDs'] = array(); + civicrm_api3('setting', 'create', array( + 'enable_components' => $params['enableComponents'], + )); + unset($params['enableComponents']); } // save checksum timeout diff --git a/CRM/Admin/Form/Setting/Component.php b/CRM/Admin/Form/Setting/Component.php index 1b71abb04e..cfc2573db1 100644 --- a/CRM/Admin/Form/Setting/Component.php +++ b/CRM/Admin/Form/Setting/Component.php @@ -112,7 +112,6 @@ class CRM_Admin_Form_Setting_Component extends CRM_Admin_Form_Setting { public function postProcess() { $params = $this->controller->exportValues($this->_name); - CRM_Case_Info::onToggleComponents($this->_defaults['enableComponents'], $params['enableComponents'], NULL); parent::commonProcess($params); // reset navigation when components are enabled / disabled diff --git a/CRM/Core/BAO/Setting.php b/CRM/Core/BAO/Setting.php index f42e66c6f9..76ca30ee36 100644 --- a/CRM/Core/BAO/Setting.php +++ b/CRM/Core/BAO/Setting.php @@ -398,7 +398,12 @@ class CRM_Core_BAO_Setting extends CRM_Core_DAO_Setting { if (isset($metadata['on_change'])) { foreach ($metadata['on_change'] as $callback) { - call_user_func($callback, unserialize($dao->value), $value, $metadata); + call_user_func( + Civi\Core\Resolver::singleton()->get($callback), + unserialize($dao->value), + $value, + $metadata + ); } } @@ -588,8 +593,8 @@ class CRM_Core_BAO_Setting extends CRM_Core_DAO_Setting { return TRUE; } else { - list($class, $fn) = explode('::', $fieldSpec['validate_callback']); - if (!$class::$fn($value, $fieldSpec)) { + $cb = Civi\Core\Resolver::singleton()->get($fieldSpec['validate_callback']); + if (!call_user_func_array($cb, array(&$value, $fieldSpec))) { throw new api_Exception("validation failed for {$fieldSpec['name']} = $value based on callback {$fieldSpec['validate_callback']}"); } } diff --git a/CRM/Core/Invoke.php b/CRM/Core/Invoke.php index 531954ede6..b37a513342 100644 --- a/CRM/Core/Invoke.php +++ b/CRM/Core/Invoke.php @@ -267,12 +267,11 @@ class CRM_Core_Invoke { } $result = NULL; - if (is_array($item['page_callback'])) { - if ($item['page_callback']{0} !== '\\') { - // Legacy class-loading for PHP 5.2 namespaces; not sure it's needed, but counter-productive for PHP 5.3 namespaces - require_once str_replace('_', DIRECTORY_SEPARATOR, $item['page_callback'][0]) . '.php'; - } - $result = call_user_func($item['page_callback']); + // WISHLIST: Refactor this. Instead of pattern-matching on page_callback, lookup + // page_callback via Civi\Core\Resolver and check the implemented interfaces. This + // would require rethinking the default constructor. + if (is_array($item['page_callback']) || strpos($item['page_callback'], ':')) { + $result = call_user_func(Civi\Core\Resolver::singleton()->get($item['page_callback'])); } elseif (strstr($item['page_callback'], '_Form')) { $wrapper = new CRM_Utils_Wrapper(); @@ -284,10 +283,6 @@ class CRM_Core_Invoke { } else { $newArgs = explode('/', $_GET[$config->userFrameworkURLVar]); - if ($item['page_callback']{0} !== '\\') { - // Legacy class-loading for PHP 5.2 namespaces; not sure it's needed, but counter-productive for PHP 5.3 namespaces - require_once str_replace('_', DIRECTORY_SEPARATOR, $item['page_callback']) . '.php'; - } $mode = 'null'; if (isset($pageArgs['mode'])) { $mode = $pageArgs['mode']; diff --git a/CRM/Core/Menu.php b/CRM/Core/Menu.php index 59359e353f..f453650a34 100644 --- a/CRM/Core/Menu.php +++ b/CRM/Core/Menu.php @@ -126,9 +126,12 @@ class CRM_Core_Menu { if (strpos($key, '_callback') && strpos($value, '::') ) { + // FIXME Remove the rewrite at this level. Instead, change downstream call_user_func*($value) + // to call_user_func*(Civi\Core\Resolver::singleton()->get($value)). $value = explode('::', $value); } elseif ($key == 'access_arguments') { + // FIXME Move the permission parser to its own class (or *maybe* CRM_Core_Permission). if (strpos($value, ',') || strpos($value, ';') ) { diff --git a/CRM/Core/PseudoConstant.php b/CRM/Core/PseudoConstant.php index aa6dec652f..00045aae8e 100644 --- a/CRM/Core/PseudoConstant.php +++ b/CRM/Core/PseudoConstant.php @@ -274,10 +274,7 @@ class CRM_Core_PseudoConstant { // if callback is specified.. if (!empty($pseudoconstant['callback'])) { - list($className, $fnName) = explode('::', $pseudoconstant['callback']); - if (method_exists($className, $fnName)) { - return call_user_func(array($className, $fnName)); - } + return call_user_func(Civi\Core\Resolver::singleton()->get($pseudoconstant['callback'])); } // Merge params with schema defaults diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index e1e54b4f28..7de921c889 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -9,6 +9,7 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\Driver\AnnotationDriver; use Doctrine\ORM\Tools\Setup; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; @@ -89,6 +90,20 @@ class Container { )) ->setFactoryService(self::SELF)->setFactoryMethod('createApiKernel'); + // Expose legacy singletons as services in the container. + $singletons = array( + 'resources' => 'CRM_Core_Resources', + 'httpClient' => 'CRM_Utils_HttpClient', + // Maybe? 'config' => 'CRM_Core_Config', + // Maybe? 'smarty' => 'CRM_Core_Smarty', + ); + foreach ($singletons as $name => $class) { + $container->setDefinition($name, new Definition( + $class + )) + ->setFactoryClass($class)->setFactoryMethod('singleton'); + } + return $container; } diff --git a/Civi/Core/Resolver.php b/Civi/Core/Resolver.php new file mode 100644 index 0000000000..0654acc2fb --- /dev/null +++ b/Civi/Core/Resolver.php @@ -0,0 +1,215 @@ +get($url['host']); + + case 'call': + // Callback: Object/method in container. + $obj = Container::singleton()->get($url['host']); + return array($obj, ltrim($url['path'], '/')); + + case 'api3': + // Callback: API. + return new ResolverApi($url); + + default: + throw new \RuntimeException("Unsupported callback scheme: " . $url['scheme']); + } + } + elseif (in_array($id, array('0', '1'))) { + // Callback: Constant value. + return new ResolverConstantCallback((int) $id); + } + elseif ($id{0} >= 'A' && $id{0} <= 'Z') { + // Object: New/default instance. + return new $id(); + } + else { + // Callback: Function. + return $id; + } + } + +} + +/** + * Private helper which produces a dummy callback. + * + * @package Civi\Core + */ +class ResolverConstantCallback { + /** + * @var mixed + */ + private $value; + + /** + * @param mixed $value + * The value to be returned by the dummy callback. + */ + public function __construct($value) { + $this->value = $value; + } + + /** + * @return mixed + */ + public function __invoke() { + return $this->value; + } + +} + +/** + * Private helper which treats an API as a callable function. + * + * @package Civi\Core + */ +class ResolverApi { + /** + * @var array + * - string scheme + * - string host + * - string path + * - string query (optional) + */ + private $url; + + /** + * @param array $url + * Parsed URL (e.g. "api3://EntityName/action?foo=bar"). + * @see parse_url + */ + public function __construct($url) { + $this->url = $url; + } + + /** + * Fire an API call. + */ + public function __invoke() { + $apiParams = array(); + if (isset($this->url['query'])) { + parse_str($this->url['query'], $apiParams); + } + + if (count($apiParams)) { + $args = func_get_args(); + if (count($args)) { + $this->interpolate($apiParams, $this->createPlaceholders('@', $args)); + } + } + + $result = civicrm_api3($this->url['host'], ltrim($this->url['path'], '/'), $apiParams); + return isset($result['values']) ? $result['values'] : NULL; + } + + /** + * @param array $args + * Positional arguments. + * @return array + * Named placeholders based on the positional arguments + * (e.g. "@1" => "firstValue"). + */ + protected function createPlaceholders($prefix, $args) { + $result = array(); + foreach ($args as $offset => $arg) { + $result[$prefix . (1 + $offset)] = $arg; + } + return $result; + } + + /** + * Recursively interpolate values. + * + * @code + * $params = array('foo' => '@1'); + * $this->interpolate($params, array('@1'=> $object)) + * assert $data['foo'] == $object; + * @endcode + * + * @param array $array + * Array which may or many not contain a mix of tokens. + * @param array $replacements + * A list of tokens to substitute. + */ + protected function interpolate(&$array, $replacements) { + foreach (array_keys($array) as $key) { + if (is_array($array[$key])) { + $this->interpolate($array[$key], $replacements); + continue; + } + foreach ($replacements as $oldVal => $newVal) { + if ($array[$key] === $oldVal) { + $array[$key] = $newVal; + } + } + } + } + +} diff --git a/settings/Core.setting.php b/settings/Core.setting.php index 965df9dcc5..21d4638173 100644 --- a/settings/Core.setting.php +++ b/settings/Core.setting.php @@ -699,8 +699,9 @@ return array( 'description' => NULL, 'help_text' => NULL, 'on_change' => array( - array('CRM_Case_Info', 'onToggleComponents'), - array('CRM_Core_Component', 'flushEnabledComponents'), + 'CRM_Case_Info::onToggleComponents', + 'CRM_Core_Component::flushEnabledComponents', + 'call://resources/resetCacheCode', ), ), 'disable_core_css' => array( diff --git a/tests/phpunit/Civi/Core/ResolverTest.php b/tests/phpunit/Civi/Core/ResolverTest.php new file mode 100644 index 0000000000..de5388fa72 --- /dev/null +++ b/tests/phpunit/Civi/Core/ResolverTest.php @@ -0,0 +1,171 @@ +resolver = new Resolver(); + } + + /** + * Test callback with a constant value. + */ + public function testConstant() { + $cb = $this->resolver->get('0'); + $actual = call_user_func($cb, 'foo'); + $this->assertTrue(0 === $actual); + + $cb = $this->resolver->get('1'); + $actual = call_user_func($cb, 'foo'); + $this->assertTrue(1 === $actual); + } + + /** + * Test callback for a global function. + */ + public function testGlobalFunc() { + // Note: civi_core_callback_dummy is implemented at the bottom of this file. + $cb = $this->resolver->get('civi_core_callback_dummy'); + $this->assertEquals('civi_core_callback_dummy', $cb); + + $expected = 'global dummy received foo'; + $actual = call_user_func($cb, 'foo'); + $this->assertEquals($expected, $actual); + } + + /** + * Test callback for a static function. + */ + public function testStatic() { + $cb = $this->resolver->get('Civi\Core\ResolverTest::dummy'); + $this->assertEquals(array('Civi\Core\ResolverTest', 'dummy'), $cb); + + $expected = 'static dummy received foo'; + $actual = call_user_func($cb, 'foo'); + $this->assertEquals($expected, $actual); + } + + /** + * Test callback for an API. + */ + public function testApi3() { + // Note: The Resolvertest.Ping API is implemented at the bottom of this file. + $cb = $this->resolver->get('api3://Resolvertest/ping?first=@1'); + $expected = 'api dummy received foo'; + $actual = call_user_func($cb, 'foo'); + $this->assertEquals($expected, $actual); + } + + /** + * Test callback for an object in the container. + */ + public function testCall() { + // Note: ResolverTestExampleService is implemented at the bottom of this file. + Container::singleton()->set('callbackTestService', new ResolverTestExampleService()); + $cb = $this->resolver->get('call://callbackTestService/ping'); + $expected = 'service dummy received foo'; + $actual = call_user_func($cb, 'foo'); + $this->assertEquals($expected, $actual); + } + + /** + * Test callback for an invalid object in the container. + * + * @expectedException \Symfony\Component\DependencyInjection\Exception\ExceptionInterface + */ + public function testCallWithInvalidService() { + $this->resolver->get('call://totallyNonexistentService/ping'); + } + + /** + * Test object-lookup in the container. + */ + public function testObj() { + // Note: ResolverTestExampleService is implemented at the bottom of this file. + Container::singleton()->set('callbackTestService', new ResolverTestExampleService()); + $obj = $this->resolver->get('obj://callbackTestService'); + $this->assertTrue($obj instanceof ResolverTestExampleService); + } + + /** + * Test object-lookup in the container (invalid name). + * + * @expectedException \Symfony\Component\DependencyInjection\Exception\ExceptionInterface + */ + public function testObjWithInvalidService() { + $this->resolver->get('obj://totallyNonexistentService'); + } + + /** + * Test default object creation. + */ + public function testClass() { + // Note: ResolverTestExampleService is implemented at the bottom of this file. + $obj = $this->resolver->get('Civi\Core\ResolverTestExampleService'); + $this->assertTrue($obj instanceof ResolverTestExampleService); + } + + /** + * @param string $arg1 + * Dummy value to pass through. + * @return array + */ + public static function dummy($arg1) { + return "static dummy received $arg1"; + } + + } + + /** + * Class ResolverTestExampleService + * + * @package Civi\Core + */ + class ResolverTestExampleService { + + /** + * @param string $arg1 + * Dummy value to pass through. + * @return string + */ + public function ping($arg1) { + return "service dummy received $arg1"; + } + + } +} + +namespace { + /** + * @param string $arg1 + * Dummy value to pass through. + * @return string + */ + function civi_core_callback_dummy($arg1) { + return "global dummy received $arg1"; + } + + /** + * @param array $params + * API parameters. + * @return array + */ + function civicrm_api3_resolvertest_ping($params) { + return civicrm_api3_create_success("api dummy received " . $params['first']); + } +} -- 2.25.1