diff --git a/Civi/Api4/Generic/AbstractAction.php b/Civi/Api4/Generic/AbstractAction.php index 8924c6c831cb..1b54da667d6e 100644 --- a/Civi/Api4/Generic/AbstractAction.php +++ b/Civi/Api4/Generic/AbstractAction.php @@ -14,6 +14,7 @@ use Civi\Api4\Utils\CoreUtil; use Civi\Api4\Utils\FormattingUtil; use Civi\Api4\Utils\ReflectionUtils; +use Civi\Schema\Traits\MagicGetterSetterTrait; /** * Base class for all api actions. @@ -35,6 +36,8 @@ */ abstract class AbstractAction implements \ArrayAccess { + use MagicGetterSetterTrait; + /** * Api version number; cannot be changed. * @@ -189,33 +192,6 @@ public function addChain($name, AbstractAction $apiRequest, $index = NULL) { return $this; } - /** - * Magic function to provide automatic getter/setter 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); - if ($this->paramExists($param)) { - switch ($mode) { - case 'get': - return $this->$param; - - case 'set': - $this->$param = $arguments[0]; - return $this; - } - } - throw new \API_Exception('Unknown api parameter: ' . $name); - } - /** * Invoke api call. * @@ -251,12 +227,9 @@ abstract public function _run(Result $result); */ 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; - } + $magicProperties = $this->getMagicProperties(); + foreach ($magicProperties as $name => $bool) { + $params[$name] = $this->$name; } return $params; } @@ -310,14 +283,14 @@ public function getActionName() { * @return bool */ public function paramExists($param) { - return array_key_exists($param, $this->getParams()); + return array_key_exists($param, $this->getMagicProperties()); } /** * @return array */ protected function getParamDefaults() { - return array_intersect_key($this->reflect()->getDefaultProperties(), $this->getParams()); + return array_intersect_key($this->reflect()->getDefaultProperties(), $this->getMagicProperties()); } /** diff --git a/Civi/Schema/Traits/MagicGetterSetterTrait.php b/Civi/Schema/Traits/MagicGetterSetterTrait.php new file mode 100644 index 000000000000..cc5e0e325a89 --- /dev/null +++ b/Civi/Schema/Traits/MagicGetterSetterTrait.php @@ -0,0 +1,96 @@ +$prop; + + case 'set': + $this->$prop = $arguments[0]; + return $this; + } + } + + throw new \CRM_Core_Exception(sprintf('Unknown method: %s::%s()', static::CLASS, $method)); + } + + /** + * Get a list of class properties for which magic methods are supported. + * + * @return array + * List of supported properties, keyed by property name. + * Array(string $propertyName => bool $true). + */ + protected static function getMagicProperties(): array { + // Thread-local cache of class metadata. This is strictly readonly and immutable, and it should ideally be reused across varied test-functions. + static $cache = []; + + if (!isset($cache[static::CLASS])) { + try { + $clazz = new \ReflectionClass(static::CLASS); + } + catch (\ReflectionException $e) { + // This shouldn't happen. Cast to RuntimeException so that we don't have a million `@throws` statements. + throw new \RuntimeException(sprintf("Class %s cannot reflect upon itself.", static::CLASS)); + } + + $fields = []; + foreach ($clazz->getProperties(\ReflectionProperty::IS_PROTECTED | \ReflectionProperty::IS_PUBLIC) as $property) { + $name = $property->getName(); + if (!$property->isStatic() && $name[0] !== '_') { + $fields[$name] = TRUE; + } + } + unset($clazz); + $cache[static::CLASS] = $fields; + } + return $cache[static::CLASS]; + } + +} diff --git a/tests/phpunit/Civi/Schema/MagicGetterSetterTest.php b/tests/phpunit/Civi/Schema/MagicGetterSetterTest.php new file mode 100644 index 000000000000..8079d3895c31 --- /dev/null +++ b/tests/phpunit/Civi/Schema/MagicGetterSetterTest.php @@ -0,0 +1,95 @@ +overriddenProtectedField . '_and_get'; + } + + /** + * @param mixed $overriddenProtectedField + * @return $this + */ + public function setOverriddenProtectedField($overriddenProtectedField) { + $this->overriddenProtectedField = $overriddenProtectedField . '_and_set'; + return $this; + } + + }; + } + + public function testExample() { + $ex = $this->createExample(); + $this->assertEquals(NULL, $ex->setProtectedField(NULL)->getProtectedField()); + $this->assertEquals('apple', $ex->setProtectedField('apple')->getProtectedField()); + $this->assertEquals('banana', $ex->setPublicField('banana')->getPublicField()); + $this->assertEquals('cherry', $ex->setSet('cherry')->getSet()); + $this->assertEquals('date', $ex->setGet('date')->getGet()); + $this->assertEquals('base_and_set_and_get', $ex->setOverriddenProtectedField('base')->getOverriddenProtectedField()); + + $nonMethods = [ + 'goozfraba', + + // Typos + 'seProtectedField', + 'geProtectedField', + 'istProtectedField', + + // Obscure fields + 'set_obscureProtectedField', + 'get_obscureProtectedField', + 'is_obscureProtectedField', + 'setObscureProtectedField', + 'getObscureProtectedField', + 'isObscureProtectedField', + 'set_obscurePublicField', + 'get_obscurePublicField', + 'is_obscurePublicField', + 'setObscurePublicField', + 'getObscurePublicField', + 'isObscurePublicField', + + // Funny substrings + 'i', + 'g', + 's', + 'set', + 'get', + 'is', + 'istanbul', + 'getter', + 'setter', + ]; + foreach ($nonMethods as $nonMethod) { + try { + $ex->{$nonMethod}(); + $this->fail("Method $nonMethod() should raise exception."); + } + catch (\CRM_Core_Exception $e) { + $message = $e->getMessage(); + $this->assertRegExp('/Unknown method.*::' . $nonMethod . '()/', $message); + } + } + } + +}