Skip to content

Commit

Permalink
Adding Macroable trait
Browse files Browse the repository at this point in the history
  • Loading branch information
lukewatts committed Mar 7, 2024
1 parent 524f067 commit e61a534
Show file tree
Hide file tree
Showing 4 changed files with 376 additions and 1 deletion.
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,53 @@ class TappableClass
$name = TappableClass::make()->tap(function ($tappable) {
$tappable->setName('MyName');
})->getName();

// Or, even though setName does not return this you can now just chain from it!
$name = TappableClass::make()->tap()->setName('MyName')->getName()
```

### Support\Traits\Macroable

Macros allow you to add methods to classes dynamically (without having to modify their code).

Let's say you are tired of having to do this:

```php
$app->get('/', function ($request, $response) {
$response = new Response;
$response->getBody()->write('Hello');

return $response;
})
```

Instead you just want to call a write method directly from the `$response` instance. First, we need to extend the Response class so we can use the `Macroable` trait, but still have all of our base Response methods.

```php
use GuzzleHttp\Psr7\Response;
use SlimFacades\Support\Traits\Macroable;

class MacroableResponse extends Response
{
use Macroable;
}
```

Then we need to add `MacroableResponse` to our container, so we are always dealing with the same instance (not all instances will have the "macroed" methods).

```php
use SlimFacades\Facades\Container;
// ... above code here

