From 257dff0f6b21c8b27c4c229557586174175b0840 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 31 Dec 2025 03:22:50 +0000 Subject: [PATCH 1/3] Add UseResource and UseResourceCollection attributes Port Laravel's resource attributes for declarative model-to-resource binding: - Add #[UseResource] attribute for binding JsonResource to models - Add #[UseResourceCollection] attribute for binding ResourceCollection to models - Add TransformsToResource trait providing toResource() on models - Add TransformsToResourceCollection trait providing toResourceCollection() on collections - Fix AnonymousResourceCollection to properly extend Hypervel's ResourceCollection --- .../Eloquent/Attributes/UseResource.php | 35 ++++ .../Attributes/UseResourceCollection.php | 35 ++++ src/core/src/Database/Eloquent/Collection.php | 3 + .../Concerns/TransformsToResource.php | 99 ++++++++++ src/core/src/Database/Eloquent/Model.php | 4 +- .../Json/AnonymousResourceCollection.php | 22 ++- src/support/src/Collection.php | 2 + .../Traits/TransformsToResourceCollection.php | 126 +++++++++++++ .../Concerns/TransformsToResourceTest.php | 96 ++++++++++ ...msToResourceTestModelInModelsNamespace.php | 12 ++ .../TransformsToResourceCollectionTest.php | 174 ++++++++++++++++++ 11 files changed, 604 insertions(+), 4 deletions(-) create mode 100644 src/core/src/Database/Eloquent/Attributes/UseResource.php create mode 100644 src/core/src/Database/Eloquent/Attributes/UseResourceCollection.php create mode 100644 src/core/src/Database/Eloquent/Concerns/TransformsToResource.php create mode 100644 src/support/src/Traits/TransformsToResourceCollection.php create mode 100644 tests/Core/Database/Eloquent/Concerns/TransformsToResourceTest.php create mode 100644 tests/Core/Database/Eloquent/Models/TransformsToResourceTestModelInModelsNamespace.php create mode 100644 tests/Support/Traits/TransformsToResourceCollectionTest.php diff --git a/src/core/src/Database/Eloquent/Attributes/UseResource.php b/src/core/src/Database/Eloquent/Attributes/UseResource.php new file mode 100644 index 000000000..fc36e97c5 --- /dev/null +++ b/src/core/src/Database/Eloquent/Attributes/UseResource.php @@ -0,0 +1,35 @@ +toResource() will use PostResource + * ``` + */ +#[Attribute(Attribute::TARGET_CLASS)] +class UseResource +{ + /** + * Create a new attribute instance. + * + * @param class-string<\Hypervel\Http\Resources\Json\JsonResource> $class + */ + public function __construct( + public string $class, + ) { + } +} diff --git a/src/core/src/Database/Eloquent/Attributes/UseResourceCollection.php b/src/core/src/Database/Eloquent/Attributes/UseResourceCollection.php new file mode 100644 index 000000000..b6c17adac --- /dev/null +++ b/src/core/src/Database/Eloquent/Attributes/UseResourceCollection.php @@ -0,0 +1,35 @@ +toResourceCollection() will use PostCollection + * ``` + */ +#[Attribute(Attribute::TARGET_CLASS)] +class UseResourceCollection +{ + /** + * Create a new attribute instance. + * + * @param class-string<\Hypervel\Http\Resources\Json\ResourceCollection> $class + */ + public function __construct( + public string $class, + ) { + } +} diff --git a/src/core/src/Database/Eloquent/Collection.php b/src/core/src/Database/Eloquent/Collection.php index d22ca9736..5533fd68e 100644 --- a/src/core/src/Database/Eloquent/Collection.php +++ b/src/core/src/Database/Eloquent/Collection.php @@ -7,6 +7,7 @@ use Hyperf\Collection\Enumerable; use Hyperf\Database\Model\Collection as BaseCollection; use Hypervel\Support\Collection as SupportCollection; +use Hypervel\Support\Traits\TransformsToResourceCollection; /** * @template TKey of array-key @@ -39,6 +40,8 @@ */ class Collection extends BaseCollection { + use TransformsToResourceCollection; + /** * @template TFindDefault * diff --git a/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php b/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php new file mode 100644 index 000000000..619cb2e32 --- /dev/null +++ b/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php @@ -0,0 +1,99 @@ + $resourceClass + */ + public function toResource(?string $resourceClass = null): JsonResource + { + if ($resourceClass === null) { + return $this->guessResource(); + } + + return $resourceClass::make($this); + } + + /** + * Guess the resource class for the model. + */ + protected function guessResource(): JsonResource + { + $resourceClass = $this->resolveResourceFromAttribute(static::class); + + if ($resourceClass !== null && class_exists($resourceClass)) { + return $resourceClass::make($this); + } + + foreach (static::guessResourceName() as $resourceClass) { + if (is_string($resourceClass) && class_exists($resourceClass)) { + return $resourceClass::make($this); + } + } + + throw new LogicException(sprintf('Failed to find resource class for model [%s].', get_class($this))); + } + + /** + * Guess the resource class name for the model. + * + * @return array> + */ + public static function guessResourceName(): array + { + $modelClass = static::class; + + if (! Str::contains($modelClass, '\Models\\')) { + return []; + } + + $relativeNamespace = Str::after($modelClass, '\Models\\'); + + $relativeNamespace = Str::contains($relativeNamespace, '\\') + ? Str::before($relativeNamespace, '\\' . class_basename($modelClass)) + : ''; + + $potentialResource = sprintf( + '%s\Http\Resources\%s%s', + Str::before($modelClass, '\Models'), + strlen($relativeNamespace) > 0 ? $relativeNamespace . '\\' : '', + class_basename($modelClass) + ); + + return [$potentialResource . 'Resource', $potentialResource]; + } + + /** + * Get the resource class from the UseResource attribute. + * + * @param class-string $class + * @return null|class-string<\Hypervel\Http\Resources\Json\JsonResource> + */ + protected function resolveResourceFromAttribute(string $class): ?string + { + if (! class_exists($class)) { + return null; + } + + $attributes = (new ReflectionClass($class))->getAttributes(UseResource::class); + + return $attributes !== [] + ? $attributes[0]->newInstance()->class + : null; + } +} diff --git a/src/core/src/Database/Eloquent/Model.php b/src/core/src/Database/Eloquent/Model.php index 2fb1e5a64..ccaef1df1 100644 --- a/src/core/src/Database/Eloquent/Model.php +++ b/src/core/src/Database/Eloquent/Model.php @@ -13,6 +13,7 @@ use Hypervel\Database\Eloquent\Concerns\HasObservers; use Hypervel\Database\Eloquent\Concerns\HasRelations; use Hypervel\Database\Eloquent\Concerns\HasRelationships; +use Hypervel\Database\Eloquent\Concerns\TransformsToResource; use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Router\Contracts\UrlRoutable; use Psr\EventDispatcher\EventDispatcherInterface; @@ -68,9 +69,10 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann { use HasAttributes; use HasCallbacks; + use HasObservers; use HasRelations; use HasRelationships; - use HasObservers; + use TransformsToResource; protected ?string $connection = null; diff --git a/src/http/src/Resources/Json/AnonymousResourceCollection.php b/src/http/src/Resources/Json/AnonymousResourceCollection.php index 10074470c..dff962552 100644 --- a/src/http/src/Resources/Json/AnonymousResourceCollection.php +++ b/src/http/src/Resources/Json/AnonymousResourceCollection.php @@ -4,8 +4,24 @@ namespace Hypervel\Http\Resources\Json; -use Hyperf\Resource\Json\AnonymousResourceCollection as BaseAnonymousResourceCollection; - -class AnonymousResourceCollection extends BaseAnonymousResourceCollection +/** + * Anonymous resource collection for wrapping arbitrary collections. + * + * This class extends ResourceCollection to ensure proper type hierarchy + * within Hypervel's resource system. + */ +class AnonymousResourceCollection extends ResourceCollection { + /** + * Create a new anonymous resource collection. + * + * @param mixed $resource the resource being collected + * @param string $collects the name of the resource being collected + */ + public function __construct(mixed $resource, string $collects) + { + $this->collects = $collects; + + parent::__construct($resource); + } } diff --git a/src/support/src/Collection.php b/src/support/src/Collection.php index 412f3823e..e23980f94 100644 --- a/src/support/src/Collection.php +++ b/src/support/src/Collection.php @@ -5,6 +5,7 @@ namespace Hypervel\Support; use Hyperf\Collection\Collection as BaseCollection; +use Hypervel\Support\Traits\TransformsToResourceCollection; /** * @template TKey of array-key @@ -14,4 +15,5 @@ */ class Collection extends BaseCollection { + use TransformsToResourceCollection; } diff --git a/src/support/src/Traits/TransformsToResourceCollection.php b/src/support/src/Traits/TransformsToResourceCollection.php new file mode 100644 index 000000000..09388e615 --- /dev/null +++ b/src/support/src/Traits/TransformsToResourceCollection.php @@ -0,0 +1,126 @@ + $resourceClass + * @throws Throwable + */ + public function toResourceCollection(?string $resourceClass = null): ResourceCollection + { + if ($resourceClass === null) { + return $this->guessResourceCollection(); + } + + return $resourceClass::collection($this); + } + + /** + * Guess the resource collection for the items. + * + * @throws Throwable + */ + protected function guessResourceCollection(): ResourceCollection + { + if ($this->isEmpty()) { + return new ResourceCollection($this); + } + + $model = $this->items[0] ?? null; + + throw_unless(is_object($model), LogicException::class, 'Resource collection guesser expects the collection to contain objects.'); + + /** @var class-string $className */ + $className = get_class($model); + + throw_unless( + method_exists($className, 'guessResourceName'), + LogicException::class, + sprintf('Expected class %s to implement guessResourceName method. Make sure the model uses the TransformsToResource trait.', $className) + ); + + $useResourceCollection = $this->resolveResourceCollectionFromAttribute($className); + + if ($useResourceCollection !== null && class_exists($useResourceCollection)) { + return new $useResourceCollection($this); + } + + $useResource = $this->resolveResourceFromAttribute($className); + + if ($useResource !== null && class_exists($useResource)) { + return $useResource::collection($this); + } + + $resourceClasses = $className::guessResourceName(); + + foreach ($resourceClasses as $resourceClass) { + $resourceCollection = $resourceClass . 'Collection'; + + if (is_string($resourceCollection) && class_exists($resourceCollection)) { + return new $resourceCollection($this); + } + } + + foreach ($resourceClasses as $resourceClass) { + if (is_string($resourceClass) && class_exists($resourceClass)) { + return $resourceClass::collection($this); + } + } + + throw new LogicException(sprintf('Failed to find resource class for model [%s].', $className)); + } + + /** + * Get the resource class from the UseResource attribute. + * + * @param class-string $class + * @return null|class-string<\Hypervel\Http\Resources\Json\JsonResource> + */ + protected function resolveResourceFromAttribute(string $class): ?string + { + if (! class_exists($class)) { + return null; + } + + $attributes = (new ReflectionClass($class))->getAttributes(UseResource::class); + + return $attributes !== [] + ? $attributes[0]->newInstance()->class + : null; + } + + /** + * Get the resource collection class from the UseResourceCollection attribute. + * + * @param class-string $class + * @return null|class-string<\Hypervel\Http\Resources\Json\ResourceCollection> + */ + protected function resolveResourceCollectionFromAttribute(string $class): ?string + { + if (! class_exists($class)) { + return null; + } + + $attributes = (new ReflectionClass($class))->getAttributes(UseResourceCollection::class); + + return $attributes !== [] + ? $attributes[0]->newInstance()->class + : null; + } +} diff --git a/tests/Core/Database/Eloquent/Concerns/TransformsToResourceTest.php b/tests/Core/Database/Eloquent/Concerns/TransformsToResourceTest.php new file mode 100644 index 000000000..ee76c2529 --- /dev/null +++ b/tests/Core/Database/Eloquent/Concerns/TransformsToResourceTest.php @@ -0,0 +1,96 @@ +toResource(TransformsToResourceTestResource::class); + + $this->assertInstanceOf(TransformsToResourceTestResource::class, $resource); + $this->assertSame($model, $resource->resource); + } + + public function testToResourceThrowsExceptionWhenResourceCannotBeFound(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Failed to find resource class for model [Hypervel\Tests\Core\Database\Eloquent\Concerns\TransformsToResourceTestModel].'); + + $model = new TransformsToResourceTestModel(); + $model->toResource(); + } + + public function testToResourceUsesUseResourceAttribute(): void + { + $model = new TransformsToResourceTestModelWithAttribute(); + $resource = $model->toResource(); + + $this->assertInstanceOf(TransformsToResourceTestResource::class, $resource); + $this->assertSame($model, $resource->resource); + } + + public function testGuessResourceNameReturnsEmptyArrayForNonModelsNamespace(): void + { + // Model not in a \Models\ namespace + $result = TransformsToResourceTestModel::guessResourceName(); + + $this->assertSame([], $result); + } + + public function testGuessResourceNameReturnsCorrectNamesForModelsNamespace(): void + { + // This model is in a \Models\ namespace + $result = TransformsToResourceTestModelInModelsNamespace::guessResourceName(); + + $this->assertSame([ + 'Hypervel\Tests\Core\Database\Eloquent\Http\Resources\TransformsToResourceTestModelInModelsNamespaceResource', + 'Hypervel\Tests\Core\Database\Eloquent\Http\Resources\TransformsToResourceTestModelInModelsNamespace', + ], $result); + } + + public function testExplicitResourceTakesPrecedenceOverAttribute(): void + { + $model = new TransformsToResourceTestModelWithAttribute(); + $resource = $model->toResource(TransformsToResourceTestAlternativeResource::class); + + // Explicit class should be used, not the attribute + $this->assertInstanceOf(TransformsToResourceTestAlternativeResource::class, $resource); + $this->assertSame($model, $resource->resource); + } +} + +// Test fixtures + +class TransformsToResourceTestModel extends Model +{ + protected ?string $table = 'test_models'; +} + +#[UseResource(TransformsToResourceTestResource::class)] +class TransformsToResourceTestModelWithAttribute extends Model +{ + protected ?string $table = 'test_models'; +} + +class TransformsToResourceTestResource extends JsonResource +{ +} + +class TransformsToResourceTestAlternativeResource extends JsonResource +{ +} diff --git a/tests/Core/Database/Eloquent/Models/TransformsToResourceTestModelInModelsNamespace.php b/tests/Core/Database/Eloquent/Models/TransformsToResourceTestModelInModelsNamespace.php new file mode 100644 index 000000000..c74fd84f9 --- /dev/null +++ b/tests/Core/Database/Eloquent/Models/TransformsToResourceTestModelInModelsNamespace.php @@ -0,0 +1,12 @@ +toResourceCollection(ResourceCollectionTestResource::class); + + $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); + } + + public function testToResourceCollectionReturnsEmptyCollectionForEmptyInput(): void + { + $collection = new EloquentCollection([]); + + $resource = $collection->toResourceCollection(); + + $this->assertInstanceOf(ResourceCollection::class, $resource); + } + + public function testToResourceCollectionThrowsExceptionWhenResourceCannotBeFound(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Failed to find resource class for model [Hypervel\Tests\Support\Traits\ResourceCollectionTestModel].'); + + $model = new ResourceCollectionTestModel(); + $collection = new EloquentCollection([$model]); + + $collection->toResourceCollection(); + } + + public function testToResourceCollectionUsesUseResourceCollectionAttribute(): void + { + $model = new ResourceCollectionTestModelWithCollectionAttribute(); + $collection = new EloquentCollection([$model]); + + $resource = $collection->toResourceCollection(); + + $this->assertInstanceOf(ResourceCollectionTestResourceCollection::class, $resource); + } + + public function testToResourceCollectionUsesUseResourceAttributeWithCollection(): void + { + $model = new ResourceCollectionTestModelWithResourceAttribute(); + $collection = new EloquentCollection([$model]); + + $resource = $collection->toResourceCollection(); + + $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); + $this->assertInstanceOf(ResourceCollectionTestResource::class, $resource[0]); + } + + public function testToResourceCollectionPrefersUseResourceCollectionOverUseResource(): void + { + $model = new ResourceCollectionTestModelWithBothAttributes(); + $collection = new EloquentCollection([$model]); + + $resource = $collection->toResourceCollection(); + + // UseResourceCollection should take precedence + $this->assertInstanceOf(ResourceCollectionTestResourceCollection::class, $resource); + } + + public function testSupportCollectionHasToResourceCollectionMethod(): void + { + $model = new ResourceCollectionTestModelWithResourceAttribute(); + $collection = new Collection([$model]); + + $resource = $collection->toResourceCollection(); + + $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); + } + + public function testToResourceCollectionThrowsForNonObjectItems(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Resource collection guesser expects the collection to contain objects.'); + + $collection = new Collection(['string', 'items']); + + $collection->toResourceCollection(); + } + + public function testToResourceCollectionThrowsForItemsWithoutGuessResourceName(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Expected class stdClass to implement guessResourceName method.'); + + $collection = new Collection([new stdClass()]); + + $collection->toResourceCollection(); + } + + public function testExplicitResourceTakesPrecedenceOverAttribute(): void + { + $model = new ResourceCollectionTestModelWithResourceAttribute(); + $collection = new EloquentCollection([$model]); + + $resource = $collection->toResourceCollection(ResourceCollectionTestAlternativeResource::class); + + // Explicit class should be used, not the attribute + $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); + } +} + +// Test fixtures + +class ResourceCollectionTestModel extends Model +{ + use TransformsToResource; + + protected ?string $table = 'test_models'; +} + +#[UseResourceCollection(ResourceCollectionTestResourceCollection::class)] +class ResourceCollectionTestModelWithCollectionAttribute extends Model +{ + use TransformsToResource; + + protected ?string $table = 'test_models'; +} + +#[UseResource(ResourceCollectionTestResource::class)] +class ResourceCollectionTestModelWithResourceAttribute extends Model +{ + use TransformsToResource; + + protected ?string $table = 'test_models'; +} + +#[UseResource(ResourceCollectionTestResource::class)] +#[UseResourceCollection(ResourceCollectionTestResourceCollection::class)] +class ResourceCollectionTestModelWithBothAttributes extends Model +{ + use TransformsToResource; + + protected ?string $table = 'test_models'; +} + +class ResourceCollectionTestResource extends JsonResource +{ +} + +class ResourceCollectionTestAlternativeResource extends JsonResource +{ +} + +class ResourceCollectionTestResourceCollection extends ResourceCollection +{ +} From d87d8e26f7f8cfea9e9d93a95bd9e8f00f98d851 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 6 Jan 2026 00:00:23 +0800 Subject: [PATCH 2/3] fix: fix phpstan --- .../src/Database/Eloquent/Concerns/TransformsToResource.php | 1 + src/horizon/src/ProcessInspector.php | 1 + src/support/src/Traits/TransformsToResourceCollection.php | 3 +-- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php b/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php index 619cb2e32..d5b06e9a9 100644 --- a/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php +++ b/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php @@ -41,6 +41,7 @@ protected function guessResource(): JsonResource } foreach (static::guessResourceName() as $resourceClass) { + /** @phpstan-ignore-next-line function.alreadyNarrowedType */ if (is_string($resourceClass) && class_exists($resourceClass)) { return $resourceClass::make($this); } diff --git a/src/horizon/src/ProcessInspector.php b/src/horizon/src/ProcessInspector.php index a03965aea..b6205b164 100644 --- a/src/horizon/src/ProcessInspector.php +++ b/src/horizon/src/ProcessInspector.php @@ -49,6 +49,7 @@ public function monitoring(): array ->pluck('pid') ->pipe(function (Collection $processes) { foreach ($processes as $process) { + /** @var string $process */ $processes = $processes->merge($this->exec->run('pgrep -P ' . (string) $process)); } diff --git a/src/support/src/Traits/TransformsToResourceCollection.php b/src/support/src/Traits/TransformsToResourceCollection.php index 09388e615..b957c21f4 100644 --- a/src/support/src/Traits/TransformsToResourceCollection.php +++ b/src/support/src/Traits/TransformsToResourceCollection.php @@ -71,8 +71,7 @@ protected function guessResourceCollection(): ResourceCollection foreach ($resourceClasses as $resourceClass) { $resourceCollection = $resourceClass . 'Collection'; - - if (is_string($resourceCollection) && class_exists($resourceCollection)) { + if (class_exists($resourceCollection)) { return new $resourceCollection($this); } } From 50d4a0b661b49b7ff2e5fdd4de6a6a5db8b49d64 Mon Sep 17 00:00:00 2001 From: Albert Chen Date: Tue, 6 Jan 2026 00:23:40 +0800 Subject: [PATCH 3/3] fix: fix php-cs --- .../src/Database/Eloquent/Concerns/TransformsToResource.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php b/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php index d5b06e9a9..9385c59f3 100644 --- a/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php +++ b/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php @@ -41,7 +41,7 @@ protected function guessResource(): JsonResource } foreach (static::guessResourceName() as $resourceClass) { - /** @phpstan-ignore-next-line function.alreadyNarrowedType */ + /* @phpstan-ignore-next-line function.alreadyNarrowedType */ if (is_string($resourceClass) && class_exists($resourceClass)) { return $resourceClass::make($this); }