Skip to content

Commit 6f22db3

Browse files
authored
Call scopes with a string or array with additional constraints (#2)
* WIP * Update README.md Co-authored-by: Pascal Baljet <pascal@protone.media>
1 parent 7f7f31d commit 6f22db3

9 files changed

+251
-41
lines changed

README.md

+60-2
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ We proudly support the community by developing Laravel packages and giving them
3030

3131
## Blogpost
3232

33-
3433
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).
3534

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

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

46-
4745
```php
4846
use ProtoneMedia\LaravelEloquentScopeAsSelect\ScopeAsSelect;
4947

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

60+
## Short API description
61+
62+
For a more practical explanation, check out the [usage](#usage) section below.
63+
64+
Using a Closure:
65+
```php
66+
$posts = Post::addScopeAsSelect('is_published', function ($query) {
67+
$query->published();
68+
})->get();
69+
```
70+
71+
Using a string:
72+
```php
73+
$posts = Post::addScopeAsSelect('is_published', 'published')->get();
74+
```
75+
76+
Using an array to call multiple scopes:
77+
```php
78+
$posts = Post::addScopeAsSelect('is_popular_and_published', ['popular', 'published'])->get();
79+
```
80+
81+
Using an associative array to call dynamic scopes:
82+
```php
83+
$posts = Post::addScopeAsSelect('is_announcement', ['ofType' => 'announcement'])->get();
84+
```
85+
86+
Using an associative array to mix (dynamic) scopes:
87+
```php
88+
$posts = Post::addScopeAsSelect('is_published_announcement', [
89+
'published',
90+
'ofType' => 'announcement'
91+
])->get();
92+
```
93+
94+
There's an optional third argument to flip the result:
95+
```php
96+
$posts = Post::addScopeAsSelect('is_not_announcement', ['ofType' => 'announcement'], false)->get();
97+
```
98+
6299
## Usage
63100

64101
Imagine you have a `Post` Eloquent model with a query scope.
@@ -171,6 +208,27 @@ Post::query()
171208
});
172209
```
173210

211+
### Shortcuts
212+
213+
Instead of using a Closure, there are some shortcuts you could use:
214+
215+
```php
216+
Post::addScopeAsSelect('is_published', function ($query) {
217+
$query->published();
218+
});
219+
220+
// is the same as:
221+
222+
Post::addScopeAsSelect('is_published', 'published');
223+
```
224+
225+
Post::addScopeAsSelect('is_published', function ($query) {
226+
$query->published();
227+
})
228+
229+
Post::addScopeAsSelect('is_published', ['published']);
230+
```
231+
174232
### Testing
175233
176234
``` bash

src/NegativeNullableBooleanCaster.php

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace ProtoneMedia\LaravelEloquentScopeAsSelect;
4+
5+
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
6+
7+
class NegativeNullableBooleanCaster implements CastsAttributes
8+
{
9+
/**
10+
* Transform the attribute from the underlying model values.
11+
*
12+
* @param \Illuminate\Database\Eloquent\Model $model
13+
* @param string $key
14+
* @param mixed $value
15+
* @param array $attributes
16+
* @return mixed
17+
*/
18+
public function get($model, $key, $value, $attributes)
19+
{
20+
return !(bool) $value;
21+
}
22+
23+
/**
24+
* Transform the attribute to its underlying model values.
25+
*
26+
* @param \Illuminate\Database\Eloquent\Model $model
27+
* @param string $key
28+
* @param mixed $value
29+
* @param array $attributes
30+
* @return mixed
31+
*/
32+
public function set($model, $key, $value, $attributes)
33+
{
34+
return $value;
35+
}
36+
}

src/ScopeAsSelect.php

+50-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace ProtoneMedia\LaravelEloquentScopeAsSelect;
44

55
use Illuminate\Database\Eloquent\Builder;
6+
use Illuminate\Support\Arr;
67
use Illuminate\Support\Facades\DB;
78

89
class ScopeAsSelect
@@ -18,28 +19,74 @@ public static function builder($value): Builder
1819
return $value;
1920
}
2021

