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\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: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))]
#[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());
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,
]);
}
+3 -1
View File
@@ -12,6 +12,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class ArticleRepository extends ServiceEntityRepository
{
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
@@ -24,7 +26,7 @@ class ArticleRepository extends ServiceEntityRepository
{
$qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC');
ContentRepositoryFilter::applyCommon($qb, 'a', $filters);
$this->applyCommonFilters($qb, 'a', $filters);
return $qb;
}
@@ -9,43 +9,41 @@ use Doctrine\ORM\QueryBuilder;
/**
* Общие фильтры для контентных репозиториев (News/Promo/Disease/MedicalCenter/Article/SiteService).
*
* Назначение не плодить одинаковые if-блоки в каждом репозитории и сохранить
* единый контракт query-параметров для list-эндпоинтов.
* Trait подключается в Doctrine-репозитории, чтобы не держать бизнес-фильтры
* в статическом helper-классе и при этом не копировать одинаковые if-блоки.
*
* Поддерживается:
* - regionId целое > 0
* - active bool (по умолчанию true, если не передан)
* - alias строка (строгое равенство)
* - search LIKE по name (case-insensitive)
*
* Параметры пагинации (page/perPage) обрабатываются Paginator'ом и здесь игнорируются.
* - regionId / region_id: целое > 0;
* - active: bool;
* - alias: точное совпадение;
* - search / q: LIKE по name в lower-case.
*/
final class ContentRepositoryFilter
trait ContentFilterTrait
{
/**
* @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) {
$qb->andWhere("$alias.regionId = :regionId")
->setParameter('regionId', $regionId);
}
$active = self::extractBool($filters, ['active']);
$active = $this->extractBoolFilter($filters, ['active']);
if ($active !== null) {
$qb->andWhere("$alias.active = :active")
->setParameter('active', $active);
}
$aliasFilter = self::extractNonEmptyString($filters, ['alias']);
$aliasFilter = $this->extractNonEmptyStringFilter($filters, ['alias']);
if ($aliasFilter !== null) {
$qb->andWhere("$alias.alias = :aliasValue")
->setParameter('aliasValue', $aliasFilter);
}
$search = self::extractNonEmptyString($filters, ['search', 'q']);
$search = $this->extractNonEmptyStringFilter($filters, ['search', 'q']);
if ($search !== null) {
$qb->andWhere("LOWER($alias.name) LIKE :search")
->setParameter('search', '%' . mb_strtolower($search) . '%');
@@ -56,16 +54,18 @@ final class ContentRepositoryFilter
* @param array<string, mixed> $filters
* @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) {
if (!array_key_exists($key, $filters)) {
continue;
}
$value = $filters[$key];
if ($value === null || $value === '') {
continue;
}
if (is_numeric($value)) {
return (int) $value;
}
@@ -78,16 +78,18 @@ final class ContentRepositoryFilter
* @param array<string, mixed> $filters
* @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) {
if (!array_key_exists($key, $filters)) {
continue;
}
$value = $filters[$key];
if ($value === null || $value === '') {
continue;
}
if (is_bool($value)) {
return $value;
}
@@ -102,16 +104,18 @@ final class ContentRepositoryFilter
* @param array<string, mixed> $filters
* @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) {
if (!array_key_exists($key, $filters)) {
continue;
}
$value = $filters[$key];
if (!is_string($value)) {
continue;
}
$trimmed = trim($value);
if ($trimmed !== '') {
return $trimmed;
+3 -1
View File
@@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class DiseaseRepository extends ServiceEntityRepository
{
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Disease::class);
@@ -27,7 +29,7 @@ class DiseaseRepository extends ServiceEntityRepository
{
$qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC');
ContentRepositoryFilter::applyCommon($qb, 'd', $filters);
$this->applyCommonFilters($qb, 'd', $filters);
return $qb;
}
+3 -1
View File
@@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class MedicalCenterRepository extends ServiceEntityRepository
{
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MedicalCenter::class);
@@ -27,7 +29,7 @@ class MedicalCenterRepository extends ServiceEntityRepository
{
$qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC');
ContentRepositoryFilter::applyCommon($qb, 'm', $filters);
$this->applyCommonFilters($qb, 'm', $filters);
return $qb;
}
+3 -1
View File
@@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class NewsRepository extends ServiceEntityRepository
{
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, News::class);
@@ -31,7 +33,7 @@ class NewsRepository extends ServiceEntityRepository
{
$qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC');
ContentRepositoryFilter::applyCommon($qb, 'n', $filters);
$this->applyCommonFilters($qb, 'n', $filters);
return $qb;
}
+3 -1
View File
@@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class PromoRepository extends ServiceEntityRepository
{
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Promo::class);
@@ -27,7 +29,7 @@ class PromoRepository extends ServiceEntityRepository
{
$qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC');
ContentRepositoryFilter::applyCommon($qb, 'p', $filters);
$this->applyCommonFilters($qb, 'p', $filters);
return $qb;
}
+3 -1
View File
@@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class SiteServiceRepository extends ServiceEntityRepository
{
use ContentFilterTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SiteService::class);
@@ -27,7 +29,7 @@ class SiteServiceRepository extends ServiceEntityRepository
{
$qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC');
ContentRepositoryFilter::applyCommon($qb, 's', $filters);
$this->applyCommonFilters($qb, 's', $filters);
return $qb;
}
+6 -3
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Crud;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -142,9 +143,11 @@ final class CrudResponder
*/
private function decodePayload(Request $request): ?array
{
$data = json_decode($request->getContent(), true);
return is_array($data) ? $data : null;
try {
return $request->toArray();
} catch (JsonException) {
return null;
}
}
private function validate(object $entity): ?JsonResponse
+43 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request;
*
* Соответствует существующему стилю проекта (см. PriceListController/SpecialistController):
* читает page/perPage из Request, ограничивает perPage и возвращает массив
* ['data' => [...], 'pagination' => [...]] в едином формате для всех контроллеров.
* ['data' => [...], 'pagination' => [...]] в едином формате для новых list-контрактов.
*/
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(),
],
];
}
}