Refactor PersonSearch and create PersonACLAwareRepository

The search api delegates the query to a person acl aware "repository"
(although this does not implements ObjectRepository interface).
This commit is contained in:
Julien Fastré 2021-09-10 17:44:01 +02:00
parent f87f03b5c0
commit 6bc83edfe9
10 changed files with 497 additions and 416 deletions

View File

@ -3,6 +3,7 @@
namespace Chill\MainBundle; namespace Chill\MainBundle;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface; use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\MainBundle\Search\SearchInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverInterface; use Chill\MainBundle\Security\Resolver\CenterResolverInterface;
use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;

View File

@ -23,45 +23,44 @@ namespace Chill\MainBundle\Search;
/** /**
* This interface must be implemented on services which provide search results. * This interface must be implemented on services which provide search results.
* *
* @todo : write doc and add a link to documentation * @todo : write doc and add a link to documentation
* *
* @author Julien Fastré <julien.fastre@champs-libres.coop> * @author Julien Fastré <julien.fastre@champs-libres.coop>
* *
*/ */
interface SearchInterface interface SearchInterface
{ {
const SEARCH_PREVIEW_OPTION = '_search_preview'; const SEARCH_PREVIEW_OPTION = '_search_preview';
/** /**
* Request parameters contained inside the `add_q` parameters * Request parameters contained inside the `add_q` parameters
*/ */
const REQUEST_QUERY_PARAMETERS = '_search_parameters'; const REQUEST_QUERY_PARAMETERS = '_search_parameters';
/** /**
* Supplementary parameters to the query string * Supplementary parameters to the query string
*/ */
const REQUEST_QUERY_KEY_ADD_PARAMETERS = 'add_q'; const REQUEST_QUERY_KEY_ADD_PARAMETERS = 'add_q';
/** /**
* return the result in a html string. The string will be inclued (as raw) * return the result in a html string. The string will be inclued (as raw)
* into a global view. * into a global view.
* *
* The global view may be : * The global view may be :
* {% for result as resultsFromDifferentSearchInterface %} * {% for result as resultsFromDifferentSearchInterface %}
* {{ result|raw }} * {{ result|raw }}
* {% endfor %} * {% endfor %}
* *
* **available options** : * **available options** :
* - SEARCH_PREVIEW_OPTION (boolean) : if renderResult should return a "preview" of * - SEARCH_PREVIEW_OPTION (boolean) : if renderResult should return a "preview" of
* the results. In this case, a subset of results should be returned, and, * the results. In this case, a subset of results should be returned, and,
* if the query return more results, a button "see all results" should be * if the query return more results, a button "see all results" should be
* displayed at the end of the list. * displayed at the end of the list.
* *
* **Interaction between `start` and `limit` and pagination : you should * **Interaction between `start` and `limit` and pagination : you should
* take only the given parameters into account; the results from pagination * take only the given parameters into account; the results from pagination
* should be ignored. (Most of the time, it should be the same). * should be ignored. (Most of the time, it should be the same).
* *
* @param array $terms the string to search * @param array $terms the string to search
* @param int $start the first result (for pagination) * @param int $start the first result (for pagination)
@ -72,10 +71,10 @@ interface SearchInterface
*/ */
public function renderResult(array $terms, $start=0, $limit=50, array $options = array(), $format = 'html'); public function renderResult(array $terms, $start=0, $limit=50, array $options = array(), $format = 'html');
/** /**
* we may desactive the search interface by default. in this case, * we may desactive the search interface by default. in this case,
* the search will be launch and rendered only with "advanced search" * the search will be launch and rendered only with "advanced search"
* *
* this may be activated/desactived from bundle definition in config.yml * this may be activated/desactived from bundle definition in config.yml
* *
* @return bool * @return bool
@ -84,18 +83,18 @@ interface SearchInterface
/** /**
* the order in which the results will appears in the global view * the order in which the results will appears in the global view
* *
* (this may be eventually defined in config.yml) * (this may be eventually defined in config.yml)
* *
* @return int * @return int
*/ */
public function getOrder(); public function getOrder();
/** /**
* indicate if the implementation supports the given domain * indicate if the implementation supports the given domain
* *
* @return boolean * @return boolean
*/ */
public function supports($domain, $format); public function supports($domain, $format);
} }

