Skip to content

Instantly share code, notes, and snippets.

@adamwathan

adamwathan/1.md Secret

Last active September 19, 2023 13:02
Show Gist options
  • Save adamwathan/120f5acb69ba84e3fa911437242796c3 to your computer and use it in GitHub Desktop.
Save adamwathan/120f5acb69ba84e3fa911437242796c3 to your computer and use it in GitHub Desktop.
Overloadable

Overloadable

This trait gives you a declarative way to support multiple signatures for the same method.

Basic Example

Say we have a Ticket class with a holdUntil method that lets us put that ticket on hold until a certain date and time by passing in a DateTime object:

class Ticket extends Model
{
    // ...

    public function holdUntil(DateTime $dateTime)
    {
        $this->update(['hold_until' => $dateTime]);
    }

    // ...
}

...but now you decide it would be convenient if it could also accept a well-formatted date string.

Normally you'd do something like this:

class Ticket extends Model
{
    // ...

    public function holdUntil($dateTime)
    {
        if (is_string($dateTime)) {
            $dateTime = Carbon::parse($dateTime);
        }

        $this->update(['hold_until' => $dateTime]);
    }

    // ...
}

The overloadable trait allows you to essentially pattern match in a declarative way instead of conditionally checking arguments:

class Ticket extends Model
{
    use Overloadable;

    // ...

    public function holdUntil(...$args)
    {
        return $this->overload($args, [
            function (string $dateTime) {
               $this->update(['hold_until' => Carbon::parse($dateTime)]);
            },
            function (DateTime $dateTime) {
               $this->update(['hold_until' => $dateTime]);
            },
        ]);
    }

    // ...
}

If you wanted to avoid that duplication, you could even do this wild recursive madness:

class Ticket extends Model
{
    use Overloadable;

    // ...

    public function holdUntil(...$args)
    {
        return $this->overload($args, [
            function (string $dateTime) {
               $this->holdUntil(Carbon::parse($dateTime));
            },
            function (DateTime $dateTime) {
               $this->update(['hold_until' => $dateTime]);
            },
        ]);
    }

    // ...
}

A cooler example

You might be thinking:

"Uhhh bro, that looks like even more code."

Yeah, because that example is boring. This one is a bit more fun.

I've always wanted Laravel's validate controller helper to accept a closure as it's last parameter that let me return whatever HTTP response I wanted if validation failed.

But the method signature for validate takes like a million things and I don't want to pass a ton of empty arrays, for example:

public function store()
{
    //                             Super grim! 😭
    //                                ⬇️  ⬇️
    $this->validate($request, $rules, [], [], function ($errors) {
        return response()->json([
            'someOtherInfo' => 'toInclude',
            'errors' => $errors
        ], 422);
    });
}

I'd love if I could just do:

public function store()
{
    $this->validate($request, $rules, function ($errors) {
        return response()->json([
            'someOtherInfo' => 'toInclude',
            'errors' => $errors
        ], 422);
    });
}

...and have it magically work, knowing I clearly don't care about the $messages or $customAttributes arguments, but can you imagine how gross it would be to add those checks inside the validate method to do all this argument counting and type checking?!

Check out how it would work with this badass trait from the gods:

trait ValidatesRequests
{
    // ...

    public function validate(...$args)
    {
        return $this->overload($args, [
            function ($request, $rules, Closure $callback) {
                return $this->validateRequest($request, $rules, [], [], $callback);
            },
            function ($request, $rules, $messages, Closure $callback) {
                return $this->validateRequest($request, $rules, $messages, [], $callback);
            },
            'validateRequest',
        ]);
    }

    // Move the real logic into a new private function...
    protected function validateRequest(Request $request, array $rules, array $messages = [], array $customAttributes = [], Closure $onErrorCallback = null)
    {
        $validator = $this->getValidationFactory()->make($request->all(), $rules, $messages, $customAttributes);

        if ($validator->fails()) {
            $this->throwValidationException($request, $validator, $onErrorCallback);
        }
    }

    // ...
}

Matching Options

Overloadable doesn't just work with closures, you can do all sorts of crazy shit!

Check out this example from the test:

class SomeOverloadable
{
    use Overloadable;

    public function someMethod(...$args)
    {
        return $this->overload($args, [
            // Call this closure if two args are passed and the first is an int
            function (int $a, $b) {
                return 'From the Closure';
            },

            // Call this method if the args match the args of `methodA` (uses reflection)
            'methodA',

            // Call this method if the args match the args of `methodB` (uses reflection)
            'methodB',

            // Call methodC if exactly 2 arguments of any type are passed
            'methodC' => ['*', '*'],

            // Call methodD if 3 args are passed and the first is an array
            'methodD' => ['array', '*', '*'],

            // Call methodE if 3 args are passed and the last is a closure
            'methodE' => ['*', '*', Closure::class],
        ]);
    }

    private function methodA($arg1)
    {
        return 'Method A';
    }

    private function methodB(\DateTime $arg1, array $arg2, int $arg3)
    {
        return 'Method B';
    }

    private function methodC($arg1, $arg2)
    {
        return 'Method C';
    }

    private function methodD($arg1, $arg2, $arg3)
    {
        return 'Method D';
    }

