Skip to content

Commit 9ec8075

Browse files
authored
Merge pull request #327 from binaryfire/feature/scope-attributes
feat: Add `#[Scope]` and `#[ScopedBy]` attributes for declarative scope registration
2 parents 07fda0f + bf86857 commit 9ec8075

File tree

11 files changed

+1001
-8
lines changed

11 files changed

+1001
-8
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Database\Eloquent\Attributes;
6+
7+
use Attribute;
8+
9+
/**
10+
* Mark a method as a local query scope.
11+
*
12+
* Methods with this attribute can be called as scopes without
13+
* the traditional 'scope' prefix convention:
14+
*
15+
* #[Scope]
16+
* protected function active(Builder $query): void
17+
* {
18+
* $query->where('active', true);
19+
* }
20+
*
21+
* // Called as: User::active() or $query->active()
22+
*/
23+
#[Attribute(Attribute::TARGET_METHOD)]
24+
class Scope
25+
{
26+
public function __construct()
27+
{
28+
}
29+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Database\Eloquent\Attributes;
6+
7+
use Attribute;
8+
9+
/**
10+
* Declare global scopes to be automatically applied to the model.
11+
*
12+
* Can be applied to model classes or traits. Supports both single scope
13+
* class and arrays of scope classes. Repeatable for multiple declarations.
14+
*/
15+
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
16+
class ScopedBy
17+
{
18+
/**
19+
* @param class-string|class-string[] $classes
20+
*/
21+
public function __construct(
22+
public array|string $classes,
23+
) {
24+
}
25+
}

src/core/src/Database/Eloquent/Builder.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,78 @@ class Builder extends BaseBuilder
5353
{
5454
use QueriesRelationships;
5555

56+
/**
57+
* Dynamically handle calls into the query instance.
58+
*
59+
* Extends parent to support methods marked with #[Scope] attribute
60+
* in addition to the traditional 'scope' prefix convention.
61+
*
62+
* @param string $method
63+
* @param array<int, mixed> $parameters
64+
* @return mixed
65+
*/
66+
public function __call($method, $parameters)
67+
{
68+
if ($method === 'macro') {
69+
$this->localMacros[$parameters[0]] = $parameters[1];
70+
71+
return;
72+
}
73+
74+
if ($method === 'mixin') {
75+
return static::registerMixin($parameters[0], $parameters[1] ?? true);
76+
}
77+
78+
if ($this->hasMacro($method)) {
79+
array_unshift($parameters, $this);
80+
81+
return $this->localMacros[$method](...$parameters);
82+
}
83+
84+
if (static::hasGlobalMacro($method)) {
85+
$macro = static::$macros[$method];
86+
87+
if ($macro instanceof Closure) {
88+
return call_user_func_array($macro->bindTo($this, static::class), $parameters);
89+
}
90+
91+
return call_user_func_array($macro, $parameters);
92+
}
93+
94+
// Check for named scopes (both 'scope' prefix and #[Scope] attribute)
95+
if ($this->hasNamedScope($method)) {
96+
return $this->callNamedScope($method, $parameters);
97+
}
98+
99+
if (in_array($method, $this->passthru)) {
100+
return $this->toBase()->{$method}(...$parameters);
101+
}
102+
103+
$this->query->{$method}(...$parameters);
104+
105+
return $this;
106+
}
107+
108+
/**
109+
* Determine if the given model has a named scope.
110+
*/
111+
public function hasNamedScope(string $scope): bool
112+
{
113+
return $this->model && $this->model->hasNamedScope($scope);
114+
}
115+
116+
/**
117+
* Call the given named scope on the model.
118+
*
119+
* @param array<int, mixed> $parameters
120+
*/
121+
protected function callNamedScope(string $scope, array $parameters = []): mixed
122+
{
123+
return $this->callScope(function (...$params) use ($scope) {
124+
return $this->model->callNamedScope($scope, $params);
125+
}, $parameters);
126+
}
127+
56128
/**
57129
* @return \Hypervel\Support\LazyCollection<int, TModel>
58130
*/
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Database\Eloquent\Concerns;
6+
7+
use Closure;
8+
use Hyperf\Collection\Collection;
9+
use Hyperf\Database\Model\GlobalScope;
10+
use Hyperf\Database\Model\Model as HyperfModel;
11+
use Hyperf\Database\Model\Scope;
12+
use Hypervel\Database\Eloquent\Attributes\ScopedBy;
13+
use InvalidArgumentException;
14+
use ReflectionAttribute;
15+
use ReflectionClass;
16+
17+
/**
18+
* Extends Hyperf's global scope functionality with attribute-based registration.
19+
*
20+
* This trait adds support for the #[ScopedBy] attribute, allowing models
21+
* to declare their global scopes declaratively on the class or traits.
22+
*/
23+
trait HasGlobalScopes
24+
{
25+
/**
26+
* Boot the has global scopes trait for a model.
27+
*
28+
* Automatically registers any global scopes declared via the ScopedBy attribute.
29+
*/
30+
public static function bootHasGlobalScopes(): void
31+
{
32+
$scopes = static::resolveGlobalScopeAttributes();
33+
34+
if (! empty($scopes)) {
35+
static::addGlobalScopes($scopes);
36+
}
37+
}
38+
39+
/**
40+
* Resolve the global scope class names from the ScopedBy attributes.
41+
*
42+
* Collects ScopedBy attributes from parent classes, traits, and the
43+
* current class itself, merging them together. The order is:
44+
* parent class scopes -> trait scopes -> class scopes.
45+
*
46+
* @return array<int, class-string<Scope>>
47+
*/
48+
public static function resolveGlobalScopeAttributes(): array
49+
{
50+
$reflectionClass = new ReflectionClass(static::class);
51+
52+
$parentClass = get_parent_class(static::class);
53+
$hasParentWithMethod = $parentClass
54+
&& $parentClass !== HyperfModel::class
55+
&& method_exists($parentClass, 'resolveGlobalScopeAttributes');
56+
57+
// Collect attributes from traits, then from the class itself
58+
$attributes = new Collection();
59+
60+
foreach ($reflectionClass->getTraits() as $trait) {
61+
foreach ($trait->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
62+
$attributes->push($attribute);
63+
}
64+
}
65+
66+
foreach ($reflectionClass->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
67+
$attributes->push($attribute);
68+
}
69+
70+
// Process all collected attributes
71+
$scopes = $attributes
72+
->map(fn (ReflectionAttribute $attribute) => $attribute->getArguments())
73+
->flatten();
74+
75+
// Prepend parent's scopes if applicable
76+
return $scopes
77+
->when($hasParentWithMethod, function (Collection $attrs) use ($parentClass) {
78+
/** @var class-string $parentClass */
79+
return (new Collection($parentClass::resolveGlobalScopeAttributes()))
80+
->merge($attrs);
81+
})
82+
->all();
83+
}
84+
85+
/**
86+
* Register multiple global scopes on the model.
87+
*
88+
* @param array<int|string, class-string<Scope>|Closure|Scope> $scopes
89+
*/
90+
public static function addGlobalScopes(array $scopes): void
91+
{
92+
foreach ($scopes as $key => $scope) {
93+
if (is_string($key)) {
94+
static::addGlobalScope($key, $scope);
95+
} else {
96+
static::addGlobalScope($scope);
97+
}
98+
}
99+
}
100+
101+
/**
102+
* Register a new global scope on the model.
103+
*
104+
* Extends Hyperf's implementation to support scope class-strings.
105+
*
106+
* @param Closure|Scope|string $scope
107+
* @return mixed
108+
*
109+
* @throws InvalidArgumentException
110+
*/
111+
public static function addGlobalScope($scope, ?Closure $implementation = null)
112+
{
113+
if (is_string($scope) && $implementation !== null) {
114+
return GlobalScope::$container[static::class][$scope] = $implementation;
115+
}
116+
117+
if ($scope instanceof Closure) {
118+
return GlobalScope::$container[static::class][spl_object_hash($scope)] = $scope;
119+
}
120+
121+
if ($scope instanceof Scope) {
122+
return GlobalScope::$container[static::class][get_class($scope)] = $scope;
123+
}
124+
125+
// Support class-string for Scope classes (Laravel compatibility)
126+
if (class_exists($scope) && is_subclass_of($scope, Scope::class)) {
127+
return GlobalScope::$container[static::class][$scope] = new $scope();
128+
}
129+
130+
throw new InvalidArgumentException(
131+
'Global scope must be an instance of Closure or Scope, or a class-string of a Scope implementation.'
132+
);
133+
}
134+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hypervel\Database\Eloquent\Concerns;
6+
7+
use Hypervel\Database\Eloquent\Attributes\Scope;
8+
use ReflectionMethod;
9+
10+
/**
11+
* Adds support for the #[Scope] attribute on model methods.
12+
*
13+
* This trait allows methods to be marked as local scopes without
14+
* requiring the traditional 'scope' prefix naming convention.
15+
*/
16+
trait HasLocalScopes
17+
{
18+
/**
19+
* Determine if the model has a named scope.
20+
*
21+
* Checks for both traditional scope prefix (scopeActive) and
22+
* methods marked with the #[Scope] attribute.
23+
*/
24+
public function hasNamedScope(string $scope): bool
25+
{
26+
return method_exists($this, 'scope' . ucfirst($scope))
27+
|| static::isScopeMethodWithAttribute($scope);
28+
}
29+
30+
/**
31+
* Apply the given named scope if possible.
32+
*
33+
* @param array<int, mixed> $parameters
34+
*/
35+
public function callNamedScope(string $scope, array $parameters = []): mixed
36+
{
37+
if (static::isScopeMethodWithAttribute($scope)) {
38+
return $this->{$scope}(...$parameters);
39+
}
40+
41+
return $this->{'scope' . ucfirst($scope)}(...$parameters);
42+
}
43+
44+
/**
45+
* Determine if the given method has a #[Scope] attribute.
46+
*/
47+
protected static function isScopeMethodWithAttribute(string $method): bool
48+
{
49+
if (! method_exists(static::class, $method)) {
50+
return false;
51+
}
52+
53+
return (new ReflectionMethod(static::class, $method))
54+
->getAttributes(Scope::class) !== [];
55+
}
56+
}

src/core/src/Database/Eloquent/Model.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
use Hypervel\Context\Context;
1111
use Hypervel\Database\Eloquent\Concerns\HasAttributes;
1212
use Hypervel\Database\Eloquent\Concerns\HasCallbacks;
13+
use Hypervel\Database\Eloquent\Concerns\HasGlobalScopes;
14+
use Hypervel\Database\Eloquent\Concerns\HasLocalScopes;
1315
use Hypervel\Database\Eloquent\Concerns\HasObservers;
1416
use Hypervel\Database\Eloquent\Concerns\HasRelations;
1517
use Hypervel\Database\Eloquent\Concerns\HasRelationships;
@@ -68,9 +70,11 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann
6870
{
6971
use HasAttributes;
7072
use HasCallbacks;
73+
use HasGlobalScopes;
74+
use HasLocalScopes;
75+
use HasObservers;
7176
use HasRelations;
7277
use HasRelationships;
73-
use HasObservers;
7478

7579
protected ?string $connection = null;
7680

@@ -231,6 +235,25 @@ public function replicateQuietly(?array $except = null): static
231235
return static::withoutEvents(fn () => $this->replicate($except));
232236
}
233237

238+
/**
239+
* Handle dynamic static method calls into the model.
240+
*
241+
* Checks for methods marked with the #[Scope] attribute before
242+
* falling back to the default behavior.
243+
*
244+
* @param string $method
245+
* @param array<int, mixed> $parameters
246+
* @return mixed
247+
*/
248+
public static function __callStatic($method, $parameters)
249+
{
250+
if (static::isScopeMethodWithAttribute($method)) {
251+
return static::query()->{$method}(...$parameters);
252+
}
253+
254+
return (new static())->{$method}(...$parameters);
255+
}
256+
234257
protected static function getWithoutEventContextKey(): string
235258
{
236259
return '__database.model.without_events.' . static::class;

0 commit comments

Comments
 (0)