extend search api to users

This commit is contained in:
Julien Fastré 2021-08-16 14:39:18 +02:00
parent 6b4e27a531
commit 54c4524b27
6 changed files with 140 additions and 42 deletions

View File

@ -22,7 +22,9 @@
namespace Chill\MainBundle\Controller; namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Search\SearchApiNoQueryException;
use Chill\MainBundle\Serializer\Model\Collection; use Chill\MainBundle\Serializer\Model\Collection;
use GuzzleHttp\Psr7\Response;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Chill\MainBundle\Search\UnknowSearchDomainException; use Chill\MainBundle\Search\UnknowSearchDomainException;
@ -33,6 +35,7 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Chill\MainBundle\Search\SearchProvider; use Chill\MainBundle\Search\SearchProvider;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Search\SearchApi; use Chill\MainBundle\Search\SearchApi;
@ -46,15 +49,15 @@ use Symfony\Component\HttpFoundation\Exception\BadRequestException;
class SearchController extends AbstractController class SearchController extends AbstractController
{ {
protected SearchProvider $searchProvider; protected SearchProvider $searchProvider;
protected TranslatorInterface $translator; protected TranslatorInterface $translator;
protected PaginatorFactory $paginatorFactory; protected PaginatorFactory $paginatorFactory;
protected SearchApi $searchApi; protected SearchApi $searchApi;
function __construct( function __construct(
SearchProvider $searchProvider, SearchProvider $searchProvider,
TranslatorInterface $translator, TranslatorInterface $translator,
PaginatorFactory $paginatorFactory, PaginatorFactory $paginatorFactory,
SearchApi $searchApi SearchApi $searchApi
@ -65,14 +68,14 @@ class SearchController extends AbstractController
$this->searchApi = $searchApi; $this->searchApi = $searchApi;
} }
public function searchAction(Request $request, $_format) public function searchAction(Request $request, $_format)
{ {
$pattern = $request->query->get('q', ''); $pattern = $request->query->get('q', '');
if ($pattern === ''){ if ($pattern === ''){
switch($_format) { switch($_format) {
case 'html': case 'html':
return $this->render('@ChillMain/Search/error.html.twig', return $this->render('@ChillMain/Search/error.html.twig',
array( array(
'message' => $this->translator->trans("Your search is empty. " 'message' => $this->translator->trans("Your search is empty. "
@ -86,16 +89,16 @@ class SearchController extends AbstractController
]); ]);
} }
} }
$name = $request->query->get('name', NULL); $name = $request->query->get('name', NULL);
try { try {
if ($name === NULL) { if ($name === NULL) {
if ($_format === 'json') { if ($_format === 'json') {
return new JsonResponse('Currently, we still do not aggregate results ' return new JsonResponse('Currently, we still do not aggregate results '
. 'from different providers', JsonResponse::HTTP_BAD_REQUEST); . 'from different providers', JsonResponse::HTTP_BAD_REQUEST);
} }
// no specific search selected. Rendering result in "preview" mode // no specific search selected. Rendering result in "preview" mode
$results = $this->searchProvider $results = $this->searchProvider
->getSearchResults( ->getSearchResults(
@ -119,7 +122,7 @@ class SearchController extends AbstractController
), ),
$_format $_format
)]; )];
if ($_format === 'json') { if ($_format === 'json') {
return new JsonResponse(\reset($results)); return new JsonResponse(\reset($results));
} }
@ -141,8 +144,8 @@ class SearchController extends AbstractController
'pattern' => $pattern 'pattern' => $pattern
)); ));
} }
return $this->render('@ChillMain/Search/list.html.twig', return $this->render('@ChillMain/Search/list.html.twig',
array('results' => $results, 'pattern' => $pattern) array('results' => $results, 'pattern' => $pattern)
); );
@ -159,29 +162,33 @@ class SearchController extends AbstractController
." one type"); ." one type");
} }
$collection = $this->searchApi->getResults($query, $types, []); try {
$collection = $this->searchApi->getResults($query, $types, []);
} catch (SearchApiNoQueryException $e) {
throw new BadRequestHttpException($e->getMessage(), $e);
}
return $this->json($collection); return $this->json($collection, \Symfony\Component\HttpFoundation\Response::HTTP_OK, [], [ "groups" => ["read"]]);
} }
public function advancedSearchListAction(Request $request) public function advancedSearchListAction(Request $request)
{ {
/* @var $variable Chill\MainBundle\Search\SearchProvider */ /* @var $variable Chill\MainBundle\Search\SearchProvider */
$searchProvider = $this->searchProvider; $searchProvider = $this->searchProvider;
$advancedSearchProviders = $searchProvider $advancedSearchProviders = $searchProvider
->getHasAdvancedFormSearchServices(); ->getHasAdvancedFormSearchServices();
if(\count($advancedSearchProviders) === 1) { if(\count($advancedSearchProviders) === 1) {
\reset($advancedSearchProviders); \reset($advancedSearchProviders);
return $this->redirectToRoute('chill_main_advanced_search', [ return $this->redirectToRoute('chill_main_advanced_search', [
'name' => \key($advancedSearchProviders) 'name' => \key($advancedSearchProviders)
]); ]);
} }
return $this->render('@ChillMain/Search/choose_list.html.twig'); return $this->render('@ChillMain/Search/choose_list.html.twig');
} }
public function advancedSearchAction($name, Request $request) public function advancedSearchAction($name, Request $request)
{ {
try { try {
@ -190,22 +197,22 @@ class SearchController extends AbstractController
/* @var $variable Chill\MainBundle\Search\HasAdvancedSearchFormInterface */ /* @var $variable Chill\MainBundle\Search\HasAdvancedSearchFormInterface */
$search = $this->searchProvider $search = $this->searchProvider
->getHasAdvancedFormByName($name); ->getHasAdvancedFormByName($name);
} catch (\Chill\MainBundle\Search\UnknowSearchNameException $e) { } catch (\Chill\MainBundle\Search\UnknowSearchNameException $e) {
throw $this->createNotFoundException("no advanced search for " throw $this->createNotFoundException("no advanced search for "
. "$name"); . "$name");
} }
if ($request->query->has('q')) { if ($request->query->has('q')) {
$data = $search->convertTermsToFormData($searchProvider->parse( $data = $search->convertTermsToFormData($searchProvider->parse(
$request->query->get('q'))); $request->query->get('q')));
} }
$form = $this->createAdvancedSearchForm($name, $data ?? []); $form = $this->createAdvancedSearchForm($name, $data ?? []);
if ($request->isMethod(Request::METHOD_POST)) { if ($request->isMethod(Request::METHOD_POST)) {
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isValid()) { if ($form->isValid()) {
$pattern = $this->searchProvider $pattern = $this->searchProvider
->getHasAdvancedFormByName($name) ->getHasAdvancedFormByName($name)
@ -215,8 +222,8 @@ class SearchController extends AbstractController
'q' => $pattern, 'name' => $name 'q' => $pattern, 'name' => $name
]); ]);
} }
} }
return $this->render('@ChillMain/Search/advanced_search.html.twig', return $this->render('@ChillMain/Search/advanced_search.html.twig',
[ [
'form' => $form->createView(), 'form' => $form->createView(),
@ -224,15 +231,15 @@ class SearchController extends AbstractController
'title' => $search->getAdvancedSearchTitle() 'title' => $search->getAdvancedSearchTitle()
]); ]);
} }
protected function createAdvancedSearchForm($name, array $data = []) protected function createAdvancedSearchForm($name, array $data = [])
{ {
$builder = $this $builder = $this
->get('form.factory') ->get('form.factory')
->createNamedBuilder( ->createNamedBuilder(
null, null,
FormType::class, FormType::class,
$data, $data,
[ 'method' => Request::METHOD_POST ] [ 'method' => Request::METHOD_POST ]
); );
@ -240,12 +247,12 @@ class SearchController extends AbstractController
->getHasAdvancedFormByName($name) ->getHasAdvancedFormByName($name)
->buildForm($builder) ->buildForm($builder)
; ;
$builder->add('submit', SubmitType::class, [ $builder->add('submit', SubmitType::class, [
'label' => 'Search' 'label' => 'Search'
]); ]);
return $builder->getForm(); return $builder->getForm();
} }
} }

