Issue316 addresses search by postal code

This commit is contained in:
Julien Fastré 2021-12-06 12:56:57 +00:00
parent 51bbcab878
commit 938720be52
18 changed files with 805 additions and 23 deletions

View File

@ -11,6 +11,9 @@ and this project adheres to
## Unreleased
<!-- write down unreleased development here -->
* [main] address: use search API end points for getting postal code and reference address (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/316)
* [main] address: in edit mode, select the encoded values in multiselect for address reference and city (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/316)
* [person search] fix bug when using birthdate after and birthdate before
* [person search] increase pertinence when lastname begins with search pattern

View File

@ -12,14 +12,66 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Repository\AddressReferenceRepository;
use Chill\MainBundle\Serializer\Model\Collection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use function trim;
/**
* Class AddressReferenceAPIController.
*/
class AddressReferenceAPIController extends ApiController
final class AddressReferenceAPIController extends ApiController
{
private AddressReferenceRepository $addressReferenceRepository;
private PaginatorFactory $paginatorFactory;
public function __construct(AddressReferenceRepository $addressReferenceRepository, PaginatorFactory $paginatorFactory)
{
$this->addressReferenceRepository = $addressReferenceRepository;
$this->paginatorFactory = $paginatorFactory;
}
/**
* @Route("/api/1.0/main/address-reference/by-postal-code/{id}/search.json")
*/
public function search(PostalCode $postalCode, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_USER');
if (!$request->query->has('q')) {
throw new BadRequestHttpException('You must supply a "q" parameter');
}
$pattern = $request->query->get('q');
if ('' === trim($pattern)) {
throw new BadRequestHttpException('the search pattern is empty');
}
$nb = $this->addressReferenceRepository->countByPostalCodePattern($postalCode, $pattern);
$paginator = $this->paginatorFactory->create($nb);
$addresses = $this->addressReferenceRepository->findByPostalCodePattern(
$postalCode,
$pattern,
false,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
return $this->json(
new Collection($addresses, $paginator),
Response::HTTP_OK,
[],
[AbstractNormalizer::GROUPS => ['read']]
);
}
protected function customizeQuery(string $action, Request $request, $qb): void
{
if ($request->query->has('postal_code')) {

View File

@ -12,13 +12,80 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\CountryRepository;
use Chill\MainBundle\Repository\PostalCodeRepository;
use Chill\MainBundle\Serializer\Model\Collection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
/**
* Class PostalCodeAPIController.
*/
class PostalCodeAPIController extends ApiController
final class PostalCodeAPIController extends ApiController
{
private CountryRepository $countryRepository;
private PaginatorFactory $paginatorFactory;
private PostalCodeRepository $postalCodeRepository;
public function __construct(
CountryRepository $countryRepository,
PostalCodeRepository $postalCodeRepository,
PaginatorFactory $paginatorFactory
) {
$this->countryRepository = $countryRepository;
$this->postalCodeRepository = $postalCodeRepository;
$this->paginatorFactory = $paginatorFactory;
}
/**
* @Route("/api/1.0/main/postal-code/search.json")
*/
public function search(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_USER');
if (!$request->query->has('q')) {
throw new BadRequestHttpException('You must supply a "q" parameter');
}
$pattern = $request->query->get('q');
if ('' === trim($pattern)) {
throw new BadRequestHttpException('the search pattern is empty');
}
if ($request->query->has('country')) {
$country = $this->countryRepository->find($request->query->getInt('country'));
if (null === $country) {
throw new NotFoundHttpException('country not found');
}
} else {
$country = null;
}
$nb = $this->postalCodeRepository->countByPattern($pattern, $country);
$paginator = $this->paginatorFactory->create($nb);
$codes = $this->postalCodeRepository->findByPattern(
$pattern,
$country,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
return $this->json(
new Collection($codes, $paginator),
Response::HTTP_OK,
[],
[AbstractNormalizer::GROUPS => ['read']]
);
}
protected function customizeQuery(string $action, Request $request, $qb): void
{
if ($request->query->has('country')) {

View File

@ -22,6 +22,15 @@ use Symfony\Component\Serializer\Annotation\Groups;
*/
class AddressReference
{
/**
* This is an internal column which is populated by database.
*
* This column will ease the search operations
*
* @ORM\Column(type="text", options={"default": ""})
*/
private string $addressCanonical = '';
/**
* @ORM\Id
* @ORM\GeneratedValue

View File

@ -29,6 +29,15 @@ use Symfony\Component\Serializer\Annotation\Groups;
*/
class PostalCode
{
/**
* This is an internal column which is populated by database.
*
* This column will ease the search operations
*
* @ORM\Column(type="text", options={"default": ""})
*/
private string $canonical = '';
/**
* @var Point
*

View File

@ -12,17 +12,29 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\AddressReference;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\Persistence\ObjectRepository;
use RuntimeException;
use function explode;
use function implode;
use function strtr;
use function trim;
final class AddressReferenceRepository implements ObjectRepository
{
private EntityManagerInterface $entityManager;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(AddressReference::class);
$this->entityManager = $entityManager;
}
public function countAll(): int
@ -33,6 +45,18 @@ final class AddressReferenceRepository implements ObjectRepository
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);
@ -57,6 +81,33 @@ final class AddressReferenceRepository implements ObjectRepository
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);
@ -66,4 +117,44 @@ final class AddressReferenceRepository implements ObjectRepository
{
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()]);
$pertinenceClause = ['STRICT_WORD_SIMILARITY(addresscanonical, UNACCENT(?))'];
$pertinenceArgs = [$pattern];
$orWhere = ['addresscanonical %>> UNACCENT(?)'];
$orWhereArgs = [$pattern];
foreach (explode(' ', $pattern) as $part) {
$part = trim($part);
if ('' === $part) {
continue;
}
$orWhere[] = "addresscanonical LIKE '%' || UNACCENT(LOWER(?)) || '%'";
$orWhereArgs[] = $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(' OR ', $orWhere), $orWhereArgs);
return $query;
}
}

View File

@ -11,18 +11,39 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Country;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\Persistence\ObjectRepository;
use RuntimeException;
final class PostalCodeRepository implements ObjectRepository
{
private EntityManagerInterface $entityManager;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(PostalCode::class);
$this->entityManager = $entityManager;
}
public function countByPattern(string $pattern, ?Country $country): int
{
$query = $this->buildQueryByPattern($pattern, $country);
$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): ?PostalCode
@ -49,6 +70,26 @@ final class PostalCodeRepository implements ObjectRepository
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findByPattern(string $pattern, ?Country $country, ?int $start = 0, ?int $limit = 50): array
{
$query = $this->buildQueryByPattern($pattern, $country);
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(PostalCode::class, 'cmpc');
$query->addSelectClause($rsm->generateSelectClause());
$sql = strtr(
$query->buildQuery() . 'ORDER BY pertinence DESC, canonical ASC OFFSET ? LIMIT ? ',
// little hack for adding sql method to point
['cmpc.center AS center' => 'ST_AsGeojson(cmpc.center) AS center']
);
$parameters = [...$query->buildParameters(), $start, $limit];
return $this->entityManager->createNativeQuery($sql, $rsm)
->setParameters($parameters)
->getResult();
}
public function findOneBy(array $criteria, ?array $orderBy = null): ?PostalCode
{
return $this->repository->findOneBy($criteria, $orderBy);
@ -58,4 +99,48 @@ final class PostalCodeRepository implements ObjectRepository
{
return PostalCode::class;
}
private function buildQueryByPattern(string $pattern, ?Country $country): SearchApiQuery
{
$pattern = trim($pattern);
if ('' === $pattern) {
throw new RuntimeException('the search pattern must not be empty');
}
$query = new SearchApiQuery();
$query
->setFromClause('chill_main_postal_code cmpc')
->andWhereClause('cmpc.origin = 0');
if (null !== $country) {
$query->andWhereClause('cmpc.country_id = ?', [$country->getId()]);
}
$pertinenceClause = ['STRICT_WORD_SIMILARITY(canonical, UNACCENT(?))'];
$pertinenceArgs = [$pattern];
$orWhere = ['canonical %>> UNACCENT(?)'];
$orWhereArgs = [$pattern];
foreach (explode(' ', $pattern) as $part) {
$part = trim($part);
if ('' === $part) {
continue;
}
$orWhere[] = "canonical LIKE '%' || UNACCENT(LOWER(?)) || '%'";
$orWhereArgs[] = $part;
$pertinenceClause[] =
"(EXISTS (SELECT 1 FROM unnest(string_to_array(canonical, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(?)))))::int";
$pertinenceClause[] =
'(canonical LIKE UNACCENT(LOWER(?)))::int';
array_push($pertinenceArgs, $part, $part);
}
$query
->setSelectPertinence(implode(' + ', $pertinenceClause), $pertinenceArgs)
->andWhereClause(implode(' OR ', $orWhere), $orWhereArgs);
return $query;
}
}

View File

@ -16,7 +16,8 @@ const fetchCountries = () => {
/**
* Endpoint chill_api_single_postal_code__index
* method GET, get Country Object
* method GET, get Cities Object
* @params {object} a country object
* @returns {Promise} a promise containing all Postal Code objects filtered with country
*/
const fetchCities = (country) => {
@ -29,6 +30,40 @@ const fetchCities = (country) => {
});
};
/**
* Endpoint chill_main_postalcodeapi_search
* method GET, get Cities Object
* @params {string} search a search string
* @params {object} country a country object
* @returns {Promise} a promise containing all Postal Code objects filtered with country and a search string
*/
const searchCities = (search, country) => {
const url = `/api/1.0/main/postal-code/search.json?q=${search}&country=${country.id}`;
return fetch(url)
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/**
* Endpoint chill_main_addressreferenceapi_search
* method GET, get AddressReference Object
* @params {string} search a search string
* @params {object} postalCode a postalCode object
* @returns {Promise} a promise containing all Postal Code objects filtered with country and a search string
*/
const searchReferenceAddresses = (search, postalCode) => {
const url = `/api/1.0/main/address-reference/by-postal-code/${postalCode.id}/search.json?q=${search}`;
return fetch(url)
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/**
* Endpoint chill_api_single_address_reference__index
* method GET, get AddressReference Object
@ -170,5 +205,7 @@ export {
postAddress,
patchAddress,
postPostalCode,
getAddress
getAddress,
searchCities,
searchReferenceAddresses
};

View File

@ -556,8 +556,8 @@ export default {
this.entity.selected.address.distribution = this.context.edit ? this.entity.address.distribution: null;
this.entity.selected.address.extra = this.context.edit ? this.entity.address.extra: null;
this.entity.selected.writeNew.address = this.context.edit;
this.entity.selected.writeNew.postcode = this.context.edit;
this.entity.selected.writeNew.address = this.context.edit && this.entity.address.addressReference === null && this.entity.address.street.length > 0
this.entity.selected.writeNew.postcode = false // NB: this used to be this.context.edit, but think it was erroneous;
console.log('!! just set writeNew.postcode to', this.entity.selected.writeNew.postcode);
},
@ -569,7 +569,6 @@ export default {
applyChanges()
{
console.log('apply changes');
let newAddress = {
'isNoAddress': this.entity.selected.isNoAddress,
'street': this.entity.selected.isNoAddress ? '' : this.entity.selected.address.street,
@ -633,7 +632,6 @@ export default {
if (!this.context.edit) {
this.addNewAddress(newAddress)
.then(payload => this.addressChangedCallback(payload));
} else {
this.updateAddress({
addressId: this.context.addressId,
@ -697,8 +695,7 @@ export default {
* Async PATCH transactions,
* then update existing address with backend datas when promise is resolved
*/
updateAddress(payload)
{
updateAddress(payload) {
this.flag.loading = true;
// TODO change the condition because it writes new postal code in edit mode now: !writeNewPostalCode

View File

@ -18,6 +18,7 @@
:taggable="true"
:multiple="false"
@tag="addAddress"
:loading="isLoading"
:options="addresses">
</VueMultiselect>
</div>
@ -48,14 +49,17 @@
<script>
import VueMultiselect from 'vue-multiselect';
import { searchReferenceAddresses, fetchReferenceAddresses } from '../../api.js';
export default {
name: 'AddressSelection',
components: { VueMultiselect },
props: ['entity', 'updateMapCenter'],
props: ['entity', 'context', 'updateMapCenter'],
data() {
return {
value: null
value: this.context.edit ? this.entity.address.addressReference : null,
isLoading: false
}
},
computed: {
@ -107,6 +111,36 @@ export default {
},
listenInputSearch(query) {
//console.log('listenInputSearch', query, this.isAddressSelectorOpen);
if (!this.entity.selected.writeNew.postcode) {
if (query.length > 2) {
this.isLoading = true;
searchReferenceAddresses(query, this.entity.selected.city).then(
addresses => new Promise((resolve, reject) => {
this.entity.loaded.addresses = addresses.results;
this.isLoading = false;
resolve();
}))
.catch((error) => {
console.log(error); //TODO better error handling
this.isLoading = false;
});
} else {
if (query.length === 0) { // Fetch all cities when suppressing the query
this.isLoading = true;
fetchReferenceAddresses(this.entity.selected.city).then(
addresses => new Promise((resolve, reject) => {
this.entity.loaded.addresses = addresses.results;
this.isLoading = false;
resolve();
}))
.catch((error) => {
console.log(error);
this.isLoading = false;
});
}
}
}
if (this.isAddressSelectorOpen) {
this.$data.value = { text: query };
} else if (this.isEnteredCustomAddress) {

View File

@ -18,6 +18,7 @@
:multiple="false"
@tag="addPostcode"
:tagPlaceholder="$t('create_postal_code')"
:loading="isLoading"
:options="cities">
</VueMultiselect>
</div>
@ -48,15 +49,17 @@
<script>
import VueMultiselect from 'vue-multiselect';
import { searchCities, fetchCities } from '../../api.js';
export default {
name: 'CitySelection',
components: { VueMultiselect },
props: ['entity', 'focusOnAddress', 'updateMapCenter'],
props: ['entity', 'context', 'focusOnAddress', 'updateMapCenter'],
emits: ['getReferenceAddresses'],
data() {
return {
value: null
value: this.context.edit ? this.entity.address.postcode : null,
isLoading: false
}
},
computed: {
@ -93,6 +96,15 @@ export default {
},
mounted() {
console.log('writeNew.postcode', this.entity.selected.writeNew.postcode, 'in mounted');
if (this.context.edit) {
this.entity.selected.city = this.value;
this.entity.selected.postcode.name = this.value.name;
this.entity.selected.postcode.code = this.value.code;
this.$emit('getReferenceAddresses', this.value);
if (this.value.center) {
this.updateMapCenter(this.value.center);
}
}
},
methods: {
transName(value) {
@ -105,7 +117,6 @@ export default {
this.entity.selected.postcode.code = value.code;
this.entity.selected.postcode.coordinates = value.center.coordinates;
this.entity.selected.writeNew.postcode = false;
console.log('writeNew.postcode false, in selectCity');
this.$emit('getReferenceAddresses', value);
this.focusOnAddress();
if (value.center) {
@ -113,7 +124,33 @@ export default {
}
},
listenInputSearch(query) {
//console.log('listenInputSearch', query, this.isCitySelectorOpen);
if (query.length > 2) {
this.isLoading = true;
searchCities(query, this.entity.selected.country).then(
cities => new Promise((resolve, reject) => {
this.entity.loaded.cities = cities.results.filter(c => c.origin !== 3); // filter out user-defined cities
this.isLoading = false;
resolve();
}))
.catch((error) => {
console.log(error); //TODO better error handling
this.isLoading = false;
});
} else {
if (query.length === 0) { // Fetch all cities when suppressing the query
this.isLoading = true;
fetchCities(this.entity.selected.country).then(
cities => new Promise((resolve, reject) => {
this.entity.loaded.cities = cities.results.filter(c => c.origin !== 3); // filter out user-defined cities
this.isLoading = false;
resolve();
}))
.catch((error) => {
console.log(error)
this.isLoading = false;
});
}
}
if (this.isCitySelectorOpen) {
this.$data.value = { text: query };
} else if (this.isEnteredCustomCity) {

View File

@ -30,6 +30,7 @@
<city-selection
v-bind:entity="entity"
v-bind:context="context"
v-bind:focusOnAddress="focusOnAddress"
v-bind:updateMapCenter="updateMapCenter"
@getReferenceAddresses="$emit('getReferenceAddresses', selected.city)">
@ -37,6 +38,7 @@
<address-selection v-if="!isNoAddress"
v-bind:entity="entity"
v-bind:context="context"
v-bind:updateMapCenter="updateMapCenter">
</address-selection>

View File

@ -0,0 +1,67 @@
<?php
/**
* 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.
*/
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 Controller;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Test\PrepareClientTrait;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
* @coversNothing
*/
final class AddressReferenceApiControllerTest extends WebTestCase
{
use PrepareClientTrait;
public function provideData()
{
self::bootKernel();
/** @var EntityManagerInterface $em */
$em = self::$container->get(EntityManagerInterface::class);
$postalCode = $em->createQueryBuilder()
->select('pc')
->from(PostalCode::class, 'pc')
->where('pc.origin = :origin')
->setParameter('origin', 0)
->setMaxResults(1)
->getQuery()
->getSingleResult();
yield [$postalCode->getId(), 'rue'];
}
/**
* @dataProvider provideData
*/
public function testSearch(int $postCodeId, string $pattern)
{
$client = $this->getClientAuthenticated();
$client->request(
'GET',
"/api/1.0/main/address-reference/by-postal-code/{$postCodeId}/search.json",
['q' => $pattern]
);
$this->assertResponseIsSuccessful();
}
}

View File

@ -0,0 +1,57 @@
<?php
/**
* 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.
*/
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 Controller;
use Chill\MainBundle\Test\PrepareClientTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use function json_decode;
/**
* @internal
* @coversNothing
*/
final class PostalCodeApiControllerTest extends WebTestCase
{
use PrepareClientTrait;
public function testSearch()
{
$client = $this->getClientAuthenticated();
$client->request(
'GET',
'/api/1.0/main/postal-code/search.json',
['q' => 'fontenay le comte']
);
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertEquals('Fontenay Le Comte', $data['results'][0]['name']);
// test response with invalid search pattern
$client->request(
'GET',
'/api/1.0/main/postal-code/search.json',
['q' => '']
);
$this->assertResponseStatusCodeSame(400);
}
}

View File

@ -360,6 +360,40 @@ paths:
401:
description: "Unauthorized"
/1.0/main/address-reference/by-postal-code/{id}/search.json:
get:
tags:
- address
- search
summary: Return a reference address by id
parameters:
- name: id
in: path
required: true
description: The reference address id
schema:
type: integer
format: integer
minimum: 1
- name: q
in: query
required: true
description: The search pattern
schema:
type: string
responses:
200:
description: "ok"
content:
application/json:
schema:
$ref: '#/components/schemas/AddressReference'
404:
description: "not found"
401:
description: "Unauthorized"
400:
description: "Bad request"
/1.0/main/postal-code.json:
get:
tags:
@ -430,6 +464,37 @@ paths:
401:
description: "Unauthorized"
/1.0/main/postal-code/search.json:
get:
tags:
- address
- search
summary: Search a postal code
parameters:
- name: q
in: query
required: true
description: The search pattern
schema:
type: string
- name: country
in: query
required: false
description: The country id
schema:
type: integer
responses:
200:
description: "ok"
content:
application/json:
schema:
$ref: '#/components/schemas/PostalCode'
404:
description: "not found"
400:
description: "Bad Request"
/1.0/main/country.json:
get:
tags:

View File

@ -0,0 +1,85 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20211125142016 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('DROP TRIGGER canonicalize_address_reference_on_insert ON chill_main_address_reference');
$this->addSql('DROP TRIGGER canonicalize_address_reference_on_update ON chill_main_address_reference');
$this->addSql('DROP FUNCTION canonicalize_address_reference()');
$this->addSql('ALTER TABLE chill_main_address_reference DROP COLUMN addressCanonical');
}
public function getDescription(): string
{
return 'Add a column "canonicalized" on chill_main_address_reference and add trigger and indexed on it';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_address_reference ADD addressCanonical TEXT DEFAULT \'\' NOT NULL');
$this->addSql('UPDATE chill_main_address_reference
SET addresscanonical =
TRIM(
UNACCENT(
LOWER(
street ||
\' \' ||
streetnumber
)
)
)');
$this->addSql('CREATE OR REPLACE FUNCTION public.canonicalize_address_reference() RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
BEGIN
NEW.addresscanonical =
TRIM(
UNACCENT(
LOWER(
NEW.street ||
\' \' ||
NEW.streetnumber
)
)
)
;
return NEW;
END
$$');
$this->addSql('CREATE TRIGGER canonicalize_address_reference_on_insert
BEFORE INSERT
ON chill_main_address_reference
FOR EACH ROW
EXECUTE procedure canonicalize_address_reference()');
$this->addSql('CREATE TRIGGER canonicalize_address_reference_on_update
BEFORE UPDATE
ON chill_main_address_reference
FOR EACH ROW
EXECUTE procedure canonicalize_address_reference()');
$this->addSql('CREATE INDEX chill_internal_address_reference_canonicalized ON chill_main_address_reference USING GIST (postcode_id, addressCanonical gist_trgm_ops)');
}
}

View File

@ -0,0 +1,85 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20211125142017 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('DROP TRIGGER canonicalize_postal_code_on_insert ON chill_main_postal_code');
$this->addSql('DROP TRIGGER canonicalize_postal_code_on_update ON chill_main_postal_code');
$this->addSql('DROP FUNCTION canonicalize_postal_code()');
$this->addSql('ALTER TABLE chill_main_postal_code DROP COLUMN canonical');
}
public function getDescription(): string
{
return 'Add a column "canonicalized" on postal code';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_postal_code ADD canonical TEXT DEFAULT \'\' NOT NULL');
$this->addSql('UPDATE chill_main_postal_code
SET canonical =
TRIM(
UNACCENT(
LOWER(
code ||
\' \' ||
label
)
)
)');
$this->addSql('CREATE OR REPLACE FUNCTION public.canonicalize_postal_code() RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
BEGIN
NEW.canonical =
TRIM(
UNACCENT(
LOWER(
NEW.code ||
\' \' ||
NEW.label
)
)
)
;
return NEW;
END
$$');
$this->addSql('CREATE TRIGGER canonicalize_postal_code_on_insert
BEFORE INSERT
ON chill_main_postal_code
FOR EACH ROW
EXECUTE procedure canonicalize_postal_code()');
$this->addSql('CREATE TRIGGER canonicalize_postal_code_on_update
BEFORE UPDATE
ON chill_main_postal_code
FOR EACH ROW
EXECUTE procedure canonicalize_postal_code()');
$this->addSql('CREATE INDEX chill_internal_postal_code_canonicalized ON chill_main_postal_code USING GIST (canonical gist_trgm_ops) WHERE origin = 0');
}
}

View File

@ -186,7 +186,7 @@ class HouseholdMemberController extends ApiController
$_format,
['groups' => ['read']]
);
} catch (Exception\InvalidArgumentException|Exception\UnexpectedValueException $e) {
} catch (Exception\InvalidArgumentException | Exception\UnexpectedValueException $e) {
throw new BadRequestException("Deserialization error: {$e->getMessage()}", 45896, $e);
}