From b5ad92529467fd78bf8399de13f56f5c0ff833f2 Mon Sep 17 00:00:00 2001 From: vokomarov Date: Mon, 22 Jan 2024 03:14:11 +0200 Subject: [PATCH] Add charge title autocomplete endpoint --- .../Charges/SuggestionsController.php | 33 ++++++ .../Wallets/Charges/ChargesController.php | 2 +- .../Wallets/Charges/TagsController.php | 2 +- app/src/Repository/ChargeRepository.php | 24 +++++ app/src/View/ChargeTitleView.php | 29 +++++ app/src/View/ChargeTitlesView.php | 30 ++++++ .../Charges/SuggestionsControllerTest.php | 102 ++++++++++++++++++ tests/Feature/View/ChargeTitleViewTest.php | 18 ++++ 8 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 app/src/Controller/Charges/SuggestionsController.php create mode 100644 app/src/View/ChargeTitleView.php create mode 100644 app/src/View/ChargeTitlesView.php create mode 100644 tests/Feature/Controller/Charges/SuggestionsControllerTest.php create mode 100644 tests/Feature/View/ChargeTitleViewTest.php diff --git a/app/src/Controller/Charges/SuggestionsController.php b/app/src/Controller/Charges/SuggestionsController.php new file mode 100644 index 00000000..5f7ec430 --- /dev/null +++ b/app/src/Controller/Charges/SuggestionsController.php @@ -0,0 +1,33 @@ +', name: 'charges.title.suggestions', methods: 'GET', group: 'auth')] + public function suggestions(string $query = ''): ResponseInterface + { + return $this->chargeTitlesView->json( + $this->chargeRepository->searchTitle((int) $this->user->id, urldecode($query)) + ); + } +} diff --git a/app/src/Controller/Wallets/Charges/ChargesController.php b/app/src/Controller/Wallets/Charges/ChargesController.php index f594f30c..5211e2dc 100644 --- a/app/src/Controller/Wallets/Charges/ChargesController.php +++ b/app/src/Controller/Wallets/Charges/ChargesController.php @@ -25,7 +25,7 @@ use Spiral\Router\Annotation\Route; use Spiral\Translator\Traits\TranslatorTrait; -class ChargesController extends Controller +final class ChargesController extends Controller { use TranslatorTrait; diff --git a/app/src/Controller/Wallets/Charges/TagsController.php b/app/src/Controller/Wallets/Charges/TagsController.php index c5d85ef6..2d274071 100644 --- a/app/src/Controller/Wallets/Charges/TagsController.php +++ b/app/src/Controller/Wallets/Charges/TagsController.php @@ -20,7 +20,7 @@ use Spiral\Http\ResponseWrapper; use Spiral\Router\Annotation\Route; -class TagsController extends Controller +final class TagsController extends Controller { public function __construct( AuthScope $auth, diff --git a/app/src/Repository/ChargeRepository.php b/app/src/Repository/ChargeRepository.php index 868a6890..13d1b303 100644 --- a/app/src/Repository/ChargeRepository.php +++ b/app/src/Repository/ChargeRepository.php @@ -5,6 +5,9 @@ namespace App\Repository; use App\Service\Filter\Filter; +use Cycle\Database\Injection\Expression; +use Cycle\Database\Injection\Fragment; +use Cycle\Database\Query\SelectQuery; use Cycle\ORM\Select\AbstractLoader; use Cycle\ORM\Select\Repository; use Cycle\Database\Injection\Parameter; @@ -230,4 +233,25 @@ public function countAllByUserPKByType(int $userID, string $type = null): int return $query->count(); } + + public function searchTitle(int $userID, string $query = '', int $limit = 10): array + { + $builder = $this->select()->getBuilder(); + $titleCol = $builder->resolve('title'); + $q = $builder->getQuery(); + + return $q?->columns([$titleCol, new Expression("count({$titleCol}) as count")]) + ->from('charges charge') + ->rightJoin('user_wallets') + ->on($builder->resolve('wallet_id'), '=', 'user_wallets.wallet_id') + ->on('user_wallets.user_id', '=', new Parameter($userID)) + ->where($titleCol, 'like', new Fragment("concat('%', ?, '%')", $query, $query, $query, $query)) + ->groupBy($titleCol) + ->orderBy(new Fragment("{$titleCol} like concat(?, '%')"), SelectQuery::SORT_DESC) + ->orderBy(new Fragment("ifnull(nullif(instr({$titleCol}, concat(' ', ?)), 0), 99999)")) + ->orderBy(new Expression("count({$titleCol})"), SelectQuery::SORT_DESC) + ->orderBy(new Fragment("ifnull(nullif(instr({$titleCol}, ?), 0), 99999)")) + ->limit($limit) + ->fetchAll(); + } } diff --git a/app/src/View/ChargeTitleView.php b/app/src/View/ChargeTitleView.php new file mode 100644 index 00000000..0ce10f10 --- /dev/null +++ b/app/src/View/ChargeTitleView.php @@ -0,0 +1,29 @@ + 'chargeTitle', + 'title' => $chargeTitle['title'] ?? null, + 'count' => $chargeTitle['count'] ?? 0, + ]; + } +} diff --git a/app/src/View/ChargeTitlesView.php b/app/src/View/ChargeTitlesView.php new file mode 100644 index 00000000..bf99cfd5 --- /dev/null +++ b/app/src/View/ChargeTitlesView.php @@ -0,0 +1,30 @@ +response->json([ + 'data' => $this->map($titles), + ]); + } + + public function map(array $titles): array + { + return array_map([$this->chargeTitleView, 'map'], $titles); + } +} diff --git a/tests/Feature/Controller/Charges/SuggestionsControllerTest.php b/tests/Feature/Controller/Charges/SuggestionsControllerTest.php new file mode 100644 index 00000000..f97e7d92 --- /dev/null +++ b/tests/Feature/Controller/Charges/SuggestionsControllerTest.php @@ -0,0 +1,102 @@ +userFactory = $this->getContainer()->get(UserFactory::class); + $this->walletFactory = $this->getContainer()->get(WalletFactory::class); + $this->chargeFactory = $this->getContainer()->get(ChargeFactory::class); + } + + public function testSuggestionsRequireAuth() + { + $query = Fixtures::string(); + + $response = $this->get("/charges/title/suggestions/{$query}"); + + $response->assertUnauthorized(); + } + + public function testSuggestionsReturnSuggestedTitles() + { + $auth = $this->makeAuth($user = $this->userFactory->create()); + $sharedUser = $this->userFactory->create(); + $otherUser = $this->userFactory->create(); + + $otherWallet = $this->walletFactory->forUser($otherUser)->create(); + + $wallet = WalletFactory::make(); + $wallet->users->add($sharedUser); + $wallet = $this->walletFactory->forUser($user)->create($wallet); + + $chargeUser1 = ChargeFactory::make(); + $chargeUser2 = ChargeFactory::make(); + $chargeUser3 = ChargeFactory::make(); + $chargeUser4 = ChargeFactory::make(); + $chargeUser5 = ChargeFactory::make(); + $chargeUser6 = ChargeFactory::make(); + $chargeSharedUser = ChargeFactory::make(); + $chargeOtherUser = ChargeFactory::make(); + + $chargeUser1->title = 'Charge title 1'; + $chargeUser2->title = 'Charge title 2'; + $chargeUser3->title = 'Other charge title 3'; + $chargeUser4->title = 'Other charge title 4'; + $chargeUser5->title = 'Other charge title 5'; + $chargeUser6->title = 'Other item outside 5'; + $chargeSharedUser->title = 'Charge title shared 1'; + $chargeOtherUser->title = 'Charge title other 1'; + $query = 'charge'; + + $this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser1); + $this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser2); + $this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser3); + $this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser4); + $this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser5); + $this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser6); + $this->chargeFactory->forUser($sharedUser)->forWallet($wallet)->create($chargeSharedUser); + $this->chargeFactory->forUser($otherUser)->forWallet($otherWallet)->create($chargeOtherUser); + + $response = $this->withAuth($auth)->get("/charges/title/suggestions/{$query}"); + + $response->assertOk(); + + $body = $this->getJsonResponseBody($response); + + $this->assertIsArray($body); + $this->assertArrayHasKey('data', $body); + + $this->assertCount(6, $body['data']); + + foreach ([$chargeSharedUser, $chargeUser1, $chargeUser2, $chargeUser3, $chargeUser5, $chargeUser4] as $charge) { + $this->assertArrayContains($charge->title, $body, 'data.*.title'); + } + + foreach ([$chargeUser6, $chargeOtherUser] as $charge) { + $this->assertArrayNotContains($charge->title, $body, 'data.*.title'); + } + } +} diff --git a/tests/Feature/View/ChargeTitleViewTest.php b/tests/Feature/View/ChargeTitleViewTest.php new file mode 100644 index 00000000..7348685a --- /dev/null +++ b/tests/Feature/View/ChargeTitleViewTest.php @@ -0,0 +1,18 @@ +getContainer()->get(ChargeTitleView::class); + + $this->assertNull($view->map(null)); + } +}