feat: eager loading

This commit is contained in:
Sami Mazouz 2024-03-08 14:39:25 +01:00
parent 7756e330b3
commit d2bbd83492
No known key found for this signature in database
9 changed files with 32 additions and 219 deletions

View File

@ -72,18 +72,24 @@ return [
(new Extend\ApiResource(Resource\DiscussionResource::class)) (new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index { ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index {
return $endpoint->eagerLoad([ return $endpoint->eagerLoadWhenIncluded([
'firstPost' => [
'firstPost.mentionsUsers', 'firstPost.mentionsPosts', 'firstPost.mentionsUsers', 'firstPost.mentionsPosts',
'firstPost.mentionsPosts.user', 'firstPost.mentionsPosts.discussion', 'firstPost.mentionsGroups', 'firstPost.mentionsPosts.user', 'firstPost.mentionsPosts.discussion', 'firstPost.mentionsGroups',
],
'lastPost' => [
'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsUsers', 'lastPost.mentionsPosts',
'lastPost.mentionsPosts.user', 'lastPost.mentionsPosts.discussion', 'lastPost.mentionsGroups', 'lastPost.mentionsPosts.user', 'lastPost.mentionsPosts.discussion', 'lastPost.mentionsGroups',
],
]); ]);
}) })
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show { ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show {
return $endpoint->addDefaultInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion']) return $endpoint->addDefaultInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion'])
->eagerLoad([ ->eagerLoadWhenIncluded([
'posts' => [
'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user', 'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user',
'posts.mentionsPosts.discussion', 'posts.mentionsGroups' 'posts.mentionsPosts.discussion', 'posts.mentionsGroups'
],
]); ]);
}), }),
@ -124,10 +130,10 @@ return [
(new Extend\ApiResource(Resource\DiscussionResource::class)) (new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show { ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show {
return $endpoint->eagerLoad(['posts.mentionsTags']); return $endpoint->eagerLoadWhenIncluded(['posts' => ['posts.mentionsTags']]);
}) })
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index { ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index {
return $endpoint->eagerLoad(['firstPost.mentionsTags', 'lastPost.mentionsTags']); return $endpoint->eagerLoadWhenIncluded(['firstPost' => ['firstPost.mentionsTags'], 'lastPost' => ['lastPost.mentionsTags']]);
}), }),
(new Extend\ApiResource(Resource\PostResource::class)) (new Extend\ApiResource(Resource\PostResource::class))

View File

