Files
chill-bundles/src/Bundle/ChillMainBundle/Repository/AddressReferenceRepository.php
Julien Fastré 34b3e290e1 [WIP] Add PostalCodeForAddressReferenceRepository and associated tests
Introduced `PostalCodeForAddressReferenceRepository` and its interface to support optimized postal code search using materialized views. Updated `AddressReferenceRepository` to improve query handling. Added test coverage for the new repository functionality.
2025-10-05 21:04:37 +02:00

271 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\AddressReference;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NativeQuery;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
final readonly class AddressReferenceRepository implements AddressReferenceRepositoryInterface
{
private EntityManagerInterface $entityManager;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(AddressReference::class);
$this->entityManager = $entityManager;
}
public function countAll(): int
{
$qb = $this->repository->createQueryBuilder('ar');
$qb->select('count(ar.id)');
return $qb->getQuery()->getSingleScalarResult();
}
public function countByPostalCodePattern(PostalCode $postalCode, string $pattern): int
{
$query = $this->buildQueryByPostalCodePattern($postalCode, $pattern);
$sql = $query->buildQuery(true);
$rsm = new ResultSetMapping();
$rsm->addScalarResult('c', 'c');
$nq = $this->entityManager->createNativeQuery($sql, $rsm)->setParameters($query->buildParameters(true));
return (int) $nq->getSingleResult()['c'];
}
public function find($id, $lockMode = null, $lockVersion = null): ?AddressReference
{
return $this->repository->find($id, $lockMode, $lockVersion);
}
/**
* @return AddressReference[]
*/
public function findAll(): array
{
return $this->repository->findAll();
}
public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$connection = $this->entityManager->getConnection();
$qb = $connection->createQueryBuilder();
$qb->select('row_number() OVER () AS row_number', 'var.street AS street', 'cmpc.id AS postcode_id', 'cmpc.code AS code', 'cmpc.label AS label', 'jsonb_object_agg(var.address_id, var.streetnumber ORDER BY var.row_number) AS positions')
->from('view_chill_main_address_reference', 'var')
->innerJoin('var', 'chill_main_postal_code', 'cmpc', 'cmpc.id = var.postcode_id')
->groupBy('cmpc.id', 'var.street')
->setFirstResult($firstResult)
->setMaxResults($maxResults);
$paramId = 0;
foreach ($terms as $term) {
$qb->andWhere('var.address like ?');
$qb->setParameter(++$paramId, "%{$term}%");
}
if (null !== $postalCode) {
$qb->andWhere('var.postcode_id = ?');
$qb->setParameter(++$paramId, $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode);
}
$result = $qb->executeQuery();
return $result->iterateAssociative();
}
/**
* @return iterable<AddressReference>
*/
public function findBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(AddressReference::class, 'ar');
$baseSql = 'SELECT '.$rsm->generateSelectClause(['ar' => 'ar']).' FROM chill_main_address_reference ar JOIN
view_chill_main_address_reference var ON var.address_id = ar.id';
$nql = $this->buildQueryBySearchString($rsm, $baseSql, $terms, $postalCode);
$orderBy = [];
$pertinence = [];
foreach ($terms as $k => $term) {
$pertinence[] =
"(EXISTS (SELECT 1 FROM unnest(string_to_array(address, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(:order{$k})))))::int";
$pertinence[] = "(address LIKE UNACCENT(LOWER(:order{$k})))::int";
$nql->setParameter('order'.$k, $term);
}
$orderBy[] = implode(' + ', $pertinence).' ASC';
$orderBy[] = implode('row_number ASC', $orderBy);
$nql->setSQL($nql->getSQL().' ORDER BY '.implode(', ', $orderBy));
return $nql->toIterable();
}
public function countBySearchString(string $search, PostalCode|int|null $postalCode = null): int
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return 0;
}
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addScalarResult('c', 'c', Types::INTEGER);
$nql = $this->buildQueryBySearchString($rsm, 'SELECT COUNT(var.*) AS c FROM view_chill_main_address_reference var', $terms, $postalCode);
return $nql->getSingleScalarResult();
}
private function buildTermsFromSearchString(string $search): array
{
return array_filter(
array_map(
static fn (string $term) => trim($term),
explode(' ', $search)
),
static fn (string $term) => '' !== $term
);
}
private function buildQueryBySearchString(ResultSetMapping $rsm, string $select, array $terms, PostalCode|int|null $postalCode = null): NativeQuery
{
$nql = $this->entityManager->createNativeQuery('', $rsm);
$sql = $select.' WHERE ';
$wheres = [];
foreach ($terms as $k => $term) {
$wheres[] = "var.address like :w{$k}";
$nql->setParameter("w{$k}", '%'.$term.'%', Types::STRING);
}
if (null !== $postalCode) {
$wheres[] = 'var.postcode_id = :postalCode';
$nql->setParameter('postalCode', $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode);
}
$nql->setSQL($sql.implode(' AND ', $wheres));
return $nql;
}
/**
* @param mixed|null $limit
* @param mixed|null $offset
*
* @return AddressReference[]
*/
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
/**
* @return AddressReference[]|array
*/
public function findByPostalCodePattern(PostalCode $postalCode, string $pattern, bool $simplify = false, int $start = 0, int $limit = 50): array
{
$query = $this->buildQueryByPostalCodePattern($postalCode, $pattern);
if (!$simplify) {
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(AddressReference::class, 'cma');
$query->addSelectClause($rsm->generateSelectClause());
} else {
throw new \RuntimeException('not implemented');
}
$sql = \strtr(
$query->buildQuery().'ORDER BY pertinence DESC, lpad(streetnumber, 10, \'0\') ASC OFFSET ? LIMIT ? ',
// little hack for adding sql method to point
['cma.point AS point' => 'ST_AsGeojson(cma.point) AS point']
);
$parameters = [...$query->buildParameters(), $start, $limit];
return $this->entityManager->createNativeQuery($sql, $rsm)
->setParameters($parameters)
->getResult();
}
public function findOneBy(array $criteria, ?array $orderBy = null): ?AddressReference
{
return $this->repository->findOneBy($criteria, $orderBy);
}
public function getClassName()
{
return AddressReference::class;
}
private function buildQueryByPostalCodePattern(PostalCode $postalCode, string $pattern): SearchApiQuery
{
$pattern = \trim($pattern);
if ('' === $pattern) {
throw new \RuntimeException('the search pattern must not be empty');
}
$query = new SearchApiQuery();
$query
->setFromClause('chill_main_address_reference cma')
->andWhereClause('postcode_id = ?', [$postalCode->getId()])
->andWhereClause('deletedAt IS NULL', []);
$pertinenceClause = ['STRICT_WORD_SIMILARITY(addresscanonical, UNACCENT(?))'];
$pertinenceArgs = [$pattern];
$andWhere = [];
$andWhereArgs = [];
foreach (\explode(' ', $pattern) as $part) {
$part = \trim($part);
if ('' === $part) {
continue;
}
$andWhere[] = "(addresscanonical LIKE '%' || UNACCENT(LOWER(?)) || '%')";
$andWhereArgs[] = $part;
$pertinenceClause[] =
"(EXISTS (SELECT 1 FROM unnest(string_to_array(addresscanonical, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(?)))))::int";
$pertinenceClause[] =
'(addresscanonical LIKE UNACCENT(LOWER(?)))::int';
array_push($pertinenceArgs, $part, $part);
}
$query
->setSelectPertinence(\implode(' + ', $pertinenceClause), $pertinenceArgs)
->andWhereClause(\implode(' AND ', $andWhere), $andWhereArgs);
return $query;
}
}