Skip to content

Commit

Permalink
[5.x] Support scopes as query methods (#5927)
Browse files Browse the repository at this point in the history
Co-authored-by: Jason Varga <[email protected]>
Co-authored-by: Duncan McClean <[email protected]>
  • Loading branch information
3 people authored Oct 3, 2024
1 parent 346acb7 commit 7de9f95
Show file tree
Hide file tree
Showing 15 changed files with 294 additions and 2 deletions.
3 changes: 3 additions & 0 deletions src/Assets/AssetRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
use Statamic\Facades\Site;
use Statamic\Facades\Stache;
use Statamic\Facades\URL;
use Statamic\Query\Scopes\AllowsScopes;
use Statamic\Support\Str;

class AssetRepository implements Contract
{
use AllowsScopes;

public function all()
{
return AssetCollection::make(AssetContainer::all()->flatMap(function ($container) {
Expand Down
3 changes: 2 additions & 1 deletion src/Auth/UserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
use Statamic\Facades\Blink;
use Statamic\Facades\Blueprint;
use Statamic\OAuth\Provider;
use Statamic\Query\Scopes\AllowsScopes;
use Statamic\Statamic;

abstract class UserRepository implements RepositoryContract
{
use StoresComputedFieldCallbacks;
use AllowsScopes, StoresComputedFieldCallbacks;

public function create()
{
Expand Down
2 changes: 2 additions & 0 deletions src/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ public function register()
app()->bind('statamic.queries.'.$alias, $binding);
});

$this->app->instance('statamic.query-scopes', collect());

$this->app->bind('statamic.imaging.guzzle', function () {
return new \GuzzleHttp\Client;
});
Expand Down
17 changes: 16 additions & 1 deletion src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Statamic\Query;

use BadMethodCallException;
use Closure;
use DateTimeInterface;
use Illuminate\Pagination\Paginator;
Expand All @@ -13,10 +14,11 @@
use Statamic\Extensions\Pagination\LengthAwarePaginator;
use Statamic\Facades\Pattern;
use Statamic\Query\Concerns\FakesQueries;
use Statamic\Query\Scopes\AppliesScopes;

abstract class Builder implements Contract
{
use FakesQueries;
use AppliesScopes, FakesQueries;

protected $columns;
protected $limit;
Expand All @@ -38,6 +40,19 @@ abstract class Builder implements Contract
'<=' => 'LessThanOrEqualTo',
];

public function __call($method, $args)
{
if ($this->canApplyScope($method)) {
$this->applyScope($method, $args[0] ?? []);

return $this;
}

throw new BadMethodCallException(sprintf(
'Call to undefined method %s::%s()', static::class, $method
));
}

public function select($columns = ['*'])
{
$this->columns = $columns;
Expand Down
9 changes: 9 additions & 0 deletions src/Query/EloquentQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
use Statamic\Contracts\Query\Builder;
use Statamic\Extensions\Pagination\LengthAwarePaginator;
use Statamic\Facades\Blink;
use Statamic\Query\Scopes\AppliesScopes;
use Statamic\Support\Arr;

abstract class EloquentQueryBuilder implements Builder
{
use AppliesScopes;

protected $builder;
protected $columns;

Expand All @@ -39,6 +42,12 @@ public function __construct(EloquentBuilder $builder)

public function __call($method, $args)
{
if ($this->canApplyScope($method)) {
$this->applyScope($method, $args[0] ?? []);

return $this;
}

$response = $this->builder->$method(...$args);

return $response instanceof EloquentBuilder ? $this : $response;
Expand Down
21 changes: 21 additions & 0 deletions src/Query/Scopes/AllowsScopes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Statamic\Query\Scopes;

use Statamic\Support\Str;

trait AllowsScopes
{
public function allowQueryScope(string $scope, ?string $method = null)
{
/** @var \Illuminate\Support\Collection $scopes */
$scopes = app('statamic.query-scopes');

$method ??= Str::camel($scope::handle());

$scopes->put(
$class = get_class($this->query()),
$scopes->get($class, collect())->put($method, $scope)
);
}
}
30 changes: 30 additions & 0 deletions src/Query/Scopes/AppliesScopes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Statamic\Query\Scopes;

use Statamic\Facades\Scope;
use Statamic\Tags\Parameters;

trait AppliesScopes
{
public function applyScope($scope, Parameters|array $context = [])
{
if (! $class = $this->getScopeClassFor($scope)) {
throw new \Exception("The [$scope] scope does not exist.");
}

Scope::find($class::handle())->apply($this, $context);
}

public function canApplyScope($scope): bool
{
return (bool) $this->getScopeClassFor($scope);
}

private function getScopeClassFor(string $method): ?string
{
return app('statamic.query-scopes')
->get(get_class($this), collect())
->get($method);
}
}
3 changes: 3 additions & 0 deletions src/Stache/Repositories/EntryRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
use Statamic\Exceptions\EntryNotFoundException;
use Statamic\Facades\Blink;
use Statamic\Facades\Collection;
use Statamic\Query\Scopes\AllowsScopes;
use Statamic\Rules\Slug;
use Statamic\Stache\Query\EntryQueryBuilder;
use Statamic\Stache\Stache;
use Statamic\Support\Arr;

class EntryRepository implements RepositoryContract
{
use AllowsScopes;

protected $stache;
protected $store;
protected $substitutionsById = [];
Expand Down
3 changes: 3 additions & 0 deletions src/Stache/Repositories/TermRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Statamic\Facades\Collection;
use Statamic\Facades\Entry;
use Statamic\Facades\Taxonomy;
use Statamic\Query\Scopes\AllowsScopes;
use Statamic\Stache\Query\TermQueryBuilder;
use Statamic\Stache\Stache;
use Statamic\Support\Str;
Expand All @@ -17,6 +18,8 @@

class TermRepository implements RepositoryContract
{
use AllowsScopes;

protected $stache;
protected $store;
protected $substitutionsById = [];
Expand Down
110 changes: 110 additions & 0 deletions tests/Auth/Eloquent/EloquentUserQueryBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace Tests\Auth\Eloquent;

use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\User as Users;
use Statamic\Query\Scopes\Scope;
use Tests\TestCase;

class EloquentUserQueryBuilderTest extends TestCase
{
public static $migrationsGenerated = false;

public function setUp(): void
{
parent::setUp();

Carbon::setTestNow(Carbon::create(2019, 11, 21, 23, 39, 29));

config([
'statamic.users.repository' => 'eloquent',
'auth.providers.users' => [
'driver' => 'eloquent',
'model' => User::class,
],
]);

$this->loadMigrationsFrom(static::migrationsDir());

$tmpDir = static::migrationsDir().'/tmp';

if (! self::$migrationsGenerated) {
$this->artisan('statamic:auth:migration', ['--path' => $tmpDir]);

self::$migrationsGenerated = true;
}

$this->loadMigrationsFrom($tmpDir);
}

private static function migrationsDir()
{
return __DIR__.'/__migrations__';
}

public function tearDown(): void
{
Users::all()->each->delete();

parent::tearDown();
}

public static function tearDownAfterClass(): void
{
// Clean up the orphaned migration file.
(new Filesystem)->deleteDirectory(static::migrationsDir().'/tmp');

parent::tearDownAfterClass();
}

#[Test]
public function it_queries_by_scope()
{
NameScope::register();
Users::allowQueryScope(NameScope::class);
Users::allowQueryScope(NameScope::class, 'byName');

User::create(['name' => 'Jack', 'email' => '[email protected]']);
User::create(['name' => 'Jason', 'email' => '[email protected]']);
User::create(['name' => 'Bob Down', 'email' => '[email protected]']);
User::create(['name' => 'Bob Vance', 'email' => '[email protected]']);

// Scope defined in model ...
// Eloquent model scopes can pass arguments.
$this->assertEquals([
'Jack', 'Jason',
], Users::query()->domain('statamic.com')->get()->map->name->all());

// Statamic style scope ...
// Statamic scopes require arrays.
$this->assertEquals([
'Bob Down', 'Bob Vance',
], Users::query()->name(['name' => 'bob'])->get()->map->name->all());

// Statamic style scope with method ...
$this->assertEquals([
'Bob Down', 'Bob Vance',
], Users::query()->byName(['name' => 'bob'])->get()->map->name->all());

// Otherwise calling a non-existent method should throw appropriate exception ...
try {
Users::query()->something()->get();
$this->fail('Undefined method exception was not thrown.');
} catch (\BadMethodCallException $e) {
$this->assertEquals('Call to undefined method Illuminate\\Database\\Eloquent\\Builder::something()', $e->getMessage());
}
}
}

class NameScope extends Scope
{
protected static $handle = 'name';

public function apply($query, $values)
{
$query->where('name', 'like', '%'.$values['name'].'%');
}
}
5 changes: 5 additions & 0 deletions tests/Auth/Eloquent/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,9 @@ class User extends Authenticatable
protected $hidden = [
'password', 'remember_token',
];

public function scopeDomain($query, $domain)
{
return $query->where('email', 'like', '%@'.$domain);
}
}
20 changes: 20 additions & 0 deletions tests/Data/Assets/AssetQueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Statamic\Facades\Asset;
use Statamic\Facades\AssetContainer;
use Statamic\Facades\Blueprint;
use Statamic\Query\Scopes\Scope;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

Expand Down Expand Up @@ -525,6 +526,17 @@ public function it_can_get_assets_using_tap()
$this->assertEquals(['a'], $assets->map->filename()->all());
}

#[Test]
public function assets_are_found_using_scopes()
{
CustomScope::register();
Asset::allowQueryScope(CustomScope::class);
Asset::allowQueryScope(CustomScope::class, 'whereCustom');

$this->assertCount(1, $this->container->queryAssets()->customScope(['path' => 'a.jpg'])->get());
$this->assertCount(1, $this->container->queryAssets()->whereCustom(['path' => 'a.jpg'])->get());
}

#[Test]
public function assets_are_found_using_offset()
{
Expand Down Expand Up @@ -632,3 +644,11 @@ public function values_can_be_plucked()
], $this->container->queryAssets()->where('extension', 'jpg')->pluck('path')->all());
}
}

class CustomScope extends Scope
{
public function apply($query, $params)
{
$query->where('path', $params['path']);
}
}
23 changes: 23 additions & 0 deletions tests/Data/Entries/EntryQueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Statamic\Facades\Blueprint;
use Statamic\Facades\Collection;
use Statamic\Facades\Entry;
use Statamic\Query\Scopes\Scope;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

Expand Down Expand Up @@ -666,6 +667,20 @@ public function it_substitutes_entries_by_uri_and_site()
$this->assertSame($found, $substituteFr);
}

#[Test]
public function entries_are_found_using_scopes()
{
CustomScope::register();
Entry::allowQueryScope(CustomScope::class);
Entry::allowQueryScope(CustomScope::class, 'whereCustom');

EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1'])->create();
EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2'])->create();

$this->assertCount(1, Entry::query()->customScope(['title' => 'Post 1'])->get());
$this->assertCount(1, Entry::query()->whereCustom(['title' => 'Post 1'])->get());
}

#[Test]
public function entries_are_found_using_offset()
{
Expand Down Expand Up @@ -908,3 +923,11 @@ public function values_can_be_plucked()
], Entry::query()->where('type', 'b')->pluck('slug')->all());
}
}

class CustomScope extends Scope
{
public function apply($query, $params)
{
$query->where('title', $params['title']);
}
}
Loading

0 comments on commit 7de9f95

Please sign in to comment.