DEV Community

Simon Bundgaard-Egeberg for IT Minds

Posted on • Originally published at insights.it-minds.dk

Soft deletes and pipelines

Knitzilla is a knitting app that I created for my wife.
Originally it was only supposed to keep track of how many rows she knitted, but in time, it grew.

It is now at a stage where you can commit to several different knitting projects, and you can add and delete counters to each project.

Since I, in time, want my wife to be able to create a portfolio of all the knitting projects that she has finished I needed a system to put this in place.

Luckily for me, Laravel ships just such a feature out of the box.

To make my Project model able to support soft deletes I needed to mutate the user table in my database. This is easily done with a migration.

A quick:

Schema::table('users', function (Blueprint $table) {
  $table->timestamp('deleted_at')
    ->nullable();
});

should fix that right up.

Now with my database updated, the only thing left is to tell my model that I want to use this feature.

Since all my database communication goes through the eloquent model, these things become a breeze.

class Project extends Model
{
  use SoftDeletes;
  // rest of the model

}

Very nice indeed.
However, I need a new way to get all projects now, even the deleted ones. Wouldn't be much of a portfolio if you could never retrieve the actual items.
This is where it becomes a wee bit advanced.

If you have ever written a backend, you surely know what request middleware is, and you properly like it.

What if I told you that such pipelines are not reserved for requests? Laravel provides their abstraction of pipelines, free to use, if you know how to.

I will just show this with a pipeline with a single middleware function, but these can be as large or small as you would like them to be.

First of all, the abstract class we will be working with.

// App/Pipelines/Project/AbstractQueryable

abstract class AbstractQueryable
{
  public function handle($request, \Closure $next)
  {
    if (!request()->has($this->filter_name())) {
      return $next($request);
    }

    $builder = $next($request);
    return $this->apply_filter($builder);
  }

  protected function filter_name()
  {
    return Str::snake(class_basename($this));
  }

  protected abstract function apply_filter($builder);
}

A few things to notice here:

  1. In the filter_name function, I am using a function called class_basename with this context. This is a standard PHP function that extracts the name of the class which it is called with. The cool thing is that it returns the inheritor's name. This means that this function can be implemented in our base class, and reused in all the different filters we want to provide.

    • This pattern is depending on a specific naming convention. It expects the filters is the snake case name of the query params from our request.
    • This means a class name of Deleted would need to have ?deleted=true in the query params, and BeforeDate would need ?before_date=<some date>
  2. With the last function in the class, we are telling everyone who extends this class that they have to implement an apply_filter function.

  3. In the handle function we decide if this specific piece if middleware needs to activate. If it don't, we just invoke the $next piece of middleware.
    If it does, however, we defer the next in the chain to the apply_filter function.

// App/Pipelines/Project/Deleted

class Deleted extends AbstractQueryable
{

  protected function apply_filter($builder)
  {
    return $builder->trashed();
  }
}

With all the smart things already packed away in our AbstractQueryable we only need to add the invoke the necessary function on the query builder.

// App/Project
class Project extends Model
{
  use SoftDeletes;

  public static function allProjectsForUser($id)
  {
    return app(Pipeline::class)
      ->send(Project::query())
      ->through([
        Deleted::class
      ])
      ->thenReturn()
      ->with(["user", "scratchpads", "counters"])
      ->where('user_id', $id)
      ->get();
  }
  // rest of the project model
}

Now it all ties together, we can define a static method on our model and begin the pipeline.
The code here is pretty self-explanatory. We get the Pipeline from our app service container, and through it, we want to send a Project::query() which returns a chainable query much like Entity frameworks fluent query builder.

The thenReturn gives back the query builder and from there was some functionality I want to always be present.

I want all related models to the project, and I am only interested in the project connected to the id the function is invoked with.

Pipelines open up for some pretty neat functionality, and it gives a way to specify a single endpoint, and then have the user, through the pipeline define how the data should be i.e sorted, filtered, paginated and so on.

Maybe it would be nice to have a ?paginate=15&page=1 for the ones who want to paginate, and all, for the ones who want all (i wouldn't know why).

Bonus

If you have read my other laravel posts, you know I like to test it.
So just for the eager learner, I have provided the code I used to test the deleted functionality of the pipeline I created.

class ProjectControllerTest extends TestCase
{
  /** @test */
  public function should_return_deleted_if_present_query_param_present()
  {

    // Assign
    $this->user_under_test->projects()->saveMany(factory(Project::class, 5)->make());
    $first_record = $this->user_under_test->projects()->first();
    $first_record->delete();

    // Act
    $res = $this->getJson("api/projects?deleted=true", $this->headers());

    // Assert
    $res_json = $res->json();
    $this->assertCount(5, $res_json);

  }


  /** @test */
  public function should_return_not_deleted_projects()
  {

    // Assign
    $this->user_under_test->projects()->saveMany(factory(Project::class, 5)->make());
    $first_record = $this->user_under_test->projects()->first();
    $first_record->delete();

    // Act
    $res = $this->getJson("api/projects", $this->headers());

    // Assert
    $res_json = $res->json();
    $this->assertCount(4, $res_json);

  }
}

Oldest comments (0)