View File

@ -0,0 +1,59 @@
<?php
namespace Chill\MainBundle\Search\Entity;
use Chill\MainBundle\Repository\UserRepository;
use Chill\MainBundle\Search\SearchApiInterface;
use Chill\MainBundle\Search\SearchApiQuery;
class SearchUserApiProvider implements SearchApiInterface
{
private UserRepository $userRepository;
/**
* @param UserRepository $userRepository
*/
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function provideQuery(string $pattern, array $parameters): SearchApiQuery
{
$query = new SearchApiQuery();
$query
->setSelectKey("user")
->setSelectJsonbMetadata("jsonb_build_object('id', u.id)")
->setSelectPertinence("GREATEST(SIMILARITY(LOWER(UNACCENT(?)), u.usernamecanonical),
SIMILARITY(LOWER(UNACCENT(?)), u.emailcanonical))", [ $pattern, $pattern ])
->setFromClause("users AS u")
->setWhereClause("SIMILARITY(LOWER(UNACCENT(?)), u.usernamecanonical) > 0.15
OR
SIMILARITY(LOWER(UNACCENT(?)), u.emailcanonical) > 0.15
", [ $pattern, $pattern ]);
return $query;
}
public function supportsTypes(string $pattern, array $types, array $parameters): bool
{
return \in_array('user', $types);
}
public function prepare(array $metadatas): void
{
$ids = \array_map(fn($m) => $m['id'], $metadatas);
$this->userRepository->findBy([ 'id' => $ids ]);
}
public function supportsResult(string $key, array $metadatas): bool
{
return $key === 'user';
}
public function getResult(string $key, array $metadata, float $pertinence)
{
return $this->userRepository->find($metadata['id']);
}
}

View File

@ -2,6 +2,7 @@
namespace Chill\MainBundle\Search; namespace Chill\MainBundle\Search;
use Chill\MainBundle\Search\Entity\SearchUserApiProvider;
use Chill\MainBundle\Serializer\Model\Collection; use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\PersonBundle\Search\SearchPersonApiProvider; use Chill\PersonBundle\Search\SearchPersonApiProvider;
@ -25,12 +26,14 @@ class SearchApi
EntityManagerInterface $em, EntityManagerInterface $em,
SearchPersonApiProvider $searchPerson, SearchPersonApiProvider $searchPerson,
ThirdPartyApiSearch $thirdPartyApiSearch, ThirdPartyApiSearch $thirdPartyApiSearch,
SearchUserApiProvider $searchUser,
PaginatorFactory $paginator PaginatorFactory $paginator
) )
{ {
$this->em = $em; $this->em = $em;
$this->providers[] = $searchPerson; $this->providers[] = $searchPerson;
$this->providers[] = $thirdPartyApiSearch; $this->providers[] = $thirdPartyApiSearch;
$this->providers[] = $searchUser;
$this->paginator = $paginator; $this->paginator = $paginator;
} }
@ -41,6 +44,10 @@ class SearchApi
{ {
$queries = $this->findQueries($pattern, $types, $parameters); $queries = $this->findQueries($pattern, $types, $parameters);
if (0 === count($queries)) {
throw new SearchApiNoQueryException($pattern, $types, $parameters);
}
$total = $this->countItems($queries, $types, $parameters); $total = $this->countItems($queries, $types, $parameters);
$paginator = $this->paginator->create($total); $paginator = $this->paginator->create($total);
@ -49,9 +56,7 @@ class SearchApi
$this->prepareProviders($rawResults); $this->prepareProviders($rawResults);
$results = $this->buildResults($rawResults); $results = $this->buildResults($rawResults);
$collection = new Collection($results, $paginator); return new Collection($results, $paginator);
return $collection;
} }
private function findQueries($pattern, array $types, array $parameters): array private function findQueries($pattern, array $types, array $parameters): array
@ -77,7 +82,7 @@ class SearchApi
$rsmCount->addScalarResult('count', 'count'); $rsmCount->addScalarResult('count', 'count');
$countNq = $this->em->createNativeQuery($countQuery, $rsmCount); $countNq = $this->em->createNativeQuery($countQuery, $rsmCount);
$countNq->setParameters($parameters); $countNq->setParameters($parameters);
return $countNq->getSingleScalarResult(); return $countNq->getSingleScalarResult();
} }
@ -130,7 +135,7 @@ class SearchApi
$nq = $this->em->createNativeQuery($union, $rsm); $nq = $this->em->createNativeQuery($union, $rsm);
$nq->setParameters($parameters); $nq->setParameters($parameters);
return $nq->getResult(); return $nq->getResult();
} }
@ -142,7 +147,7 @@ class SearchApi
if ($p->supportsResult($r['key'], $r['metadata'])) { if ($p->supportsResult($r['key'], $r['metadata'])) {
$metadatas[$k][] = $r['metadata']; $metadatas[$k][] = $r['metadata'];
break; break;
} }
} }
} }
@ -161,7 +166,7 @@ class SearchApi
$p->getResult($r['key'], $r['metadata'], $r['pertinence']) $p->getResult($r['key'], $r['metadata'], $r['pertinence'])
); );
break; break;
} }
} }
} }