@ -36,10 +36,6 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
$eagerLoadTagState = function ($query, ServerRequestInterface $request, array $relations) {
$query->withStateFor(RequestUtil::getActor($request));
};
return [ return [
(new Extend\Frontend('forum')) (new Extend\Frontend('forum'))
->js(__DIR__.'/js/dist/forum.js') ->js(__DIR__.'/js/dist/forum.js')
@ -95,29 +91,29 @@ return [
return $endpoint->addDefaultInclude(['tags', 'tags.parent']); return $endpoint->addDefaultInclude(['tags', 'tags.parent']);
}), }),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->fields(Api\DiscussionResourceFields::class),
(new Extend\ApiResource(Resource\PostResource::class)) (new Extend\ApiResource(Resource\PostResource::class))
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) { ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) {
return $endpoint->eagerLoad('discussion.tags'); return $endpoint->eagerLoadWhenIncluded(['discussion' => ['discussion.tags']]);
}), }),
(new Extend\Conditional()) (new Extend\Conditional())
->whenExtensionEnabled('flarum-flags', fn () => [ ->whenExtensionEnabled('flarum-flags', fn () => [
(new Extend\ApiResource(FlagResource::class)) (new Extend\ApiResource(FlagResource::class))
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) { ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) {
return $endpoint->eagerLoad(['post.discussion.tags']); return $endpoint->eagerLoadWhenIncluded(['post.discussion' => ['post.discussion.tags']]);
}), }),
]), ]),
(new Extend\ApiResource(Resource\DiscussionResource::class)) (new Extend\ApiResource(Resource\DiscussionResource::class))
->fields(Api\DiscussionResourceFields::class)
->endpoint( ->endpoint(
[Endpoint\Index::class, Endpoint\Show::class, Endpoint\Create::class], [Endpoint\Index::class, Endpoint\Show::class, Endpoint\Create::class],
function (Endpoint\Index|Endpoint\Show|Endpoint\Create $endpoint) use ($eagerLoadTagState) { function (Endpoint\Index|Endpoint\Show|Endpoint\Create $endpoint) {
return $endpoint return $endpoint
->addDefaultInclude(['tags', 'tags.parent']) ->addDefaultInclude(['tags', 'tags.parent'])
->eagerLoadWhere('tags', $eagerLoadTagState); ->eagerLoadWhere('tags', function ($query, Context $context) {
$query->withStateFor($context->getActor());
});
} }
), ),
@ -181,18 +177,12 @@ return [
]) ])
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) { ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) {
return $endpoint return $endpoint
->addDefaultInclude(['eventPostMentionsTags']) ->addDefaultInclude(['eventPostMentionsTags']);
->eagerLoadWhere('eventPostMentionsTags', function (Relation|Builder $query, ServerRequestInterface $request) {
$query->whereVisibleTo(RequestUtil::getActor($request));
});
}), }),
(new Extend\ApiResource(Resource\DiscussionResource::class)) (new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) { ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) {
return $endpoint return $endpoint
->addDefaultInclude(['posts.eventPostMentionsTags']) ->addDefaultInclude(['posts.eventPostMentionsTags']);
->eagerLoadWhere('posts.eventPostMentionsTags', function (Relation|Builder $query, ServerRequestInterface $request) {
$query->whereVisibleTo(RequestUtil::getActor($request));
});
}), }),
]; ];

View File

@ -1,155 +0,0 @@
<?php
namespace Flarum\Api\Endpoint\Concerns;
use Flarum\Api\Context;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Str;
use Tobyz\JsonApiServer\Laravel\EloquentResource;
/**
* This is directed at eager loading relationships apart from the request includes.
*/
trait HasEagerLoading
{
/**
* @var string[]
*/
protected array $loadRelations = [];
/**
* @var array<string, callable>
*/
protected array $loadRelationCallables = [];
/**
* Eager loads relationships needed for serializer logic.
*
* First level relationships will be loaded regardless of whether they are included in the response.
* Sub-level relationships will only be loaded if the upper level was included or manually loaded.
*
* @example If a relationship such as: 'relation.subRelation' is specified,
* it will only be loaded if 'relation' is or has been loaded.
* To force load the relationship, both levels have to be specified,
* example: ['relation', 'relation.subRelation'].
*
* @param string|string[] $relations
*/
public function eagerLoad(array|string $relations): self
{
$this->loadRelations = array_merge($this->loadRelations, array_map('strval', (array) $relations));
return $this;
}
/**
* Allows loading a relationship with additional query modification.
*
* @param string $relation: Relationship name, see load method description.
* @template R of Relation
* @param (callable(Builder|R, \Psr\Http\Message\ServerRequestInterface|null, array): void) $callback
*
* The callback to modify the query, should accept:
* - \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $query: A query object.
* - \Psr\Http\Message\ServerRequestInterface|null $request: An instance of the request.
* - array $relations: An array of relations that are to be loaded.
*/
public function eagerLoadWhere(string $relation, callable $callback): self
{
$this->loadRelationCallables = array_merge($this->loadRelationCallables, [$relation => $callback]);
return $this;
}
/**
* Eager loads the required relationships.
*/
protected function loadRelations(Collection $models, Context $context, array $included = []): void
{
if (! $context->collection instanceof EloquentResource) {
return;
}
$request = $context->request;
$included = $this->stringInclude($included);
$models = $models->filter(fn ($model) => $model instanceof Model);
$addedRelations = $this->loadRelations;
$addedRelationCallables = $this->loadRelationCallables;
$relations = $included;
foreach ($addedRelationCallables as $name => $relation) {
$addedRelations[] = $name;
}
if (! empty($addedRelations)) {
usort($addedRelations, function ($a, $b) {
return substr_count($a, '.') - substr_count($b, '.');
});
foreach ($addedRelations as $relation) {
if (str_contains($relation, '.')) {
$parentRelation = Str::beforeLast($relation, '.');
if (! in_array($parentRelation, $relations, true)) {
continue;
}
}
$relations[] = $relation;
}
}
if (! empty($relations)) {
$relations = array_unique($relations);
}
$callableRelations = [];
$nonCallableRelations = [];
foreach ($relations as $relation) {
if (isset($addedRelationCallables[$relation])) {
$load = $addedRelationCallables[$relation];
$callableRelations[$relation] = function ($query) use ($load, $request, $relations) {
$load($query, $request, $relations);
};
} else {
$nonCallableRelations[] = $relation;
}
}
if (! empty($callableRelations)) {
$models->loadMissing($callableRelations);
}
if (! empty($nonCallableRelations)) {
$models->loadMissing($nonCallableRelations);
}
}
/**
* From format of: 'relation' => [ ...nested ] to ['relation', 'relation.nested']
*/
private function stringInclude(array $include): array
{
$relations = [];
foreach ($include as $relation => $nested) {
$relations[] = $relation;
if (is_array($nested)) {
foreach ($this->stringInclude($nested) as $nestedRelation) {
$relations[] = $relation.'.'.$nestedRelation;
}
}
}
return $relations;
}
}

