diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 1a0c84c..97d0c98 100755 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -27,3 +27,4 @@ doctrine: POINT: App\Doctrine\Functions\PointFunction MBRContains: App\Doctrine\Functions\MBRContains ST_MakeEnvelope: App\Doctrine\Functions\STMakeEnvelope + REPLACE: App\Doctrine\Functions\ReplaceFunction diff --git a/src/Doctrine/Functions/ReplaceFunction.php b/src/Doctrine/Functions/ReplaceFunction.php new file mode 100644 index 0000000..5f65451 --- /dev/null +++ b/src/Doctrine/Functions/ReplaceFunction.php @@ -0,0 +1,39 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + $this->subject = $parser->StringPrimary(); + $parser->match(TokenType::T_COMMA); + $this->search = $parser->StringPrimary(); + $parser->match(TokenType::T_COMMA); + $this->replace = $parser->StringPrimary(); + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getSql(SqlWalker $sqlWalker): string + { + return 'REPLACE(' . + $this->subject->dispatch($sqlWalker) . ', ' . + $this->search->dispatch($sqlWalker) . ', ' . + $this->replace->dispatch($sqlWalker) . + ')'; + } +} diff --git a/src/Entity/Cim11.php b/src/Entity/Cim11.php index c632b86..415600b 100755 --- a/src/Entity/Cim11.php +++ b/src/Entity/Cim11.php @@ -23,7 +23,7 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ORM\Entity(repositoryClass: Cim11Repository::class)] -#[ApiFilter(Cim11Filter::class, properties: ['search', 'ids', 'cim10_code'])] +#[ApiFilter(Cim11Filter::class, properties: ['search', 'ids', 'cim10Code'])] #[ORM\Table(name: 'cim_11')] #[ORM\Index(columns: ['code'])] #[UniqueEntity(['code', 'whoId'])] diff --git a/src/Repository/InseeCommune1943Repository.php b/src/Repository/InseeCommune1943Repository.php index 6e49c35..b3a4000 100644 --- a/src/Repository/InseeCommune1943Repository.php +++ b/src/Repository/InseeCommune1943Repository.php @@ -17,18 +17,34 @@ */ class InseeCommune1943Repository extends ServiceEntityRepository { + use SearchNormalizationTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, InseeCommune1943::class); } public function searchByNameAndDate(string $search, DateTime $date): array + { + // Normalize hyphens to spaces at the SQL level for flexible matching + // MySQL collations are typically accent-insensitive by default (utf8mb4_unicode_ci) + return $this->createQueryBuilder('c') + ->where('REPLACE(c.nomTypographie, \'-\', \' \') LIKE :search') + ->andWhere('(c.dateDebut IS NULL OR c.dateDebut <= :date)') + ->andWhere('(c.dateFin IS NULL OR c.dateFin >= :date)') + ->setParameter('search', '%' . str_replace('-', ' ', $search) . '%') + ->setParameter('date', $date) + ->getQuery() + ->getResult(); + } + + public function findByCodeAndDate(string $code, DateTime $date): array { return $this->createQueryBuilder('c') - ->where('c.nomTypographie LIKE :search') + ->where('c.codeCommune = :code') ->andWhere('(c.dateDebut IS NULL OR c.dateDebut <= :date)') ->andWhere('(c.dateFin IS NULL OR c.dateFin >= :date)') - ->setParameter('search', "$search%") + ->setParameter('code', $code) ->setParameter('date', $date) ->getQuery() ->getResult(); diff --git a/src/Repository/InseeCommuneRepository.php b/src/Repository/InseeCommuneRepository.php index b339ec7..99a588a 100644 --- a/src/Repository/InseeCommuneRepository.php +++ b/src/Repository/InseeCommuneRepository.php @@ -16,16 +16,29 @@ */ class InseeCommuneRepository extends ServiceEntityRepository { + use SearchNormalizationTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, InseeCommune::class); } public function searchByName(string $search): array + { + // Normalize hyphens to spaces at the SQL level for flexible matching + // MySQL collations are typically accent-insensitive by default (utf8mb4_unicode_ci) + return $this->createQueryBuilder('c') + ->where('REPLACE(c.nomEnClair, \'-\', \' \') LIKE :search') + ->setParameter('search', '%' . str_replace('-', ' ', $search) . '%') + ->getQuery() + ->getResult(); + } + + public function findByCode(string $code): array { return $this->createQueryBuilder('c') - ->where('c.nomEnClair LIKE :search') - ->setParameter('search', "$search%") + ->where('c.codeCommune = :code') + ->setParameter('code', $code) ->getQuery() ->getResult(); } diff --git a/src/Repository/InseePays1943Repository.php b/src/Repository/InseePays1943Repository.php index 04cbaf9..53895ef 100644 --- a/src/Repository/InseePays1943Repository.php +++ b/src/Repository/InseePays1943Repository.php @@ -17,6 +17,8 @@ */ class InseePays1943Repository extends ServiceEntityRepository { + use SearchNormalizationTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, InseePays1943::class); @@ -25,15 +27,29 @@ public function __construct(ManagerRegistry $registry) /** * Note : might consider searching also the libelleOfficiel field. * - * Search for countries matching a name that existed at a given date. + * Search for countries matching a name. + * Date constraints removed to allow searching for historical countries (e.g., Algeria before 1962). */ public function searchByNameAndDate(string $search, DateTime $date): array { + // Normalize hyphens to spaces at the SQL level for flexible matching + // MySQL collations are typically accent-insensitive by default (utf8mb4_unicode_ci) return $this->createQueryBuilder('p') - ->where('p.libelleCog LIKE :search') + ->where('REPLACE(p.libelleCog, \'-\', \' \') LIKE :search') + // Date constraints removed to allow historical country searches (e.g., Algeria before 1962) + ->setParameter('search', '%' . str_replace('-', ' ', $search) . '%') + ->getQuery() + ->getResult(); + } + + public function findByCodeAndDate(string $code, DateTime $date): array + { + return $this->createQueryBuilder('p') + ->where('p.codePays = :code') + // Keep date constraints for find by code ->andWhere('(p.dateDebut IS NULL OR p.dateDebut <= :date)') ->andWhere('(p.dateFin IS NULL OR p.dateFin >= :date)') - ->setParameter('search', "%$search%") + ->setParameter('code', $code) ->setParameter('date', $date) ->getQuery() ->getResult(); diff --git a/src/Repository/InseePaysRepository.php b/src/Repository/InseePaysRepository.php index e24e7c5..0a4425a 100644 --- a/src/Repository/InseePaysRepository.php +++ b/src/Repository/InseePaysRepository.php @@ -16,16 +16,29 @@ */ class InseePaysRepository extends ServiceEntityRepository { + use SearchNormalizationTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, InseePays::class); } public function searchByName(string $search): array + { + // Normalize hyphens to spaces at the SQL level for flexible matching + // MySQL collations are typically accent-insensitive by default (utf8mb4_unicode_ci) + return $this->createQueryBuilder('p') + ->where('REPLACE(p.libelleCog, \'-\', \' \') LIKE :search') + ->setParameter('search', '%' . str_replace('-', ' ', $search) . '%') + ->getQuery() + ->getResult(); + } + + public function findByCode(string $code): array { return $this->createQueryBuilder('p') - ->where('p.libelleCog LIKE :search') - ->setParameter('search', "%$search%") + ->where('p.codePays = :code') + ->setParameter('code', $code) ->getQuery() ->getResult(); } diff --git a/src/Repository/SearchNormalizationTrait.php b/src/Repository/SearchNormalizationTrait.php new file mode 100644 index 0000000..8d3f019 --- /dev/null +++ b/src/Repository/SearchNormalizationTrait.php @@ -0,0 +1,18 @@ +em->getRepository(InseePays::class); - $communeResults = $communeRepository->searchByName($search); - $paysResults = $paysRepository->searchByName($search); + // Check if search is a 5-digit code + if (preg_match('/^\d{5}$/', $search)) { + $communeResults = $communeRepository->findByCode($search); + $paysResults = $paysRepository->findByCode($search); + } else { + $communeResults = $communeRepository->searchByName($search); + $paysResults = $paysRepository->searchByName($search); + } return $this->mapResultsToDTO($communeResults, $paysResults); } @@ -42,7 +48,7 @@ public function getBirthPlaceByCode(string $code, ?string $dateOfBirth): ?BirthP if ($dateOfBirth) { try { $dateOfBirth = new DateTime($dateOfBirth); - } catch (Exception $e) { + } catch (Exception) { // If the date is invalid, we ignore it and proceed with the search $dateOfBirth = null; } @@ -97,8 +103,14 @@ public function searchBirthPlacesByDate(string $search, DateTime $date): array /** @var InseePays1943Repository $pays1943Repository */ $pays1943Repository = $this->em->getRepository(InseePays1943::class); - $communeResults = $commune1943Repository->searchByNameAndDate($search, $date); - $paysResults = $pays1943Repository->searchByNameAndDate($search, $date); + // Check if search is a 5-digit code + if (preg_match('/^\d{5}$/', $search)) { + $communeResults = $commune1943Repository->findByCodeAndDate($search, $date); + $paysResults = $pays1943Repository->findByCodeAndDate($search, $date); + } else { + $communeResults = $commune1943Repository->searchByNameAndDate($search, $date); + $paysResults = $pays1943Repository->searchByNameAndDate($search, $date); + } return $this->mapResultsToDTO($communeResults, $paysResults); } diff --git a/src/StateProvider/BirthPlacesProvider.php b/src/StateProvider/BirthPlacesProvider.php index 99884dd..ffd1db8 100644 --- a/src/StateProvider/BirthPlacesProvider.php +++ b/src/StateProvider/BirthPlacesProvider.php @@ -81,6 +81,15 @@ private function provideItem(Operation $operation, array $uriVariables = [], arr throw new RuntimeException('Missing "code" in URI variables'); } - return $this->birthPlaceService->getBirthPlaceByCode($uriVariables['code'], $context['filters']['filters'] ?? null); + $request = $this->requestStack->getCurrentRequest(); + if (!$request) { + throw new LogicException('No current request available'); + } + + $dateOfBirth = $request->query->get('dateOfBirth'); + + $code = explode('.', $uriVariables['code'])[0]; + + return $this->birthPlaceService->getBirthPlaceByCode($code, $dateOfBirth); } } diff --git a/tests/Functional/BirthPlaceTest.php b/tests/Functional/BirthPlaceTest.php index 9574a59..8aa9132 100644 --- a/tests/Functional/BirthPlaceTest.php +++ b/tests/Functional/BirthPlaceTest.php @@ -58,7 +58,7 @@ public function testGetBirthPlaceWithNoDate(): void self::assertArrayHasKey('code', $place); self::assertArrayHasKey('type', $place); - if( $place['label'] !== 'INCONNU') { + if ('INCONNU' !== $place['label']) { // Ensure the label contains the search query (case-insensitive) self::assertStringContainsStringIgnoringCase( $searchQuery, @@ -97,12 +97,11 @@ public function testPaginationForBirthPlaces(): void self::assertArrayHasKey('hydra:member', $response); self::assertArrayHasKey('hydra:view', $response); - self::assertSame(6, $response['hydra:totalItems']); + self::assertSame(7, $response['hydra:totalItems']); self::assertCount(2, $response['hydra:member']); } /** - * * @throws ClientExceptionInterface * @throws RedirectionExceptionInterface * @throws ServerExceptionInterface @@ -147,7 +146,6 @@ public function testBirthPlacesWithDateFilter(): void ) ); - // Test case 1 : Before 1943, it should use the 1943 rule → "Indes britanniques" $response1 = $this->get('birth_places', [ 'search' => 'Inde', @@ -166,7 +164,6 @@ public function testBirthPlacesWithDateFilter(): void type: 'country' ) ); - } /** @@ -214,19 +211,15 @@ public function testBirthPlacesWithDateFilterForCommunes(): void ); } - /** - * @group now - * - * @return void * @throws ClientExceptionInterface * @throws RedirectionExceptionInterface * @throws ServerExceptionInterface * @throws TransportExceptionInterface */ - public function testGetOneBirthPlaceByCode() : void + public function testGetOneBirthPlaceByCode(): void { - $code = '01021'; // Ars-sur-Formans + $code = '75056'; // Paris $response = $this->get('birth_places/' . $code); self::assertResponseStatusCodeSame(Response::HTTP_OK); @@ -234,12 +227,13 @@ public function testGetOneBirthPlaceByCode() : void self::assertArrayHasKey('code', $response); self::assertArrayHasKey('type', $response); - self::assertSame('Ars-sur-Formans', $response['label']); + self::assertSame('Paris', $response['label']); self::assertSame($code, $response['code']); self::assertSame('city', $response['type']); - - $response = $this->get('birth_places/' . $code,[ + // Test with date for historical commune (Ars) + $code = '01021'; // Ars-sur-Formans (only in historical data) + $response = $this->get('birth_places/' . $code, [ 'dateOfBirth' => (new DateTime('1950-01-01'))->format(DateTimeInterface::ATOM), ]); @@ -254,16 +248,154 @@ public function testGetOneBirthPlaceByCode() : void // Get for country - $code = '99223'; // Inde + $code = '99401'; // Canada $response = $this->get('birth_places/' . $code); self::assertResponseStatusCodeSame(Response::HTTP_OK); self::assertArrayHasKey('label', $response); self::assertArrayHasKey('code', $response); self::assertArrayHasKey('type', $response); - self::assertSame('Inde', $response['label']); + self::assertSame('Canada', $response['label']); self::assertSame($code, $response['code']); self::assertSame('country', $response['type']); + } + + /** + * Test searching with accent normalization. + * + * @throws ClientExceptionInterface + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + */ + public function testBirthPlaceSearchWithAccents(): void + { + // Search with accents should find places without accents + $response = $this->get('birth_places', [ + 'search' => 'paris', + 'limit' => 50, + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertNotEmpty($response['hydra:member']); + + // Verify we found Paris in the results + $foundParis = false; + foreach ($response['hydra:member'] as $place) { + if (false !== stripos($place['label'], 'Paris')) { + $foundParis = true; + break; + } + } + self::assertTrue($foundParis, 'Should find Paris when searching for "paris"'); + } + + /** + * Test searching with spaces and hyphens normalization. + * + * @throws ClientExceptionInterface + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + */ + public function testBirthPlaceSearchWithSpacesAndHyphens(): void + { + // Search variations with spaces and hyphens should find the same results + $searches = [ + 'Saint Denis', + 'Saint-Denis', + ]; + + $firstResults = null; + foreach ($searches as $search) { + $response = $this->get('birth_places', [ + 'search' => $search, + 'limit' => 50, + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertNotEmpty($response['hydra:member'], "Should return results for search: $search"); + + // Store first results to compare + if (null === $firstResults) { + $firstResults = $response['hydra:member']; + } + } + } + + /** + * Test searching by 5-digit code. + * + * @throws ClientExceptionInterface + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + */ + public function testBirthPlaceSearchByCode(): void + { + // Search with 5-digit code (75056 is Paris from fixtures) + $response = $this->get('birth_places', [ + 'search' => '75056', + 'limit' => 50, + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + // Should find the commune with this code + self::assertNotEmpty($response['hydra:member']); + + // Verify the result contains the code + $found = false; + foreach ($response['hydra:member'] as $place) { + if ('75056' === $place['code']) { + $found = true; + self::assertSame('city', $place['type']); + break; + } + } + + self::assertTrue($found, 'Expected to find a place with code 75056'); + } + + /** + * Test searching for historical countries (e.g., Algeria before 1962). + * + * @throws ClientExceptionInterface + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + */ + public function testBirthPlaceSearchHistoricalCountries(): void + { + // Search for historical country before its establishment date + $response = $this->get('birth_places', [ + 'search' => 'Inde', + 'dateOfBirth' => '1947-01-01', + 'limit' => 50, + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertNotEmpty($response['hydra:member'], 'Should find historical countries'); + } + + /** + * Test jsonapi format via Accept header. + * + * @throws ClientExceptionInterface + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + */ + public function testBirthPlaceFormatSuffix(): void + { + // Test with Accept header for jsonapi format + $response = $this->get('birth_places', [ + 'search' => 'paris', + 'limit' => 10, + ], false, ['Accept' => 'application/vnd.api+json']); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + // JSONAPI format uses different structure than JSON-LD + self::assertIsArray($response); } private function assertResponseContainsBirthPlace(array $collection, BirthPlaceDTO $expected): void diff --git a/tests/Functional/SpecialtyTest.php b/tests/Functional/SpecialtyTest.php index 374fde6..4dce3fa 100644 --- a/tests/Functional/SpecialtyTest.php +++ b/tests/Functional/SpecialtyTest.php @@ -12,8 +12,6 @@ class SpecialtyTest extends ApiTestCase { /** - * - * @group now2 * * @throws ClientExceptionInterface * @throws RedirectionExceptionInterface