Container::set('response', function () {
return new MacroableResponse();
});
```

Then we can get our `MacroableResponse` instance from the container however you want, and just call `write`!

```php
App::get('/', function () {
return Container::get('response')->write('Macro!');
});
```
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "affinity4/slimphp-facades",
"description": "Add Laravel style facades and helper functions to any SlimPHP app",
"description": "Add Laravel style facades, traits and helper functions to any SlimPHP app",
"type": "library",
"license": "MIT",
"authors": [
Expand Down
125 changes: 125 additions & 0 deletions src/Support/Traits/Macroable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

namespace SlimFacades\Support\Traits;

use BadMethodCallException;
use Closure;
use ReflectionClass;
use ReflectionMethod;

trait Macroable
{
/**
* The registered string macros.
*
* @var array
*/
protected static $macros = [];

/**
* Register a custom macro.
*
* @param string $name
* @param object|callable $macro
* @return void
*/
public static function macro($name, $macro)
{
static::$macros[$name] = $macro;
}

/**
* Mix another object into the class.
*
* @param object $mixin
* @param bool $replace
* @return void
*
* @throws \ReflectionException
*/
public static function mixin($mixin, $replace = true)
{
$methods = (new ReflectionClass($mixin))->getMethods(
ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED
);

foreach ($methods as $method) {
if ($replace || ! static::hasMacro($method->name)) {
static::macro($method->name, $method->invoke($mixin));
}
}
}

/**
* Checks if macro is registered.
*
* @param string $name
* @return bool
*/
public static function hasMacro($name)
{
return isset(static::$macros[$name]);
}

/**
* Flush the existing macros.
*
* @return void
*/
public static function flushMacros()
{
static::$macros = [];
}

/**
* Dynamically handle calls to the class.
*
* @param string $method
* @param array $parameters
* @return mixed
*
* @throws \BadMethodCallException
*/
public static function __callStatic($method, $parameters)
{
if (! static::hasMacro($method)) {
throw new BadMethodCallException(sprintf(
'Method %s::%s does not exist.', static::class, $method
));
}

$macro = static::$macros[$method];

if ($macro instanceof Closure) {
$macro = $macro->bindTo(null, static::class);
}

return $macro(...$parameters);
}

/**
* Dynamically handle calls to the class.
*
* @param string $method
* @param array $parameters
* @return mixed
*
* @throws \BadMethodCallException
*/
public function __call($method, $parameters)
{
if (! static::hasMacro($method)) {
throw new BadMethodCallException(sprintf(
'Method %s::%s does not exist.', static::class, $method
));
}

$macro = static::$macros[$method];

if ($macro instanceof Closure) {
$macro = $macro->bindTo($this, static::class);
}

return $macro(...$parameters);
}
}
201 changes: 201 additions & 0 deletions tests/Support/Traits/MacroableTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<?php declare(strict_types=1);

use BadMethodCallException;
use SlimFacades\Support\Traits\Macroable;
use PHPUnit\Framework\TestCase;

class SupportMacroableTest extends TestCase
{
private $macroable;

protected function setUp(): void
{
$this->macroable = $this->createObjectForTrait();
}

private function createObjectForTrait()
{
return new EmptyMacroable;
}

public function testRegisterMacro()
{
$macroable = $this->macroable;
$macroable::macro(__CLASS__, function () {
return 'Taylor';
});
$this->assertSame('Taylor', $macroable::{__CLASS__}());
}

public function testHasMacro()
{
$macroable = $this->macroable;
$macroable::macro('foo', function () {
return 'Taylor';
});
$this->assertTrue($macroable::hasMacro('foo'));
$this->assertFalse($macroable::hasMacro('bar'));
}

public function testRegisterMacroAndCallWithoutStatic()
{
$macroable = $this->macroable;
$macroable::macro(__CLASS__, function () {
return 'Taylor';
});
$this->assertSame('Taylor', $macroable->{__CLASS__}());
}

public function testWhenCallingMacroClosureIsBoundToObject()
{
TestMacroable::macro('tryInstance', function () {
return $this->protectedVariable;
});
TestMacroable::macro('tryStatic', function () {
return static::getProtectedStatic();
});
$instance = new TestMacroable;

$result = $instance->tryInstance();
$this->assertSame('instance', $result);

$result = TestMacroable::tryStatic();
$this->assertSame('static', $result);
}

public function testClassBasedMacros()
{
TestMacroable::mixin(new TestMixin);
$instance = new TestMacroable;
$this->assertSame('instance-Adam', $instance->methodOne('Adam'));
}

public function testClassBasedMacrosNoReplace()
{
TestMacroable::macro('methodThree', function () {
return 'bar';
});
TestMacroable::mixin(new TestMixin, false);
$instance = new TestMacroable;
$this->assertSame('bar', $instance->methodThree());

TestMacroable::mixin(new TestMixin);
$this->assertSame('foo', $instance->methodThree());
}

public function testFlushMacros()
{
TestMacroable::macro('flushMethod', function () {
return 'flushMethod';
});

$instance = new TestMacroable;

$this->assertSame('flushMethod', $instance->flushMethod());

TestMacroable::flushMacros();

$this->expectException(BadMethodCallException::class);

$instance->flushMethod();
}

public function testFlushMacrosStatic()
{
TestMacroable::macro('flushMethod', function () {
return 'flushMethod';
});

$instance = new TestMacroable;

$this->assertSame('flushMethod', $instance::flushMethod());

TestMacroable::flushMacros();

$this->expectException(BadMethodCallException::class);

$instance::flushMethod();
}

public function testMacroWithArguments()
{
$this->macroable::macro('concatenate', function ($arg1, $arg2) {
return $arg1.' '.$arg2;
});

$result = $this->macroable::concatenate('Hello', 'World');
$this->assertSame('Hello World', $result);
}

public function testMacroWithDefaultArguments()
{
$this->macroable::macro('greet', function ($name = 'Guest') {
return 'Hello, '.$name;
});

$this->assertSame('Hello, Guest', $this->macroable::greet());
$this->assertSame('Hello, Saleh', $this->macroable::greet('Saleh'));
}

public function testCallingUndefinedMacroThrowsException()
{
$this->expectException(BadMethodCallException::class);

$this->macroable::nonExistentMacro();
}

public function testMethodConflictDoesNotThrowException()
{
$this->macroable::macro('existingMethod', function () {
return 'oldMethod';
});

// Replacing existing macro.
$this->macroable::macro('existingMethod', function () {
return 'newMethod';
});

$this->assertSame('newMethod', $this->macroable::existingMethod());
}
}

class EmptyMacroable
{
use Macroable;
}

class TestMacroable
{
use Macroable;

protected $protectedVariable = 'instance';

protected static function getProtectedStatic()
{
return 'static';
}
}

class TestMixin
{
public function methodOne()
{
return function ($value) {
return $this->methodTwo($value);
};
}

protected function methodTwo()
{
return function ($value) {
return $this->protectedVariable.'-'.$value;
};
}

protected function methodThree()
{
return function () {
return 'foo';
};
}
}

0 comments on commit e61a534

Please sign in to comment.