View File

@ -0,0 +1,23 @@
<?php
namespace Chill\MainBundle\Search;
use Throwable;
class SearchApiNoQueryException extends \RuntimeException
{
private string $pattern;
private array $types;
private array $parameters;
public function __construct(string $pattern = "", array $types = [], array $parameters = [], $code = 0, Throwable $previous = null)
{
$typesStr = \implode(", ", $types);
$message = "No query for this search: pattern : {$pattern}, types: {$typesStr}";
$this->pattern = $pattern;
$this->types = $types;
$this->parameters = $parameters;
parent::__construct($message, $code, $previous);
}
}

View File

@ -127,8 +127,6 @@ paths:
- person - person
- thirdparty - thirdparty
description: > description: >
**Warning**: This is currently a stub (not really implemented
The search is performed across multiple entities. The entities must be listed into The search is performed across multiple entities. The entities must be listed into
`type` parameters. `type` parameters.
@ -152,6 +150,7 @@ paths:
enum: enum:
- person - person
- thirdparty - thirdparty
- user
responses: responses:
200: 200:
description: "OK" description: "OK"

View File

@ -7,3 +7,8 @@ services:
Chill\MainBundle\Search\SearchApi: Chill\MainBundle\Search\SearchApi:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Chill\MainBundle\Search\Entity\:
autowire: true
autoconfigure: true
resource: '../../Search/Entity'