Skip to content

Call scopes with a string or array with additional constraints #2

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

Merged
merged 4 commits into from
Dec 8, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ We proudly support the community by developing Laravel packages and giving them

## Blogpost


If you want to know more about the background of this package, please read [the blogpost](https://protone.media/blog/stop-duplicating-your-eloquent-query-scopes-and-constraints-re-use-them-as-select-statements-with-a-new-laravel-package).

## Installation
Expand All @@ -43,7 +42,6 @@ composer require protonemedia/laravel-eloquent-scope-as-select

Add the `macro` to the query builder, for example, in your `AppServiceProvider`:


```php
use ProtoneMedia\LaravelEloquentScopeAsSelect\ScopeAsSelect;

Expand All @@ -59,6 +57,45 @@ By default, the name of the macro is `addScopeAsSelect`, but you can customize i
ScopeAsSelect::addMacro('withScopeAsSubQuery');
```

## Short API description

For a more practical explanation, check out the [usage](#usage) section below.

Using a Closure:
```php
$posts = Post::addScopeAsSelect('is_published', function ($query) {
$query->published();
})->get();
```

Using a string:
```php
$posts = Post::addScopeAsSelect('is_published', 'published')->get();
```

Using an array to call multiple scopes:
```php
$posts = Post::addScopeAsSelect('is_popular_and_published', ['popular', 'published'])->get();
```

Using an associative array to call dynamic scopes:
```php
$posts = Post::addScopeAsSelect('is_announcement', ['ofType' => 'announcement'])->get();
```

Using an associative array to mix (dynamic) scopes:
```php
$posts = Post::addScopeAsSelect('is_published_announcement', [
'published',
'ofType' => 'announcement'
])->get();
```

There's an optional third argument to flip the result:
```php
$posts = Post::addScopeAsSelect('is_not_announcement', ['ofType' => 'announcement'], false)->get();
```

## Usage

Imagine you have a `Post` Eloquent model with a query scope.
Expand Down Expand Up @@ -171,6 +208,27 @@ Post::query()
});
```

### Shortcuts

Instead of using a Closure, there are some shortcuts you could use:

```php
Post::addScopeAsSelect('is_published', function ($query) {
$query->published();
});

// is the same as:

Post::addScopeAsSelect('is_published', 'published');
```

Post::addScopeAsSelect('is_published', function ($query) {
$query->published();
})

Post::addScopeAsSelect('is_published', ['published']);
```

### Testing

