-
Notifications
You must be signed in to change notification settings - Fork 2
/
BaseQuery.php
396 lines (361 loc) · 20.6 KB
/
BaseQuery.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
<?php
namespace App\Http\PostsQuery;
use App\Helper;
use App\Tieba\Eloquent\PostModel;
use App\Tieba\Eloquent\PostModelFactory;
use App\Tieba\Eloquent\ReplyModel;
use App\Tieba\Eloquent\SubReplyModel;
use App\Tieba\Eloquent\ThreadModel;
use Barryvdh\Debugbar\Facades\Debugbar;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\Cursor;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use JetBrains\PhpStorm\ArrayShape;
use TbClient\Wrapper\PostContentWrapper;
abstract class BaseQuery
{
#[ArrayShape([
'fid' => 'int',
'threads' => '?Collection<ThreadModel>',
'replies' => '?Collection<ReplyModel>',
'subReplies' => '?Collection<SubReplyModel>',
])] protected array $queryResult;
private array $queryResultPages;
protected string $orderByField;
protected bool $orderByDesc;
abstract public function query(QueryParams $params, ?string $cursor): self;
public function __construct(protected int $perPageItems = 200)
{
}
public function getResultPages(): array
{
return $this->queryResultPages;
}
/**
* @param int $fid
* @param Collection<string, Builder> $queries key by post type
* @param string|null $cursorParamValue
* @param string|null $queryByPostIDParamName
* @return void
*/
protected function setResult(int $fid, Collection $queries, ?string $cursorParamValue, ?string $queryByPostIDParamName = null): void
{
Debugbar::startMeasure('setResult');
$addOrderByForBuilder = fn (Builder $qb, string $postType): Builder => $qb
->addSelect($this->orderByField)
->orderBy($this->orderByField, $this->orderByDesc === true ? 'DESC' : 'ASC')
// cursor paginator requires values of orderBy column are unique
// if not it should fall back to other unique field (here is the post id primary key)
// we don't have to select the post id since it's already selected by invokes of PostModel::scopeSelectCurrentAndParentPostID()
->orderBy(Helper::POST_TYPE_TO_ID[$postType]);
$queriesWithOrderBy = $queries->map($addOrderByForBuilder);
if ($cursorParamValue !== null) {
$cursorKeyByPostType = $this->decodePageCursor($cursorParamValue);
// remove queries for post types with encoded cursor ',,'
$queriesWithOrderBy = $queriesWithOrderBy->intersectByKeys($cursorKeyByPostType);
}
Debugbar::startMeasure('initPaginators');
/** @var Collection<string, CursorPaginator> $paginators key by post type */
$paginators = $queriesWithOrderBy->map(fn (Builder $qb, string $type) =>
$qb->cursorPaginate($this->perPageItems, cursor: $cursorKeyByPostType[$type] ?? null));
Debugbar::stopMeasure('initPaginators');
/** @var Collection<string, Collection> $postKeyByTypePluralName */
$postKeyByTypePluralName = $paginators
->map(static fn (CursorPaginator $paginator) => $paginator->collect()) // cast paginator with queried posts to Collection<PostModel>
->mapWithKeys(static fn (Collection $posts, string $type) => [Helper::POST_TYPE_TO_PLURAL[$type] => $posts]);
Helper::abortAPIIf(40401, $postKeyByTypePluralName->every(static fn (Collection $i) => $i->isEmpty()));
$this->queryResult = ['fid' => $fid, ...$postKeyByTypePluralName];
$this->queryResultPages = [
'nextCursor' => $this->encodeNextPageCursor($queryByPostIDParamName === null
? $postKeyByTypePluralName
: $postKeyByTypePluralName->except([Helper::POST_ID_TO_TYPE_PLURAL[$queryByPostIDParamName]])),
'hasMorePages' => self::unionPageStats($paginators, 'hasMorePages',
static fn (Collection $v) => $v->filter()->count() !== 0) // Collection->filter() will remove false values
];
Debugbar::stopMeasure('setResult');
}
/**
* @param Collection<string, PostModel> $postKeyByTypePluralName
* @return string
* @test-input collect(['threads' => collect([new ThreadModel(['tid' => 1,'postTime' => 0])]),'replies' => collect([new ReplyModel(['pid' => 2,'postTime' => -2147483649])]),'subReplies' => collect([new SubReplyModel(['spid' => 3,'postTime' => 'test'])])])
*/
private function encodeNextPageCursor(Collection $postKeyByTypePluralName): string
{
$encodedCursorKeyByPostType = $postKeyByTypePluralName
->mapWithKeys(static fn (Collection $posts, string $type) => [
Helper::POST_TYPE_PLURAL_TO_TYPE[$type] => $posts->last() // null when no posts
]) // [singularPostTypeName => lastPostInResult]
->filter() // remove post types that have no posts
->map(fn (PostModel $post, string $typePluralName) => [ // [postID, orderByField]
$post->getAttribute(Helper::POST_TYPE_TO_ID[$typePluralName]),
$post->getAttribute($this->orderByField)
])
->map(static fn (array $cursors) => collect($cursors)
->map(static function (int|string $cursor): string {
if (\is_int($cursor) && $cursor === 0) {
// quick exit to keep 0 as is
// to prevent packed 0 with the default format 'P' after 0x00 trimming is an empty string
// that will be confused with post types without a cursor, they will have a blank encoded cursor ',,'
return '0';
}
$firstKeyFromTableFilterByTrue = static fn (array $table, string $default): string =>
array_keys(array_filter($table, static fn (bool $f) => $f === true))[0] ?? $default;
$prefix = $firstKeyFromTableFilterByTrue([
'-' => \is_int($cursor) && $cursor < 0,
'' => \is_int($cursor),
'S' => \is_string($cursor)
], '');
$value = \is_int($cursor)
// remove trailing 0x00 for unsigned int or 0xFF for signed negative int
? rtrim(pack('P', $cursor), $cursor >= 0 ? "\x00" : "\xFF")
: ($prefix === 'S'
? $cursor // keep string as is since encoded string will always longer than the original string
: throw new \RuntimeException('Invalid cursor value'));
if ($prefix !== 'S') {
$value = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($value)); // https://en.wikipedia.org/wiki/Base64#URL_applications
}
return $prefix . ($prefix === '' ? '' : ':') . $value;
})
->join(','));
return collect(Helper::POST_TYPES)
// merge cursors into flipped Helper::POST_TYPES with the same post type key
// value of keys that non exists in $encodedCursorKeyByPostType will remain as int
->flip()->merge($encodedCursorKeyByPostType)
// if the flipped value is an default int key there's no posts of this type (type key not exists in $postKeyByTypePluralName)
// so we just return an empty ',' as placeholder
->map(static fn (string|int $cursor) => \is_int($cursor) ? ',' : $cursor)
->join(',');
}
/**
* @param string $encodedCursors
* @return Collection<string, Cursor>
*/
private function decodePageCursor(string $encodedCursors): Collection
{
return collect(Helper::POST_TYPES)
->combine(Str::of($encodedCursors)
->explode(',')
->map(static function (string $encodedCursor): int|string|null {
[$prefix, $cursor] = array_pad(explode(':', $encodedCursor), 2, null);
if ($cursor === null) { // no prefix being provided means the value of cursor is an positive int
$cursor = $prefix;
$prefix = '';
}
return match($prefix) {
null => null, // original encoded cursor is an empty string
'0' => 0, // keep 0 as is
'S' => $cursor, // string literal is not base64 encoded
default => ((array)(
unpack('P',
str_pad( // re-add removed trailing 0x00 or 0xFF
base64_decode(
str_replace(['-', '_'], ['+', '/'], $cursor) // https://en.wikipedia.org/wiki/Base64#URL_applications
), 8, $prefix === '-' ? "\xFF" : "\x00"))
))[1] // the returned array of unpack() will starts index from 1
};
})
->chunk(2) // split six values into three post type pairs
->map(static fn (Collection $i) => $i->values())) // reorder keys after chunk
->mapWithKeys(fn (Collection $cursors, string $postType) =>
[$postType =>
$cursors->mapWithKeys(fn (int|string|null $cursor, int $index) =>
[$index === 0 ? Helper::POST_TYPE_TO_ID[$postType] : $this->orderByField => $cursor])
])
// filter out cursors with all fields value being null, their encoded cursor is ',,'
->reject(static fn (Collection $cursors) =>
$cursors->every(static fn (int|string|null $cursor) => $cursor === null))
->map(static fn (Collection $cursors) => new Cursor($cursors->toArray()));
}
/**
* Union builders pagination $unionMethodName data by $unionStatement
*
* @param Collection<CursorPaginator> $paginators
* @param string $unionMethodName
* @param Closure $unionCallback (Collection)
* @return mixed returned by $unionCallback()
*/
private static function unionPageStats(Collection $paginators, string $unionMethodName, Closure $unionCallback): mixed
{
// Collection::filter() will remove falsy values
$unionValues = $paginators->map(static fn (CursorPaginator $p) => $p->$unionMethodName());
return $unionCallback($unionValues->isEmpty() ? collect(0) : $unionValues); // prevent empty array
}
#[ArrayShape([
'fid' => 'int',
'postsQueryMatchCount' => 'array{thread: int, reply: int, subReply: int}',
'notMatchQueryParentPostsCount' => 'array{thread: int, reply: int}',
'threads' => 'Collection<ThreadModel>',
'replies' => 'Collection<ReplyModel>',
'subReplies' => 'Collection<SubReplyModel>'
])] public function fillWithParentPost(): array
{
$result = $this->queryResult;
/** @var Collection<int> $tids */
/** @var Collection<int> $pids */
/** @var Collection<int> $spids */
/** @var Collection<ThreadModel> $threads */
/** @var Collection<ReplyModel> $replies */
/** @var Collection<SubReplyModel> $subReplies */
[[, $tids], [$replies, $pids], [$subReplies, $spids]] = array_map(
/**
* @param string $postIDName
* @return array{0: Collection<PostModel>, 1: Collection<int>}
*/
static function (string $postIDName) use ($result): array {
$postTypePluralName = Helper::POST_ID_TO_TYPE_PLURAL[$postIDName];
return \array_key_exists($postTypePluralName, $result)
? [$result[$postTypePluralName], $result[$postTypePluralName]->pluck($postIDName)]
: [collect(), collect()];
},
Helper::POST_ID
);
/** @var int $fid */
$fid = $result['fid'];
$postModels = PostModelFactory::getPostModelsByFid($fid);
Debugbar::startMeasure('fillWithThreadsFields');
/** @var Collection<int> $parentThreadsID parent tid of all replies and their sub replies */
$parentThreadsID = $replies->pluck('tid')->concat($subReplies->pluck('tid'))->unique();
$threads = $postModels['thread']
->tid($parentThreadsID->concat($tids)) // from the original $this->queryResult, see PostModel::scopeSelectCurrentAndParentPostID()
->hidePrivateFields()->get()
->map(static fn (ThreadModel $t) => // mark threads that in the original $this->queryResult
$t->setAttribute('isQueryMatch', $tids->contains($t->tid)));
Debugbar::stopMeasure('fillWithThreadsFields');
Debugbar::startMeasure('fillWithRepliesFields');
/** @var Collection<int> $parentRepliesID parent pid of all sub replies */
$parentRepliesID = $subReplies->pluck('pid')->unique();
$replies = $postModels['reply']
->pid($parentRepliesID->concat($pids)) // from the original $this->queryResult, see PostModel::scopeSelectCurrentAndParentPostID()
->hidePrivateFields()->get()
->map(static fn (ReplyModel $r) => // mark replies that in the original $this->queryResult
$r->setAttribute('isQueryMatch', $pids->contains($r->pid)));
$subReplies = $postModels['subReply']->spid($spids)->hidePrivateFields()->get();
Debugbar::stopMeasure('fillWithRepliesFields');
self::fillPostsContent($fid, $replies, $subReplies);
return [
'fid' => $fid,
'postsQueryMatchCount' => collect(Helper::POST_TYPES)
->combine([$tids, $pids, $spids])
->map(static fn (Collection $ids, string $type) => $ids->count()),
'notMatchQueryParentPostsCount' => [
'thread' => $parentThreadsID->diff($tids)->count(),
'reply' => $parentRepliesID->diff($pids)->count(),
],
...array_combine(Helper::POST_TYPES_PLURAL, [$threads, $replies, $subReplies])
];
}
private static function fillPostsContent(int $fid, Collection $replies, Collection $subReplies): void
{
$parseThenRenderContentModel = static function (Model $contentModel): ?string {
if ($contentModel->content === null) {
return null;
}
$proto = new PostContentWrapper();
$proto->mergeFromString($contentModel->content);
return str_replace("\n", '', trim(view('renderPostContent', ['content' => $proto->getValue()])->render()));
};
/**
* @param Collection<?string> $contents
* @param string $postIDName
* @return \Closure
*/
$appendParsedContent = static fn (Collection $contents, string $postIDName) =>
static function (PostModel $post) use ($contents, $postIDName): PostModel {
$post->content = $contents[$post[$postIDName]];
return $post;
};
if ($replies->isNotEmpty()) {
Debugbar::measure('fillRepliesContent', static fn () =>
$replies->transform($appendParsedContent(
PostModelFactory::newReplyContent($fid)
->pid($replies->pluck('pid'))->get()
->keyBy('pid')->map($parseThenRenderContentModel),
'pid')));
}
if ($subReplies->isNotEmpty()) {
Debugbar::measure('fillSubRepliesContent', static fn () =>
$subReplies->transform($appendParsedContent(
PostModelFactory::newSubReplyContent($fid)
->spid($subReplies->pluck('spid'))->get()
->keyBy('spid')->map($parseThenRenderContentModel),
'spid')));
}
}
public static function nestPostsWithParent(Collection $threads, Collection $replies, Collection $subReplies, ...$_): Collection
{
Debugbar::startMeasure('nestPostsWithParent');
$replies = $replies->groupBy('tid');
$subReplies = $subReplies->groupBy('pid');
$ret = $threads->map(fn (ThreadModel $thread) => [
...$thread->toArray(),
'replies' => $replies->get($thread->tid, collect())
->map(fn (ReplyModel $reply) => [
...$reply->toArray(),
'subReplies' => $subReplies->get($reply->pid, collect())
->map(static fn (SubReplyModel $subReply) => $subReply->toArray())
])
]);
Debugbar::stopMeasure('nestPostsWithParent');
return $ret;
}
/**
* @param Collection<array<array>> $nestedPosts
* @return array<array<array>>
* @test-input [{"postTime":1,"isQueryMatch":true,"replies":[{"postTime":2,"isQueryMatch":true,"subReplies":[{"postTime":30}]},{"postTime":20,"isQueryMatch":false,"subReplies":[{"postTime":3}]},{"postTime":4,"isQueryMatch":false,"subReplies":[{"postTime":5},{"postTime":60}]}]},{"postTime":7,"isQueryMatch":false,"replies":[{"postTime":31,"isQueryMatch":true,"subReplies":[]}]}]
* @test-output [{"postTime":1,"isQueryMatch":true,"replies":[{"postTime":4,"isQueryMatch":false,"subReplies":[{"postTime":60},{"postTime":5}],"sortingKey":60},{"postTime":2,"isQueryMatch":true,"subReplies":[{"postTime":30}],"sortingKey":30},{"postTime":20,"isQueryMatch":false,"subReplies":[{"postTime":3}],"sortingKey":3}],"sortingKey":60},{"postTime":7,"isQueryMatch":false,"replies":[{"postTime":31,"isQueryMatch":true,"subReplies":[],"sortingKey":31}],"sortingKey":31}]
*/
public function reOrderNestedPosts(Collection $nestedPosts): array
{
Debugbar::startMeasure('reOrderNestedPosts');
$getSortingKeyFromCurrentAndChildPosts = function (array $curPost, string $childPostTypePluralName) {
/** @var Collection<array> $childPosts sorted child posts */
$childPosts = $curPost[$childPostTypePluralName];
$curPost[$childPostTypePluralName] = $childPosts->values()->toArray(); // assign child post back to current post
/** @var Collection<array> $topmostChildPostInQueryMatch the first child post which is isQueryMatch after previous sorting */
$topmostChildPostInQueryMatch = $childPosts
->filter(static fn (array $p) => ($p['isQueryMatch'] ?? true) === true); // sub replies won't have isQueryMatch
// 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
// if no child posts matching the query, use null as the sorting key
$topmostChildPostInQueryMatch->first()[$this->orderByField] ?? null,
// sorting key from the first sorted child posts
// not requiring isQueryMatch since a child post without isQueryMatch might have its own child posts with isQueryMatch
// and its sortingKey would be selected from its own child posts
$childPosts->first()['sortingKey'] ?? null
]);
if ($curPost['isQueryMatch'] === true) {
// also try to use the value of orderBy field in current post
$curAndChildSortingKeys->push($curPost[$this->orderByField]);
}
$curAndChildSortingKeys = $curAndChildSortingKeys->filter()->sort(); // Collection->filter() will remove falsy values like null
$curPost['sortingKey'] = $this->orderByDesc ? $curAndChildSortingKeys->last() : $curAndChildSortingKeys->first();
return $curPost;
};
$sortPosts = fn (Collection $posts) => $posts
->sortBy('sortingKey', descending: $this->orderByDesc)
->map(static function (array $post) { // remove sorting key from posts after sorting
unset($post['sortingKey']);
return $post;
});
$ret = $sortPosts($nestedPosts
->map(function (array $thread) use ($sortPosts, $getSortingKeyFromCurrentAndChildPosts) {
$thread['replies'] = $sortPosts(collect($thread['replies'])
->map(function (array $reply) use ($getSortingKeyFromCurrentAndChildPosts) {
$reply['subReplies'] = collect($reply['subReplies'])->sortBy(
fn (array $subReplies) => $subReplies[$this->orderByField],
descending: $this->orderByDesc
);
return $getSortingKeyFromCurrentAndChildPosts($reply, 'subReplies');
}));
return $getSortingKeyFromCurrentAndChildPosts($thread, 'replies');
})
)->values()->toArray();
Debugbar::stopMeasure('reOrderNestedPosts');
return $ret;
}
}