22+
/**
23+
* Returns a callable that applies the scope and arguments
24+
* to the given query builder.
25+
*
26+
* @param mixed $value
27+
* @return callable
28+
*/
29+
public static function makeCallable($value): callable
30+
{
31+
// We both allow single and multiple scopes...
32+
$scopes = Arr::wrap($value);
33+
34+
return function ($query) use ($scopes) {
35+
// If $scope is numeric, there are no arguments, and we can
36+
// safely assume the scope is in the $arguments variable.
37+
foreach ($scopes as $scope => $arguments) {
38+
if (is_numeric($scope)) {
39+
[$scope, $arguments] = [$arguments, null];
40+
}
41+
42+
// As we allow a constraint to be a single arguments.
43+
$arguments = Arr::wrap($arguments);
44+
45+
$query->{$scope}(...$arguments);
46+
}
47+
48+
return $query;
49+
};
50+
}
51+
52+
/**
53+
* Adds a macro to the query builder.
54+
*
55+
* @param string $name
56+
* @return void
57+
*/
2158
public static function addMacro(string $name = 'addScopeAsSelect')
2259
{
23-
Builder::macro($name, function (string $name, callable $callable): Builder {
60+
Builder::macro($name, function (string $name, $withQuery, bool $exists = true): Builder {
61+
$callable = is_callable($withQuery)
62+
? $withQuery
63+
: ScopeAsSelect::makeCallable($withQuery);
64+
65+
// We do this to make sure the $query variable is an Eloquent Query Builder.
2466
$query = ScopeAsSelect::builder($this);
2567

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

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

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

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

86+
// Add the subquery and query-time cast.
4087
return $query
4188
->addSelect([$name => $subSelect])
42-
->withCasts([$name => NullableBooleanCaster::class]);
89+
->withCasts([$name => $exists ? NullableBooleanCaster::class : NegativeNullableBooleanCaster::class]);
4390
});
4491
}
4592
}

tests/Comment.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?php
1+
<?php declare(strict_types=1);
22

33
namespace ProtoneMedia\LaravelEloquentScopeAsSelect\Tests;
44

tests/Post.php

+13-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?php
1+
<?php declare(strict_types=1);
22

33
namespace ProtoneMedia\LaravelEloquentScopeAsSelect\Tests;
44

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

14+
public function scopeTitleIs($query, $title)
15+
{
16+
$query->where($query->qualifyColumn('title'), $title);
17+
}
18+
1419
public function scopeTitleIsFoo($query)
1520
{
16-
$query->where($query->qualifyColumn('title'), 'foo');
21+
$query->titleIs('foo');
22+
}
23+
24+
public function scopeHasMoreCommentsThan($query, $value)
25+
{
26+
$query->has('comments', '>', $value);
1727
}
1828

1929
public function scopeHasSixOrMoreComments($query)
2030
{
21-
$query->has('comments', '>=', 6);
31+
$query->hasMoreCommentsThan(5);
2232
}
2333
}

tests/ScopeAsSelectTest.php

+88-29
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
1-
<?php
1+
<?php declare(strict_types=1);
22

33
namespace ProtoneMedia\LaravelEloquentScopeAsSelect\Tests;
44

