diff --git a/README.md b/README.md index b8b90e6..21305a7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,225 @@ -# magic +# Affinity4 Magic + Magic Trait used to easily add event listeners, spelling suggestions in errors and Javascript __set and __get style setters an getters to any class. Magic! + +## Installation + +```php +composer require affinity4/magic +``` + +## Usage + +### Events + +Simply include Magic in any class to instantly have event listeners! + +Once you've included Magic as a trait you can then add any public "camelCased" property starting with "on". You now have an event listener! That's all it takes! + +Let's say we have a Model called User + +```php +class User extends Model +{ + public function register(string $username, string $email, string $password) + { + // ... saves to `users` table + echo "New user saved to `users` table\n"; + } +} +``` + +When a new user is registered, we want to email them to let them know their login details. + +We'll add the Magic trait and create a public `onRegistration` property. It must be an array. + +```php +use Affinity4\Magic\Magic; + +class User extends Model +{ + trait Magic; + + /** + * @var array + */ + public $onRegistration = []; + + public function register(string $username, string $email, string $password) + { + echo "New user saved to `users` table"; + + $this->onRegistration($username, $email, $password); + } +} +``` + +Now each time `User::register()` is called the `User::onRegistration()` method will also be called, with the users details available to any event listener attached. + +#### Event Listeners + +To attach an event listener you simply need to add a callback to the onRegistration array. They will then be called in order every time `User::registration()` is executed. + +```php +require_once __DIR__ . '/vendor/autoload.php'; + +use Affinity4\Magic\Magic; + +class Model {} + +class User extends Model +{ + use Magic; + + /** + * @var array + */ + public $onRegistration = []; + + public function register(string $username, string $email, string $password) + { + echo "New user saved to `users` table\n"; + + $this->onRegistration($username, $email, $password); + } +} + +$User = new User; + +$User->onRegistration[] = function($username, $email, $password) +{ + echo "Send email to $email\n"; + echo "Hi $username!"; + echo "Thank you for signing up! Your password is '$password'"; +}; + +$User->register('johndoe', 'john.doe@somewhere.com', 'whoami'); + +// echos: +// New user saved to `users` table +// Send email to john.doe@somewhere.com +// Hi johndoe! +// Thank you for signing up!. Your password is 'whoami' +``` + +Of course you'll want to do something more clever (and security conscious) than this but you get the idea. + +### "Chained" or "nested" events + +__IMPORTANT__ + +One thing to always be conscious of is that event listeners are not shared across all instances of the class. If you create the following: + +```php +require_once __DIR__ . '/vendor/autoload.php'; + +use Affinity4\Magic\Magic; +use Some\Library\Log; + +class Email +{ + use Magic; + + public $onEmail; + + public function send($to, $from, $body) + { + // Email stuff... + + $this->onEmail($to, $from, $body); + } +} + +$EmailA = new Email; + +$EmailA->onEmail[] = function($to, $from, $body) { + Log::info("Email sent to $to from $from that said $body"); +}; + +$EmailB = new Email; + +$EmailB->send('someone@work.com', 'my.email@home.com', 'Check this out!'); +``` + +No log event will be fired. This is because the events listener that will log the email is only listening to `$EmailA`. + +This might be fairly obvious when side-by-side like this but in a large project this can be confusing if you forget what instance you are dealing with and what events are bound to it. You could get your logs mixed up, or worse. SO BE CAREFUL! + +#### Containers to the Rescue! + +This is where ServiceManagers, or IoC and DI Containers, are a life saver. However, because Containers will by default always return the same instance of the class when you get it from the container, you will need to use factories if you intend to set your events in the container while creating the class. + +```php +require_once __DIR__ . '/vendor/autoload.php'; + +use Affinity4\Magic\Magic; +use Pimple\Container; + +class Email +{ + use Magic; + + public $onEmail = []; + + public function send($to) + { + echo "Emailed $to\n"; + + $this->onEmail($to); + } +} + +class User +{ + use Magic; + + public $onSave = []; + + public function save($id) + { + echo "Saved $id\n"; + + $this->onSave($id); + } +} + +$Container = new Container(); + +$Container[User::class] = $Container->factory(function($c) { + $User = new User; + + $User->onSave[] = function($id) use ($c) { + echo "EVENT: Saved $id\n"; + + $c[Email::class]->send('email'); + }; + + return $User; +}); + + +$Container[Email::class] = $Container->factory(function($c) { + $Email = new Email; + + $Email->onEmail[] = function($to) { + echo "EVENT: Emailed $to"; + }; + + return $Email; +}); + +$Container[User::class]->onSave[] = function($id) use ($Container) { + echo "EVENT: Saved $id\n"; + + $Container[Email::class]->send('email'); +}; + +$Container[User::class]->save(1); + +// Will echo: +// Saved 1 +// EVENT: Saved 1 +// Emailed email +// EVENT: Emailed email +``` + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9119a5f --- /dev/null +++ b/composer.json @@ -0,0 +1,19 @@ +{ + "name": "affinity4/magic", + "description": "Magic Trait used to easily add event listeners, spelling suggestions in errors and Javascript __set and __get style setters an getters to any class. Magic!", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Luke Watts", + "email": "luke@affinity4.ie" + } + ], + "autoload": { + "psr-4": { + "Affinity4\\Magic\\": "src/" + } + }, + "require": { + } +} diff --git a/src/Magic.php b/src/Magic.php new file mode 100644 index 0000000..c836768 --- /dev/null +++ b/src/Magic.php @@ -0,0 +1,451 @@ + + * + * @since 1.0.0 + * + * @param string $class + * @param string $name + * + * @return bool + */ + public static function __isEventProperty(string $class, string $name): bool + { + static $cache; + + $prop = &$cache[$class][$name]; + + if ($prop === null) { + $prop = false; + try { + $rp = new \ReflectionProperty($class, $name); + if ($rp->isPublic() && !$rp->isStatic()) { + $prop = (preg_match('/^on[A-Z]+\w*/', $name) === 1); + } + } catch (\ReflectionException $e) { + // No prop...return false + } + } + + return ($prop); + } + + /** + * __getSpellingSuggestion + * + * Finds the best suggestion (for 8-bit encoding) + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param (\ReflectionFunctionAbstract|\ReflectionParameter|\ReflectionClass|\ReflectionProperty|string)[] $possibilities + * + * @internal + */ + public static function __getSpellingSuggestion(array $possibilities, string $value): ?string + { + $norm = preg_replace($re = '#^(get|set|has|is|add)(?=[A-Z])#', '', $value); + $best = null; + $min = (strlen($value) / 4 + 1) * 10 + .1; + foreach (array_unique($possibilities, SORT_REGULAR) as $item) { + $item = $item instanceof \Reflector ? $item->getName() : $item; + if ($item !== $value && ( + ($len = levenshtein($item, $value, 10, 11, 10)) < $min + || ($len = levenshtein(preg_replace($re, '', $item), $norm, 10, 11, 10) + 20) < $min + )) { + $min = $len; + $best = $item; + } + } + + return $best; + } + + /** + * __parseFullDoc + * + * Parse full PHP file and get docblock, traits and parent class info + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param \ReflectionClass $rc + * @param string $pattern + * + * @return array + */ + private static function __parseFullDoc(\ReflectionClass $rc, string $pattern): array + { + do { + $doc[] = $rc->getDocComment(); + $traits = $rc->getTraits(); + while ($trait = array_pop($traits)) { + $doc[] = $trait->getDocComment(); + $traits += $trait->getTraits(); + } + } while ($rc = $rc->getParentClass()); + + return preg_match_all($pattern, implode($doc), $m) ? $m[1] : []; + } + + /** + * __strictStaticCall + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param string $class + * @param string $method + * + * @throws \Error + */ + public static function __strictStaticCall(string $class, string $method): void + { + $hint = self::getSuggestion( + array_filter( + (new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC), + function ($m) { + return $m->isStatic(); + } + ), + $method + ); + + throw new \Error("Call to undefined static method $class::$method()" . ($hint ? ", did you mean $hint()?" : '.')); + } + + /** + * __strictCall + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @throws \Error + */ + public static function __strictCall(string $class, string $method, array $additionalMethods = []): void + { + $hint = self::__getSpellingSuggestion(array_merge( + get_class_methods($class), + self::__parseFullDoc(new \ReflectionClass($class), '~^[ \t*]*@method[ \t]+(?:\S+[ \t]+)??(\w+)\(~m'), + $additionalMethods + ), $method); + + if (method_exists($class, $method)) { // called parent::$method() + $class = 'parent'; + } + + throw new \Error("Call to undefined method $class::$method()" . ($hint ? ", did you mean $hint()?" : '.')); + } + + /** + * __getMagicProperties + * + * Returns array of magic properties defined by annotation @property + + @author Luke Watts + * + * @since 1.0.0 + * + * @param string $class + + * @return array of [name => bit mask] + */ + public static function __getMagicProperties(string $class): array + { + static $cache; + $props = &$cache[$class]; + if ($props !== null) { + return $props; + } + + $rc = new \ReflectionClass($class); + preg_match_all( + '~^ [ \t*]* @property(|-read|-write) [ \t]+ [^\s$]+ [ \t]+ \$ (\w+) ()~mx', + (string) $rc->getDocComment(), $matches, PREG_SET_ORDER + ); + + $props = []; + foreach ($matches as [, $type, $name]) { + $uname = ucfirst($name); + $write = $type !== '-read' + && $rc->hasMethod($nm = 'set' . $uname) + && ($rm = $rc->getMethod($nm)) && $rm->getName() === $nm && !$rm->isPrivate() && !$rm->isStatic(); + $read = $type !== '-write' + && ($rc->hasMethod($nm = 'get' . $uname) || $rc->hasMethod($nm = 'is' . $uname)) + && ($rm = $rc->getMethod($nm)) && $rm->getName() === $nm && !$rm->isPrivate() && !$rm->isStatic(); + + if ($read || $write) { + $props[$name] = $read << 0 | ($nm[0] === 'g') << 1 | $rm->returnsReference() << 2 | $write << 3; + } + } + + foreach ($rc->getTraits() as $trait) { + $props += self::__getMagicProperties($trait->getName()); + } + + if ($parent = get_parent_class($class)) { + $props += self::__getMagicProperties($parent); + } + + return $props; + } + + /** + * __strictGet + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param string $class + * @param string $name + * + * @throws \Error + */ + public static function __strictGet(string $class, string $name): void + { + $rc = new \ReflectionClass($class); + $hint = self::__getSpellingSuggestion(array_merge( + array_filter( + $rc->getProperties(\ReflectionProperty::IS_PUBLIC), + function ($p) { + return !$p->isStatic(); + } + ), + self::__parseFullDoc($rc, '~^[ \t*]*@property(?:-read)?[ \t]+(?:\S+[ \t]+)??\$(\w+)~m') + ), $name); + + throw new \Error("Cannot read an undeclared property $class::\$$name" . ($hint ? ", did you mean \$$hint?" : '.')); + } + + /** + * __strictSet + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param string $class + * @param string $name + * + * @throws \Error + */ + public static function __strictSet(string $class, string $name): void + { + $rc = new \ReflectionClass($class); + $hint = self::__getSpellingSuggestion(array_merge( + array_filter( + $rc->getProperties(\ReflectionProperty::IS_PUBLIC), + function ($p) { + return !$p->isStatic(); + } + ), + self::__parseFullDoc($rc, '~^[ \t*]*@property(?:-write)?[ \t]+(?:\S+[ \t]+)??\$(\w+)~m') + ), $name); + + throw new \Error("Cannot write to an undeclared property $class::\$$name" . ($hint ? ", did you mean \$$hint?" : '.')); + } + + /** + * Checks if the public non-static property exists + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @return bool|string returns 'event' if the property exists and has event like name + * + * @internal + */ + public static function __hasProperty(string $class, string $name) + { + static $cache; + $prop = &$cache[$class][$name]; + if ($prop === null) { + $prop = false; + try { + $rp = new \ReflectionProperty($class, $name); + if ($rp->isPublic() && !$rp->isStatic()) { + $prop = true; + } + } catch (\ReflectionException $e) { + } + } + return $prop; + } + + // -------------------------------------------------- + // INTERNAL MAGIC METHODS + // -------------------------------------------------- + + /** + * __call + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param string $name + * @param array $args + * + * @throws \Error + */ + public function __call(string $name, array $args) + { + $class = get_class($this); + + // calling event handlers + if (self::__isEventProperty($class, $name)) { + if (is_iterable($this->$name)) { + foreach ($this->$name as $handler) { + $handler(...$args); + } + } elseif ($this->$name !== null) { + throw new \Error("Property $class::$$name must be iterable or null, " . gettype($this->$name) . ' given.'); + } + } else { + self::__strictCall($class, $name); + } + } + + /** + * __callStatic + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param string $name + * @param array $args + * + * @throws \Error + */ + public static function __callStatic(string $name, array $args) + { + self::__strictStaticCall(static::class, $name); + } + + /** + * &__get + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param string $name + * + * @return mixed + * + * @throws \Error if the property is not defined. + */ + public function &__get(string $name) + { + $class = get_class($this); + + // property getter + if ($prop = self::__getMagicProperties($class)[$name] ?? null) { + if (!($prop & 0b0001)) { + throw new \Error("Cannot read a write-only property $class::\$$name."); + } + + $m = ($prop & 0b0010 ? 'get' : 'is') . $name; + if ($prop & 0b0100) { // return by reference + return $this->$m(); + } else { + $val = $this->$m(); + + return $val; + } + } else { + self::__strictGet($class, $name); + } + } + + /** + * __set + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param string $name + * @param mixed $value + * + * @throws \Error if the property is not defined or is read-only + */ + public function __set(string $name, $value) + { + $class = get_class($this); + + if (self::__hasProperty($class, $name)) { + $this->$name = $value; + } elseif ($prop = self::__getMagicProperties($class)[$name] ?? null) { + if (!($prop & 0b1000)) { + throw new \Error("Cannot write to a read-only property $class::\$$name."); + } + + $this->{'set' . $name}($value); + } else { + self::__strictSet($class, $name); + } + } + + /** + * __unset + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param string $name + * + * @throws \Error + */ + public function __unset(string $name) + { + $class = get_class($this); + if (!self::__hasProperty($class, $name)) { + throw new \Error("Cannot unset the property $class::\$$name."); + } + } + + /** + * __isset + * + * @author Luke Watts + * + * @since 1.0.0 + * + * @param string $name + * + * @return bool + */ + public function __isset(string $name): bool + { + return isset(self::__getMagicProperties(get_class($this))[$name]); + } +}