``` bash
Expand Down
36 changes: 36 additions & 0 deletions src/NegativeNullableBooleanCaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php declare(strict_types=1);

namespace ProtoneMedia\LaravelEloquentScopeAsSelect;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class NegativeNullableBooleanCaster implements CastsAttributes
{
/**
* Transform the attribute from the underlying model values.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return mixed
*/
public function get($model, $key, $value, $attributes)
{
return !(bool) $value;
}

/**
* Transform the attribute to its underlying model values.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return mixed
*/
public function set($model, $key, $value, $attributes)
{
return $value;
}
}
53 changes: 50 additions & 3 deletions src/ScopeAsSelect.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ProtoneMedia\LaravelEloquentScopeAsSelect;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;

class ScopeAsSelect
Expand All @@ -18,28 +19,74 @@ public static function builder($value): Builder
return $value;
}

/**
* Returns a callable that applies the scope and arguments
* to the given query builder.
*
* @param mixed $value
* @return callable
*/
public static function makeCallable($value): callable
{
// We both allow single and multiple scopes...
$scopes = Arr::wrap($value);

return function ($query) use ($scopes) {
// If $scope is numeric, there are no arguments, and we can
// safely assume the scope is in the $arguments variable.
foreach ($scopes as $scope => $arguments) {
if (is_numeric($scope)) {
[$scope, $arguments] = [$arguments, null];
}

// As we allow a constraint to be a single arguments.
$arguments = Arr::wrap($arguments);

$query->{$scope}(...$arguments);
}

return $query;
};
}

/**
* Adds a macro to the query builder.
*
* @param string $name
* @return void
*/
public static function addMacro(string $name = 'addScopeAsSelect')
{
Builder::macro($name, function (string $name, callable $callable): Builder {
Builder::macro($name, function (string $name, $withQuery, bool $exists = true): Builder {
$callable = is_callable($withQuery)
? $withQuery
: ScopeAsSelect::makeCallable($withQuery);

// We do this to make sure the $query variable is an Eloquent Query Builder.
$query = ScopeAsSelect::builder($this);

$originalTable = $query->getModel()->getTable();

// Instantiate a new model that uses the aliased table.
$aliasedTable = "{$name}_{$originalTable}";
$aliasedModel = $query->newModelInstance()->setTable($aliasedTable);

$subSelect = $aliasedModel::query()->setModel($aliasedModel);
// Query the model and explicitly set the targetted table, as the model's table
// is just the aliased table with the 'as' statement.
$subSelect = $aliasedModel::query();
$subSelect->getQuery()->from("{$originalTable} as {$aliasedTable}");

// Apply the where constraint based on the model's key name and apply the $callable.
$subSelect
->select(DB::raw(1))
->whereColumn($aliasedModel->getQualifiedKeyName(), $query->getModel()->getQualifiedKeyName())
->limit(1)
->tap(fn ($query) => $callable($query));

// Add the subquery and query-time cast.
return $query
->addSelect([$name => $subSelect])
->withCasts([$name => NullableBooleanCaster::class]);
->withCasts([$name => $exists ? NullableBooleanCaster::class : NegativeNullableBooleanCaster::class]);
});
}
}
2 changes: 1 addition & 1 deletion tests/Comment.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php
<?php declare(strict_types=1);

namespace ProtoneMedia\LaravelEloquentScopeAsSelect\Tests;

Expand Down
16 changes: 13 additions & 3 deletions tests/Post.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php
<?php declare(strict_types=1);

namespace ProtoneMedia\LaravelEloquentScopeAsSelect\Tests;

Expand All @@ -11,13 +11,23 @@ public function comments()
return $this->hasMany(Comment::class);
}

public function scopeTitleIs($query, $title)
{
$query->where($query->qualifyColumn('title'), $title);
}

public function scopeTitleIsFoo($query)
{
$query->where($query->qualifyColumn('title'), 'foo');
$query->titleIs('foo');
}

public function scopeHasMoreCommentsThan($query, $value)
{
$query->has('comments', '>', $value);
}

public function scopeHasSixOrMoreComments($query)
{
$query->has('comments', '>=', 6);
$query->hasMoreCommentsThan(5);
}
}
117 changes: 88 additions & 29 deletions tests/ScopeAsSelectTest.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
<?php
<?php declare(strict_types=1);

namespace ProtoneMedia\LaravelEloquentScopeAsSelect\Tests;

class ScopeAsSelectTest extends TestCase
{
private function prepareFourPosts(): array
{
$postA = Post::create(['title' => 'foo']);
$postB = Post::create(['title' => 'foo']);
$postC = Post::create(['title' => 'bar']);
$postD = Post::create(['title' => 'bar']);

foreach (range(1, 5) as $i) {
$postA->comments()->create(['body' => 'ok']);
$postC->comments()->create(['body' => 'ok']);
}

foreach (range(1, 10) as $i) {
$postB->comments()->create(['body' => 'ok']);
$postD->comments()->create(['body' => 'ok']);
}

return [$postA, $postB, $postC, $postD];
}

/** @test */
public function it_can_add_a_scope_as_a_select()
{
Expand All @@ -19,6 +39,71 @@ public function it_can_add_a_scope_as_a_select()
$this->assertFalse($posts->get(1)->title_is_foo);
}

/** @test */
public function it_can_add_a_scope_as_a_select_and_cast_inversed()
{
$postA = Post::create(['title' => 'foo']);
$postB = Post::create(['title' => 'bar']);

$posts = Post::query()
->addScopeAsSelect('title_is_foo', fn ($query) => $query->titleIsFoo(), false)
->orderBy('id')
->get();

$this->assertFalse($posts->get(0)->title_is_foo);
$this->assertTrue($posts->get(1)->title_is_foo);
}

/** @test */
public function it_can_add_a_scope_by_using_the_name()
{
$postA = Post::create(['title' => 'foo']);
$postB = Post::create(['title' => 'bar']);

$posts = Post::query()
->addScopeAsSelect('title_is_foo', 'titleIsFoo')
->orderBy('id')
->get();

$this->assertTrue($posts->get(0)->title_is_foo);
$this->assertFalse($posts->get(1)->title_is_foo);
}

/** @test */
public function it_can_add_multiple_scopes_by_using_an_array()
{
[$postA, $postB, $postC, $postD] = $this->prepareFourPosts();

$posts = Post::query()
->addScopeAsSelect('title_is_foo_and_has_six_comments_or_more', ['titleIsFoo', 'hasSixOrMoreComments'])
->orderBy('id')
->get();

$this->assertFalse($posts->get(0)->title_is_foo_and_has_six_comments_or_more);
$this->assertTrue($posts->get(1)->title_is_foo_and_has_six_comments_or_more);
$this->assertFalse($posts->get(2)->title_is_foo_and_has_six_comments_or_more);
$this->assertFalse($posts->get(3)->title_is_foo_and_has_six_comments_or_more);
}

/** @test */
public function it_can_add_multiple_dynamic_scopes_by_using_an_array()
{
[$postA, $postB, $postC, $postD] = $this->prepareFourPosts();

$posts = Post::query()
->addScopeAsSelect('title_is_foo_and_has_more_than_five_comments', [
'titleIsFoo',
'hasMoreCommentsThan' => 5,
])
->orderBy('id')
->get();

$this->assertFalse($posts->get(0)->title_is_foo_and_has_more_than_five_comments);
$this->assertTrue($posts->get(1)->title_is_foo_and_has_more_than_five_comments);
$this->assertFalse($posts->get(2)->title_is_foo_and_has_more_than_five_comments);
$this->assertFalse($posts->get(3)->title_is_foo_and_has_more_than_five_comments);
}

/** @test */
public function it_can_add_multiple_and_has_relation_scopes()
{
Expand Down Expand Up @@ -53,20 +138,7 @@ public function it_can_add_multiple_and_has_relation_scopes()
/** @test */
public function it_can_do_inline_contraints_as_well()
{
$postA = Post::create(['title' => 'foo']);
$postB = Post::create(['title' => 'foo']);
$postC = Post::create(['title' => 'bar']);
$postD = Post::create(['title' => 'bar']);

foreach (range(1, 5) as $i) {
$postA->comments()->create(['body' => 'ok']);
$postC->comments()->create(['body' => 'ok']);
}

foreach (range(1, 10) as $i) {
$postB->comments()->create(['body' => 'ok']);
$postD->comments()->create(['body' => 'ok']);
}
[$postA, $postB, $postC, $postD] = $this->prepareFourPosts();

$posts = Post::query()
->addScopeAsSelect('title_is_foo_and_has_six_comments_or_more', function ($query) {
Expand All @@ -84,20 +156,7 @@ public function it_can_do_inline_contraints_as_well()
/** @test */
public function it_can_mix_scopes_outside_of_the_closure()
{
$postA = Post::create(['title' => 'foo']);
$postB = Post::create(['title' => 'foo']);
$postC = Post::create(['title' => 'bar']);
$postD = Post::create(['title' => 'bar']);

foreach (range(1, 5) as $i) {
$postA->comments()->create(['body' => 'ok']);
$postC->comments()->create(['body' => 'ok']);
}

foreach (range(1, 10) as $i) {
$postB->comments()->create(['body' => 'ok']);
$postD->comments()->create(['body' => 'ok']);
}
[$postA, $postB, $postC, $postD] = $this->prepareFourPosts();

$posts = Post::query()
->where('title', 'foo')
Expand Down
Loading