View File

@ -0,0 +1,272 @@
<?php
namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\CountryRepository;
use Chill\MainBundle\Search\ParsingException;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Role\Role;
use Symfony\Component\Security\Core\Security;
final class PersonACLAwareRepository implements PersonACLAwareRepositoryInterface
{
private Security $security;
private EntityManagerInterface $em;
private CountryRepository $countryRepository;
private AuthorizationHelper $authorizationHelper;
public function __construct(
Security $security,
EntityManagerInterface $em,
CountryRepository $countryRepository,
AuthorizationHelper $authorizationHelper
) {
$this->security = $security;
$this->em = $em;
$this->countryRepository = $countryRepository;
$this->authorizationHelper = $authorizationHelper;
}
/**
* @return array|Person[]
* @throws NonUniqueResultException
* @throws ParsingException
*/
public function findBySearchCriteria(
int $start,
int $limit,
bool $simplify = false,
string $default = null,
string $firstname = null,
string $lastname = null,
?\DateTime $birthdate = null,
?\DateTime $birthdateBefore = null,
?\DateTime $birthdateAfter = null,
string $gender = null,
string $countryCode = null
): array {
$qb = $this->createSearchQuery($default, $firstname, $lastname,
$birthdate, $birthdateBefore, $birthdateAfter, $gender,
$countryCode);
$this->addACLClauses($qb, 'p');
if ($simplify) {
$qb->select(
'p.id',
$qb->expr()->concat(
'p.firstName',
$qb->expr()->literal(' '),
'p.lastName'
).'AS text'
);
} else {
$qb->select('p');
}
$qb
->setMaxResults($limit)
->setFirstResult($start);
//order by firstname, lastname
$qb
->orderBy('p.firstName')
->addOrderBy('p.lastName');
if ($simplify) {
return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY);
} else {
return $qb->getQuery()->getResult();
}
}
public function countBySearchCriteria(
string $default = null,
string $firstname = null,
string $lastname = null,
?\DateTime $birthdate = null,
?\DateTime $birthdateBefore = null,
?\DateTime $birthdateAfter = null,
string $gender = null,
string $countryCode = null
): int {
$qb = $this->createSearchQuery($default, $firstname, $lastname,
$birthdate, $birthdateBefore, $birthdateAfter, $gender,
$countryCode);
$this->addACLClauses($qb, 'p');
$qb->select('COUNT(p.id)');
return $qb->getQuery()->getSingleScalarResult();
}
public function findBySimilaritySearch(string $pattern, int $firstResult,
int $maxResult, bool $simplify = false)
{
$qb = $this->createSimilarityQuery($pattern);
$this->addACLClauses($qb, 'sp');
if ($simplify) {
$qb->select(
'sp.id',
$qb->expr()->concat(
'sp.firstName',
$qb->expr()->literal(' '),
'sp.lastName'
).'AS text'
);
} else {
$qb->select('p');
}
$qb
->setMaxResults($maxResult)
->setFirstResult($firstResult);
//order by firstname, lastname
$qb
->orderBy('sp.firstName')
->addOrderBy('sp.lastName');
if ($simplify) {
return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY);
} else {
return $qb->getQuery()->getResult();
}
}
public function countBySimilaritySearch(string $pattern)
{
$qb = $this->createSimilarityQuery($pattern);
$this->addACLClauses($qb, 'sp');
$qb->select('COUNT(sp.id)');
return $qb->getQuery()->getSingleScalarResult();
}
private function createSearchQuery(
string $default = null,
string $firstname = null,
string $lastname = null,
?\DateTime $birthdate = null,
?\DateTime $birthdateBefore = null,
?\DateTime $birthdateAfter = null,
string $gender = null,
string $countryCode = null
): QueryBuilder {
if (!$this->security->getUser() instanceof User) {
throw new \RuntimeException("Search must be performed by a valid user");
}
$qb = $this->em->createQueryBuilder();
$qb->from(Person::class, 'p');
if (NULL !== $firstname) {
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.firstName))', ':firstname'))
->setParameter('firstname', '%'.$firstname.'%');
}
if (NULL !== $lastname) {
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.lastName))', ':lastname'))
->setParameter('lastname', '%'.$lastname.'%');
}
if (NULL !== $birthdate) {
$qb->andWhere($qb->expr()->eq('s.birthdate', ':birthdate'))
->setParameter('birthdate', $birthdate);
}
if (NULL !== $birthdateAfter) {
$qb->andWhere($qb->expr()->gt('p.birthdate', ':birthdateafter'))
->setParameter('birthdateafter', $birthdateAfter);
}
if (NULL !== $birthdateBefore) {
$qb->andWhere($qb->expr()->lt('p.birthdate', ':birthdatebefore'))
->setParameter('birthdatebefore', $birthdateBefore);
}
if (NULL !== $gender) {
$qb->andWhere($qb->expr()->eq('p.gender', ':gender'))
->setParameter('gender', $gender);
}
if (NULL !== $countryCode) {
try {
$country = $this->countryRepository->findOneBy(['countryCode' => $countryCode]);
} catch (NoResultException $ex) {
throw new ParsingException('The country code "'.$countryCode.'" '
. ', used in nationality, is unknow', 0, $ex);
} catch (NonUniqueResultException $e) {
throw $e;
}
$qb->andWhere($qb->expr()->eq('p.nationality', ':nationality'))
->setParameter('nationality', $country);
}
if (NULL !== $default) {
$grams = explode(' ', $default);
foreach($grams as $key => $gram) {
$qb->andWhere($qb->expr()
->like('p.fullnameCanonical', 'UNACCENT(LOWER(:default_'.$key.'))'))
->setParameter('default_'.$key, '%'.$gram.'%');
}
}
return $qb;
}
private function addACLClauses(QueryBuilder $qb, string $personAlias): void
{
// restrict center for security
$reachableCenters = $this->authorizationHelper
->getReachableCenters($this->security->getUser(), 'CHILL_PERSON_SEE');
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()
->in($personAlias.'.center', ':centers'),
$qb->expr()
->isNull($personAlias.'.center')
)
);
$qb->setParameter('centers', $reachableCenters);
}
private function createSimilarityQuery($pattern): QueryBuilder
{
$qb = $this->em->createQueryBuilder();
$qb->from(Person::class, 'sp');
$grams = explode(' ', $pattern);
foreach($grams as $key => $gram) {
$qb->andWhere('SIMILARITY(sp.fullnameCanonical, UNACCENT(LOWER(:default_'.$key.')) ) >= 0.15')
->setParameter('default_'.$key, '%'.$gram.'%');
}
$qb->andWhere($qb->expr()
->notIn(
'sp.id',
$this->createSearchQuery($pattern)
->addSelect('p.id')
->getDQL()
)
);
return $qb;
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Search\ParsingException;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\NonUniqueResultException;
interface PersonACLAwareRepositoryInterface
{
/**
* @return array|Person[]
* @throws NonUniqueResultException
* @throws ParsingException
*/
public function findBySearchCriteria(
int $start,
int $limit,
bool $simplify = false,
string $default = null,
string $firstname = null,
string $lastname = null,
?\DateTime $birthdate = null,
?\DateTime $birthdateBefore = null,
?\DateTime $birthdateAfter = null,
string $gender = null,
string $countryCode = null
): array;
public function countBySearchCriteria(
string $default = null,
string $firstname = null,
string $lastname = null,
?\DateTime $birthdate = null,
?\DateTime $birthdateBefore = null,
?\DateTime $birthdateAfter = null,
string $gender = null,
string $countryCode = null
);
public function findBySimilaritySearch(
string $pattern,
int $firstResult,
int $maxResult,
bool $simplify = false
);
public function countBySimilaritySearch(string $pattern);
}

View File

@ -23,9 +23,11 @@ use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use UnexpectedValueException;
final class PersonRepository final class PersonRepository implements ObjectRepository
{ {
private EntityRepository $repository; private EntityRepository $repository;
@ -44,6 +46,26 @@ final class PersonRepository
return $this->repository->findBy(['id' => $ids]); return $this->repository->findBy(['id' => $ids]);
} }
public function findAll()
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null)
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria)
{
return $this->repository->findOneBy($criteria);
}
public function getClassName()
{
return Person::class;
}
/** /**
* @param $centers * @param $centers
* @param $firstResult * @param $firstResult

View File

@ -20,71 +20,39 @@
namespace Chill\PersonBundle\Search; namespace Chill\PersonBundle\Search;
use Chill\MainBundle\Search\AbstractSearch; use Chill\MainBundle\Search\AbstractSearch;
use Doctrine\ORM\EntityManagerInterface; use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Search\SearchInterface; use Chill\MainBundle\Search\SearchInterface;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
use Chill\MainBundle\Search\ParsingException; use Chill\MainBundle\Search\ParsingException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Symfony\Component\Security\Core\Role\Role;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Form\Type\ChillDateType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Chill\MainBundle\Search\HasAdvancedSearchFormInterface; use Chill\MainBundle\Search\HasAdvancedSearchFormInterface;
use Doctrine\ORM\Query; use Symfony\Component\Templating\EngineInterface;
class PersonSearch extends AbstractSearch implements ContainerAwareInterface, class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterface
HasAdvancedSearchFormInterface
{ {
use ContainerAwareTrait; protected EngineInterface $templating;
protected PaginatorFactory $paginatorFactory;
/** protected PersonACLAwareRepositoryInterface $personACLAwareRepository;
*
* @var EntityManagerInterface
*/
private $em;
/**
*
* @var \Chill\MainBundle\Entity\User
*/
private $user;
/**
*
* @var AuthorizationHelper
*/
private $helper;
/**
*
* @var PaginatorFactory
*/
protected $paginatorFactory;
const NAME = "person_regular"; const NAME = "person_regular";
private const POSSIBLE_KEYS = [
'_default', 'firstname', 'lastname', 'birthdate', 'birthdate-before',
'birthdate-after', 'gender', 'nationality'
];
public function __construct( public function __construct(
EntityManagerInterface $em, EngineInterface $templating,
TokenStorageInterface $tokenStorage, PaginatorFactory $paginatorFactory,
AuthorizationHelper $helper, PersonACLAwareRepositoryInterface $personACLAwareRepository
PaginatorFactory $paginatorFactory) ) {
{ $this->templating = $templating;
$this->em = $em;
$this->user = $tokenStorage->getToken()->getUser();
$this->helper = $helper;
$this->paginatorFactory = $paginatorFactory; $this->paginatorFactory = $paginatorFactory;
$this->personACLAwareRepository = $personACLAwareRepository;
// throw an error if user is not a valid user
if (!$this->user instanceof \Chill\MainBundle\Entity\User) {
throw new \LogicException('The user provided must be an instance'
. ' of Chill\MainBundle\Entity\User');
}
} }
/* /*
@ -120,12 +88,14 @@ class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
$paginator = $this->paginatorFactory->create($total); $paginator = $this->paginatorFactory->create($total);
if ($format === 'html') { if ($format === 'html') {
return $this->container->get('templating')->render('@ChillPerson/Person/list_with_period.html.twig', return $this->templating->render('@ChillPerson/Person/list_with_period.html.twig',
array( array(
'persons' => $this->search($terms, $start, $limit, $options), 'persons' => $this->search($terms, $start, $limit, $options),
'pattern' => $this->recomposePattern($terms, array('nationality', 'pattern' => $this->recomposePattern(
'firstname', 'lastname', 'birthdate', 'gender', $terms,
'birthdate-before','birthdate-after'), $terms['_domain']), \array_filter(self::POSSIBLE_KEYS, fn($item) => $item !== '_default'),
$terms['_domain']
),
'total' => $total, 'total' => $total,
'start' => $start, 'start' => $start,
'search_name' => self::NAME, 'search_name' => self::NAME,
@ -152,158 +122,81 @@ class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
*/ */
protected function search(array $terms, $start, $limit, array $options = array()) protected function search(array $terms, $start, $limit, array $options = array())
{ {
$qb = $this->createQuery($terms, 'search'); [
'_default' => $default,
'firstname' => $firstname,
'lastname' => $lastname,
'birthdate' => $birthdate,
'birthdate-before' => $birthdateBefore,
'birthdate-after' => $birthdateAfter,
'gender' => $gender,
'nationality' => $countryCode,
if ($options['simplify'] ?? false) { ] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null);
$qb->select(
'p.id', foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) {
$qb->expr()->concat( if (NULL !== ${$v}) {
'p.firstName', try {
$qb->expr()->literal(' '), ${$v} = new \DateTime(${$v});
'p.lastName' } catch (\Exception $e) {
).'AS text' throw new ParsingException('The date is '
); . 'not parsable', 0, $e);
} else { }
$qb->select('p'); }
} }
$qb return $this->personACLAwareRepository
->setMaxResults($limit) ->findBySearchCriteria(
->setFirstResult($start); $start,
$limit,
//order by firstname, lastname $options['simplify'] ?? false,
$default,
$qb $firstname,
->orderBy('p.firstName') $lastname,
->addOrderBy('p.lastName'); $birthdate,
$birthdateBefore,
if ($options['simplify'] ?? false) { $birthdateAfter,
return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY); $gender,
} else { $countryCode,
return $qb->getQuery()->getResult(); );
}
} }
protected function count(array $terms) protected function count(array $terms): int
{ {
$qb = $this->createQuery($terms); [
'_default' => $default,
'firstname' => $firstname,
'lastname' => $lastname,
'birthdate' => $birthdate,
'birthdate-before' => $birthdateBefore,
'birthdate-after' => $birthdateAfter,
'gender' => $gender,
'nationality' => $countryCode,
$qb->select('COUNT(p.id)'); ] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null);
return $qb->getQuery()->getSingleScalarResult(); foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) {
} if (NULL !== ${$v}) {
try {
${$v} = new \DateTime(${$v});
private $_cacheQuery = array(); } catch (\Exception $e) {
throw new ParsingException('The date is '
/** . 'not parsable', 0, $e);
* }
* @param array $terms
* @return \Doctrine\ORM\QueryBuilder
*/
public function createQuery(array $terms)
{
//get from cache
$cacheKey = md5(serialize($terms));
if (array_key_exists($cacheKey, $this->_cacheQuery)) {
return clone $this->_cacheQuery[$cacheKey];
}
$qb = $this->em->createQueryBuilder();
$qb->from('ChillPersonBundle:Person', 'p');
if (array_key_exists('firstname', $terms)) {
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.firstName))', ':firstname'))
->setParameter('firstname', '%'.$terms['firstname'].'%');
}
if (array_key_exists('lastname', $terms)) {
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.lastName))', ':lastname'))
->setParameter('lastname', '%'.$terms['lastname'].'%');
}
foreach (['birthdate', 'birthdate-before', 'birthdate-after'] as $key)
if (array_key_exists($key, $terms)) {
try {
$date = new \DateTime($terms[$key]);
} catch (\Exception $ex) {
throw new ParsingException('The date is '
. 'not parsable', 0, $ex);
}
switch($key) {
case 'birthdate':
$qb->andWhere($qb->expr()->eq('p.birthdate', ':birthdate'))
->setParameter('birthdate', $date);
break;
case 'birthdate-before':
$qb->andWhere($qb->expr()->lt('p.birthdate', ':birthdatebefore'))
->setParameter('birthdatebefore', $date);
break;
case 'birthdate-after':
$qb->andWhere($qb->expr()->gt('p.birthdate', ':birthdateafter'))
->setParameter('birthdateafter', $date);
break;
default:
throw new \LogicException("this case $key should not exists");
}
}
if (array_key_exists('gender', $terms)) {
if (!in_array($terms['gender'], array(Person::MALE_GENDER, Person::FEMALE_GENDER))) {
throw new ParsingException('The gender '
.$terms['gender'].' is not accepted. Should be "'.Person::MALE_GENDER
.'" or "'.Person::FEMALE_GENDER.'"');
}
$qb->andWhere($qb->expr()->eq('p.gender', ':gender'))
->setParameter('gender', $terms['gender']);
}
if (array_key_exists('nationality', $terms)) {
try {
$country = $this->em->createQuery('SELECT c FROM '
. 'ChillMainBundle:Country c WHERE '
. 'LOWER(c.countryCode) LIKE :code')
->setParameter('code', $terms['nationality'])
->getSingleResult();
} catch (\Doctrine\ORM\NoResultException $ex) {
throw new ParsingException('The country code "'.$terms['nationality'].'" '
. ', used in nationality, is unknow', 0, $ex);
}
$qb->andWhere($qb->expr()->eq('p.nationality', ':nationality'))
->setParameter('nationality', $country);
}
if ($terms['_default'] !== '') {
$grams = explode(' ', $terms['_default']);
foreach($grams as $key => $gram) {
$qb->andWhere($qb->expr()
->like('p.fullnameCanonical', 'UNACCENT(LOWER(:default_'.$key.'))'))
->setParameter('default_'.$key, '%'.$gram.'%');
} }
} }
//restraint center for security return $this->personACLAwareRepository
$reachableCenters = $this->helper->getReachableCenters($this->user, ->countBySearchCriteria(
new Role('CHILL_PERSON_SEE')); $default,
$qb->andWhere( $firstname,
$qb->expr()->orX( $lastname,
$qb->expr() $birthdate,
->in('p.center', ':centers'), $birthdateBefore,
$qb->expr() $birthdateAfter,
->isNull('p.center') $gender,
) $countryCode,
); );
$qb->setParameter('centers', $reachableCenters);
$this->_cacheQuery[$cacheKey] = $qb;
return clone $qb;
} }
public function buildForm(FormBuilderInterface $builder) public function buildForm(FormBuilderInterface $builder)
@ -396,4 +289,10 @@ class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
return 'Search within persons'; return 'Search within persons';
} }
public static function getAlias(): string
{
return self::NAME;
}
} }

