From 67012ba23c250b3ca6c29c790276d0c9394ad27f Mon Sep 17 00:00:00 2001 From: n0099 Date: Mon, 14 Oct 2024 23:26:33 +0000 Subject: [PATCH] * split methods `fillWithParentPost()`, `nestPostsWithParent()` & `reOrderNestedPosts()` into a new class `PostsTree` * mark props `$orderBy(Field|Desc)` as `public` visibility to allow `PostsController->query()` pass them as params of `PostsTree->reOrderNestedPosts()` @ `App\PostsQuery\BaseQuery` @ be --- be/src/Controller/PostsController.php | 8 +- be/src/PostsQuery/BaseQuery.php | 220 +----------------------- be/src/PostsQuery/IndexQuery.php | 9 +- be/src/PostsQuery/PostsTree.php | 231 ++++++++++++++++++++++++++ be/src/PostsQuery/SearchQuery.php | 9 +- 5 files changed, 246 insertions(+), 231 deletions(-) create mode 100644 be/src/PostsQuery/PostsTree.php diff --git a/be/src/Controller/PostsController.php b/be/src/Controller/PostsController.php index 4a6d63a1..06ff5baf 100644 --- a/be/src/Controller/PostsController.php +++ b/be/src/Controller/PostsController.php @@ -83,7 +83,7 @@ public function query(Request $request): array $query->query($params, $request->query->get('cursor')); $this->stopwatch->stop('$queryClass->query()'); $this->stopwatch->start('fillWithParentPost'); - $result = $query->fillWithParentPost(); + $result = $query->postsTree->fillWithParentPost($query->queryResult); $this->stopwatch->stop('fillWithParentPost'); $this->stopwatch->start('queryUsers'); @@ -118,7 +118,11 @@ public function query(Request $request): array ...Arr::except($result, ['fid', ...Helper::POST_TYPES_PLURAL]), ], 'forum' => $this->forumRepository->getForum($fid), - 'threads' => $query->reOrderNestedPosts($query->nestPostsWithParent(...$result)), + 'threads' => $query->postsTree->reOrderNestedPosts( + $query->postsTree->nestPostsWithParent(), + $query->orderByField, + $query->orderByDesc, + ), 'users' => $users, 'latestRepliers' => $latestRepliers, ]; diff --git a/be/src/PostsQuery/BaseQuery.php b/be/src/PostsQuery/BaseQuery.php index b21da72c..313e24b1 100644 --- a/be/src/PostsQuery/BaseQuery.php +++ b/be/src/PostsQuery/BaseQuery.php @@ -2,31 +2,15 @@ namespace App\PostsQuery; -use App\Entity\Post\Content\ReplyContent; -use App\Entity\Post\Content\SubReplyContent; -use App\Entity\Post\Reply; -use App\Entity\Post\SubReply; -use App\Entity\Post\Thread; -use App\DTO\PostKey\Thread as ThreadKey; -use App\DTO\PostKey\Reply as ReplyKey; -use App\DTO\PostKey\SubReply as SubReplyKey; -use App\Helper; -use App\Repository\Post\PostRepositoryFactory; -use Illuminate\Support\Collection; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Stopwatch\Stopwatch; - abstract readonly class BaseQuery { - protected string $orderByField; + public string $orderByField; - protected bool $orderByDesc; + public bool $orderByDesc; public function __construct( - private NormalizerInterface $normalizer, - private Stopwatch $stopwatch, - private PostRepositoryFactory $postRepositoryFactory, public QueryResult $queryResult, + public PostsTree $postsTree, ) {} abstract public function query(QueryParams $params, ?string $cursor): void; @@ -40,202 +24,4 @@ protected function setOrderByDesc(bool $orderByDesc): void { $this->orderByDesc = $orderByDesc; } - - /** - * @return array{ - * fid: int, - * threads: Collection, - * replies: Collection, - * subReplies: Collection, - * matchQueryPostCount: array{thread: int, reply: int, subReply: int}, - * notMatchQueryParentPostCount: array{thread: int, reply: int}, - * } - */ - public function fillWithParentPost(): array - { - $result = $this->queryResult; - /** @var Collection $tids */ - $tids = $result->threads->map(fn(ThreadKey $postKey) => $postKey->postId); - /** @var Collection $pids */ - $pids = $result->replies->map(fn(ReplyKey $postKey) => $postKey->postId); - /** @var Collection $spids */ - $spids = $result->subReplies->map(fn(SubReplyKey $postKey) => $postKey->postId); - $postModels = $this->postRepositoryFactory->newForumPosts($result->fid); - - $this->stopwatch->start('fillWithThreadsFields'); - /** @var Collection $parentThreadsID parent tid of all replies and their sub replies */ - $parentThreadsID = $result->replies - ->map(fn(ReplyKey $postKey) => $postKey->parentPostId) - ->concat($result->subReplies->map(fn(SubReplyKey $postKey) => $postKey->tid)) - ->unique(); - /** @var Collection $threads */ - $threads = collect($postModels['thread']->getPosts($parentThreadsID->concat($tids))) - ->each(static fn(Thread $thread) => - $thread->setIsMatchQuery($tids->contains($thread->getTid()))); - $this->stopwatch->stop('fillWithThreadsFields'); - - $this->stopwatch->start('fillWithRepliesFields'); - /** @var Collection $parentRepliesID parent pid of all sub replies */ - $parentRepliesID = $result->subReplies->map(fn(SubReplyKey $postKey) => $postKey->parentPostId)->unique(); - $allRepliesId = $parentRepliesID->concat($pids); - /** @var Collection $replies */ - $replies = collect($postModels['reply']->getPosts($allRepliesId)) - ->each(static fn(Reply $reply) => - $reply->setIsMatchQuery($pids->contains($reply->getPid()))); - $this->stopwatch->stop('fillWithRepliesFields'); - - $this->stopwatch->start('fillWithSubRepliesFields'); - /** @var Collection $subReplies */ - $subReplies = collect($postModels['subReply']->getPosts($spids)); - $this->stopwatch->stop('fillWithSubRepliesFields'); - - $this->stopwatch->start('parsePostContentProtoBufBytes'); - // not using one-to-one association due to relying on PostRepository->getTableNameSuffix() - $replyContents = collect($this->postRepositoryFactory->newReplyContent($result->fid)->getPostsContent($allRepliesId)) - ->mapWithKeys(fn(ReplyContent $content) => [$content->getPid() => $content->getContent()]); - $replies->each(fn(Reply $reply) => - $reply->setContent($replyContents->get($reply->getPid()))); - - $subReplyContents = collect($this->postRepositoryFactory->newSubReplyContent($result->fid)->getPostsContent($spids)) - ->mapWithKeys(fn(SubReplyContent $content) => [$content->getSpid() => $content->getContent()]); - $subReplies->each(fn(SubReply $subReply) => - $subReply->setContent($subReplyContents->get($subReply->getSpid()))); - $this->stopwatch->stop('parsePostContentProtoBufBytes'); - - return [ - 'fid' => $result->fid, - 'matchQueryPostCount' => collect(Helper::POST_TYPES) - ->combine([$tids, $pids, $spids]) - ->map(static fn(Collection $ids, string $type) => $ids->count()), - 'notMatchQueryParentPostCount' => [ - 'thread' => $parentThreadsID->diff($tids)->count(), - 'reply' => $parentRepliesID->diff($pids)->count(), - ], - ...array_combine(Helper::POST_TYPES_PLURAL, [$threads, $replies, $subReplies]), - ]; - } - - /** - * @param Collection $threads - * @param Collection $replies - * @param Collection $subReplies - * @phpcs:ignore Generic.Files.LineLength.TooLong - * @return Collection>>>>> - * @SuppressWarnings(PHPMD.CamelCaseParameterName) - */ - public function nestPostsWithParent( - Collection $threads, - Collection $replies, - Collection $subReplies, - ...$_, - ): Collection { - $this->stopwatch->start('nestPostsWithParent'); - - $replies = $replies->groupBy(fn(Reply $reply) => $reply->getTid()); - $subReplies = $subReplies->groupBy(fn(SubReply $subReply) => $subReply->getPid()); - $ret = $threads - ->map(fn(Thread $thread) => [ - ...$this->normalizer->normalize($thread), - 'replies' => $replies - ->get($thread->getTid(), collect()) - ->map(fn(Reply $reply) => [ - ...$this->normalizer->normalize($reply), - 'subReplies' => $this->normalizer->normalize($subReplies->get($reply->getPid(), collect())), - ]), - ]) - ->recursive(); - - $this->stopwatch->stop('nestPostsWithParent'); - return $ret; - } - - /** - * @phpcs:ignore Generic.Files.LineLength.TooLong - * @param Collection>>>>> $nestedPosts - * @return list>>>>> - */ - public function reOrderNestedPosts(Collection $nestedPosts, bool $shouldRemoveSortingKey = true): array - { - $this->stopwatch->start('reOrderNestedPosts'); - - /** - * @param Collection>>> $curPost - * @param string $childPostTypePluralName - * @return Collection>>> - */ - $setSortingKeyFromCurrentAndChildPosts = function ( - Collection $curPost, - string $childPostTypePluralName, - ): Collection { - /** @var Collection> $childPosts sorted child posts */ - $childPosts = $curPost[$childPostTypePluralName]; - $curPost[$childPostTypePluralName] = $childPosts->values(); // reset keys - - // use the topmost value between sorting key or value of orderBy field within its child posts - $curAndChildSortingKeys = collect([ - // value of orderBy field in the first sorted child post that isMatchQuery after previous sorting - $childPosts // sub replies won't have isMatchQuery - ->filter(static fn(Collection $p) => ($p['isMatchQuery'] ?? true) === true) - // if no child posts matching the query, use null as the sorting key - ->first()[$this->orderByField] ?? null, - // sorting key from the first sorted child posts - // not requiring isMatchQuery since a child post without isMatchQuery - // might have its own child posts with isMatchQuery - // and its sortingKey would be selected from its own child posts - $childPosts->first()['sortingKey'] ?? null, - ]); - if ($curPost['isMatchQuery'] === true) { - // also try to use the value of orderBy field in current post - $curAndChildSortingKeys->push($curPost[$this->orderByField]); - } - - // Collection->filter() will remove falsy values like null - $curAndChildSortingKeys = $curAndChildSortingKeys->filter()->sort(); - $curPost['sortingKey'] = $this->orderByDesc - ? $curAndChildSortingKeys->last() - : $curAndChildSortingKeys->first(); - - return $curPost; - }; - $sortBySortingKey = fn(Collection $posts): Collection => $posts - ->sortBy(fn(Collection $i) => $i['sortingKey'], descending: $this->orderByDesc); - $removeSortingKey = $shouldRemoveSortingKey - ? /** @psalm-return Collection */ - static fn(Collection $posts): Collection => $posts - ->map(fn(Collection $i) => $i->except('sortingKey')) - : static fn($i) => $i; - $ret = $removeSortingKey($sortBySortingKey( - $nestedPosts->map( - /** - * @param Collection{replies: Collection} $thread - * @return Collection{replies: Collection} - */ - function (Collection $thread) use ( - $sortBySortingKey, - $removeSortingKey, - $setSortingKeyFromCurrentAndChildPosts - ) { - $thread['replies'] = $sortBySortingKey($thread['replies']->map( - /** - * @param Collection{subReplies: Collection} $reply - * @return Collection{subReplies: Collection} - */ - function (Collection $reply) use ($setSortingKeyFromCurrentAndChildPosts) { - $reply['subReplies'] = $reply['subReplies']->sortBy( - fn(Collection $subReplies) => $subReplies->get($this->orderByField), - descending: $this->orderByDesc, - ); - return $setSortingKeyFromCurrentAndChildPosts($reply, 'subReplies'); - }, - )); - $setSortingKeyFromCurrentAndChildPosts($thread, 'replies'); - $thread['replies'] = $removeSortingKey($thread['replies']); - return $thread; - }, - ), - ))->values()->toArray(); - - $this->stopwatch->stop('reOrderNestedPosts'); - return $ret; - } } diff --git a/be/src/PostsQuery/IndexQuery.php b/be/src/PostsQuery/IndexQuery.php index fdb6ea62..0e80e004 100644 --- a/be/src/PostsQuery/IndexQuery.php +++ b/be/src/PostsQuery/IndexQuery.php @@ -8,19 +8,16 @@ use App\Helper; use Doctrine\ORM\QueryBuilder; use Illuminate\Support\Collection; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Stopwatch\Stopwatch; readonly class IndexQuery extends BaseQuery { public function __construct( - NormalizerInterface $normalizer, - Stopwatch $stopwatch, - private PostRepositoryFactory $postRepositoryFactory, QueryResult $queryResult, + PostsTree $postsTree, + private PostRepositoryFactory $postRepositoryFactory, private ForumRepository $forumRepository, ) { - parent::__construct($normalizer, $stopwatch, $postRepositoryFactory, $queryResult); + parent::__construct($queryResult, $postsTree); } /** @SuppressWarnings(PHPMD.ElseExpression) */ diff --git a/be/src/PostsQuery/PostsTree.php b/be/src/PostsQuery/PostsTree.php new file mode 100644 index 00000000..90774d62 --- /dev/null +++ b/be/src/PostsQuery/PostsTree.php @@ -0,0 +1,231 @@ + $threads */ + public Collection $threads; + + /** @var Collection $replies */ + public Collection $replies; + + /** @var Collection $subReplies */ + public Collection $subReplies; + + public function __construct( + private NormalizerInterface $normalizer, + private Stopwatch $stopwatch, + private PostRepositoryFactory $postRepositoryFactory, + ) {} + + /** + * @return array{ + * fid: int, + * threads: Collection, + * replies: Collection, + * subReplies: Collection, + * matchQueryPostCount: array{thread: int, reply: int, subReply: int}, + * notMatchQueryParentPostCount: array{thread: int, reply: int}, + * } + */ + public function fillWithParentPost(QueryResult $result): array + { + /** @var Collection $tids */ + $tids = $result->threads->map(fn(ThreadKey $postKey) => $postKey->postId); + /** @var Collection $pids */ + $pids = $result->replies->map(fn(ReplyKey $postKey) => $postKey->postId); + /** @var Collection $spids */ + $spids = $result->subReplies->map(fn(SubReplyKey $postKey) => $postKey->postId); + $postModels = $this->postRepositoryFactory->newForumPosts($result->fid); + + $this->stopwatch->start('fillWithThreadsFields'); + /** @var Collection $parentThreadsID parent tid of all replies and their sub replies */ + $parentThreadsID = $result->replies + ->map(fn(ReplyKey $postKey) => $postKey->parentPostId) + ->concat($result->subReplies->map(fn(SubReplyKey $postKey) => $postKey->tid)) + ->unique(); + $this->threads = collect($postModels['thread']->getPosts($parentThreadsID->concat($tids))) + ->each(static fn(Thread $thread) => + $thread->setIsMatchQuery($tids->contains($thread->getTid()))); + $this->stopwatch->stop('fillWithThreadsFields'); + + $this->stopwatch->start('fillWithRepliesFields'); + /** @var Collection $parentRepliesID parent pid of all sub replies */ + $parentRepliesID = $result->subReplies->map(fn(SubReplyKey $postKey) => $postKey->parentPostId)->unique(); + $allRepliesId = $parentRepliesID->concat($pids); + $this->replies = collect($postModels['reply']->getPosts($allRepliesId)) + ->each(static fn(Reply $reply) => + $reply->setIsMatchQuery($pids->contains($reply->getPid()))); + $this->stopwatch->stop('fillWithRepliesFields'); + + $this->stopwatch->start('fillWithSubRepliesFields'); + $this->subReplies = collect($postModels['subReply']->getPosts($spids)); + $this->stopwatch->stop('fillWithSubRepliesFields'); + + $this->stopwatch->start('parsePostContentProtoBufBytes'); + // not using one-to-one association due to relying on PostRepository->getTableNameSuffix() + $replyContents = collect($this->postRepositoryFactory + ->newReplyContent($result->fid)->getPostsContent($allRepliesId)) + ->mapWithKeys(fn(ReplyContent $content) => [$content->getPid() => $content->getContent()]); + $this->replies->each(fn(Reply $reply) => + $reply->setContent($replyContents->get($reply->getPid()))); + + $subReplyContents = collect($this->postRepositoryFactory + ->newSubReplyContent($result->fid)->getPostsContent($spids)) + ->mapWithKeys(fn(SubReplyContent $content) => [$content->getSpid() => $content->getContent()]); + $this->subReplies->each(fn(SubReply $subReply) => + $subReply->setContent($subReplyContents->get($subReply->getSpid()))); + $this->stopwatch->stop('parsePostContentProtoBufBytes'); + + return [ + 'fid' => $result->fid, + 'matchQueryPostCount' => collect(Helper::POST_TYPES) + ->combine([$tids, $pids, $spids]) + ->map(static fn(Collection $ids, string $type) => $ids->count()), + 'notMatchQueryParentPostCount' => [ + 'thread' => $parentThreadsID->diff($tids)->count(), + 'reply' => $parentRepliesID->diff($pids)->count(), + ], + ...array_combine(Helper::POST_TYPES_PLURAL, [$this->threads, $this->replies, $this->subReplies]), + ]; + } + + /** + * @phpcs:ignore Generic.Files.LineLength.TooLong + * @return Collection>>>>> + * @SuppressWarnings(PHPMD.CamelCaseParameterName) + */ + public function nestPostsWithParent(): Collection { + $this->stopwatch->start('nestPostsWithParent'); + + $replies = $this->replies->groupBy(fn(Reply $reply) => $reply->getTid()); + $subReplies = $this->subReplies->groupBy(fn(SubReply $subReply) => $subReply->getPid()); + $ret = $this->threads + ->map(fn(Thread $thread) => [ + ...$this->normalizer->normalize($thread), + 'replies' => $replies + ->get($thread->getTid(), collect()) + ->map(fn(Reply $reply) => [ + ...$this->normalizer->normalize($reply), + 'subReplies' => $this->normalizer->normalize($subReplies->get($reply->getPid(), collect())), + ]), + ]) + ->recursive(); + + $this->stopwatch->stop('nestPostsWithParent'); + return $ret; + } + + /** + * @phpcs:ignore Generic.Files.LineLength.TooLong + * @param Collection>>>>> $nestedPosts + * @return list>>>>> + */ + public function reOrderNestedPosts( + Collection $nestedPosts, + string $orderByField, + bool $orderByDesc, + bool $shouldRemoveSortingKey = true + ): array + { + $this->stopwatch->start('reOrderNestedPosts'); + + /** + * @param Collection>>> $curPost + * @param string $childPostTypePluralName + * @return Collection>>> + */ + $setSortingKeyFromCurrentAndChildPosts = function ( + Collection $curPost, + string $childPostTypePluralName, + ) use ($orderByField, $orderByDesc): Collection { + /** @var Collection> $childPosts sorted child posts */ + $childPosts = $curPost[$childPostTypePluralName]; + $curPost[$childPostTypePluralName] = $childPosts->values(); // reset keys + + // use the topmost value between sorting key or value of orderBy field within its child posts + $curAndChildSortingKeys = collect([ + // value of orderBy field in the first sorted child post that isMatchQuery after previous sorting + $childPosts // sub replies won't have isMatchQuery + ->filter(static fn(Collection $p) => ($p['isMatchQuery'] ?? true) === true) + // if no child posts matching the query, use null as the sorting key + ->first()[$orderByField] ?? null, + // sorting key from the first sorted child posts + // not requiring isMatchQuery since a child post without isMatchQuery + // might have its own child posts with isMatchQuery + // and its sortingKey would be selected from its own child posts + $childPosts->first()['sortingKey'] ?? null, + ]); + if ($curPost['isMatchQuery'] === true) { + // also try to use the value of orderBy field in current post + $curAndChildSortingKeys->push($curPost[$orderByField]); + } + + // Collection->filter() will remove falsy values like null + $curAndChildSortingKeys = $curAndChildSortingKeys->filter()->sort(); + $curPost['sortingKey'] = $orderByDesc + ? $curAndChildSortingKeys->last() + : $curAndChildSortingKeys->first(); + + return $curPost; + }; + $sortBySortingKey = fn(Collection $posts): Collection => $posts + ->sortBy(fn(Collection $i) => $i['sortingKey'], descending: $orderByDesc); + $removeSortingKey = $shouldRemoveSortingKey + ? /** @psalm-return Collection */ + static fn(Collection $posts): Collection => $posts + ->map(fn(Collection $i) => $i->except('sortingKey')) + : static fn($i) => $i; + $ret = $removeSortingKey($sortBySortingKey( + $nestedPosts->map( + /** + * @param Collection{replies: Collection} $thread + * @return Collection{replies: Collection} + */ + function (Collection $thread) use ( + $orderByField, + $orderByDesc, + $sortBySortingKey, + $removeSortingKey, + $setSortingKeyFromCurrentAndChildPosts + ) { + $thread['replies'] = $sortBySortingKey($thread['replies']->map( + /** + * @param Collection{subReplies: Collection} $reply + * @return Collection{subReplies: Collection} + */ + function (Collection $reply) use ($orderByField, $orderByDesc, $setSortingKeyFromCurrentAndChildPosts) { + $reply['subReplies'] = $reply['subReplies']->sortBy( + fn(Collection $subReplies) => $subReplies->get($orderByField), + descending: $orderByDesc, + ); + return $setSortingKeyFromCurrentAndChildPosts($reply, 'subReplies'); + }, + )); + $setSortingKeyFromCurrentAndChildPosts($thread, 'replies'); + $thread['replies'] = $removeSortingKey($thread['replies']); + return $thread; + }, + ), + ))->values()->toArray(); + + $this->stopwatch->stop('reOrderNestedPosts'); + return $ret; + } +} diff --git a/be/src/PostsQuery/SearchQuery.php b/be/src/PostsQuery/SearchQuery.php index 2603fbf1..e68c060c 100644 --- a/be/src/PostsQuery/SearchQuery.php +++ b/be/src/PostsQuery/SearchQuery.php @@ -7,19 +7,16 @@ use App\Repository\UserRepository; use Doctrine\ORM\QueryBuilder; use Illuminate\Support\Collection; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Stopwatch\Stopwatch; readonly class SearchQuery extends BaseQuery { public function __construct( - NormalizerInterface $normalizer, - Stopwatch $stopwatch, - private PostRepositoryFactory $postRepositoryFactory, QueryResult $queryResult, + PostsTree $postsTree, + private PostRepositoryFactory $postRepositoryFactory, private UserRepository $userRepository, ) { - parent::__construct($normalizer, $stopwatch, $postRepositoryFactory, $queryResult); + parent::__construct($queryResult, $postsTree); } public function query(QueryParams $params, ?string $cursor): void