CRM-15578 - Add Civi\Core\Resolver. Fix loading of Angular content after updating...
authorTim Otten <totten@civicrm.org>
Fri, 30 Jan 2015 08:40:22 +0000 (00:40 -0800)
committerTim Otten <totten@civicrm.org>
Sat, 31 Jan 2015 05:42:31 +0000 (21:42 -0800)
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
CRM/Admin/Form/Setting/Component.php
CRM/Core/BAO/Setting.php
CRM/Core/Invoke.php
CRM/Core/Menu.php
CRM/Core/PseudoConstant.php
Civi/Core/Container.php
Civi/Core/Resolver.php [new file with mode: 0644]
settings/Core.setting.php
tests/phpunit/Civi/Core/ResolverTest.php [new file with mode: 0644]

index 15c197977d5ae05d8dcc293be537a917bf91f417..af3a49aa5d5d9a2ae2eb50592b621b469c74780b 100644 (file)
@@ -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
index 1b71abb04e5197dba8b38304b438d0a1ba3d611a..cfc2573db15efcf4d34a648d1354228af604085f 100644 (file)
@@ -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
index f42e66c6f98d669ea54e30e687285f4204205970..76ca30ee36d25a534e0632b3e95fa55d7a631765 100644 (file)
@@ -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']}");
       }
     }
index 531954ede60a4e604082946e9558dfd571f3eb95..b37a51334202cbbe37ea1a3e85a83e5767576323 100644 (file)
@@ -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'];
index 59359e353fbe18e2edffe8ac8044f68bf6acd3f6..f453650a34bd7a95e4ce8d46448ab49d707e170d 100644 (file)
@@ -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, ';')
           ) {
index aa6dec652ff5c21fe2c5808e2cb90b3d29175621..00045aae8ed80d6732192f7e1e7f59e01f00678f 100644 (file)
@@ -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
index e1e54b4f2856addaa60e281fbfdf0ab1026221ee..7de921c8891daf8be49482f16397728543da7540 100644 (file)
@@ -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 (file)
index 0000000..0654acc
--- /dev/null
@@ -0,0 +1,215 @@
+<?php
+namespace Civi\Core;
+
+/**
+ * The resolver takes a string expression and returns an object or callable.
+ *
+ * The following patterns will resolve to objects:
+ *   - 'obj://objectName' - An object from Civi\Core\Container
+ *   - 'ClassName' - An instance of ClassName (with default constructor).
+ *     If you need more control over construction, then register with the
+ *     container.
+ *
+ * The following patterns will resolve to callables:
+ *   - 'function_name' - A function(callable).
+ *   - 'ClassName::methodName" - A static method of a class.
+ *   - 'call://objectName/method' - A method on an object from Civi\Core\Container.
+ *   - 'api3://EntityName/action' - A method call on an API.
+ *     (Performance note: Requires full setup/teardown of API subsystem.)
+ *   - 'api3://EntityName/action?first=@1&second=@2' - Call an API method, mapping the
+ *     first & second args to named parameters.
+ *     (Performance note: Requires parsing/interpolating arguments).
+ *   - '0' or '1' - A dummy which returns the constant '0' or '1'.
+ *
+ * Note: To differentiate classes and functions, there is a hard requirement that
+ * class names begin with an uppercase letter.
+ *
+ * Note: If you are working in a context which requires a callable, it is legitimate to use
+ * an object notation ("obj://objectName" or "ClassName") if the object supports __invoke().
+ *
+ * @package Civi\Core
+ */
+class Resolver {
+
+  protected static $_singleton;
+
+  /**
+   * @return Resolver
+   */
+  public static function singleton() {
+    if (self::$_singleton === NULL) {
+      self::$_singleton = new Resolver();
+    }
+    return self::$_singleton;
+  }
+
+  /**
+   * Convert a callback expression to a valid PHP callback.
+   *
+   * @param string|array $id
+   *   A callback expression; any of the following.
+   * @return array
+   *   A PHP callback. Do not serialize (b/c it may include an object).
+   */
+  public function get($id) {
+    if (!is_string($id)) {
+      // An array or object does not need to be further resolved.
+      return $id;
+    }
+
+    if (strpos($id, '::') !== FALSE) {
+      // Callback: Static method.
+      return explode('::', $id);
+    }
+    elseif (strpos($id, '://') !== FALSE) {
+      $url = parse_url($id);
+      switch ($url['scheme']) {
+        case 'obj':
+          // Object: Lookup in container.
+          return Container::singleton()->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;
+        }
+      }
+    }
+  }
+
+}
index 965df9dcc52c83d1b3e4a2dc28e6d3538d6963c1..21d4638173857afafd0f59e1136c50c5c7286103 100644 (file)
@@ -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 (file)
index 0000000..de5388f
--- /dev/null
@@ -0,0 +1,171 @@
+<?php
+
+namespace Civi\Core {
+  require_once 'CiviTest/CiviUnitTestCase.php';
+
+  /**
+   * Class ResolverTest
+   * @package Civi\Core
+   */
+  class ResolverTest extends \CiviUnitTestCase {
+
+    /**
+     * @var Resolver
+     */
+    protected $resolver;
+
+    /**
+     * Test setup.
+     */
+    protected function setUp() {
+      parent::setUp(); // TODO: Change the autogenerated stub
+      $this->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']);
+  }
+}