View File

@ -2,90 +2,32 @@
namespace Chill\PersonBundle\Search; namespace Chill\PersonBundle\Search;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Search\AbstractSearch; use Chill\MainBundle\Search\AbstractSearch;
use Chill\MainBundle\Search\SearchInterface; use Chill\MainBundle\Search\SearchInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper; use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Templating\EngineInterface;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Role\Role;
/**
* Class SimilarityPersonSearch
*
* @package Chill\PersonBundle\Search
*/
class SimilarityPersonSearch extends AbstractSearch class SimilarityPersonSearch extends AbstractSearch
{ {
protected PaginatorFactory $paginatorFactory;
use ContainerAwareTrait;
private PersonACLAwareRepositoryInterface $personACLAwareRepository;
/**
* private EngineInterface $templating;
* @var EntityManagerInterface
*/
private $em;
/**
*
* @var \Chill\MainBundle\Entity\User
*/
private $user;
/**
*
* @var AuthorizationHelper
*/
private $helper;
/**
*
* @var PaginatorFactory
*/
protected $paginatorFactory;
const NAME = "person_similarity"; const NAME = "person_similarity";
/**
*
* @var PersonSearch
*/
private $personSearch;
/**
* SimilarityPersonSearch constructor.
*
* @param EntityManagerInterface $em
* @param TokenStorageInterface $tokenStorage
* @param AuthorizationHelper $helper
* @param PaginatorFactory $paginatorFactory
* @param PersonSearch $personSearch
*/
public function __construct( public function __construct(
EntityManagerInterface $em,
TokenStorageInterface $tokenStorage,
AuthorizationHelper $helper,
PaginatorFactory $paginatorFactory, PaginatorFactory $paginatorFactory,
PersonSearch $personSearch) PersonACLAwareRepositoryInterface $personACLAwareRepository,
{ EngineInterface $templating
$this->em = $em; ) {
$this->user = $tokenStorage->getToken()->getUser();
$this->helper = $helper;
$this->paginatorFactory = $paginatorFactory; $this->paginatorFactory = $paginatorFactory;
$this->personSearch = $personSearch; $this->personACLAwareRepository = $personACLAwareRepository;
$this->templating = $templating;
// throw an error if user is not a valid user
if (!$this->user instanceof \Chill\MainBundle\Entity\User) {
throw new \LogicException('The user provided must be an instance'
. ' of Chill\MainBundle\Entity\User');
}
} }
/* /*
* (non-PHPdoc) * (non-PHPdoc)
* @see \Chill\MainBundle\Search\SearchInterface::getOrder() * @see \Chill\MainBundle\Search\SearchInterface::getOrder()
@ -94,7 +36,7 @@ class SimilarityPersonSearch extends AbstractSearch
{ {
return 200; return 200;
} }
/* /*
* (non-PHPdoc) * (non-PHPdoc)
* @see \Chill\MainBundle\Search\SearchInterface::isActiveByDefault() * @see \Chill\MainBundle\Search\SearchInterface::isActiveByDefault()
@ -103,30 +45,27 @@ class SimilarityPersonSearch extends AbstractSearch
{ {
return true; return true;
} }
public function supports($domain, $format) public function supports($domain, $format)
{ {
return 'person' === $domain; return 'person' === $domain;
} }
/** /**
* @param array $terms * @param array $terms
* @param int $start * @param int $start
* @param int $limit * @param int $limit
* @param array $options * @param array $options
* @param string $format * @param string $format
* @return array
*/ */
public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html') public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html')
{ {
$total = $this->count($terms); $total = $this->count($terms);
$paginator = $this->paginatorFactory->create($total); $paginator = $this->paginatorFactory->create($total);
if ($format === 'html') if ($format === 'html') {
{ if ($total !== 0) {
if ($total !== 0) return $this->templating->render('ChillPersonBundle:Person:list.html.twig',
{
return $this->container->get('templating')->render('ChillPersonBundle:Person:list.html.twig',
array( array(
'persons' => $this->search($terms, $start, $limit, $options), 'persons' => $this->search($terms, $start, $limit, $options),
'pattern' => $this->recomposePattern($terms, array('nationality', 'pattern' => $this->recomposePattern($terms, array('nationality',
@ -143,9 +82,8 @@ class SimilarityPersonSearch extends AbstractSearch
else { else {
return null; return null;
} }
} elseif ($format === 'json') } elseif ($format === 'json') {
{
return [ return [
'results' => $this->search($terms, $start, $limit, \array_merge($options, [ 'simplify' => true ])), 'results' => $this->search($terms, $start, $limit, \array_merge($options, [ 'simplify' => true ])),
'pagination' => [ 'pagination' => [
@ -154,8 +92,7 @@ class SimilarityPersonSearch extends AbstractSearch
]; ];
} }
} }
/** /**
* *
* @param string $pattern * @param string $pattern
@ -166,101 +103,12 @@ class SimilarityPersonSearch extends AbstractSearch
*/ */
protected function search(array $terms, $start, $limit, array $options = array()) protected function search(array $terms, $start, $limit, array $options = array())
{ {
$qb = $this->createQuery($terms, 'search'); return $this->personACLAwareRepository
->findBySimilaritySearch($terms['_default']);
if ($options['simplify'] ?? false) {
$qb->select(
'sp.id',
$qb->expr()->concat(
'sp.firstName',
$qb->expr()->literal(' '),
'sp.lastName'
).'AS text'
);
} else {
$qb->select('sp');
}
$qb
->setMaxResults($limit)
->setFirstResult($start);
//order by firstname, lastname
$qb
->orderBy('sp.firstName')
->addOrderBy('sp.lastName');
if ($options['simplify'] ?? false) {
return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY);
} else {
return $qb->getQuery()->getResult();
}
} }
protected function count(array $terms) protected function count(array $terms)
{ {
$qb = $this->createQuery($terms); return $this->personACLAwareRepository->countBySimilaritySearch($terms['_default']);
$qb->select('COUNT(sp.id)');
return $qb->getQuery()->getSingleScalarResult();
} }
}
private $_cacheQuery = array();
/**
*
* @param array $terms
* @return \Doctrine\ORM\QueryBuilder
*/
protected function createQuery(array $terms)
{
//get from cache
$cacheKey = md5(serialize($terms));
if (array_key_exists($cacheKey, $this->_cacheQuery)) {
return clone $this->_cacheQuery[$cacheKey];
}
$qb = $this->em->createQueryBuilder();
$qb ->select('sp')
->from('ChillPersonBundle:Person', 'sp');
if ($terms['_default'] !== '') {
$grams = explode(' ', $terms['_default']);
foreach($grams as $key => $gram) {
$qb->andWhere('SIMILARITY(sp.fullnameCanonical, UNACCENT(LOWER(:default_'.$key.')) ) >= 0.15')
->setParameter('default_'.$key, '%'.$gram.'%');
}
$qb->andWhere($qb->expr()
->notIn(
'sp.id',
$this->personSearch
->createQuery($terms)
->addSelect('p.id')
->getDQL()
)
);
}
//restraint center for security
$reachableCenters = $this->helper->getReachableCenters($this->user,
new Role('CHILL_PERSON_SEE'));
$qb->andWhere($qb->expr()
->in('sp.center', ':centers'))
->setParameter('centers', $reachableCenters)
;
$this->_cacheQuery[$cacheKey] = $qb;
return clone $qb;
}
}

View File

@ -70,7 +70,6 @@ services:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
resource: '../Repository/' resource: '../Repository/'
tags: ['doctrine.repository_service']
Chill\PersonBundle\Controller\: Chill\PersonBundle\Controller\:
autowire: true autowire: true

View File

@ -1,6 +1,11 @@
services: services:
# note: the services.yaml file define some autoloading
chill.person.repository.person: chill.person.repository.person:
class: Chill\PersonBundle\Repository\PersonRepository class: Chill\PersonBundle\Repository\PersonRepository
autowire: true autowire: true
autoconfigure: true autoconfigure: true
Chill\PersonBundle\Repository\PersonRepository: '@chill.person.repository.person' Chill\PersonBundle\Repository\PersonRepository: '@chill.person.repository.person'
Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface: '@Chill\PersonBundle\Repository\PersonACLAwareRepository'

View File

@ -1,25 +1,11 @@
services: services:
chill.person.search_person: Chill\PersonBundle\Search\PersonSearch:
class: Chill\PersonBundle\Search\PersonSearch autowire: true
arguments:
- "@doctrine.orm.entity_manager"
- "@security.token_storage"
- "@chill.main.security.authorization.helper"
- "@chill_main.paginator_factory"
calls:
- ['setContainer', ["@service_container"]]
tags: tags:
- { name: chill.search, alias: 'person_regular' } - { name: chill.search, alias: 'person_regular' }
Chill\PersonBundle\Search\SimilarityPersonSearch: Chill\PersonBundle\Search\SimilarityPersonSearch:
arguments: autowire: true
- "@doctrine.orm.entity_manager"
- "@security.token_storage"
- "@chill.main.security.authorization.helper"
- "@chill_main.paginator_factory"
- '@chill.person.search_person'
calls:
- ['setContainer', ["@service_container"]]
tags: tags:
- { name: chill.search, alias: 'person_similarity' } - { name: chill.search, alias: 'person_similarity' }