View File

@ -2,27 +2,17 @@
namespace Flarum\Api\Endpoint; namespace Flarum\Api\Endpoint;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks; use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Flarum\Api\Endpoint\Concerns\HasEagerLoading;
use Illuminate\Database\Eloquent\Collection;
use Tobyz\JsonApiServer\Endpoint\Create as BaseCreate; use Tobyz\JsonApiServer\Endpoint\Create as BaseCreate;
class Create extends BaseCreate implements EndpointInterface class Create extends BaseCreate implements EndpointInterface
{ {
use HasAuthorization; use HasAuthorization;
use HasEagerLoading;
use HasCustomHooks; use HasCustomHooks;
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->beforeSerialization(function (Context $context, object $model) {
$this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context));
return $model;
});
} }
} }

View File

@ -5,13 +5,11 @@ namespace Flarum\Api\Endpoint;
use Flarum\Api\Endpoint\Concerns\ExtractsListingParams; use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks; use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Flarum\Api\Endpoint\Concerns\HasEagerLoading;
use Tobyz\JsonApiServer\Endpoint\Endpoint as BaseEndpoint; use Tobyz\JsonApiServer\Endpoint\Endpoint as BaseEndpoint;
class Endpoint extends BaseEndpoint implements EndpointInterface class Endpoint extends BaseEndpoint implements EndpointInterface
{ {
use HasAuthorization; use HasAuthorization;
use HasCustomHooks; use HasCustomHooks;
use HasEagerLoading;
use ExtractsListingParams; use ExtractsListingParams;
} }

View File

@ -6,11 +6,9 @@ use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\ExtractsListingParams; use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks; use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Flarum\Api\Endpoint\Concerns\HasEagerLoading;
use Flarum\Search\SearchCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager; use Flarum\Search\SearchManager;
use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Tobyz\JsonApiServer\Endpoint\Index as BaseIndex; use Tobyz\JsonApiServer\Endpoint\Index as BaseIndex;
use Tobyz\JsonApiServer\Pagination\OffsetPagination; use Tobyz\JsonApiServer\Pagination\OffsetPagination;
use Tobyz\JsonApiServer\Pagination\Pagination; use Tobyz\JsonApiServer\Pagination\Pagination;
@ -18,7 +16,6 @@ use Tobyz\JsonApiServer\Pagination\Pagination;
class Index extends BaseIndex implements EndpointInterface class Index extends BaseIndex implements EndpointInterface
{ {
use HasAuthorization; use HasAuthorization;
use HasEagerLoading;
use ExtractsListingParams; use ExtractsListingParams;
use HasCustomHooks; use HasCustomHooks;
@ -63,9 +60,6 @@ class Index extends BaseIndex implements EndpointInterface
} }
return $context; return $context;
})
->beforeSerialization(function (Context $context, iterable $models) {
$this->loadRelations(Collection::make($models), $context, $this->getInclude($context));
}); });
} }

