Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[11.x] without_recursion() helper #52865

Closed
wants to merge 5 commits into from

Conversation

samlev
Copy link
Contributor

@samlev samlev commented Sep 20, 2024

Not too long ago, to get chaperone()/inverse() through, I added the PreventsCircularRecursion trait for Eloquent in #52461 to better handle serializing models that have circular references. There was some discussion on that PR between @rodrigopedra and @Tofandel where it was questioned why I was using a static WeakMap instead of just a property on the object, and some of the other decisions made there.

I made those decisions because I had never planned this functionality to be tied solely to Eloquent.

Introducing without_recursion()

This PR adds a new global helper function that can be used to prevent recusion of any function, not just functions inside of Models. This is done by using two new items added into the Illuminate\Support\ namespace:

  • Illuminate\Support\Recursable - a class that is similar to Illuminate\Support\Onceable, but is better suited to the needs of tracking and preventing recursive calls.
  • Illuminate\Support\Recurser - a class that serves a similar role to Illuminate\Support\Once by tracking recursive function calls against individual instances, and handling returning alternate values as required.

These have a very small public interface because you're not meant to interact with them directly (although you totally could if you wanted). The intention is that you only interact with them via the new support helper function without_recursion().

Stop blabbing, and show me how to use it, Sam.

Sure! It's super simple!

function some_recursive_function($start)
{
    return without_recursion(
        // $callback is what we want to run the first time
        fn () => implode(' ', [$start + 1, some_recursive_function(), some_recursive_function(), some_recursive_function()]),
        // $onRecursion is what we want to return if this function is called again
        $start,
    );
}

dump(some_recursive_function(1));   // 2 1 1 1
dump(some_recursive_function(2));   // 3 2 2 2
dump(some_recursive_function(100)); // 101 100 100 100

That's an odd example, but you can see that every time some_recursive_function() is called inside without_recursion(), it returns the second value - the callback isn't called again, but as soon as the current call stack finishes, that stored value is forgotten and the callback will get called again.

How about use in a class?

Classes are where without_recursion() shines:

class LinkedList 
{
    protected ?LinkedList $next = null;
    
    public function __construct(
       protected int $id,
       protected ?LinkedList $prev = null,
    ) {
        $this->prev?->setNext($this);
    }

    public function setPrev(LinkedList $prev)
    {
        $this->prev === $prev || ($this->prev = $next && $prev->setNext($this));
    }

    public function setNext(LinkedList $next)
    {
        $this->next === $next || ($this->next = $next && $next->setPrev($this));
    }

    public function children(): array
    {
        return without_recursion(
            fn () => array_filter([$this->next?->id, ... $this->next?->children()]),
            [],
        );
    }
}

$head = new LinkedList(1);
$body = new LinkedList(2, $head);
$tail = new LinkedList(3, $body);

dump($tail->children()); // []
dump($body->children()); // [3]
dump($head->children()); // [2, 3]

// Turn it into a circle
$tail->setNext($head);

dump($tail->children()); // [1, 2]
dump($body->children()); // [3, 1]
dump($head->children()); // [2, 3]

As you can see, without_recursion() is tied to the specific object that called it automatically, so other objects from the same class don't get affected. This is linked to the file where the call was made, the class, and the function. Unlike once() it doesn't take into account the line that without_recursion() was called from unless it's a function in the global scope.

The value that I want to return on recursion is expensive to calculate

That's fine! If you pass a callable for the second parameter, it won't actually be called until the function recurses, and the result will be cached in its place:

class TravellingSalesman
{
    // ...
    public function traverse(): int
    {
        return without_recursion(
            fn () => $this->distance + min($this->paths->map->traverse()),
            fn () => $this->calculateTotalDistanceTravelled(),
        );
    }
}

In this example, we don't want to do the expensive calculation until the path loops back on itself, and we don't want to do it again if it loops back again from somewhere else. Passing a closure to $onRecursion will only resolve when the same function is called on the same object in the same call tree. If the function is called a third time, the result of the closure is provided without calling the closure again.

