From 79ac619bcf81c40471762088d8bbfde5fa4299ec Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 27 Sep 2026 22:29:35 +0600 Subject: [PATCH] Make PresenterBundle directly returnable as a JSON API resource --- .../Support/Presenter/Presenter.php | 39 +++- .../Support/Presenter/PresenterBundle.php | 215 ++++++++++++------ tests/API/Presenter/PresenterBundleTest.php | 81 ++++++- tests/API/Presenter/PresenterTest.php | 162 +++++++------ 4 files changed, 347 insertions(+), 150 deletions(-) diff --git a/src/Phaseolies/Support/Presenter/Presenter.php b/src/Phaseolies/Support/Presenter/Presenter.php index 4ddc68f4..8f26de8b 100644 --- a/src/Phaseolies/Support/Presenter/Presenter.php +++ b/src/Phaseolies/Support/Presenter/Presenter.php @@ -2,8 +2,9 @@ namespace Phaseolies\Support\Presenter; -use Phaseolies\Database\Entity\Model; use Phaseolies\Database\Entity\Builder; +use Phaseolies\Database\Entity\Model; +use Phaseolies\Support\Collection; use JsonSerializable; abstract class Presenter implements JsonSerializable @@ -55,7 +56,15 @@ public function __construct(mixed $presenter) */ public function __get(string $key) { - return $this->presenter->{$key}; + if (is_array($this->presenter)) { + return $this->presenter[$key] ?? null; + } + + if (is_object($this->presenter)) { + return $this->presenter->{$key}; + } + + return null; } /** @@ -130,26 +139,32 @@ public function jsonSerialize(): array * @param mixed $value * @return mixed */ - protected function processValue($value) + protected function processValue(mixed $value): mixed { - if ($value instanceof \Phaseolies\Support\Collection) { - return $value->toArray(); + if ($value instanceof Collection) { + return $this->processValue($value->all()); } - if ($value instanceof \Phaseolies\Database\Entity\Builder) { - return $value->get()->toArray(); + if ($value instanceof Builder) { + return $this->processValue($value->get()); } - if ($value instanceof \Phaseolies\Database\Entity\Model) { - return $value->toArray(); + if ($value instanceof Model) { + return $this->processValue($value->toArray()); } - if ($value instanceof self || $value instanceof \JsonSerializable) { - return $value->jsonSerialize(); + if ($value instanceof self || $value instanceof JsonSerializable) { + return $this->processValue($value->jsonSerialize()); } if ($value instanceof \stdClass || $value instanceof \ArrayObject) { - return (array) $value; + return $this->processValue((array) $value); + } + + if (is_array($value)) { + foreach ($value as $key => $item) { + $value[$key] = $this->processValue($item); + } } return $value; diff --git a/src/Phaseolies/Support/Presenter/PresenterBundle.php b/src/Phaseolies/Support/Presenter/PresenterBundle.php index 48d273e2..0987fb23 100644 --- a/src/Phaseolies/Support/Presenter/PresenterBundle.php +++ b/src/Phaseolies/Support/Presenter/PresenterBundle.php @@ -65,6 +65,8 @@ class PresenterBundle implements JsonSerializable */ public function __construct($collection, string $presenter) { + $this->assertPresenter($presenter); + if (is_array($collection)) { if (isset($collection['data']) && $this->isPaginatedArray($collection)) { $this->paginationMeta = $this->extractPaginationMeta($collection); @@ -81,6 +83,26 @@ public function __construct($collection, string $presenter) $this->presenter = $presenter; } + /** + * Ensure the configured class is a concrete Presenter implementation. + * + * @param string $presenter + * @return void + * @throws \InvalidArgumentException + */ + protected function assertPresenter(string $presenter): void + { + if (!class_exists($presenter) || !is_a($presenter, Presenter::class, true)) { + throw new \InvalidArgumentException( + "Presenter [{$presenter}] must be a concrete " . Presenter::class . ' class.' + ); + } + + if (!(new \ReflectionClass($presenter))->isInstantiable()) { + throw new \InvalidArgumentException("Presenter [{$presenter}] must be instantiable."); + } + } + /** * Check if the given array matches a paginated structure * @@ -90,7 +112,8 @@ public function __construct($collection, string $presenter) protected function isPaginatedArray(array $data): bool { return isset($data['data']) && - (isset($data['current_page']) || isset($data['meta'])); + is_array($data['data']) && + (isset($data['current_page']) || isset($data['last_page']) || is_array($data['meta'] ?? null)); } /** @@ -101,32 +124,84 @@ protected function isPaginatedArray(array $data): bool */ protected function extractPaginationMeta(array $paginatedData): array { + $nestedMeta = is_array($paginatedData['meta'] ?? null) + ? $paginatedData['meta'] + : []; + $source = array_merge($nestedMeta, $paginatedData); + + $currentPage = max((int) ($source['current_page'] ?? 1), 1); + $perPage = max((int) ($source['per_page'] ?? 15), 1); + $total = max((int) ($source['total'] ?? count($paginatedData['data'])), 0); + $lastPage = max((int) ($source['last_page'] ?? ceil($total / $perPage)), 1); + $path = (string) ($source['path'] ?? $this->requestUrl()); + $meta = [ - 'current_page' => $paginatedData['current_page'] ?? 1, - 'per_page' => $paginatedData['per_page'] ?? 15, - 'total' => $paginatedData['total'] ?? count($paginatedData['data']), - 'last_page' => $paginatedData['last_page'] ?? 1, - 'from' => $paginatedData['from'] ?? 1, - 'to' => $paginatedData['to'] ?? count($paginatedData['data']), - 'path' => $paginatedData['path'] ?? request()->url(), + 'current_page' => $currentPage, + 'per_page' => $perPage, + 'total' => $total, + 'last_page' => $lastPage, + 'from' => $source['from'] ?? 1, + 'to' => $source['to'] ?? count($paginatedData['data']), + 'path' => $path, ]; - $currentPage = $meta['current_page']; - $lastPage = $meta['last_page']; - $path = $meta['path']; + $meta['first_page_url'] = $this->paginationUrl($source, 'first_page_url', $path, 1); + $meta['last_page_url'] = $this->paginationUrl($source, 'last_page_url', $path, $lastPage); + $meta['next_page_url'] = array_key_exists('next_page_url', $source) + ? $source['next_page_url'] + : ($currentPage < $lastPage ? $this->buildPageUrl($path, $currentPage + 1) : null); + $meta['previous_page_url'] = array_key_exists('previous_page_url', $source) + ? $source['previous_page_url'] + : ($source['prev_page_url'] ?? ($currentPage > 1 + ? $this->buildPageUrl($path, $currentPage - 1) + : null)); - $meta['first_page_url'] = $paginatedData['first_page_url'] ?? $this->buildPageUrl($path, 1); - $meta['last_page_url'] = $paginatedData['last_page_url'] ?? $this->buildPageUrl($path, $lastPage); + return $meta; + } - $meta['next_page_url'] = $currentPage < $lastPage - ? $this->buildPageUrl($path, $currentPage + 1) - : null; + /** + * Get the pagination URL + * + * @param array $source + * @param string $key + * @param string $path + * @param int $page + * @return string|null + */ + protected function paginationUrl(array $source, string $key, string $path, int $page): ?string + { + return array_key_exists($key, $source) + ? $source[$key] + : $this->buildPageUrl($path, $page); + } - $meta['prev_page_url'] = $currentPage > 1 - ? $this->buildPageUrl($path, $currentPage - 1) - : null; + /** + * Get the request URL + * + * @return string + */ + protected function requestUrl(): string + { + try { + return (string) request()->url(); + } catch (\Throwable) { + return ''; + } + } - return $meta; + /** + * Get the request query string + * + * @return array + */ + protected function requestQuery(): array + { + try { + $query = request()->query(); + return is_array($query) ? $query : []; + } catch (\Throwable) { + return []; + } } /** @@ -138,11 +213,18 @@ protected function extractPaginationMeta(array $paginatedData): array */ protected function buildPageUrl(string $path, int $page): string { - $query = request()->query(); - + $query = $this->requestQuery(); $query['page'] = $page; - return $path . '?' . http_build_query($query); + if ($path === '') { + return '?' . http_build_query($query); + } + + $separator = str_contains($path, '?') + ? (str_ends_with($path, '?') || str_ends_with($path, '&') ? '' : '&') + : '?'; + + return $path . $separator . http_build_query($query); } /** @@ -212,11 +294,18 @@ public function lazy(bool $lazy = true): self */ public function jsonSerialize(): array { - if ($this->lazy) { - return $this->serializeLazy(); + $data = $this->lazy + ? $this->serializeLazy() + : $this->serializeEager(); + + if (!empty($this->paginationMeta)) { + return [ + 'data' => $data, + 'meta' => $this->paginationMeta, + ]; } - return $this->serializeEager(); + return $data; } /** @@ -226,8 +315,33 @@ public function jsonSerialize(): array */ protected function serializeEager(): array { - $data = []; + return iterator_to_array($this->resources(), true); + } + /** + * Serialize resources using a generator. JSON serialization still + * materializes an array for JSON compatibility. + */ + protected function serializeLazy(): array + { + return iterator_to_array($this->resources(), true); + } + + /** + * Yield transformed resources without materializing the complete result. + * + * @return \Generator + */ + public function toIterable(): \Generator + { + yield from $this->resources(); + } + + /** + * @return \Generator + */ + protected function resources(): \Generator + { foreach ($this->collection as $key => $item) { $resource = new $this->presenter($item); @@ -240,43 +354,11 @@ protected function serializeEager(): array } if ($this->preserveKeys) { - $data[$key] = $resource->jsonSerialize(); + yield $key => $resource->jsonSerialize(); } else { - $data[] = $resource->jsonSerialize(); + yield $resource->jsonSerialize(); } } - - return $data; - } - - /** - * Serialize resources using a generator - * - * @return array - */ - protected function serializeLazy(): array - { - $generator = function () { - foreach ($this->collection as $key => $item) { - $resource = new $this->presenter($item); - - if (!empty($this->only)) { - $resource->only($this->only); - } - - if (!empty($this->except)) { - $resource->except($this->except); - } - - if ($this->preserveKeys) { - yield $key => $resource->jsonSerialize(); - } else { - yield $resource->jsonSerialize(); - } - } - }; - - return iterator_to_array($generator()); } /** @@ -286,16 +368,7 @@ protected function serializeLazy(): array */ public function paginate(): array { - $data = $this->jsonSerialize(); - - if (!empty($this->paginationMeta)) { - return [ - 'data' => $data, - 'meta' => $this->paginationMeta - ]; - } - - return $data; + return $this->jsonSerialize(); } /** diff --git a/tests/API/Presenter/PresenterBundleTest.php b/tests/API/Presenter/PresenterBundleTest.php index 5c2caf41..ca6c13a4 100644 --- a/tests/API/Presenter/PresenterBundleTest.php +++ b/tests/API/Presenter/PresenterBundleTest.php @@ -178,7 +178,86 @@ public function testExtractPaginationMeta() $this->assertEquals(10, $meta['total']); $this->assertEquals(5, $meta['last_page']); $this->assertNotNull($meta['next_page_url']); - $this->assertNotNull($meta['prev_page_url']); + $this->assertNotNull($meta['previous_page_url']); + } + + public function testPaginatedBundleIsReadyToReturnDirectly(): void + { + $data = [ + 'data' => [['id' => 1]], + 'current_page' => 1, + 'per_page' => 1, + 'total' => 2, + 'last_page' => 2, + 'path' => '/users', + ]; + + $result = $this->createTestPresenterBundle($data)->only('id')->jsonSerialize(); + + $this->assertSame([['id' => 1]], $result['data']); + $this->assertSame(1, $result['meta']['current_page']); + } + + public function testNestedPaginationMetadataAndProvidedUrlsArePreserved(): void + { + $data = [ + 'data' => [['id' => 1]], + 'meta' => [ + 'current_page' => 2, + 'per_page' => 1, + 'total' => 3, + 'last_page' => 3, + 'path' => '/users?filter=active', + 'first_page_url' => '/users?filter=active&page=1', + 'last_page_url' => '/users?filter=active&page=3', + 'next_page_url' => '/users?filter=active&page=3', + 'previous_page_url' => '/users?filter=active&page=1', + ], + ]; + + $meta = $this->createTestPresenterBundle($data)->paginate()['meta']; + + $this->assertSame(2, $meta['current_page']); + $this->assertSame('/users?filter=active&page=1', $meta['first_page_url']); + $this->assertSame('/users?filter=active&page=3', $meta['next_page_url']); + $this->assertSame('/users?filter=active&page=1', $meta['previous_page_url']); + } + + public function testPaginationCanBeSerializedWithoutAnHttpRequest(): void + { + Container::forgetInstance(); + + $bundle = new PresenterBundle([ + 'data' => [['id' => 1]], + 'current_page' => 1, + 'per_page' => 1, + 'total' => 2, + 'last_page' => 2, + 'path' => '/users', + ], get_class($this->createTestPresenterClass())); + + $meta = $bundle->paginate()['meta']; + + $this->assertSame('/users?page=2', $meta['next_page_url']); + $this->assertNull($meta['previous_page_url']); + } + + public function testToIterableYieldsTransformedResources(): void + { + $bundle = $this->createTestPresenterBundle([['id' => 1], ['id' => 2]]) + ->only('id'); + + $iterable = $bundle->toIterable(); + + $this->assertInstanceOf(\Generator::class, $iterable); + $this->assertSame([['id' => 1], ['id' => 2]], iterator_to_array($iterable)); + } + + public function testInvalidPresenterClassThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + + new PresenterBundle([], \stdClass::class); } public function testInvalidCollectionTypeThrowsException() diff --git a/tests/API/Presenter/PresenterTest.php b/tests/API/Presenter/PresenterTest.php index b5abc097..77338e14 100644 --- a/tests/API/Presenter/PresenterTest.php +++ b/tests/API/Presenter/PresenterTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\API\Presenter; use Phaseolies\Support\Presenter\Presenter; +use Phaseolies\Support\Collection; use PHPUnit\Framework\TestCase; class PresenterTest extends TestCase @@ -92,92 +93,101 @@ public function testJsonSerializeWithOnlyAndExcept() $this->assertArrayNotHasKey('is_active', $result); } - public function testValueMethod() + public function testValueMethod(): void { - $data = [ - 'id' => 1, - 'name' => 'Test', - 'email' => 'test@example.com', - 'is_active' => true - ]; - $presenter = new TestablePresenter($data); + $presenter = new TestablePresenter([]); - // Test with non-closure value - // $this->assertEquals('test', $presenter->value('test')); + $this->assertSame('test', $presenter->exposeValue('test')); + $this->assertSame('closure result', $presenter->exposeValue(fn() => 'closure result')); + } - // // Test with closure - // $this->assertEquals('closure result', $presenter->value(function () { - // return 'closure result'; - // })); + public function testWhenMethod(): void + { + $presenter = new TestablePresenter([]); + + $this->assertSame('yes', $presenter->exposeWhen(true, 'yes')); + $this->assertSame('yes', $presenter->exposeWhen(true, fn() => 'yes')); + $this->assertNull($presenter->exposeWhen(false, 'yes')); + $this->assertSame('no', $presenter->exposeWhen(false, 'yes', 'no')); + $this->assertSame('no', $presenter->exposeWhen(false, 'yes', fn() => 'no')); } - public function testWhenMethod() + public function testMergeWhenMethod(): void { - $data = [ - 'id' => 1, - 'name' => 'Test', - 'email' => 'test@example.com', - 'is_active' => true - ]; - $presenter = new TestablePresenter($data); + $presenter = new TestablePresenter([]); - // Test when condition is true - // $this->assertEquals('yes', $presenter->when(true, 'yes')); - // $this->assertEquals('yes', $presenter->when(true, function () { - // return 'yes'; - // })); + $this->assertSame(['key' => 'value'], $presenter->exposeMergeWhen(true, ['key' => 'value'])); + $this->assertSame([], $presenter->exposeMergeWhen(false, ['key' => 'value'])); + } - // Test when condition is false - // $this->assertNull($presenter->when(false, 'yes')); - // $this->assertEquals('no', $presenter->when(false, 'yes', 'no')); + public function testUnlessMethod(): void + { + $presenter = new TestablePresenter([]); - // // Test with closure default - // $this->assertEquals('no', $presenter->when(false, 'yes', function () { - // return 'no'; - // })); + $this->assertSame('yes', $presenter->exposeUnless(false, 'yes')); + $this->assertSame('yes', $presenter->exposeUnless(false, fn() => 'yes')); + $this->assertNull($presenter->exposeUnless(true, 'yes')); + $this->assertSame('no', $presenter->exposeUnless(true, 'yes', 'no')); } - public function testMergeWhenMethod() + public function testArrayBackedPresenterSupportsMagicPropertyAccess(): void { - $data = [ - 'id' => 1, - 'name' => 'Test', - 'email' => 'test@example.com', - 'is_active' => true - ]; - $presenter = new TestablePresenter($data); + $presenter = new TestablePresenter(['name' => 'Test']); - // Test when condition is true - // $this->assertEquals(['key' => 'value'], $presenter->mergeWhen(true, ['key' => 'value'])); + $this->assertSame('Test', $presenter->name); + $this->assertNull($presenter->missing); + } - // // Test when condition is false - // $this->assertEquals([], $presenter->mergeWhen(false, ['key' => 'value'])); + public function testConditionalHelpersEvaluateValuesAndDefaults(): void + { + $presenter = new TestablePresenter([]); + + $this->assertSame('yes', $presenter->exposeWhen(true, fn() => 'yes')); + $this->assertSame('no', $presenter->exposeWhen(false, 'yes', fn() => 'no')); + $this->assertNull($presenter->exposeWhen(false, 'yes')); + $this->assertSame('yes', $presenter->exposeUnless(false, fn() => 'yes')); + $this->assertSame(['key' => 'value'], $presenter->exposeMergeWhen(true, ['key' => 'value'])); + $this->assertSame([], $presenter->exposeMergeWhen(false, ['key' => 'value'])); } - public function testUnlessMethod() + public function testNestedJsonValuesAreNormalizedRecursively(): void { - $data = [ - 'id' => 1, - 'name' => 'Test', - 'email' => 'test@example.com', - 'is_active' => true - ]; - $presenter = new TestablePresenter($data); + $jsonValue = new class implements \JsonSerializable { + public function jsonSerialize(): array + { + return ['value' => 'nested']; + } + }; + + $presenter = new class([$jsonValue]) extends Presenter { + protected function toArray(): array + { + return [ + 'object' => $this->presenter[0], + 'array' => [$this->presenter[0]], + 'collection' => new Collection(static::class, [$this->presenter[0]]), + ]; + } + }; - // Test when condition is false (unless true) - // $this->assertEquals('yes', $presenter->unless(false, 'yes')); - // $this->assertEquals('yes', $presenter->unless(false, function () { - // return 'yes'; - // })); + $this->assertSame([ + 'object' => ['value' => 'nested'], + 'array' => [['value' => 'nested']], + 'collection' => [['value' => 'nested']], + ], $presenter->jsonSerialize()); + } - // Test when condition is true (unless false) - // $this->assertNull($presenter->unless(true, 'yes')); - // $this->assertEquals('no', $presenter->unless(true, 'yes', 'no')); + public function testConditionalHelpersDoNotEvaluateUnusedClosures(): void + { + $presenter = new TestablePresenter([]); + $called = false; + $closure = function () use (&$called): string { + $called = true; + return 'unused'; + }; - // // Test with closure default - // $this->assertEquals('no', $presenter->unless(true, 'yes', function () { - // return 'no'; - // })); + $this->assertNull($presenter->exposeWhen(false, $closure)); + $this->assertFalse($called); } public function testComplexScenario() @@ -217,6 +227,26 @@ protected function toArray(): array class TestablePresenter extends Presenter { + public function exposeValue($value) + { + return $this->value($value); + } + + public function exposeWhen(bool $condition, $value, $default = null) + { + return $this->when($condition, $value, $default); + } + + public function exposeUnless(bool $condition, $value, $default = null) + { + return $this->unless($condition, $value, $default); + } + + public function exposeMergeWhen(bool $condition, array $value): array + { + return $this->mergeWhen($condition, $value); + } + protected function toArray(): array { return [