Skip to content
Merged
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
21 changes: 21 additions & 0 deletions docs/1-essentials/01-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/database/src/IsDatabaseModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Tempest\Router\Exceptions;

use Exception;

final class RouteBindingDidNotSupportRelations extends Exception implements RouterException
{
public function __construct(string $className)
{
parent::__construct(sprintf(
'#[WithRelations] was added to %s, but %s::resolve() does not accept a $relations parameter. Use IsDatabaseModel or update the resolver.',
$className,
$className,
));
}
}
18 changes: 17 additions & 1 deletion packages/router/src/RouteBindingInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Tempest\Container\Container;
use Tempest\Container\DynamicInitializer;
use Tempest\Reflection\ClassReflector;
use Tempest\Router\Exceptions\RouteBindingDidNotSupportRelations;
use Tempest\Router\Exceptions\RouteBindingFailed;
use UnitEnum;

Expand Down Expand Up @@ -37,7 +38,22 @@ public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Con
throw new RouteBindingFailed();
}

$object = $class->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();
Expand Down
19 changes: 19 additions & 0 deletions packages/router/src/WithRelations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Tempest\Router;

use Attribute;

#[Attribute(Attribute::TARGET_PARAMETER)]
final readonly class WithRelations
{
/** @var array<string> */
public array $relations;

public function __construct(string ...$relations)
{
$this->relations = $relations;
}
}
7 changes: 7 additions & 0 deletions tests/Fixtures/Modules/Books/BookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
{
Expand Down
76 changes: 76 additions & 0 deletions tests/Integration/Route/RouterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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;
Expand Down
Loading