Skip to content
Open
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
29 changes: 29 additions & 0 deletions src/core/src/Database/Eloquent/Attributes/Scope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database\Eloquent\Attributes;

use Attribute;

/**
* Mark a method as a local query scope.
*
* Methods with this attribute can be called as scopes without
* the traditional 'scope' prefix convention:
*
* #[Scope]
* protected function active(Builder $query): void
* {
* $query->where('active', true);
* }
*
* // Called as: User::active() or $query->active()
*/
#[Attribute(Attribute::TARGET_METHOD)]
class Scope
{
public function __construct()
{
}
}
25 changes: 25 additions & 0 deletions src/core/src/Database/Eloquent/Attributes/ScopedBy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database\Eloquent\Attributes;

use Attribute;

/**
* Declare global scopes to be automatically applied to the model.
*
* Can be applied to model classes or traits. Supports both single scope
* class and arrays of scope classes. Repeatable for multiple declarations.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
class ScopedBy
{
/**
* @param class-string|class-string[] $classes
*/
public function __construct(
public array|string $classes,
) {
}
}
72 changes: 72 additions & 0 deletions src/core/src/Database/Eloquent/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,78 @@ class Builder extends BaseBuilder
{
use QueriesRelationships;

/**
* Dynamically handle calls into the query instance.
*
* Extends parent to support methods marked with #[Scope] attribute
* in addition to the traditional 'scope' prefix convention.
*
* @param string $method
* @param array<int, mixed> $parameters
* @return mixed
*/
public function __call($method, $parameters)
{
if ($method === 'macro') {
$this->localMacros[$parameters[0]] = $parameters[1];

return;
}

if ($method === 'mixin') {
return static::registerMixin($parameters[0], $parameters[1] ?? true);
}

if ($this->hasMacro($method)) {
array_unshift($parameters, $this);

return $this->localMacros[$method](...$parameters);
}

if (static::hasGlobalMacro($method)) {
$macro = static::$macros[$method];

if ($macro instanceof Closure) {
return call_user_func_array($macro->bindTo($this, static::class), $parameters);
}

return call_user_func_array($macro, $parameters);
}

// Check for named scopes (both 'scope' prefix and #[Scope] attribute)
if ($this->hasNamedScope($method)) {
return $this->callNamedScope($method, $parameters);
}

if (in_array($method, $this->passthru)) {
return $this->toBase()->{$method}(...$parameters);
}

$this->query->{$method}(...$parameters);

return $this;
}

/**
* Determine if the given model has a named scope.
*/
public function hasNamedScope(string $scope): bool
{
return $this->model && $this->model->hasNamedScope($scope);
}

/**
* Call the given named scope on the model.
*
* @param array<int, mixed> $parameters
*/
protected function callNamedScope(string $scope, array $parameters = []): mixed
{
return $this->callScope(function (...$params) use ($scope) {
return $this->model->callNamedScope($scope, $params);
}, $parameters);
}

/**
* @return \Hypervel\Support\LazyCollection<int, TModel>
*/
Expand Down
134 changes: 134 additions & 0 deletions src/core/src/Database/Eloquent/Concerns/HasGlobalScopes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database\Eloquent\Concerns;

use Closure;
use Hyperf\Collection\Collection;
use Hyperf\Database\Model\GlobalScope;
use Hyperf\Database\Model\Model as HyperfModel;
use Hyperf\Database\Model\Scope;
use Hypervel\Database\Eloquent\Attributes\ScopedBy;
use InvalidArgumentException;
use ReflectionAttribute;
use ReflectionClass;

/**
* Extends Hyperf's global scope functionality with attribute-based registration.
*
* This trait adds support for the #[ScopedBy] attribute, allowing models
* to declare their global scopes declaratively on the class or traits.
*/
trait HasGlobalScopes
{
/**
* Boot the has global scopes trait for a model.
*
* Automatically registers any global scopes declared via the ScopedBy attribute.
*/
public static function bootHasGlobalScopes(): void
{
$scopes = static::resolveGlobalScopeAttributes();

if (! empty($scopes)) {
static::addGlobalScopes($scopes);
}
}

/**
* Resolve the global scope class names from the ScopedBy attributes.
*
* Collects ScopedBy attributes from parent classes, traits, and the
* current class itself, merging them together. The order is:
* parent class scopes -> trait scopes -> class scopes.
*
* @return array<int, class-string<Scope>>
*/
public static function resolveGlobalScopeAttributes(): array
{
$reflectionClass = new ReflectionClass(static::class);

$parentClass = get_parent_class(static::class);
$hasParentWithMethod = $parentClass
&& $parentClass !== HyperfModel::class
&& method_exists($parentClass, 'resolveGlobalScopeAttributes');

// Collect attributes from traits, then from the class itself
$attributes = new Collection();

foreach ($reflectionClass->getTraits() as $trait) {
foreach ($trait->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$attributes->push($attribute);
}
}

foreach ($reflectionClass->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$attributes->push($attribute);
}

// Process all collected attributes
$scopes = $attributes
->map(fn (ReflectionAttribute $attribute) => $attribute->getArguments())
->flatten();

// Prepend parent's scopes if applicable
return $scopes
->when($hasParentWithMethod, function (Collection $attrs) use ($parentClass) {
/** @var class-string $parentClass */
return (new Collection($parentClass::resolveGlobalScopeAttributes()))
->merge($attrs);
})
->all();
}

/**
* Register multiple global scopes on the model.
*
* @param array<int|string, class-string<Scope>|Closure|Scope> $scopes
*/
public static function addGlobalScopes(array $scopes): void
{
foreach ($scopes as $key => $scope) {
if (is_string($key)) {
static::addGlobalScope($key, $scope);
} else {
static::addGlobalScope($scope);
}
}
}

/**
* Register a new global scope on the model.
*
* Extends Hyperf's implementation to support scope class-strings.
*
* @param Closure|Scope|string $scope
* @return mixed
*
* @throws InvalidArgumentException
*/
public static function addGlobalScope($scope, ?Closure $implementation = null)
{
if (is_string($scope) && $implementation !== null) {
return GlobalScope::$container[static::class][$scope] = $implementation;
}

if ($scope instanceof Closure) {
return GlobalScope::$container[static::class][spl_object_hash($scope)] = $scope;
}

if ($scope instanceof Scope) {
return GlobalScope::$container[static::class][get_class($scope)] = $scope;
}

// Support class-string for Scope classes (Laravel compatibility)
if (is_string($scope) && class_exists($scope) && is_subclass_of($scope, Scope::class)) {
return GlobalScope::$container[static::class][$scope] = new $scope();
}

throw new InvalidArgumentException(
'Global scope must be an instance of Closure or Scope, or a class-string of a Scope implementation.'
);
}
}
56 changes: 56 additions & 0 deletions src/core/src/Database/Eloquent/Concerns/HasLocalScopes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database\Eloquent\Concerns;

use Hypervel\Database\Eloquent\Attributes\Scope;
use ReflectionMethod;

/**
* Adds support for the #[Scope] attribute on model methods.
*
* This trait allows methods to be marked as local scopes without
* requiring the traditional 'scope' prefix naming convention.
*/
trait HasLocalScopes
{
/**
* Determine if the model has a named scope.
*
* Checks for both traditional scope prefix (scopeActive) and
* methods marked with the #[Scope] attribute.
*/
public function hasNamedScope(string $scope): bool
{
return method_exists($this, 'scope' . ucfirst($scope))
|| static::isScopeMethodWithAttribute($scope);
}

/**
* Apply the given named scope if possible.
*
* @param array<int, mixed> $parameters
*/
public function callNamedScope(string $scope, array $parameters = []): mixed
{
if (static::isScopeMethodWithAttribute($scope)) {
return $this->{$scope}(...$parameters);
}

return $this->{'scope' . ucfirst($scope)}(...$parameters);
}

/**
* Determine if the given method has a #[Scope] attribute.
*/
protected static function isScopeMethodWithAttribute(string $method): bool
{
if (! method_exists(static::class, $method)) {
return false;
}

return (new ReflectionMethod(static::class, $method))
->getAttributes(Scope::class) !== [];
}
}
25 changes: 23 additions & 2 deletions src/core/src/Database/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
use Hypervel\Context\Context;
use Hypervel\Database\Eloquent\Concerns\HasAttributes;
use Hypervel\Database\Eloquent\Concerns\HasCallbacks;
use Hypervel\Database\Eloquent\Concerns\HasObservers;
use Hypervel\Database\Eloquent\Concerns\HasGlobalScopes;
use Hypervel\Database\Eloquent\Concerns\HasLocalScopes;
use Hypervel\Database\Eloquent\Concerns\HasRelations;
use Hypervel\Database\Eloquent\Concerns\HasRelationships;
use Hypervel\Database\Eloquent\Relations\Pivot;
Expand Down Expand Up @@ -68,9 +69,10 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann
{
use HasAttributes;
use HasCallbacks;
use HasGlobalScopes;
use HasLocalScopes;
use HasRelations;
use HasRelationships;
use HasObservers;

protected ?string $connection = null;

Expand Down Expand Up @@ -231,6 +233,25 @@ public function replicateQuietly(?array $except = null): static
return static::withoutEvents(fn () => $this->replicate($except));
}

/**
* Handle dynamic static method calls into the model.
*
* Checks for methods marked with the #[Scope] attribute before
* falling back to the default behavior.
*
* @param string $method
* @param array<int, mixed> $parameters
* @return mixed
*/
public static function __callStatic($method, $parameters)
{
if (static::isScopeMethodWithAttribute($method)) {
return static::query()->{$method}(...$parameters);
}

return (new static())->{$method}(...$parameters);
}

protected static function getWithoutEventContextKey(): string
{
return '__database.model.without_events.' . static::class;
Expand Down
2 changes: 2 additions & 0 deletions src/core/src/Database/Eloquent/Relations/MorphPivot.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
namespace Hypervel\Database\Eloquent\Relations;

use Hyperf\Database\Model\Relations\MorphPivot as BaseMorphPivot;
use Hypervel\Database\Eloquent\Concerns\HasGlobalScopes;

class MorphPivot extends BaseMorphPivot
{
use HasGlobalScopes;
}
2 changes: 2 additions & 0 deletions src/core/src/Database/Eloquent/Relations/Pivot.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
namespace Hypervel\Database\Eloquent\Relations;

use Hyperf\Database\Model\Relations\Pivot as BasePivot;
use Hypervel\Database\Eloquent\Concerns\HasGlobalScopes;

class Pivot extends BasePivot
{
use HasGlobalScopes;
}
Loading