Skip to content

Commit

Permalink
Civi/Schema - Extract MagicGetterSetterTrait. Add test coverage.
Browse files Browse the repository at this point in the history
  • Loading branch information
totten committed Jul 17, 2021
1 parent b6601e0 commit e1f0b33
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 35 deletions.
43 changes: 8 additions & 35 deletions Civi/Api4/Generic/AbstractAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -35,6 +36,8 @@
*/
abstract class AbstractAction implements \ArrayAccess {

use MagicGetterSetterTrait;

/**
* Api version number; cannot be changed.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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());
}

/**
Expand Down
96 changes: 96 additions & 0 deletions Civi/Schema/Traits/MagicGetterSetterTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/

namespace Civi\Schema\Traits;

/**
* Automatically define getter/setter methods for public and protected fields.
*
* BASIC USAGE
*
* - Choose a class
* - Add the trait (`use MagicGetterSetterTrait;`).
* - Add a public or protected property (`protected $fooBar;`).
* - When using the class, you may now call `setFooBar($value)` and `getFooBar()`.
*
* TIPS AND TRICKS
*
* - To provide better hints/DX in IDEs, you may add the `@method` notations
* to the class docblock. There are several examples of this in APIv4
* (see e.g. `AbstractAction.php` or `AbstractQueryAction.php`).
* - When/if you need to customize the behavior of a getter/setter, then simply
* add your own method. This takes precedence over magic mehods.
* - If a field name begins with `_`, then it will be excluded.
*
* @package Civi\Schema\Traits
*/
trait MagicGetterSetterTrait {

/**
* Magic function to provide getters/setters.
*
* @param string $method
* @param array $arguments
* @return static|mixed
* @throws \CRM_Core_Exception
*/
public function __call($method, $arguments) {
$mode = substr($method, 0, 3);
$prop = lcfirst(substr($method, 3));
$props = static::getMagicProperties();
if (isset($props[$prop])) {
switch ($mode) {
case 'get':
return $this->$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];
}

}
95 changes: 95 additions & 0 deletions tests/phpunit/Civi/Schema/MagicGetterSetterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace Civi\Schema;

use Civi\Schema\Traits\MagicGetterSetterTrait;

class MagicGetterSetterTest extends \CiviUnitTestCase {

public function createExample() {
return new class() {

use MagicGetterSetterTrait;

protected $protectedField;
public $publicField;
protected $_obscureProtectedField;
public $_obscurePublicField;
protected $overriddenProtectedField;
protected $set;
protected $get;

/**
* @return mixed
*/
public function getOverriddenProtectedField() {
return $this->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);
}
}
}

}

0 comments on commit e1f0b33

Please sign in to comment.