diff --git a/src/Kdyby/Doctrine/Entities/BaseEntity.php b/src/Kdyby/Doctrine/Entities/BaseEntity.php index 082710fe..ac8f937f 100644 --- a/src/Kdyby/Doctrine/Entities/BaseEntity.php +++ b/src/Kdyby/Doctrine/Entities/BaseEntity.php @@ -25,6 +25,7 @@ /** * @author Filip Procházka * + * @deprecated * @ORM\MappedSuperclass() */ abstract class BaseEntity extends Nette\Object implements \Serializable diff --git a/src/Kdyby/Doctrine/Entities/MagicAccessors.php b/src/Kdyby/Doctrine/Entities/MagicAccessors.php new file mode 100644 index 00000000..88f028dc --- /dev/null +++ b/src/Kdyby/Doctrine/Entities/MagicAccessors.php @@ -0,0 +1,450 @@ + + */ +trait MagicAccessors +{ + + /** + * @var array + */ + private static $__properties = array(); + + /** + * @var array + */ + private static $__methods = array(); + + + + /** + */ + public function __construct() + { + } + + + + /** + * @param string $property property name + * @param array $args + * @return Collection|array + */ + protected function convertCollection($property, array $args = NULL) + { + return new ReadOnlyCollectionWrapper($this->$property); + } + + + + /** + * Utility method, that can be replaced with `::class` since php 5.5 + * @return string + */ + public static function getClassName() + { + return get_called_class(); + } + + + + /** + * Access to reflection. + * + * @return Nette\Reflection\ClassType|\ReflectionClass + */ + public static function getReflection() + { + $class = class_exists('Nette\Reflection\ClassType') ? 'Nette\Reflection\ClassType' : 'ReflectionClass'; + return new $class(get_called_class()); + } + + + + /** + * Allows the user to access through magic methods to protected and public properties. + * There are get() and set($value) methods for every protected or public property, + * and for protected or public collections there are add($entity), remove($entity) and has($entity). + * When you'll try to call setter on collection, or collection manipulator on generic value, it will throw. + * Getters on collections will return all it's items. + * + * @param string $name method name + * @param array $args arguments + * + * @throws \Kdyby\Doctrine\UnexpectedValueException + * @throws \Kdyby\Doctrine\MemberAccessException + * @return mixed + */ + public function __call($name, $args) + { + if (strlen($name) > 3) { + $properties = $this->listObjectProperties(); + + $op = substr($name, 0, 3); + $prop = strtolower($name[3]) . substr($name, 4); + if ($op === 'set' && isset($properties[$prop])) { + if ($this->$prop instanceof Collection) { + throw UnexpectedValueException::collectionCannotBeReplaced($this, $prop); + } + + $this->$prop = $args[0]; + + return $this; + + } elseif ($op === 'get' && isset($properties[$prop])) { + if ($this->$prop instanceof Collection) { + return $this->convertCollection($prop, $args); + + } else { + return $this->$prop; + } + + } else { // collections + if ($op === 'add') { + if (isset($properties[$prop . 's'])) { + if (!$this->{$prop . 's'} instanceof Collection) { + throw UnexpectedValueException::notACollection($this, $prop . 's'); + } + + $this->{$prop . 's'}->add($args[0]); + + return $this; + + } elseif (substr($prop, -1) === 'y' && isset($properties[$prop = substr($prop, 0, -1) . 'ies'])) { + if (!$this->$prop instanceof Collection) { + throw UnexpectedValueException::notACollection($this, $prop); + } + + $this->$prop->add($args[0]); + + return $this; + + } elseif (isset($properties[$prop])) { + throw UnexpectedValueException::notACollection($this, $prop); + } + + } elseif ($op === 'has') { + if (isset($properties[$prop . 's'])) { + if (!$this->{$prop . 's'} instanceof Collection) { + throw UnexpectedValueException::notACollection($this, $prop . 's'); + } + + return $this->{$prop . 's'}->contains($args[0]); + + } elseif (substr($prop, -1) === 'y' && isset($properties[$prop = substr($prop, 0, -1) . 'ies'])) { + if (!$this->$prop instanceof Collection) { + throw UnexpectedValueException::notACollection($this, $prop); + } + + return $this->$prop->contains($args[0]); + + } elseif (isset($properties[$prop])) { + throw UnexpectedValueException::notACollection($this, $prop); + } + + } elseif (strlen($name) > 6 && ($op = substr($name, 0, 6)) === 'remove') { + $prop = strtolower($name[6]) . substr($name, 7); + + if (isset($properties[$prop . 's'])) { + if (!$this->{$prop . 's'} instanceof Collection) { + throw UnexpectedValueException::notACollection($this, $prop . 's'); + } + + $this->{$prop . 's'}->removeElement($args[0]); + + return $this; + + } elseif (substr($prop, -1) === 'y' && isset($properties[$prop = substr($prop, 0, -1) . 'ies'])) { + if (!$this->$prop instanceof Collection) { + throw UnexpectedValueException::notACollection($this, $prop); + } + + $this->$prop->removeElement($args[0]); + + return $this; + + } elseif (isset($properties[$prop])) { + throw UnexpectedValueException::notACollection($this, $prop); + } + } + } + } + + if ($name === '') { + throw MemberAccessException::callWithoutName($this); + } + $class = get_class($this); + + // event functionality + if (preg_match('#^on[A-Z]#', $name) && property_exists($class, $name)) { + $rp = new \ReflectionProperty($this, $name); + if ($rp->isPublic() && !$rp->isStatic()) { + if (is_array($list = $this->$name) || $list instanceof \Traversable) { + foreach ($list as $handler) { + Callback::invokeArgs($handler, $args); + } + } elseif ($list !== NULL) { + throw UnexpectedValueException::invalidEventValue($list, $this, $name); + } + + return NULL; + } + } + + // extension methods + if ($cb = static::extensionMethod($name)) { + /** @var \Nette\Callback $cb */ + array_unshift($args, $this); + + return $cb->invokeArgs($args); + } + + throw MemberAccessException::undefinedMethodCall($this, $name); + } + + + + /** + * Call to undefined static method. + * + * @param string method name (in lower case!) + * @param array arguments + * @return mixed + * @throws MemberAccessException + */ + public static function __callStatic($name, $args) + { + return ObjectMixin::callStatic(get_called_class(), $name, $args); + } + + + + /** + * Adding method to class. + * + * @param string method name + * @param callable + * @return mixed + */ + public static function extensionMethod($name, $callback = NULL) + { + if (strpos($name, '::') === FALSE) { + $class = get_called_class(); + } else { + list($class, $name) = explode('::', $name); + $class = (new \ReflectionClass($class))->getName(); + } + if ($callback === NULL) { + return ObjectMixin::getExtensionMethod($class, $name); + } else { + ObjectMixin::setExtensionMethod($class, $name, $callback); + } + } + + + + /** + * Returns property value. Do not call directly. + * + * @param string $name property name + * + * @throws MemberAccessException if the property is not defined. + * @return mixed property value + */ + public function &__get($name) + { + if ($name === '') { + throw MemberAccessException::propertyReadWithoutName($this); + } + + // property getter support + $name[0] = $name[0] & "\xDF"; // case-sensitive checking, capitalize first character + $m = 'get' . $name; + + $methods = $this->listObjectMethods(); + if (isset($methods[$m])) { + // ampersands: + // - uses &__get() because declaration should be forward compatible (e.g. with Nette\Utils\Html) + // - doesn't call &$_this->$m because user could bypass property setter by: $x = & $obj->property; $x = 'new value'; + $val = $this->$m(); + + return $val; + } + + $m = 'is' . $name; + if (isset($methods[$m])) { + $val = $this->$m(); + + return $val; + } + + // protected attribute support + $properties = $this->listObjectProperties(); + if (isset($properties[$name = func_get_arg(0)])) { + if ($this->$name instanceof Collection) { + $coll = $this->convertCollection($name); + + return $coll; + + } else { + $val = $this->$name; + + return $val; + } + } + + $type = isset($methods['set' . $name]) ? 'a write-only' : 'an undeclared'; + throw MemberAccessException::propertyNotReadable($type, $this, func_get_arg(0)); + } + + + + /** + * Sets value of a property. Do not call directly. + * + * @param string $name property name + * @param mixed $value property value + * + * @throws UnexpectedValueException + * @throws MemberAccessException if the property is not defined or is read-only + */ + public function __set($name, $value) + { + if ($name === '') { + throw MemberAccessException::propertyWriteWithoutName($this); + } + + // property setter support + $name[0] = $name[0] & "\xDF"; // case-sensitive checking, capitalize first character + + $methods = $this->listObjectMethods(); + $m = 'set' . $name; + if (isset($methods[$m])) { + $this->$m($value); + + return; + } + + // protected attribute support + $properties = $this->listObjectProperties(); + if (isset($properties[$name = func_get_arg(0)])) { + if ($this->$name instanceof Collection) { + throw UnexpectedValueException::collectionCannotBeReplaced($this, $name); + } + + $this->$name = $value; + + return; + } + + $type = isset($methods['get' . $name]) || isset($methods['is' . $name]) ? 'a read-only' : 'an undeclared'; + throw MemberAccessException::propertyNotWritable($type, $this, func_get_arg(0)); + } + + + + /** + * Is property defined? + * + * @param string $name property name + * + * @return bool + */ + public function __isset($name) + { + $properties = $this->listObjectProperties(); + if (isset($properties[$name])) { + return TRUE; + } + + if ($name === '') { + return FALSE; + } + + $methods = $this->listObjectMethods(); + $name[0] = $name[0] & "\xDF"; + + return isset($methods['get' . $name]) || isset($methods['is' . $name]); + } + + + + /** + * Access to undeclared property. + * + * @param string property name + * @return void + * @throws MemberAccessException + */ + public function __unset($name) + { + ObjectMixin::remove($this, $name); + } + + + + /** + * Should return only public or protected properties of class + * + * @return array + */ + private function listObjectProperties() + { + $class = get_class($this); + if (!isset(self::$__properties[$class])) { + $refl = new \ReflectionClass($class); + $properties = array_map(function (\ReflectionProperty $property) { + return $property->getName(); + }, $refl->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED)); + + self::$__properties[$class] = array_flip($properties); + } + + return self::$__properties[$class]; + } + + + + /** + * Should return all public methods of class + * + * @return array + */ + private function listObjectMethods() + { + $class = get_class($this); + if (!isset(self::$__methods[$class])) { + $refl = new \ReflectionClass($class); + $methods = array_map(function (\ReflectionMethod $method) { + return $method->getName(); + }, $refl->getMethods(\ReflectionMethod::IS_PUBLIC)); + + self::$__methods[$class] = array_flip($methods); + } + + return self::$__methods[$class]; + } + +} diff --git a/tests/KdybyTests/Doctrine/MagicAccesors.phpt b/tests/KdybyTests/Doctrine/MagicAccesors.phpt new file mode 100644 index 00000000..9bc08d5b --- /dev/null +++ b/tests/KdybyTests/Doctrine/MagicAccesors.phpt @@ -0,0 +1,506 @@ + + * @package Kdyby\Doctrine + */ + +namespace KdybyTests\Doctrine; + +use Doctrine\Common\Collections\ArrayCollection; +use Kdyby\Doctrine\Entities\BaseEntity; +use Kdyby; +use Nette; +use Tester; +use Tester\Assert; + +require_once __DIR__ . '/../bootstrap.php'; + + + +/** + * @author Filip Procházka + */ +class MagicAccessorsTest extends Tester\TestCase +{ + + public function testUnsetPrivateException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + unset($entity->one); + }, 'Nette\MemberAccessException', 'Cannot unset the property KdybyTests\Doctrine\BadlyNamedEntity::$one.'); + } + + + + public function testUnsetProtectedException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + unset($entity->two); + }, 'Nette\MemberAccessException', 'Cannot unset the property KdybyTests\Doctrine\BadlyNamedEntity::$two.'); + } + + + + public function testIsset() + { + $entity = new BadlyNamedEntity(); + Assert::false(isset($entity->one)); + Assert::true(isset($entity->two)); + Assert::true(isset($entity->three)); + Assert::false(isset($entity->ones)); + Assert::true(isset($entity->twos)); + Assert::true(isset($entity->proxies)); + Assert::true(isset($entity->threes)); + } + + + + public function testGetPrivateException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->one; + }, 'Kdyby\Doctrine\MemberAccessException', 'Cannot read an undeclared property KdybyTests\Doctrine\BadlyNamedEntity::$one.'); + } + + + + public function testGetProtected() + { + $entity = new BadlyNamedEntity(); + Assert::equal(2, $entity->two->id); + } + + + + public function testGetPrivateCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->ones; + }, 'Kdyby\Doctrine\MemberAccessException', 'Cannot read an undeclared property KdybyTests\Doctrine\BadlyNamedEntity::$ones.'); + } + + + + public function testGetProtectedCollection() + { + $entity = new BadlyNamedEntity(); + + Assert::equal($entity->twos, $entity->getTwos()); + Assert::type('Kdyby\Doctrine\Collections\ReadOnlyCollectionWrapper', $entity->twos); + Assert::type('Kdyby\Doctrine\Collections\ReadOnlyCollectionWrapper', $entity->getTwos()); + + Assert::equal($entity->proxies, $entity->getProxies()); + Assert::type('Kdyby\Doctrine\Collections\ReadOnlyCollectionWrapper', $entity->proxies); + Assert::type('Kdyby\Doctrine\Collections\ReadOnlyCollectionWrapper', $entity->getProxies()); + } + + + + public function testSetPrivateException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->one = 1; + }, 'Kdyby\Doctrine\MemberAccessException', 'Cannot write to an undeclared property KdybyTests\Doctrine\BadlyNamedEntity::$one.'); + } + + + + public function testSetProtected() + { + $entity = new BadlyNamedEntity(); + $entity->two = 2; + Assert::equal(2, $entity->two); + } + + + + public function testSetPrivateCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->ones = 1; + }, 'Kdyby\Doctrine\MemberAccessException', 'Cannot write to an undeclared property KdybyTests\Doctrine\BadlyNamedEntity::$ones.'); + } + + + + public function testSetProtectedCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->twos = 1; + }, 'Kdyby\Doctrine\UnexpectedValueException', 'Class property KdybyTests\Doctrine\BadlyNamedEntity::$twos is an instance of Doctrine\Common\Collections\Collection. Use add() and remove() methods to manipulate it or declare your own.'); + } + + + + public function testSetProtectedCollection2Exception() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->proxies = 1; + }, 'Kdyby\Doctrine\UnexpectedValueException', 'Class property KdybyTests\Doctrine\BadlyNamedEntity::$proxies is an instance of Doctrine\Common\Collections\Collection. Use add() and remove() methods to manipulate it or declare your own.'); + } + + + + public function testCallSetterOnPrivateException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->setOne(1); + }, 'Kdyby\Doctrine\MemberAccessException', 'Call to undefined method KdybyTests\Doctrine\BadlyNamedEntity::setOne().'); + } + + + + public function testCallSetterOnProtected() + { + $entity = new BadlyNamedEntity(); + $entity->setTwo(2); + Assert::equal(2, $entity->two); + } + + + + public function testValidSetterProvidesFluentInterface() + { + $entity = new BadlyNamedEntity(); + Assert::same($entity, $entity->setTwo(2)); + } + + + + public function testCallSetterOnPrivateCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->setOnes(1); + }, 'Kdyby\Doctrine\MemberAccessException', 'Call to undefined method KdybyTests\Doctrine\BadlyNamedEntity::setOnes().'); + } + + + + public function testCallSetterOnProtectedCollection() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->setTwos(2); + }, 'Kdyby\Doctrine\UnexpectedValueException', 'Class property KdybyTests\Doctrine\BadlyNamedEntity::$twos is an instance of Doctrine\Common\Collections\Collection. Use add() and remove() methods to manipulate it or declare your own.'); + } + + + + public function testCallSetterOnProtected2Collection() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->setProxies(3); + }, 'Kdyby\Doctrine\UnexpectedValueException', 'Class property KdybyTests\Doctrine\BadlyNamedEntity::$proxies is an instance of Doctrine\Common\Collections\Collection. Use add() and remove() methods to manipulate it or declare your own.'); + } + + + + public function testCallGetterOnPrivateException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->getOne(); + }, 'Kdyby\Doctrine\MemberAccessException', 'Call to undefined method KdybyTests\Doctrine\BadlyNamedEntity::getOne().'); + } + + + + public function testCallGetterOnProtected() + { + $entity = new BadlyNamedEntity(); + Assert::equal(2, $entity->getTwo()->id); + } + + + + public function testCallGetterOnPrivateCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->getOnes(); + }, 'Kdyby\Doctrine\MemberAccessException', 'Call to undefined method KdybyTests\Doctrine\BadlyNamedEntity::getOnes().'); + } + + + + public function testCallGetterOnProtectedCollection() + { + $entity = new BadlyNamedEntity(); + Assert::equal(array((object) array('id' => 2)), $entity->getTwos()->toArray()); + Assert::equal(array((object) array('id' => 3)), $entity->getProxies()->toArray()); + } + + + + public function testCallNonExistingMethodException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->thousand(1000); + }, 'Kdyby\Doctrine\MemberAccessException', 'Call to undefined method KdybyTests\Doctrine\BadlyNamedEntity::thousand().'); + } + + + + public function testCallAddOnPrivateCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->addOne((object) array('id' => 1)); + }, 'Kdyby\Doctrine\MemberAccessException', 'Call to undefined method KdybyTests\Doctrine\BadlyNamedEntity::addOne().'); + } + + + + public function testCallAddOnNonCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->addFour((object) array('id' => 4)); + }, 'Kdyby\Doctrine\UnexpectedValueException', 'Class property KdybyTests\Doctrine\BadlyNamedEntity::$four is not an instance of Doctrine\Common\Collections\Collection.'); + } + + + + public function testCallAddOnProtectedCollection() + { + $entity = new BadlyNamedEntity(); + $entity->addTwo($a = (object) array('id' => 2)); + Assert::truthy($entity->getTwos()->filter(function ($two) use ($a) { + return $two === $a; + })); + + $entity->addProxy($b = (object) array('id' => 3)); + Assert::truthy((bool) $entity->getProxies()->filter(function ($two) use ($b) { + return $two === $b; + })); + } + + + + public function testCallHasOnPrivateCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->hasOne((object) array('id' => 1)); + }, 'Kdyby\Doctrine\MemberAccessException', 'Call to undefined method KdybyTests\Doctrine\BadlyNamedEntity::hasOne().'); + } + + + + public function testCallHasOnNonCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->hasFour((object) array('id' => 4)); + }, 'Kdyby\Doctrine\UnexpectedValueException', 'Class property KdybyTests\Doctrine\BadlyNamedEntity::$four is not an instance of Doctrine\Common\Collections\Collection.'); + } + + + + public function testCallHasOnProtectedCollection() + { + $entity = new BadlyNamedEntity(); + Assert::false($entity->hasTwo((object) array('id' => 2))); + Assert::false($entity->hasProxy((object) array('id' => 3))); + + $twos = $entity->getTwos(); + Assert::false($twos->isEmpty()); + Assert::true($entity->hasTwo($twos->first())); + + $proxies = $entity->getProxies(); + Assert::false($proxies->isEmpty()); + Assert::true($entity->hasProxy($proxies->first())); + } + + + + public function testCallRemoveOnPrivateCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->removeOne((object) array('id' => 1)); + }, 'Kdyby\Doctrine\MemberAccessException', 'Call to undefined method KdybyTests\Doctrine\BadlyNamedEntity::removeOne().'); + } + + + + public function testCallRemoveOnNonCollectionException() + { + Assert::exception(function () { + $entity = new BadlyNamedEntity(); + $entity->removeFour((object) array('id' => 4)); + }, 'Kdyby\Doctrine\UnexpectedValueException', 'Class property KdybyTests\Doctrine\BadlyNamedEntity::$four is not an instance of Doctrine\Common\Collections\Collection.'); + } + + + + public function testCallRemoveOnProtectedCollection() + { + $entity = new BadlyNamedEntity(); + $twos = $entity->getTwos(); + Assert::false($twos->isEmpty()); + $entity->removeTwo($twos->first()); + $twos = $entity->getTwos(); + Assert::true($twos->isEmpty()); + + $proxies = $entity->getProxies(); + Assert::false($proxies->isEmpty()); + $entity->removeProxy($proxies->first()); + $proxies = $entity->getProxies(); + Assert::true($proxies->isEmpty()); + } + + + + public function testGetterHaveHigherPriority() + { + $entity = new BadlyNamedEntity(); + Assert::equal(4, $entity->something); + } + + + + public function testSetterHaveHigherPriority() + { + $entity = new BadlyNamedEntity(); + $entity->something = 4; + Assert::same(2, $entity->getRealSomething()); + } + +} + + + +/** + * @author Filip Procházka + * @method setTwo() + * @method addTwo() + * @method getTwo() + * @method removeTwo() + * @method hasTwo() + * @method \Doctrine\Common\Collections\ArrayCollection getTwos() + * @method addProxy() + * @method hasProxy() + * @method removeProxy() + * @method \Doctrine\Common\Collections\ArrayCollection getProxies() + */ +class BadlyNamedEntity +{ + + use Kdyby\Doctrine\Entities\MagicAccessors; + + /** + * @var array events + */ + private $onSomething = array(); + + /** + * @var object + */ + private $one; + + /** + * @var object + */ + protected $two; + + /** + * @var object + */ + protected $four; + + /** + * @var object + */ + public $three; + + /** + * @var \Doctrine\Common\Collections\ArrayCollection + */ + private $ones; + + /** + * @var \Doctrine\Common\Collections\ArrayCollection + */ + protected $twos; + + /** + * @var \Doctrine\Common\Collections\ArrayCollection + */ + protected $proxies; + + /** + * @var \Doctrine\Common\Collections\ArrayCollection + */ + public $threes; + + /** + * @var int + */ + protected $something = 2; + + + + /** + */ + public function __construct() + { + $this->one = (object) array('id' => 1); + $this->two = (object) array('id' => 2); + $this->three = (object) array('id' => 3); + + $this->ones = new ArrayCollection(array((object) array('id' => 1))); + $this->twos = new ArrayCollection(array((object) array('id' => 2))); + $this->proxies = new ArrayCollection(array((object) array('id' => 3))); + $this->threes = new ArrayCollection(array((object) array('id' => 4))); + } + + + + /** + * @param int $something + */ + public function setSomething($something) + { + $this->something = (int) ceil($something / 2); + } + + + + /** + * @return int + */ + public function getSomething() + { + return $this->something * 2; + } + + + + /** + * @return int + */ + public function getRealSomething() + { + return $this->something; + } + +} + +\run(new MagicAccessorsTest());