    private function methodE($arg1, $arg2, $arg3)
    {
        return 'Method E';
    }
}

Methods are matched in the order they are specified when you call overload.

Notes

I'm still just hacking around with this and there's probably a bunch of things I'm missing.

For example, it just occurred to me that I haven't really considered how the reflection-based detection stuff should handle optional arguments, and off the top of my head I don't even know what it should do ¯\(ツ)

Either way, I think it's some pretty fun code and I thought it was pretty cool that we could even come up with an API for it at all.

<?php
namespace Illuminate\Support\Traits;
use Closure;
use Exception;
use ReflectionMethod;
trait Overloadable
{
public function overload($args, $signatures)
{
return collect($signatures)->map(function ($value, $key) {
return new OverloadedMethodCandidate($value, $key, $this);
})->first(function ($candidate) use ($args) {
return $candidate->matches($args);
})->call($args);
}
}
class OverloadedMethodCandidate
{
private $callable;
private $arguments = [];
public function __construct($signatureOrMethod, $methodOrKey, $object)
{
if (! is_int($methodOrKey)) {
$this->buildFromSignature($signatureOrMethod, $methodOrKey, $object);
} elseif (is_string($signatureOrMethod) && method_exists($object, $signatureOrMethod)) {
$this->buildUsingMethodReflection($object, $signatureOrMethod);
} elseif ($signatureOrMethod instanceof Closure) {
$this->buildUsingClosureReflection($object, $signatureOrMethod);
} else {
throw new Exception('Unrecognized overloaded method definition.');
}
}
public function matches($args)
{
if (count($args) !== count($this->arguments)) {
return false;
}
return collect($args)->zip($this->arguments)->reduce(function ($isMatch, $argAndType) {
list($arg, $type) = $argAndType;
return $isMatch && ($type === '*' || gettype($arg) === $type || $arg instanceof $type);
}, true);
}
public function call($args)
{
return $this->callable->__invoke(...$args);
}
private function buildFromSignature($signature, $method, $object)
{
$this->callable = $this->bindCallable($object, $method);
$this->arguments = $this->normalizeTypes($signature);
}
private function buildUsingMethodReflection($object, $method)
{
$this->callable = $this->bindCallable($object, $method);
$reflected = new ReflectionMethod($object, $method);
$this->arguments = $this->mapArguments($reflected);
}
private function bindCallable($object, $method)
{
$closure = function (...$args) use ($method) {
return $this->{$method}(...$args);
};
return $closure->bindTo($object, $object);
}
private function buildUsingClosureReflection($object, $closure)
{
$this->callable = $closure->bindTo($object);
$reflected = new \ReflectionFunction($closure);
$this->arguments = $this->mapArguments($reflected);
}
private function mapArguments($reflectionFunction)
{
$types = array_map(function ($parameter) {
if (! $parameter->hasType()) {
return '*';
}
return $parameter->getType()->__toString();
}, $reflectionFunction->getParameters());
return $this->normalizeTypes($types);
}
private function normalizeTypes($types)
{
return array_map(function ($type) {
return $type == 'int' ? 'integer' : $type;
}, $types);
}
}
<?php
use Illuminate\Support\Traits\Overloadable;
class SupportOverloadableTest extends PHPUnit_Framework_TestCase
{
private $overloadable;
public function setUp()
{
$this->overloadable = new TestOverloadable;
}
public function testOverloadable()
{
$this->assertEquals('Method A', $this->overloadable->someMethod(true));
$this->assertEquals('From the Closure', $this->overloadable->someMethod(5, true));
$this->assertEquals('Method B', $this->overloadable->someMethod(new \DateTime, [1, 2, 3], 9));
$this->assertEquals('Method C', $this->overloadable->someMethod('foo', 'bar'));
$this->assertEquals('Method D', $this->overloadable->someMethod([], true, true));
$this->assertEquals('Method E', $this->overloadable->someMethod(true, true, function () {}));
}
}
class TestOverloadable
{
use Overloadable;
public function someMethod(...$args)
{
return $this->overload($args, [
function (int $a, $b) {
return 'From the Closure';
},
'methodA',
'methodB',
'methodC' => ['*', '*'],
'methodD' => ['array', '*', '*'],
'methodE' => ['*', '*', Closure::class],
]);
}
private function methodA($arg1)
{
return 'Method A';
}
private function methodB(\DateTime $arg1, array $arg2, int $arg3)
{
return 'Method B';
}
private function methodC($arg1, $arg2)
{
return 'Method C';
}
private function methodD($arg1, $arg2, $arg3)
{
return 'Method D';
}
private function methodE($arg1, $arg2, $arg3)
{
return 'Method E';
}
}
@jurchiks
Copy link

jurchiks commented Oct 21, 2016

Too bad you can't properly document methods overloaded like this, and IDEs won't understand this either. Otherwise yes, it's nice.

@rflipo-dev
Copy link

👍

@nunomaduro
Copy link

Nice shit there. 👯‍♂️

@jakeguti
Copy link

Cool stuff, I was looking for this.

@n-gram-hub
Copy link

@adamwathan: just a question. Does this work only with Laravel or standalone too? I'm confused by:

namespace Illuminate\Support\Traits;
use Closure;
use Exception;
use ReflectionMethod;

at the top of Overloadable.php file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment