From 7b1131c178518f98253bb9a308a6fd93d64f3e0b Mon Sep 17 00:00:00 2001 From: n0099 Date: Sat, 21 Sep 2024 07:54:51 +0000 Subject: [PATCH] * move method `encodeNextCursor()` & `decodeCursor()` into a new class `CursorCodec` and add it as ctor param for DI @ `App\Http\PostsQuery\BaseQuery` * move method `encodeNextCursor()` & `decodeCursor()` into a new Class `CursorCodecTest` @ `Tests\Feature\App\Http\PostsQuery\BaseQuery` * fix not injecting the first param in the ctor of class `ParamsValidator` since 57363c51717adc3d31988740b5cf777f6ee7280d @ `App\Http\Controllers\PostsQuery` * renamed from class `Param` @ `App\Http\PostsQuery\QueryParam` @ be --- be/app/Http/Controllers/PostsQuery.php | 10 +- be/app/Http/PostsQuery/BaseQuery.php | 111 ++---------------- be/app/Http/PostsQuery/CursorCodec.php | 109 +++++++++++++++++ be/app/Http/PostsQuery/IndexQuery.php | 2 +- .../PostsQuery/{Param.php => QueryParam.php} | 2 +- be/app/Http/PostsQuery/QueryParams.php | 14 +-- be/app/Http/PostsQuery/SearchQuery.php | 2 +- .../App/Http/PostsQuery/BaseQueryTest.php | 35 +----- .../App/Http/PostsQuery/CursorCodecTest.php | 55 +++++++++ 9 files changed, 191 insertions(+), 149 deletions(-) create mode 100644 be/app/Http/PostsQuery/CursorCodec.php rename be/app/Http/PostsQuery/{Param.php => QueryParam.php} (97%) create mode 100644 be/tests/Feature/App/Http/PostsQuery/CursorCodecTest.php diff --git a/be/app/Http/Controllers/PostsQuery.php b/be/app/Http/Controllers/PostsQuery.php index b259268c..5cb2e755 100644 --- a/be/app/Http/Controllers/PostsQuery.php +++ b/be/app/Http/Controllers/PostsQuery.php @@ -10,17 +10,21 @@ use App\Eloquent\Model\Forum; use App\Eloquent\Model\User; use Barryvdh\Debugbar\LaravelDebugbar; +use Illuminate\Container\Container; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Arr; use Illuminate\Support\Collection; class PostsQuery extends Controller { - public function __construct(private readonly LaravelDebugbar $debugbar) {} + public function __construct( + private readonly LaravelDebugbar $debugbar, + protected Container $app, + ) {} public function query(\Illuminate\Http\Request $request): array { - $validator = new ParamsValidator(Helper::jsonDecode( + $validator = $this->app->makeWith(ParamsValidator::class, ['params' => Helper::jsonDecode( $request->validate([ 'cursor' => [ // https://stackoverflow.com/questions/475074/regex-to-parse-or-validate-base64-data // (,|$)|,){5,6} means allow at most 5~6 parts of base64 segment or empty string to exist @@ -28,7 +32,7 @@ public function query(\Illuminate\Http\Request $request): array ], 'query' => 'json|required', ])['query'], - )); + )]); $params = $validator->params; $postIDParams = $params->pick(...Helper::POST_ID); diff --git a/be/app/Http/PostsQuery/BaseQuery.php b/be/app/Http/PostsQuery/BaseQuery.php index b7ba5b5c..7325355c 100644 --- a/be/app/Http/PostsQuery/BaseQuery.php +++ b/be/app/Http/PostsQuery/BaseQuery.php @@ -11,10 +11,8 @@ use Closure; use Barryvdh\Debugbar\LaravelDebugbar; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Pagination\Cursor; use Illuminate\Pagination\CursorPaginator; use Illuminate\Support\Collection; -use Illuminate\Support\Str; abstract class BaseQuery { @@ -37,6 +35,7 @@ abstract public function query(QueryParams $params, ?string $cursor): self; public function __construct( private readonly LaravelDebugbar $debugbar, + private readonly CursorCodec $cursorCodec, private readonly int $perPageItems = 50, ) {} @@ -76,7 +75,7 @@ protected function setResult( $queriesWithOrderBy = $queries->map($addOrderByForBuilder); $cursorsKeyByPostType = null; if ($cursorParamValue !== null) { - $cursorsKeyByPostType = $this->decodeCursor($cursorParamValue); + $cursorsKeyByPostType = $this->cursorCodec->decodeCursor($cursorParamValue, $this->orderByField); // remove queries for post types with encoded cursor ',,' $queriesWithOrderBy = $queriesWithOrderBy->intersectByKeys($cursorsKeyByPostType); } @@ -103,112 +102,18 @@ protected function setResult( $this->queryResultPages = [ 'currentCursor' => $cursorParamValue ?? '', 'nextCursor' => $hasMore - ? $this->encodeNextCursor($queryByPostIDParamName === null - ? $postsKeyByTypePluralName - : $postsKeyByTypePluralName->except([Helper::POST_ID_TO_TYPE_PLURAL[$queryByPostIDParamName]])) + ? $this->cursorCodec->encodeNextCursor( + $queryByPostIDParamName === null + ? $postsKeyByTypePluralName + : $postsKeyByTypePluralName->except([Helper::POST_ID_TO_TYPE_PLURAL[$queryByPostIDParamName]]), + $this->orderByField, + ) : null, ]; $this->debugbar->stopMeasure('setResult'); } - /** @param Collection $postsKeyByTypePluralName */ - public function encodeNextCursor(Collection $postsKeyByTypePluralName): string - { - $encodedCursorsKeyByPostType = $postsKeyByTypePluralName - ->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(Post $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 ($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 that is a blank encoded cursor ',,' - return '0'; - } - $prefix = match (true) { - \is_int($cursor) && $cursor < 0 => '-', - \is_string($cursor) => 'S', - default => '', - }; - - $value = \is_int($cursor) - // remove trailing 0x00 for an unsigned int or 0xFF for a signed negative int - ? rtrim(pack('P', $cursor), $cursor >= 0 ? "\x00" : "\xFF") - : ($prefix === 'S' - // keep string as is since encoded string will always longer than the original string - ? $cursor - : throw new \RuntimeException('Invalid cursor value')); - if ($prefix !== 'S') { - // https://en.wikipedia.org/wiki/Base64#URL_applications - $value = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($value)); - } - - 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 $encodedCursorsKeyByPostType will remain as int - ->flip()->merge($encodedCursorsKeyByPostType) - // if the flipped value is a default int key there's no posts of this type - // (type key not exists in $postsKeyByTypePluralName) - // so we just return an empty ',' as placeholder - ->map(static fn(string|int $cursor) => \is_int($cursor) ? ',' : $cursor) - ->join(','); - } - - /** @psalm-return Collection<'reply'|'subReply'|'thread', Cursor> */ - public function decodeCursor(string $encodedCursors): Collection - { - return collect(Helper::POST_TYPES) - ->combine(Str::of($encodedCursors) - ->explode(',') - ->map(static function (string $encodedCursor): int|string|null { - /** - * @var string $cursor - * @var string $prefix - */ - [$prefix, $cursor] = array_pad(explode(':', $encodedCursor), 2, null); - if ($cursor === null) { // no prefix being provided means the value of cursor is a positive int - $cursor = $prefix; - $prefix = ''; - } - return $cursor === '0' ? 0 : match ($prefix) { // keep 0 as is - 'S' => $cursor, // string literal is not base64 encoded - default => ((array) ( - unpack( - format: 'P', - string: str_pad( // re-add removed trailing 0x00 or 0xFF - base64_decode( - // https://en.wikipedia.org/wiki/Base64#URL_applications - str_replace(['-', '_'], ['+', '/'], $cursor), - ), - length: 8, - pad_string: $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 * diff --git a/be/app/Http/PostsQuery/CursorCodec.php b/be/app/Http/PostsQuery/CursorCodec.php new file mode 100644 index 00000000..9b185058 --- /dev/null +++ b/be/app/Http/PostsQuery/CursorCodec.php @@ -0,0 +1,109 @@ + $postsKeyByTypePluralName */ + public function encodeNextCursor(Collection $postsKeyByTypePluralName, string $orderByField): string + { + $encodedCursorsKeyByPostType = $postsKeyByTypePluralName + ->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(Post $post, string $typePluralName) => [ // [postID, orderByField] + $post->getAttribute(Helper::POST_TYPE_TO_ID[$typePluralName]), + $post->getAttribute($orderByField), + ]) + ->map(static fn(array $cursors) => collect($cursors) + ->map(static function (int|string $cursor): string { + if ($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 that is a blank encoded cursor ',,' + return '0'; + } + $prefix = match (true) { + \is_int($cursor) && $cursor < 0 => '-', + \is_string($cursor) => 'S', + default => '', + }; + + $value = \is_int($cursor) + // remove trailing 0x00 for an unsigned int or 0xFF for a signed negative int + ? rtrim(pack('P', $cursor), $cursor >= 0 ? "\x00" : "\xFF") + : ($prefix === 'S' + // keep string as is since encoded string will always longer than the original string + ? $cursor + : throw new \RuntimeException('Invalid cursor value')); + if ($prefix !== 'S') { + // https://en.wikipedia.org/wiki/Base64#URL_applications + $value = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($value)); + } + + 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 $encodedCursorsKeyByPostType will remain as int + ->flip()->merge($encodedCursorsKeyByPostType) + // if the flipped value is a default int key there's no posts of this type + // (type key not exists in $postsKeyByTypePluralName) + // so we just return an empty ',' as placeholder + ->map(static fn(string|int $cursor) => \is_int($cursor) ? ',' : $cursor) + ->join(','); + } + + /** @psalm-return Collection<'reply'|'subReply'|'thread', Cursor> */ + public function decodeCursor(string $encodedCursors, string $orderByField): Collection + { + return collect(Helper::POST_TYPES) + ->combine(Str::of($encodedCursors) + ->explode(',') + ->map(static function (string $encodedCursor): int|string|null { + /** + * @var string $cursor + * @var string $prefix + */ + [$prefix, $cursor] = array_pad(explode(':', $encodedCursor), 2, null); + if ($cursor === null) { // no prefix being provided means the value of cursor is a positive int + $cursor = $prefix; + $prefix = ''; + } + return $cursor === '0' ? 0 : match ($prefix) { // keep 0 as is + 'S' => $cursor, // string literal is not base64 encoded + default => ((array) ( + unpack( + format: 'P', + string: str_pad( // re-add removed trailing 0x00 or 0xFF + base64_decode( + // https://en.wikipedia.org/wiki/Base64#URL_applications + str_replace(['-', '_'], ['+', '/'], $cursor), + ), + length: 8, + pad_string: $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] : $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())); + } +} diff --git a/be/app/Http/PostsQuery/IndexQuery.php b/be/app/Http/PostsQuery/IndexQuery.php index ba8e3a56..9c807026 100644 --- a/be/app/Http/PostsQuery/IndexQuery.php +++ b/be/app/Http/PostsQuery/IndexQuery.php @@ -19,7 +19,7 @@ public function query(QueryParams $params, ?string $cursor): self /** @var array $flatParams key by param name */ $flatParams = array_reduce( $params->pick(...ParamsValidator::UNIQUE_PARAMS_NAME, ...Helper::POST_ID), - static fn(array $accParams, Param $param) => + static fn(array $accParams, QueryParam $param) => [...$accParams, $param->name => $param->value, ...$param->getAllSub()], [], ); // flatten unique query params diff --git a/be/app/Http/PostsQuery/Param.php b/be/app/Http/PostsQuery/QueryParam.php similarity index 97% rename from be/app/Http/PostsQuery/Param.php rename to be/app/Http/PostsQuery/QueryParam.php index 07c3db6f..c1201dd1 100644 --- a/be/app/Http/PostsQuery/Param.php +++ b/be/app/Http/PostsQuery/QueryParam.php @@ -2,7 +2,7 @@ namespace App\Http\PostsQuery; -class Param +class QueryParam { public string $name; diff --git a/be/app/Http/PostsQuery/QueryParams.php b/be/app/Http/PostsQuery/QueryParams.php index 2eaff98a..0ef4c67d 100644 --- a/be/app/Http/PostsQuery/QueryParams.php +++ b/be/app/Http/PostsQuery/QueryParams.php @@ -6,13 +6,13 @@ class QueryParams { - /** @var Param[] */ + /** @var QueryParam[] */ protected array $params; /** @param array[] $params */ public function __construct(array $params) { - $this->params = array_map(static fn($p) => new Param($p), $params); + $this->params = array_map(static fn($p) => new QueryParam($p), $params); } /** @psalm-return int<0, max> */ @@ -22,8 +22,8 @@ public function count(): int } /** - * @return Param[] - * @psalm-return list + * @return QueryParam[] + * @psalm-return list */ public function pick(string ...$names): array { @@ -35,8 +35,8 @@ public function pick(string ...$names): array } /** - * @return Param[] - * @psalm-return list + * @return QueryParam[] + * @psalm-return list */ public function omit(string ...$names): array { @@ -71,7 +71,7 @@ public function addDefaultValueOnUniqueParams(): void ]; foreach ($uniqueParamsDefaultValue as $name => $value) { // add unique params with default value when it's not presented in $this->params - $paramFilledWithDefaults = new Param([ + $paramFilledWithDefaults = new QueryParam([ $name => $this->getUniqueParamValue($name) ?? $value['value'], ...($this->pick($name)[0]->subParam ?? $value['subParam'] ?? []), ]); diff --git a/be/app/Http/PostsQuery/SearchQuery.php b/be/app/Http/PostsQuery/SearchQuery.php index 70d60fd1..658c4df2 100644 --- a/be/app/Http/PostsQuery/SearchQuery.php +++ b/be/app/Http/PostsQuery/SearchQuery.php @@ -52,7 +52,7 @@ public function query(QueryParams $params, ?string $cursor): self */ private static function applyQueryParamsOnQuery( Builder $query, - Param $param, + QueryParam $param, ?Collection &$outCachedUserQueryResult, ): Builder { $name = $param->name; diff --git a/be/tests/Feature/App/Http/PostsQuery/BaseQueryTest.php b/be/tests/Feature/App/Http/PostsQuery/BaseQueryTest.php index cb692876..c4bd2f64 100644 --- a/be/tests/Feature/App/Http/PostsQuery/BaseQueryTest.php +++ b/be/tests/Feature/App/Http/PostsQuery/BaseQueryTest.php @@ -7,8 +7,8 @@ use App\Eloquent\Model\Post\SubReply; use App\Eloquent\Model\Post\Thread; use App\Http\PostsQuery\BaseQuery; +use App\Http\PostsQuery\CursorCodec; use Barryvdh\Debugbar\LaravelDebugbar; -use Illuminate\Pagination\Cursor; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -23,7 +23,7 @@ protected function setUp(): void { parent::setUp(); $this->sut = $this->getMockBuilder(BaseQuery::class) - ->setConstructorArgs([$this->createMock(LaravelDebugbar::class)]) + ->setConstructorArgs([$this->createMock(LaravelDebugbar::class), new CursorCodec()]) ->getMockForAbstractClass(); (new \ReflectionProperty(BaseQuery::class, 'orderByField')) ->setValue($this->sut, 'postedAt'); @@ -223,37 +223,6 @@ public static function reOrderNestedPostsDataProvider(): array ]; } - #[Test] - /** @backupStaticAttributes enabled */ - public function encodeNextCursor(): void - { - (new \ReflectionClass(Post::class))->setStaticPropertyValue('unguarded', true); - $input = collect([ - 'threads' => [new Thread(['tid' => 1, 'postedAt' => 0])], - 'replies' => [new Reply(['pid' => 2, 'postedAt' => -2147483649])], - 'subReplies' => [new SubReply(['spid' => 3, 'postedAt' => 'test'])], - ])->recursive(maxDepth: 0); - self::assertEquals('AQ,0,Ag,-:____fw,Aw,S:test', $this->sut->encodeNextCursor($input)); - } - - #[Test] - public function decodeCursor(): void - { - $expected = collect([ - 'thread' => new Cursor(['tid' => 1, 'postedAt' => 0]), - 'reply' => new Cursor(['pid' => 2, 'postedAt' => -2147483649]), - 'subReply' => new Cursor(['spid' => 3, 'postedAt' => 'test']), - ]); - self::assertEquals($expected, $this->sut->decodeCursor('AQ,0,Ag,-:____fw,Aw,S:test')); - - $expected = collect([ - 'thread' => new Cursor(['tid' => 0, 'postedAt' => 0]), - 'reply' => new Cursor(['pid' => 0, 'postedAt' => 0]), - 'subReply' => new Cursor(['spid' => 0, 'postedAt' => 0]), - ]); - self::assertEquals($expected, $this->sut->decodeCursor(',,,,0,0')); - } - #[Test] /** @backupStaticAttributes enabled */ public function nestPostsWithParent(): void diff --git a/be/tests/Feature/App/Http/PostsQuery/CursorCodecTest.php b/be/tests/Feature/App/Http/PostsQuery/CursorCodecTest.php new file mode 100644 index 00000000..bcbdc727 --- /dev/null +++ b/be/tests/Feature/App/Http/PostsQuery/CursorCodecTest.php @@ -0,0 +1,55 @@ +sut = new CursorCodec(); + } + + #[Test] + /** @backupStaticAttributes enabled */ + public function encodeNextCursor(): void + { + (new \ReflectionClass(Post::class))->setStaticPropertyValue('unguarded', true); + $input = collect([ + 'threads' => [new Thread(['tid' => 1, 'postedAt' => 0])], + 'replies' => [new Reply(['pid' => 2, 'postedAt' => -2147483649])], + 'subReplies' => [new SubReply(['spid' => 3, 'postedAt' => 'test'])], + ])->recursive(maxDepth: 0); + self::assertEquals('AQ,0,Ag,-:____fw,Aw,S:test', $this->sut->encodeNextCursor($input, 'postedAt')); + } + + #[Test] + public function decodeCursor(): void + { + $expected = collect([ + 'thread' => new Cursor(['tid' => 1, 'postedAt' => 0]), + 'reply' => new Cursor(['pid' => 2, 'postedAt' => -2147483649]), + 'subReply' => new Cursor(['spid' => 3, 'postedAt' => 'test']), + ]); + self::assertEquals($expected, $this->sut->decodeCursor('AQ,0,Ag,-:____fw,Aw,S:test', 'postedAt')); + + $expected = collect([ + 'thread' => new Cursor(['tid' => 0, 'postedAt' => 0]), + 'reply' => new Cursor(['pid' => 0, 'postedAt' => 0]), + 'subReply' => new Cursor(['spid' => 0, 'postedAt' => 0]), + ]); + self::assertEquals($expected, $this->sut->decodeCursor(',,,,0,0', 'postedAt')); + } +}