View File

@ -2,27 +2,19 @@
namespace Flarum\Api\Endpoint; namespace Flarum\Api\Endpoint;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\ExtractsListingParams; use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks; use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Flarum\Api\Endpoint\Concerns\HasEagerLoading;
use Illuminate\Database\Eloquent\Collection;
use Tobyz\JsonApiServer\Endpoint\Show as BaseShow; use Tobyz\JsonApiServer\Endpoint\Show as BaseShow;
class Show extends BaseShow implements EndpointInterface class Show extends BaseShow implements EndpointInterface
{ {
use HasAuthorization; use HasAuthorization;
use HasEagerLoading;
use ExtractsListingParams; use ExtractsListingParams;
use HasCustomHooks; use HasCustomHooks;
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();;
$this->beforeSerialization(function (Context $context, object $model) {
$this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context));
});
} }
} }

View File

@ -2,25 +2,17 @@
namespace Flarum\Api\Endpoint; namespace Flarum\Api\Endpoint;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks; use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Flarum\Api\Endpoint\Concerns\HasEagerLoading;
use Illuminate\Database\Eloquent\Collection;
use Tobyz\JsonApiServer\Endpoint\Update as BaseUpdate; use Tobyz\JsonApiServer\Endpoint\Update as BaseUpdate;
class Update extends BaseUpdate implements EndpointInterface class Update extends BaseUpdate implements EndpointInterface
{ {
use HasAuthorization; use HasAuthorization;
use HasEagerLoading;
use HasCustomHooks; use HasCustomHooks;
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->beforeSerialization(function (Context $context, object $model) {
$this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context));
});
} }
} }

View File

@ -94,6 +94,7 @@ class DiscussionResource extends AbstractDatabaseResource
'mostRelevantPost.user' 'mostRelevantPost.user'
]) ])
->defaultSort('-lastPostedAt') ->defaultSort('-lastPostedAt')
->eagerLoad('state')
->paginate(), ->paginate(),
]; ];
} }
@ -184,12 +185,14 @@ class DiscussionResource extends AbstractDatabaseResource
->includable(), ->includable(),
Schema\Relationship\ToOne::make('firstPost') Schema\Relationship\ToOne::make('firstPost')
->includable() ->includable()
->inverse('discussion')
->type('posts'), ->type('posts'),
Schema\Relationship\ToOne::make('lastPostedUser') Schema\Relationship\ToOne::make('lastPostedUser')
->includable() ->includable()
->type('users'), ->type('users'),
Schema\Relationship\ToOne::make('lastPost') Schema\Relationship\ToOne::make('lastPost')
->includable() ->includable()
->inverse('discussion')
->type('posts'), ->type('posts'),
Schema\Relationship\ToMany::make('posts') Schema\Relationship\ToMany::make('posts')
->withLinkage(function (Context $context) { ->withLinkage(function (Context $context) {
@ -216,6 +219,8 @@ class DiscussionResource extends AbstractDatabaseResource
$posts = $discussion->posts() $posts = $discussion->posts()
->whereVisibleTo($actor) ->whereVisibleTo($actor)
->with($context->endpoint->getEagerLoadsFor('posts', $context))
->with($context->endpoint->getWhereEagerLoadsFor('posts', $context))
->orderBy('number') ->orderBy('number')
->skip($offset) ->skip($offset)
->take($limit) ->take($limit)
@ -236,6 +241,7 @@ class DiscussionResource extends AbstractDatabaseResource
Schema\Relationship\ToOne::make('mostRelevantPost') Schema\Relationship\ToOne::make('mostRelevantPost')
->visible(fn (Discussion $model, Context $context) => $context->listing()) ->visible(fn (Discussion $model, Context $context) => $context->listing())
->includable() ->includable()
->inverse('discussion')
->type('posts'), ->type('posts'),
Schema\Relationship\ToOne::make('hideUser') Schema\Relationship\ToOne::make('hideUser')
->type('users'), ->type('users'),