diff --git a/src/Repository/ContentFilterTrait.php b/src/Repository/ContentFilterTrait.php index 6a47ec2..87366e9 100644 --- a/src/Repository/ContentFilterTrait.php +++ b/src/Repository/ContentFilterTrait.php @@ -17,17 +17,24 @@ use Doctrine\ORM\QueryBuilder; * - regionId / region_id: целое > 0; * - active: bool; * - alias: точное совпадение; - * - search / q: LIKE по name в lower-case. + * - search / q: LIKE по lower-case значению заданного поля (по умолчанию `name`). * - * Важно: search использует LOWER(name), поэтому для больших таблиц нужен - * функциональный индекс в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)). + * Поле поиска параметризовано через $searchField на случай сущностей, + * где основное текстовое поле называется иначе (например, `title`). + * Если у сущности нет такого свойства, Doctrine упадёт с QueryException — это + * лучше ловится тестами на этапе разработки, чем 500 в проде. + * + * Важно: LOWER($alias.$searchField) при больших таблицах требует функционального + * индекса в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)). */ trait ContentFilterTrait { - /** - */ - private function applyCommonFilters(QueryBuilder $qb, string $alias, ContentFilterDto $filters): void - { + private function applyCommonFilters( + QueryBuilder $qb, + string $alias, + ContentFilterDto $filters, + string $searchField = 'name', + ): void { if ($filters->regionId !== null) { $qb->andWhere("$alias.regionId = :regionId") ->setParameter('regionId', $filters->regionId); @@ -44,7 +51,7 @@ trait ContentFilterTrait } if ($filters->search !== null) { - $qb->andWhere("LOWER($alias.name) LIKE :search") + $qb->andWhere("LOWER($alias.$searchField) LIKE :search") ->setParameter('search', '%' . mb_strtolower($filters->search) . '%'); } } diff --git a/src/Service/Crud/CrudResponder.php b/src/Service/Crud/CrudResponder.php index e8dd60a..a775767 100644 --- a/src/Service/Crud/CrudResponder.php +++ b/src/Service/Crud/CrudResponder.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Service\Crud; +use Doctrine\DBAL\Exception as DbalException; use Doctrine\ORM\EntityManagerInterface; use JsonException; use Symfony\Component\HttpFoundation\JsonResponse; @@ -11,23 +12,28 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; /** * Универсальный CRUD-ответчик для тонких контент-контроллеров. * - * Инкапсулирует общую логику десериализации тела запроса по группе :write, - * валидации сущности, persist/flush и сериализации ответа по группе :read. - * - * Контроллер становится декларативным: - * return $this->crud->create($request, News::class, ['news:write'], ['news:read']); + * Контракт ответов специально сохранён близким к старым *CrudService/контроллерам, + * чтобы не ломать существующих клиентов (фронтенд/мобильное): + * - валидация: HTTP 400 + сериализованный ConstraintViolationList + * (формат Symfony Serializer по умолчанию, т.е. RFC 7807 с ключом violations); + * - удаление с ошибкой БД (например, FK constraint): HTTP 500 + {error, message}; + * - JSON-ключи запросов/ответов используют camelCase (см. свойства сущностей и группы *:write). + * Name converter в config/packages/serializer.yaml не задан намеренно — клиенту + * нужен консистентный camelCase, иначе незнакомые ключи будут проигнорированы. */ final class CrudResponder { public function __construct( private EntityManagerInterface $em, private SerializerInterface $serializer, + private DenormalizerInterface $denormalizer, private ValidatorInterface $validator, ) { } @@ -61,15 +67,15 @@ final class CrudResponder try { /** @var T $entity */ - $entity = $this->serializer->deserialize( - $this->encodePayload($payload), + $entity = $this->denormalizer->denormalize( + $payload, $entityClass, - 'json', + null, [ AbstractNormalizer::GROUPS => $writeGroups, ], ); - } catch (JsonException|SerializerExceptionInterface $e) { + } catch (SerializerExceptionInterface $e) { return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); } @@ -100,16 +106,16 @@ final class CrudResponder unset($payload['id']); try { - $this->serializer->deserialize( - $this->encodePayload($payload), + $this->denormalizer->denormalize( + $payload, $entity::class, - 'json', + null, [ AbstractNormalizer::GROUPS => $writeGroups, AbstractNormalizer::OBJECT_TO_POPULATE => $entity, ], ); - } catch (JsonException|SerializerExceptionInterface $e) { + } catch (SerializerExceptionInterface $e) { return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); } @@ -124,34 +130,37 @@ final class CrudResponder public function delete(object $entity): JsonResponse { - $this->em->remove($entity); - $this->em->flush(); + try { + $this->em->remove($entity); + $this->em->flush(); + } catch (DbalException $e) { + // Сохраняем легаси-контракт: при FK / NOT NULL / unique ошибках БД + // отдаём 500 + {error, message}. См. старый ArticleController::delete. + return new JsonResponse( + ['error' => 'Ошибка при удалении записи', 'message' => $e->getMessage()], + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } return new JsonResponse(null, Response::HTTP_NO_CONTENT); } /** * @return array|null null если тело не является JSON-объектом + * + * Ловим как нативный \JsonException, так и Symfony\...\HttpFoundation\Exception\JsonException + * (последний наследует UnexpectedValueException, а не \JsonException, и без + * широкого перехвата Symfony ErrorListener перехватит ошибку до нашего try/catch). */ private function decodePayload(Request $request): ?array { try { return $request->toArray(); - } catch (JsonException) { + } catch (JsonException|\UnexpectedValueException) { return null; } } - /** - * @param array $payload - * - * @throws JsonException - */ - private function encodePayload(array $payload): string - { - return json_encode($payload, JSON_THROW_ON_ERROR); - } - private function validate(object $entity): ?JsonResponse { $errors = $this->validator->validate($entity); @@ -159,15 +168,12 @@ final class CrudResponder return null; } - $formatted = []; - foreach ($errors as $error) { - $formatted[] = [ - 'property' => $error->getPropertyPath(), - 'message' => $error->getMessage(), - ]; - } + // BC: легаси-контроллеры возвращали именно сериализованный ConstraintViolationList + // с кодом 400. Этот же формат продолжаем отдавать здесь, чтобы фронтенду + // не пришлось переписывать парсинг ошибок. + $json = $this->serializer->serialize($errors, 'json'); - return new JsonResponse(['errors' => $formatted], Response::HTTP_UNPROCESSABLE_ENTITY); + return new JsonResponse($json, Response::HTTP_BAD_REQUEST, [], true); } /**