Specifying the recursion key and object

When used in its most basic form, without_recursion() will generate a unique key based the filename, class, and function that called it. The key for the LinkedList example above would be something like /path/to/LinkedList.php:LinkedList@children. For Model::toArray() it would be /path/to/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php:Illuminate\Database\Eloquent\Model@toArray. If the caller isn't in a class, then the key will be something like /path/to/file.php:functionName or if there's no function name .0it will fall back to the line where it was called: /path/to/file.php:123

Each key is stored against the object itself (if the call was made from an instantiated class), so each object will have it's own stack of recursive calls without interfering with eachother.

If, for whatever reason, you need to specify a different key, you can specify it with the third parameter, as: 'your key'

function fib(int $number) {
    return without_recursion(
        fn () => fib($number - 1) + fib($number - 2)
        $number ? max(0, $number) : 1,
        as: 'fib:' . ($number ? max(0, $number) : 1),
    );
}

dump(fib(0));   // 0
dump(fib(1));   // 1
dump(fib(2));   // 1
dump(fib(3));   // 2
dump(fib(10));  // 55
dump(fib(-50)); // 0

Note that if you specify the key it won't automatically attach to an object, so you can also specify the object with the fourth parameter, for: $object

class Fibonacci
{
    public function __invoke(int $number): int
    {
        return without_recursion(
            fn () => $this($number - 1) + $this($number - 2)
            $this->number($number),
            as: 'fib:' . $this->number($number),
            for: $this,
        );
    }
    
    protected function number(int $number): int
    {
        return $number ? max(0, $number) : 1;
    }
}

Or you can always just scope the method to any other object:

class SomeMiddleware
{
    public function handle($request, $next)
    {
        return without_recursion(fn () => $next($request), $request, for: $request));
    }
}

Advanced usage

You can make use of the callable $onRecursion value to do fun things like dispatching a job if an object appears more than once in a tree without dispatching several jobs for the same object:

class TreeNode extends Model
{
    //...
    function deDupe()
    {
        without_recursion(
            fn () => $this->leaves->each->deDupe(),
            fn () => DeDupe::dispatch($this),
        );
    }
    // ...
}

Or you can return a callable from your callable, so that you could log the number of duplicates in a tree:

class DupeLogger
{
    protected int $seen = 0;

    public function __construct(
        protected TreeNode $node,
    ) {}

    public function __invoke(): self
    {
        $this->seen ++;
      
        return $this;
    }

    public function __destruct()
    {
        if ($this->seen) {
            Log::info(sprintf(
                'Node[%d] duplicated %d time(s)',
                $this->node->id,
                $this->seen,
            ));
        }
    }
}

class TreeNode extends Model
{
    //...
    function deDupe()
    {
        without_recursion(
            fn () => $this->leaves->each->deDupe(),
            new DupeLogger($this),
        );
    }
    // ...
}

Other Changes

Model methods migrated

I didn't think that it was right to add this new global function, and leave Model out, so I've updated everything that relied on PreventsCircularRecursion to just use without_recursion() now.

PreventsCircularRecursion deprecated

It hasn't been in the wild for long, but I've left PreventsCircularRecursion itself alone (aside from marking it as @deprecated) just in case somebody actually used it. I probably should have put this effort in originally (and it was half-done, I just wanted to get the PR up and out). This method is better.

touchesParents() is now covered

In #52660, @AndrewMast mentioned that showed up when you had $touches = ['parent'] on a chaperone'd child model. I've included a fix for this too.

Final notes

I would have done this without using static instances, instead using the application/a service provider, but I didn't feel like this really "belonged" in Foundation, so I took the lead from Once/Onceable and put it into Support without any reliance on the service container. There's not really any guidance here, so I just played it by ear.

Copy link

Thanks for submitting a PR!

Note that draft PR's are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

