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..9385c59f3 --- /dev/null +++ b/src/core/src/Database/Eloquent/Concerns/TransformsToResource.php @@ -0,0 +1,100 @@ + $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) { + /* @phpstan-ignore-next-line function.alreadyNarrowedType */ + 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 2fa4b38d2..db7e0b15a 100644 --- a/src/core/src/Database/Eloquent/Model.php +++ b/src/core/src/Database/Eloquent/Model.php @@ -15,6 +15,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; @@ -75,6 +76,7 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann use HasObservers; use HasRelations; use HasRelationships; + use TransformsToResource; protected ?string $connection = null; 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/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..b957c21f4 --- /dev/null +++ b/src/support/src/Traits/TransformsToResourceCollection.php @@ -0,0 +1,125 @@ + $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 (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 +{ +}