55
class ScopeAsSelectTest extends TestCase
66
{
7+
private function prepareFourPosts(): array
8+
{
9+
$postA = Post::create(['title' => 'foo']);
10+
$postB = Post::create(['title' => 'foo']);
11+
$postC = Post::create(['title' => 'bar']);
12+
$postD = Post::create(['title' => 'bar']);
13+
14+
foreach (range(1, 5) as $i) {
15+
$postA->comments()->create(['body' => 'ok']);
16+
$postC->comments()->create(['body' => 'ok']);
17+
}
18+
19+
foreach (range(1, 10) as $i) {
20+
$postB->comments()->create(['body' => 'ok']);
21+
$postD->comments()->create(['body' => 'ok']);
22+
}
23+
24+
return [$postA, $postB, $postC, $postD];
25+
}
26+
727
/** @test */
828
public function it_can_add_a_scope_as_a_select()
929
{
@@ -19,6 +39,71 @@ public function it_can_add_a_scope_as_a_select()
1939
$this->assertFalse($posts->get(1)->title_is_foo);
2040
}
2141

42+
/** @test */
43+
public function it_can_add_a_scope_as_a_select_and_cast_inversed()
44+
{
45+
$postA = Post::create(['title' => 'foo']);
46+
$postB = Post::create(['title' => 'bar']);
47+
48+
$posts = Post::query()
49+
->addScopeAsSelect('title_is_foo', fn ($query) => $query->titleIsFoo(), false)
50+
->orderBy('id')
51+
->get();
52+
53+
$this->assertFalse($posts->get(0)->title_is_foo);
54+
$this->assertTrue($posts->get(1)->title_is_foo);
55+
}
56+
57+
/** @test */
58+
public function it_can_add_a_scope_by_using_the_name()
59+
{
60+
$postA = Post::create(['title' => 'foo']);
61+
$postB = Post::create(['title' => 'bar']);
62+
63+
$posts = Post::query()
64+
->addScopeAsSelect('title_is_foo', 'titleIsFoo')
65+
->orderBy('id')
66+
->get();
67+
68+
$this->assertTrue($posts->get(0)->title_is_foo);
69+
$this->assertFalse($posts->get(1)->title_is_foo);
70+
}
71+
72+
/** @test */
73+
public function it_can_add_multiple_scopes_by_using_an_array()
74+
{
75+
[$postA, $postB, $postC, $postD] = $this->prepareFourPosts();
76+
77+
$posts = Post::query()
78+
->addScopeAsSelect('title_is_foo_and_has_six_comments_or_more', ['titleIsFoo', 'hasSixOrMoreComments'])
79+
->orderBy('id')
80+
->get();
81+
82+
$this->assertFalse($posts->get(0)->title_is_foo_and_has_six_comments_or_more);
83+
$this->assertTrue($posts->get(1)->title_is_foo_and_has_six_comments_or_more);
84+
$this->assertFalse($posts->get(2)->title_is_foo_and_has_six_comments_or_more);
85+
$this->assertFalse($posts->get(3)->title_is_foo_and_has_six_comments_or_more);
86+
}
87+
88+
/** @test */
89+
public function it_can_add_multiple_dynamic_scopes_by_using_an_array()
90+
{
91+
[$postA, $postB, $postC, $postD] = $this->prepareFourPosts();
92+
93+
$posts = Post::query()
94+
->addScopeAsSelect('title_is_foo_and_has_more_than_five_comments', [
95+
'titleIsFoo',
96+
'hasMoreCommentsThan' => 5,
97+
])
98+
->orderBy('id')
99+
->get();
100+
101+
$this->assertFalse($posts->get(0)->title_is_foo_and_has_more_than_five_comments);
102+
$this->assertTrue($posts->get(1)->title_is_foo_and_has_more_than_five_comments);
103+
$this->assertFalse($posts->get(2)->title_is_foo_and_has_more_than_five_comments);
104+
$this->assertFalse($posts->get(3)->title_is_foo_and_has_more_than_five_comments);
105+
}
106+
22107
/** @test */
23108
public function it_can_add_multiple_and_has_relation_scopes()
24109
{
@@ -53,20 +138,7 @@ public function it_can_add_multiple_and_has_relation_scopes()
53138
/** @test */
54139
public function it_can_do_inline_contraints_as_well()
55140
{
56-
$postA = Post::create(['title' => 'foo']);
57-
$postB = Post::create(['title' => 'foo']);
58-
$postC = Post::create(['title' => 'bar']);
59-
$postD = Post::create(['title' => 'bar']);
60-
61-
foreach (range(1, 5) as $i) {
62-
$postA->comments()->create(['body' => 'ok']);
63-
$postC->comments()->create(['body' => 'ok']);
64-
}
65-
66-
foreach (range(1, 10) as $i) {
67-
$postB->comments()->create(['body' => 'ok']);
68-
$postD->comments()->create(['body' => 'ok']);
69-
}
141+
[$postA, $postB, $postC, $postD] = $this->prepareFourPosts();
70142

71143
$posts = Post::query()
72144
->addScopeAsSelect('title_is_foo_and_has_six_comments_or_more', function ($query) {
@@ -84,20 +156,7 @@ public function it_can_do_inline_contraints_as_well()
84156
/** @test */
85157
public function it_can_mix_scopes_outside_of_the_closure()
86158
{
87-
$postA = Post::create(['title' => 'foo']);
88-
$postB = Post::create(['title' => 'foo']);
89-
$postC = Post::create(['title' => 'bar']);
90-
$postD = Post::create(['title' => 'bar']);
91-
92-
foreach (range(1, 5) as $i) {
93-
$postA->comments()->create(['body' => 'ok']);
94-
$postC->comments()->create(['body' => 'ok']);
95-
}
96-
97-
foreach (range(1, 10) as $i) {
98-
$postB->comments()->create(['body' => 'ok']);
99-
$postD->comments()->create(['body' => 'ok']);
100-
}
159+
[$postA, $postB, $postC, $postD] = $this->prepareFourPosts();
101160

102161
$posts = Post::query()
103162
->where('title', 'foo')

0 commit comments

Comments
 (0)