issues/27: filter DTO, strip id from payloads, lifecycle updateAt

This commit is contained in:
Valery Petrov
2026-05-15 15:35:50 +03:00
committed by Valeriy Petrov
parent da5f7bb242
commit 76044381fd
22 changed files with 153 additions and 129 deletions
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Dto\Content\ContentFilterDto;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use App\Service\Crud\CrudResponder;
@@ -37,7 +38,7 @@ final class ArticleController extends AbstractController
#[Route('/list', name: 'article_list', methods: ['GET'])]
public function list(Request $request, ArticleRepository $repository): JsonResponse
{
$qb = $repository->createFilteredQueryBuilder($request->query->all());
$qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request));
return $this->json($this->paginator->paginateWithLegacyMeta($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS,
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Dto\Content\ContentFilterDto;
use App\Entity\Disease;
use App\Repository\DiseaseRepository;
use App\Service\Crud\CrudResponder;
@@ -36,7 +37,7 @@ final class DiseaseController extends AbstractController
#[Route('/list', name: 'disease_list', methods: ['GET'])]
public function list(Request $request, DiseaseRepository $repository): JsonResponse
{
$qb = $repository->createFilteredQueryBuilder($request->query->all());
$qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request));
return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS,
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Dto\Content\ContentFilterDto;
use App\Entity\MedicalCenter;
use App\Repository\MedicalCenterRepository;
use App\Service\Crud\CrudResponder;
@@ -36,7 +37,7 @@ final class MedicalCenterController extends AbstractController
#[Route('/list', name: 'medical_center_list', methods: ['GET'])]
public function list(Request $request, MedicalCenterRepository $repository): JsonResponse
{
$qb = $repository->createFilteredQueryBuilder($request->query->all());
$qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request));
return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS,
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Dto\Content\ContentFilterDto;
use App\Entity\News;
use App\Repository\NewsRepository;
use App\Service\Crud\CrudResponder;
@@ -36,7 +37,7 @@ final class NewsController extends AbstractController
#[Route('/list', name: 'news_list', methods: ['GET'])]
public function list(Request $request, NewsRepository $repository): JsonResponse
{
$qb = $repository->createFilteredQueryBuilder($request->query->all());
$qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request));
return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS,
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Dto\Content\ContentFilterDto;
use App\Entity\Promo;
use App\Repository\PromoRepository;
use App\Service\Crud\CrudResponder;
@@ -36,7 +37,7 @@ final class PromoController extends AbstractController
#[Route('/list', name: 'promo_list', methods: ['GET'])]
public function list(Request $request, PromoRepository $repository): JsonResponse
{
$qb = $repository->createFilteredQueryBuilder($request->query->all());
$qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request));
return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS,
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Dto\Content\ContentFilterDto;
use App\Entity\SiteService;
use App\Repository\SiteServiceRepository;
use App\Service\Crud\CrudResponder;
@@ -36,7 +37,7 @@ final class SiteServiceController extends AbstractController
#[Route('/list', name: 'site_service_list', methods: ['GET'])]
public function list(Request $request, SiteServiceRepository $repository): JsonResponse
{
$qb = $repository->createFilteredQueryBuilder($request->query->all());
$qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request));
return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS,
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Dto\Content;
use Symfony\Component\HttpFoundation\Request;
final readonly class ContentFilterDto
{
public function __construct(
public ?int $regionId = null,
public ?bool $active = null,
public ?string $alias = null,
public ?string $search = null,
) {
}
public static function fromRequest(Request $request): self
{
return new self(
regionId: self::positiveInt($request->query->get('regionId', $request->query->get('region_id'))),
active: self::nullableBool($request->query->get('active')),
alias: self::nonEmptyString($request->query->get('alias')),
search: self::nonEmptyString($request->query->get('search', $request->query->get('q'))),
);
}
private static function positiveInt(mixed $value): ?int
{
if ($value === null || $value === '' || !is_numeric($value)) {
return null;
}
$value = (int) $value;
return $value > 0 ? $value : null;
}
private static function nullableBool(mixed $value): ?bool
{
if ($value === null || $value === '') {
return null;
}
if (is_bool($value)) {
return $value;
}
return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}
private static function nonEmptyString(mixed $value): ?string
{
if (!is_string($value)) {
return null;
}
$value = trim($value);
return $value !== '' ? $value : null;
}
}
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Entity;
use App\Entity\Behavior\UpdateTimestampTrait;
use App\Repository\ArticleRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -12,8 +13,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'article')]
#[ORM\Index(name: 'idx_article_region_id', columns: ['region_id'])]
#[ORM\Index(name: 'idx_article_active', columns: ['active'])]
#[ORM\HasLifecycleCallbacks]
class Article
{
use UpdateTimestampTrait;
#[Groups(['article:read'])]
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
@@ -56,7 +60,7 @@ class Article
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $content = null;
#[Groups(['article:read', 'article:write'])]
#[Groups(['article:read'])]
#[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $updateAt = null;
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Entity\Behavior;
use Doctrine\ORM\Mapping as ORM;
trait UpdateTimestampTrait
{
#[ORM\PrePersist]
public function setInitialUpdateAt(): void
{
if ($this->updateAt === null) {
$this->updateAt = new \DateTime();
}
}
#[ORM\PreUpdate]
public function refreshUpdateAt(): void
{
$this->updateAt = new \DateTime();
}
}
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Entity;
use App\Entity\Behavior\UpdateTimestampTrait;
use App\Repository\DiseaseRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -11,8 +12,11 @@ use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Table(name: 'disease')]
#[ORM\Index(name: 'idx_disease_region_id', columns: ['region_id'])]
#[ORM\Index(name: 'idx_disease_active', columns: ['active'])]
#[ORM\HasLifecycleCallbacks]
class Disease
{
use UpdateTimestampTrait;
#[Groups(['disease:read'])]
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
@@ -43,7 +47,7 @@ class Disease
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $anons = null;
#[Groups(['disease:read', 'disease:write'])]
#[Groups(['disease:read'])]
#[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $updateAt = null;
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Entity;
use App\Entity\Behavior\UpdateTimestampTrait;
use App\Repository\MedicalCenterRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -9,8 +10,11 @@ use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity(repositoryClass: MedicalCenterRepository::class)]
#[ORM\Table(name: 'medical_center')]
#[ORM\HasLifecycleCallbacks]
class MedicalCenter
{
use UpdateTimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
@@ -42,7 +46,7 @@ class MedicalCenter
private ?string $content = null;
#[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
#[Groups(['medical_center:read', 'medical_center:write'])]
#[Groups(['medical_center:read'])]
private ?\DateTimeInterface $updateAt = null;
#[ORM\Column(name: 'kod_uslug', type: 'jsonb', nullable: true)]
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Entity;
use App\Entity\Behavior\UpdateTimestampTrait;
use App\Repository\NewsRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -11,8 +12,11 @@ use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Table(name: 'news')]
#[ORM\Index(name: 'idx_news_region_id', columns: ['region_id'])]
#[ORM\Index(name: 'idx_news_active', columns: ['active'])]
#[ORM\HasLifecycleCallbacks]
class News
{
use UpdateTimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
@@ -44,7 +48,7 @@ class News
private ?string $content = null;
#[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
#[Groups(['news:read', 'news:write'])]
#[Groups(['news:read'])]
private ?\DateTimeInterface $updateAt = null;
#[ORM\Column(name: 'link_el_price', type: Types::TEXT, nullable: true)]
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Entity;
use App\Entity\Behavior\UpdateTimestampTrait;
use App\Repository\PromoRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -11,8 +12,11 @@ use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Table(name: 'promo')]
#[ORM\Index(name: 'idx_promo_region_id', columns: ['region_id'])]
#[ORM\Index(name: 'idx_promo_active', columns: ['active'])]
#[ORM\HasLifecycleCallbacks]
class Promo
{
use UpdateTimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
@@ -44,7 +48,7 @@ class Promo
private ?string $content = null;
#[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
#[Groups(['promo:read', 'promo:write'])]
#[Groups(['promo:read'])]
private ?\DateTimeInterface $updateAt = null;
#[ORM\Column(type: 'jsonb', nullable: true)]
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Entity;
use App\Entity\Behavior\UpdateTimestampTrait;
use App\Repository\SiteServiceRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -11,8 +12,11 @@ use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Table(name: 'site_services')]
#[ORM\Index(name: 'idx_site_services_region_id', columns: ['region_id'])]
#[ORM\Index(name: 'idx_site_services_active', columns: ['active'])]
#[ORM\HasLifecycleCallbacks]
class SiteService
{
use UpdateTimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
@@ -44,7 +48,7 @@ class SiteService
private ?string $content = null;
#[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
#[Groups(['site_service:read', 'site_service:write'])]
#[Groups(['site_service:read'])]
private ?\DateTimeInterface $updateAt = null;
#[ORM\Column(name: 'link_videoreviews', type: 'jsonb', nullable: true)]
+2 -2
View File
@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Dto\Content\ContentFilterDto;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -20,9 +21,8 @@ class ArticleRepository extends ServiceEntityRepository
}
/**
* @param array<string, mixed> $filters
*/
public function createFilteredQueryBuilder(array $filters): QueryBuilder
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
$qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC');
+10 -89
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Repository;
use App\Dto\Content\ContentFilterDto;
use Doctrine\ORM\QueryBuilder;
/**
@@ -24,107 +25,27 @@ use Doctrine\ORM\QueryBuilder;
trait ContentFilterTrait
{
/**
* @param array<string, mixed> $filters
*/
private function applyCommonFilters(QueryBuilder $qb, string $alias, array $filters): void
private function applyCommonFilters(QueryBuilder $qb, string $alias, ContentFilterDto $filters): void
{
$regionId = $this->extractIntFilter($filters, ['regionId', 'region_id']);
if ($regionId !== null && $regionId > 0) {
if ($filters->regionId !== null) {
$qb->andWhere("$alias.regionId = :regionId")
->setParameter('regionId', $regionId);
->setParameter('regionId', $filters->regionId);
}
$active = $this->extractBoolFilter($filters, ['active']);
if ($active !== null) {
if ($filters->active !== null) {
$qb->andWhere("$alias.active = :active")
->setParameter('active', $active);
->setParameter('active', $filters->active);
}
$aliasFilter = $this->extractNonEmptyStringFilter($filters, ['alias']);
if ($aliasFilter !== null) {
if ($filters->alias !== null) {
$qb->andWhere("$alias.alias = :aliasValue")
->setParameter('aliasValue', $aliasFilter);
->setParameter('aliasValue', $filters->alias);
}
$search = $this->extractNonEmptyStringFilter($filters, ['search', 'q']);
if ($search !== null) {
if ($filters->search !== null) {
$qb->andWhere("LOWER($alias.name) LIKE :search")
->setParameter('search', '%' . mb_strtolower($search) . '%');
->setParameter('search', '%' . mb_strtolower($filters->search) . '%');
}
}
/**
* @param array<string, mixed> $filters
* @param list<string> $keys
*/
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;
}
}
return null;
}
/**
* @param array<string, mixed> $filters
* @param list<string> $keys
*/
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;
}
return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}
return null;
}
/**
* @param array<string, mixed> $filters
* @param list<string> $keys
*/
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;
}
}
return null;
}
}
+2 -2
View File
@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Dto\Content\ContentFilterDto;
use App\Entity\Disease;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -23,9 +24,8 @@ class DiseaseRepository extends ServiceEntityRepository
}
/**
* @param array<string, mixed> $filters
*/
public function createFilteredQueryBuilder(array $filters): QueryBuilder
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
$qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC');
+2 -2
View File
@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Dto\Content\ContentFilterDto;
use App\Entity\MedicalCenter;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -23,9 +24,8 @@ class MedicalCenterRepository extends ServiceEntityRepository
}
/**
* @param array<string, mixed> $filters
*/
public function createFilteredQueryBuilder(array $filters): QueryBuilder
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
$qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC');
+2 -2
View File
@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Dto\Content\ContentFilterDto;
use App\Entity\News;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -27,9 +28,8 @@ class NewsRepository extends ServiceEntityRepository
*
* Поддерживаемые фильтры: regionId, active (по умолчанию true), alias, search.
*
* @param array<string, mixed> $filters
*/
public function createFilteredQueryBuilder(array $filters): QueryBuilder
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
$qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC');
+2 -2
View File
@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Dto\Content\ContentFilterDto;
use App\Entity\Promo;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -23,9 +24,8 @@ class PromoRepository extends ServiceEntityRepository
}
/**
* @param array<string, mixed> $filters
*/
public function createFilteredQueryBuilder(array $filters): QueryBuilder
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
$qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC');
+2 -2
View File
@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Dto\Content\ContentFilterDto;
use App\Entity\SiteService;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -23,9 +24,8 @@ class SiteServiceRepository extends ServiceEntityRepository
}
/**
* @param array<string, mixed> $filters
*/
public function createFilteredQueryBuilder(array $filters): QueryBuilder
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
$qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC');
+2 -16
View File
@@ -52,22 +52,17 @@ final class CrudResponder
string $entityClass,
array $writeGroups,
array $readGroups,
bool $allowIdFromPayload = false,
): JsonResponse {
$payload = $this->decodePayload($request);
if ($payload === null) {
return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
}
$deserializationPayload = $payload;
if (!$allowIdFromPayload) {
unset($deserializationPayload['id']);
}
unset($payload['id']);
try {
/** @var T $entity */
$entity = $this->serializer->deserialize(
$this->encodePayload($deserializationPayload),
$this->encodePayload($payload),
$entityClass,
'json',
[
@@ -78,15 +73,6 @@ final class CrudResponder
return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
}
// По умолчанию публичный CRUD не принимает id от клиента. Если системной
// интеграции понадобится внешний id, конкретный вызов должен явно передать true.
if ($allowIdFromPayload && isset($payload['id']) && method_exists($entity, 'setId')) {
$id = (int) $payload['id'];
if ($id > 0) {
$entity->setId($id);
}
}
if (($validationResponse = $this->validate($entity)) !== null) {
return $validationResponse;
}