issues/27: old meta with pagination and trait and more

This commit is contained in:
Valery Petrov
2026-05-15 14:08:47 +03:00
committed by Valeriy Petrov
parent bc5468e5a0
commit 656f79ff4e
10 changed files with 90 additions and 29 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ final class ArticleController extends AbstractController
#[OA\Tag(name: 'Статьи')] #[OA\Tag(name: 'Статьи')]
#[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))]
#[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer'))]
#[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))]
#[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))]
#[OA\Parameter(name: 'alias', in: 'query', schema: new OA\Schema(type: 'string'))] #[OA\Parameter(name: 'alias', in: 'query', schema: new OA\Schema(type: 'string'))]
@@ -38,7 +38,7 @@ final class ArticleController extends AbstractController
{ {
$qb = $repository->createFilteredQueryBuilder($request->query->all()); $qb = $repository->createFilteredQueryBuilder($request->query->all());
return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ return $this->json($this->paginator->paginateWithLegacyMeta($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS, 'groups' => self::READ_GROUPS,
]); ]);
} }
+3 -1
View File
@@ -12,6 +12,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/ */
class ArticleRepository extends ServiceEntityRepository class ArticleRepository extends ServiceEntityRepository
{ {
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, Article::class); parent::__construct($registry, Article::class);
@@ -24,7 +26,7 @@ class ArticleRepository extends ServiceEntityRepository
{ {
$qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC'); $qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC');
ContentRepositoryFilter::applyCommon($qb, 'a', $filters); $this->applyCommonFilters($qb, 'a', $filters);
return $qb; return $qb;
} }
@@ -9,43 +9,41 @@ use Doctrine\ORM\QueryBuilder;
/** /**
* Общие фильтры для контентных репозиториев (News/Promo/Disease/MedicalCenter/Article/SiteService). * Общие фильтры для контентных репозиториев (News/Promo/Disease/MedicalCenter/Article/SiteService).
* *
* Назначение не плодить одинаковые if-блоки в каждом репозитории и сохранить * Trait подключается в Doctrine-репозитории, чтобы не держать бизнес-фильтры
* единый контракт query-параметров для list-эндпоинтов. * в статическом helper-классе и при этом не копировать одинаковые if-блоки.
* *
* Поддерживается: * Поддерживается:
* - regionId целое > 0 * - regionId / region_id: целое > 0;
* - active bool (по умолчанию true, если не передан) * - active: bool;
* - alias строка (строгое равенство) * - alias: точное совпадение;
* - search LIKE по name (case-insensitive) * - search / q: LIKE по name в lower-case.
*
* Параметры пагинации (page/perPage) обрабатываются Paginator'ом и здесь игнорируются.
*/ */
final class ContentRepositoryFilter trait ContentFilterTrait
{ {
/** /**
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
*/ */
public static function applyCommon(QueryBuilder $qb, string $alias, array $filters): void private function applyCommonFilters(QueryBuilder $qb, string $alias, array $filters): void
{ {
$regionId = self::extractInt($filters, ['regionId', 'region_id']); $regionId = $this->extractIntFilter($filters, ['regionId', 'region_id']);
if ($regionId !== null && $regionId > 0) { if ($regionId !== null && $regionId > 0) {
$qb->andWhere("$alias.regionId = :regionId") $qb->andWhere("$alias.regionId = :regionId")
->setParameter('regionId', $regionId); ->setParameter('regionId', $regionId);
} }
$active = self::extractBool($filters, ['active']); $active = $this->extractBoolFilter($filters, ['active']);
if ($active !== null) { if ($active !== null) {
$qb->andWhere("$alias.active = :active") $qb->andWhere("$alias.active = :active")
->setParameter('active', $active); ->setParameter('active', $active);
} }
$aliasFilter = self::extractNonEmptyString($filters, ['alias']); $aliasFilter = $this->extractNonEmptyStringFilter($filters, ['alias']);
if ($aliasFilter !== null) { if ($aliasFilter !== null) {
$qb->andWhere("$alias.alias = :aliasValue") $qb->andWhere("$alias.alias = :aliasValue")
->setParameter('aliasValue', $aliasFilter); ->setParameter('aliasValue', $aliasFilter);
} }
$search = self::extractNonEmptyString($filters, ['search', 'q']); $search = $this->extractNonEmptyStringFilter($filters, ['search', 'q']);
if ($search !== null) { if ($search !== null) {
$qb->andWhere("LOWER($alias.name) LIKE :search") $qb->andWhere("LOWER($alias.name) LIKE :search")
->setParameter('search', '%' . mb_strtolower($search) . '%'); ->setParameter('search', '%' . mb_strtolower($search) . '%');
@@ -56,16 +54,18 @@ final class ContentRepositoryFilter
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @param list<string> $keys * @param list<string> $keys
*/ */
private static function extractInt(array $filters, array $keys): ?int private function extractIntFilter(array $filters, array $keys): ?int
{ {
foreach ($keys as $key) { foreach ($keys as $key) {
if (!array_key_exists($key, $filters)) { if (!array_key_exists($key, $filters)) {
continue; continue;
} }
$value = $filters[$key]; $value = $filters[$key];
if ($value === null || $value === '') { if ($value === null || $value === '') {
continue; continue;
} }
if (is_numeric($value)) { if (is_numeric($value)) {
return (int) $value; return (int) $value;
} }
@@ -78,16 +78,18 @@ final class ContentRepositoryFilter
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @param list<string> $keys * @param list<string> $keys
*/ */
private static function extractBool(array $filters, array $keys): ?bool private function extractBoolFilter(array $filters, array $keys): ?bool
{ {
foreach ($keys as $key) { foreach ($keys as $key) {
if (!array_key_exists($key, $filters)) { if (!array_key_exists($key, $filters)) {
continue; continue;
} }
$value = $filters[$key]; $value = $filters[$key];
if ($value === null || $value === '') { if ($value === null || $value === '') {
continue; continue;
} }
if (is_bool($value)) { if (is_bool($value)) {
return $value; return $value;
} }
@@ -102,16 +104,18 @@ final class ContentRepositoryFilter
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @param list<string> $keys * @param list<string> $keys
*/ */
private static function extractNonEmptyString(array $filters, array $keys): ?string private function extractNonEmptyStringFilter(array $filters, array $keys): ?string
{ {
foreach ($keys as $key) { foreach ($keys as $key) {
if (!array_key_exists($key, $filters)) { if (!array_key_exists($key, $filters)) {
continue; continue;
} }
$value = $filters[$key]; $value = $filters[$key];
if (!is_string($value)) { if (!is_string($value)) {
continue; continue;
} }
$trimmed = trim($value); $trimmed = trim($value);
if ($trimmed !== '') { if ($trimmed !== '') {
return $trimmed; return $trimmed;
+3 -1
View File
@@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/ */
class DiseaseRepository extends ServiceEntityRepository class DiseaseRepository extends ServiceEntityRepository
{ {
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, Disease::class); parent::__construct($registry, Disease::class);
@@ -27,7 +29,7 @@ class DiseaseRepository extends ServiceEntityRepository
{ {
$qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC'); $qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC');
ContentRepositoryFilter::applyCommon($qb, 'd', $filters); $this->applyCommonFilters($qb, 'd', $filters);
return $qb; return $qb;
} }
+3 -1
View File
@@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/ */
class MedicalCenterRepository extends ServiceEntityRepository class MedicalCenterRepository extends ServiceEntityRepository
{ {
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, MedicalCenter::class); parent::__construct($registry, MedicalCenter::class);
@@ -27,7 +29,7 @@ class MedicalCenterRepository extends ServiceEntityRepository
{ {
$qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC'); $qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC');
ContentRepositoryFilter::applyCommon($qb, 'm', $filters); $this->applyCommonFilters($qb, 'm', $filters);
return $qb; return $qb;
} }
+3 -1
View File
@@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/ */
class NewsRepository extends ServiceEntityRepository class NewsRepository extends ServiceEntityRepository
{ {
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, News::class); parent::__construct($registry, News::class);
@@ -31,7 +33,7 @@ class NewsRepository extends ServiceEntityRepository
{ {
$qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC'); $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC');
ContentRepositoryFilter::applyCommon($qb, 'n', $filters); $this->applyCommonFilters($qb, 'n', $filters);
return $qb; return $qb;
} }
+3 -1
View File
@@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/ */
class PromoRepository extends ServiceEntityRepository class PromoRepository extends ServiceEntityRepository
{ {
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, Promo::class); parent::__construct($registry, Promo::class);
@@ -27,7 +29,7 @@ class PromoRepository extends ServiceEntityRepository
{ {
$qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC'); $qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC');
ContentRepositoryFilter::applyCommon($qb, 'p', $filters); $this->applyCommonFilters($qb, 'p', $filters);
return $qb; return $qb;
} }
+3 -1
View File
@@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/ */
class SiteServiceRepository extends ServiceEntityRepository class SiteServiceRepository extends ServiceEntityRepository
{ {
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, SiteService::class); parent::__construct($registry, SiteService::class);
@@ -27,7 +29,7 @@ class SiteServiceRepository extends ServiceEntityRepository
{ {
$qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC'); $qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC');
ContentRepositoryFilter::applyCommon($qb, 's', $filters); $this->applyCommonFilters($qb, 's', $filters);
return $qb; return $qb;
} }
+6 -3
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Crud; namespace App\Service\Crud;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -142,9 +143,11 @@ final class CrudResponder
*/ */
private function decodePayload(Request $request): ?array private function decodePayload(Request $request): ?array
{ {
$data = json_decode($request->getContent(), true); try {
return $request->toArray();
return is_array($data) ? $data : null; } catch (JsonException) {
return null;
}
} }
private function validate(object $entity): ?JsonResponse private function validate(object $entity): ?JsonResponse
+43 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request;
* *
* Соответствует существующему стилю проекта (см. PriceListController/SpecialistController): * Соответствует существующему стилю проекта (см. PriceListController/SpecialistController):
* читает page/perPage из Request, ограничивает perPage и возвращает массив * читает page/perPage из Request, ограничивает perPage и возвращает массив
* ['data' => [...], 'pagination' => [...]] в едином формате для всех контроллеров. * ['data' => [...], 'pagination' => [...]] в едином формате для новых list-контрактов.
*/ */
final class Paginator final class Paginator
{ {
@@ -62,4 +62,46 @@ final class Paginator
], ],
]; ];
} }
/**
* Legacy-формат для ArticleController.
*
* Старый контракт /article/list уже использовался клиентами:
* - размер страницы приходит в query-параметре limit;
* - метаданные лежат в ключе meta;
* - поля называются total/page/limit/totalPages.
*
* @return array{data: list<mixed>, meta: array{total: int, page: int, limit: int, totalPages: int}}
*/
public function paginateWithLegacyMeta(
QueryBuilder $qb,
Request $request,
int $defaultLimit = 20,
int $maxLimit = 100,
): array {
$page = max(1, $request->query->getInt('page', 1));
$limit = min(
max(1, $request->query->getInt('limit', $defaultLimit)),
$maxLimit,
);
$pagerfanta = (new Pagerfanta(new QueryAdapter($qb)))
->setMaxPerPage($limit);
try {
$pagerfanta->setCurrentPage($page);
} catch (NotValidCurrentPageException) {
$pagerfanta->setCurrentPage(max(1, $pagerfanta->getNbPages()));
}
return [
'data' => iterator_to_array($pagerfanta->getCurrentPageResults(), false),
'meta' => [
'total' => $pagerfanta->getNbResults(),
'page' => $pagerfanta->getCurrentPage(),
'limit' => $pagerfanta->getMaxPerPage(),
'totalPages' => $pagerfanta->getNbPages(),
],
];
}
} }