@samlev samlev force-pushed the feature/11.x_recursion_manager branch 2 times, most recently from 09d4fa4 to 9d0a1fa Compare September 20, 2024 10:06
Deprecated `$model->withoutRecursion()` in favour of the more
generalized global `withoust_recursion()` helper.
@sanfair
Copy link

sanfair commented Sep 20, 2024

Hi, does it have the same issue as PreventsCircularRecursion? #52727

@Tofandel
Copy link
Contributor

@sanfair From a quick read at the code, it shouldn't have this issue

@samlev
Copy link
Contributor Author

samlev commented Sep 20, 2024

Hi, does it have the same issue as PreventsCircularRecursion? #52727

It shouldn't do because it's not a part of the model any longer. It's using an external class to keep track of recursion which means that you shouldn't have issues with mocked classes.

@devajmeireles
Copy link
Contributor

Couldn't we think of this as a support class rather than a function-based helper?

use Illuminate\Support\Recursion;

Recursion::run(fn () => /* ... */); // based on a prior check to execute or not...

Maybe with this approach, we can even think about other things:

use Illuminate\Support\Recursion;

Recursion::allowed(fn () => /* ... */);

Recursion::unallowed(fn () => /* ... */);

Recursion::alwaysAllowed();

Recursion::alwaysUnallowed();

@Tofandel
Copy link
Contributor

What would Recursion::allowed bring? It seems like it would be a noop, I don't think it fits this use case, it's not like it's switching on and off recursion on demand, it is there just to avoid infinite recursion issues. Allowing to turn off this check will result in guaranteed infinite loops

@devajmeireles
Copy link
Contributor

What would Recursion::allowed bring? It seems like it would be a noop, I don't think it fits this use case, it's not like it's switching on and off recursion on demand, it is there just to avoid infinite recursion issues. Allowing to turn off this check will result in guaranteed infinite loops

If you think about it for the framework, okay, I agree with you. Now, if you think of it offering as a feature to the dev, well... you never know!

@samlev
Copy link
Contributor Author

samlev commented Sep 20, 2024

Couldn't we think of this as a support class rather than a function-based helper?

If you want to access the classes that are doing the work, you can. The global helper mostly serves the purpose of making it easier to access, and providing the best frame of reference for the debug_backtrace().

You could always, if you really wanted to, do it manually:

use Illuminate\Support\Recursable;
use Illuminate\Support\Recurser;

// ...

Recurser::instance()->withoutRecursion(new Recursable(
    fn () => /* ... */,
    null,
    $this,
    'some key',
));

Maybe with this approach, we can even think about other things:

use Illuminate\Support\Recursion;

Recursion::allowed(fn () => /* ... */);

Recursion::unallowed(fn () => /* ... */);

Recursion::alwaysAllowed();

Recursion::alwaysUnallowed();

I'm honestly not sure what those would do... This change is explicitly to guard against infinite recursion - something that is 100% a bug, without question, every single time that it happens.

This isn't a function that I expect that many people will reach for often, but when they need it, it'll be helpful.

samlev and others added 2 commits September 22, 2024 20:38
To reduce code for `: void` returning functions
Co-authored-by: Adrien Foulon <6115458+Tofandel@users.noreply.github.com>
@taylorotwell
Copy link
Member

I think I'll leave this one for a package implementation for now. 👍

@AndrewMast
Copy link

I was super excited about this getting merged. Can we at least get the #52660 issue with $touches = [‘parent’]; fixed?

@samlev
Copy link
Contributor Author

samlev commented Sep 23, 2024

@AndrewMast I've pulled out just those fixes into #52883

@samlev
Copy link
Contributor Author

samlev commented Sep 30, 2024

In case anyone was actually interested in this, I've pulled the core concept out into a standalone package:

samlev/recursion-guard

It's more or less the same thing, but built to have no dependencies.

It's very unlikely that anyone will ever actually need this in standard code, but hey - it exists now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants