diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index 37e28d35b..e6857240c 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -163,6 +163,27 @@ final class AircraftController } ``` +Database models may preload relations as part of the route binding query using the {b`#[Tempest\Router\WithRelations]`} parameter attribute: + +```php app/AircraftController.php +use Tempest\Router\Get; +use Tempest\Router\WithRelations; +use Tempest\Http\Response; +use Tempest\Http\Responses\Ok; +use App\Aircraft; + +final class AircraftController +{ + #[Get('/aircraft/{aircraft}')] + public function show( + #[WithRelations('manufacturer', 'airline')] + Aircraft $aircraft, + ): Response { + return new Ok($aircraft->manufacturer->name); + } +} +``` + Route binding can be enabled for any class that implements the {b`Tempest\Router\Bindable`} interface, which requires a static `resolve()` method responsible for returning the correct instance. ```php diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index f94a1dd77..93a744230 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -137,10 +137,10 @@ public static function findById(string|int|PrimaryKey $id): ?self /** * Finds a model instance by its ID. Use through {@see \Tempest\Router\Bindable}. */ - public static function resolve(string $input): ?static + public static function resolve(string $input, array $relations = []): ?static { // @phpstan-ignore-next-line - return self::queryBuilder()->resolve($input); + return self::queryBuilder()->get($input, $relations); } /** diff --git a/packages/router/src/Exceptions/RouteBindingDidNotSupportRelations.php b/packages/router/src/Exceptions/RouteBindingDidNotSupportRelations.php new file mode 100644 index 000000000..17cd30126 --- /dev/null +++ b/packages/router/src/Exceptions/RouteBindingDidNotSupportRelations.php @@ -0,0 +1,19 @@ +callStatic('resolve', $matchedRoute->params[$parameter->getName()]); + $withRelations = $parameter->getAttribute(WithRelations::class); + + if ($withRelations !== null) { + $resolve = $class->getMethod('resolve'); + + if ($resolve->getParameter('relations') === null && ! $resolve->getReflection()->isVariadic()) { + throw new RouteBindingDidNotSupportRelations($class->getName()); + } + } + + $input = $matchedRoute->params[$parameter->getName()]; + + $object = match ($withRelations) { + null => $class->callStatic('resolve', $input), + default => $class->callStatic('resolve', $input, relations: $withRelations->relations), + }; if ($object === null) { throw new RouteBindingFailed(); diff --git a/packages/router/src/WithRelations.php b/packages/router/src/WithRelations.php new file mode 100644 index 000000000..e8719a090 --- /dev/null +++ b/packages/router/src/WithRelations.php @@ -0,0 +1,19 @@ + */ + public array $relations; + + public function __construct(string ...$relations) + { + $this->relations = $relations; + } +} diff --git a/tests/Fixtures/Modules/Books/BookController.php b/tests/Fixtures/Modules/Books/BookController.php index 039f1bb39..560a1d29f 100644 --- a/tests/Fixtures/Modules/Books/BookController.php +++ b/tests/Fixtures/Modules/Books/BookController.php @@ -10,6 +10,7 @@ use Tempest\Http\Responses\Redirect; use Tempest\Router\Get; use Tempest\Router\Post; +use Tempest\Router\WithRelations; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Fixtures\Modules\Books\Requests\CreateBookRequest; @@ -24,6 +25,12 @@ public function show(Book $book): Response return new Ok($book->title); } + #[Get('/books-with-relations/{book}')] + public function showWithRelations(#[WithRelations('author', 'chapters')] Book $book): Response + { + return new Ok(sprintf('%s:%d', $book->author->name, count($book->chapters))); + } + #[Post('/books')] public function store(CreateBookRequest $request): Response { diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index 89ab6e63f..1b06897d6 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -16,18 +16,30 @@ use Tempest\Http\Session\SessionId; use Tempest\Http\Session\SessionManager; use Tempest\Http\Status; +use Tempest\Reflection\ClassReflector; +use Tempest\Reflection\MethodReflector; +use Tempest\Router\Bindable; +use Tempest\Router\Exceptions\RouteBindingDidNotSupportRelations; use Tempest\Router\GenericRouter; +use Tempest\Router\Get; +use Tempest\Router\MatchedRoute; +use Tempest\Router\RouteBindingInitializer; use Tempest\Router\RouteConfig; use Tempest\Router\Router; +use Tempest\Router\Routing\Construction\DiscoveredRoute; use Tempest\Router\SecFetchMode; use Tempest\Router\SecFetchSite; +use Tempest\Router\WithRelations; use Tests\Tempest\Fixtures\Controllers\TestGlobalMiddleware; use Tests\Tempest\Fixtures\Controllers\TestMiddleware; +use Tests\Tempest\Fixtures\Events\QueryLogger; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; +use Tests\Tempest\Fixtures\Modules\Books\Models\Chapter; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\Route\Fixtures\HeadController; use Tests\Tempest\Integration\Route\Fixtures\Http500Controller; @@ -114,6 +126,54 @@ public function route_binding(): void $this->assertSame('Test', $response->body); } + #[Test] + public function route_binding_with_relations(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + ); + + $book = Book::create( + title: 'Test', + author: new Author(name: 'Brent'), + ); + + Chapter::create(title: 'Chapter', book: $book); + + QueryLogger::reset(); + + $this->http + ->get('/books-with-relations/1') + ->assertOk() + ->assertSee('Brent:1'); + + $this->assertCount(1, QueryLogger::$queries); + } + + #[Test] + public function route_binding_with_relations_rejects_an_unsupported_resolver(): void + { + $handler = MethodReflector::fromParts(UnsupportedRelationsController::class, 'handle'); + $route = DiscoveredRoute::fromRoute(new Get('/unsupported/{binding}'), [], $handler); + + $this->container->singleton( + MatchedRoute::class, + new MatchedRoute($route, ['binding' => 'test']), + ); + + $this->expectException(RouteBindingDidNotSupportRelations::class); + + new RouteBindingInitializer()->initialize( + new ClassReflector(UnsupportedRelationsBindable::class), + tag: null, + container: $this->container, + ); + } + #[Test] public function route_binding_with_nonexistent_model_returns_404(): void { @@ -374,6 +434,22 @@ public function multiple_optional_parameters(): void } } +final readonly class UnsupportedRelationsController +{ + public function handle(#[WithRelations('author')] UnsupportedRelationsBindable $binding): Ok + { + return new Ok(); + } +} + +final readonly class UnsupportedRelationsBindable implements Bindable +{ + public static function resolve(string $input): static + { + return new self(); + } +} + final class RouteTestingSessionManager implements SessionManager { public int $createdSessions = 0;