feat: add tag filtering to blog posts and implement JSON_CONTAINS DQL function
This commit is contained in:
@@ -18,6 +18,9 @@ doctrine:
|
|||||||
alias: App
|
alias: App
|
||||||
controller_resolver:
|
controller_resolver:
|
||||||
auto_mapping: false
|
auto_mapping: false
|
||||||
|
dql:
|
||||||
|
string_functions:
|
||||||
|
JSON_CONTAINS: App\Shared\Doctrine\JsonContains
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
doctrine:
|
doctrine:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ List published blog posts.
|
|||||||
|-------|------|----------|---------|-------------|
|
|-------|------|----------|---------|-------------|
|
||||||
| `page` | integer | ❌ | 1 | Page number |
|
| `page` | integer | ❌ | 1 | Page number |
|
||||||
| `limit` | integer | ❌ | 20 | Items per page |
|
| `limit` | integer | ❌ | 20 | Items per page |
|
||||||
|
| `tag` | string | ❌ | — | Filter by exact tag **name** (e.g. `?tag=دیابت`). Blog tags are stored as a JSON array of names; only blogs whose `tags` array contains this exact name are returned. |
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -60,9 +60,10 @@ class BlogController extends BaseController
|
|||||||
{
|
{
|
||||||
$page = max(1, (int) $request->query->get('page', 1));
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||||
|
$tag = $request->query->get('tag') ?: null;
|
||||||
|
|
||||||
$blogs = array_map(fn(Blog $b) => $b->toListArray(), $this->blogRepo->findPublished($page, $limit));
|
$blogs = array_map(fn(Blog $b) => $b->toListArray(), $this->blogRepo->findPublished($page, $limit, $tag));
|
||||||
$total = $this->blogRepo->countPublished();
|
$total = $this->blogRepo->countPublished($tag);
|
||||||
|
|
||||||
return $this->paginated($blogs, $total, $page, $limit);
|
return $this->paginated($blogs, $total, $page, $limit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,24 +14,40 @@ class BlogRepository extends ServiceEntityRepository
|
|||||||
public function findBySlug(string $slug): ?Blog { return $this->findOneBy(['slug' => $slug]); }
|
public function findBySlug(string $slug): ?Blog { return $this->findOneBy(['slug' => $slug]); }
|
||||||
|
|
||||||
/** @return Blog[] published, newest first */
|
/** @return Blog[] published, newest first */
|
||||||
public function findPublished(int $page = 1, int $limit = 20): array
|
public function findPublished(int $page = 1, int $limit = 20, ?string $tag = null): array
|
||||||
{
|
{
|
||||||
return $this->createQueryBuilder('b')
|
$qb = $this->createQueryBuilder('b')
|
||||||
->where('b.status = :status')
|
->where('b.status = :status')
|
||||||
->setParameter('status', Blog::STATUS_PUBLISHED)
|
->setParameter('status', Blog::STATUS_PUBLISHED)
|
||||||
->orderBy('b.createdAt', 'DESC')
|
->orderBy('b.createdAt', 'DESC')
|
||||||
->setFirstResult(($page - 1) * $limit)
|
->setFirstResult(($page - 1) * $limit)
|
||||||
->setMaxResults($limit)
|
->setMaxResults($limit);
|
||||||
->getQuery()->getResult();
|
|
||||||
|
$this->applyTagFilter($qb, $tag);
|
||||||
|
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countPublished(): int
|
public function countPublished(?string $tag = null): int
|
||||||
{
|
{
|
||||||
return (int) $this->createQueryBuilder('b')
|
$qb = $this->createQueryBuilder('b')
|
||||||
->select('COUNT(b.id)')
|
->select('COUNT(b.id)')
|
||||||
->where('b.status = :status')
|
->where('b.status = :status')
|
||||||
->setParameter('status', Blog::STATUS_PUBLISHED)
|
->setParameter('status', Blog::STATUS_PUBLISHED);
|
||||||
->getQuery()->getSingleScalarResult();
|
|
||||||
|
$this->applyTagFilter($qb, $tag);
|
||||||
|
|
||||||
|
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyTagFilter(\Doctrine\ORM\QueryBuilder $qb, ?string $tag): void
|
||||||
|
{
|
||||||
|
if ($tag === null || $tag === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Blog.tags is a JSON array of tag names; match exact name membership.
|
||||||
|
$qb->andWhere('JSON_CONTAINS(b.tags, :tag) = 1')
|
||||||
|
->setParameter('tag', json_encode($tag, JSON_UNESCAPED_UNICODE));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save(Blog $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
public function save(Blog $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Shared\Doctrine;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
|
||||||
|
use Doctrine\ORM\Query\AST\Node;
|
||||||
|
use Doctrine\ORM\Query\Parser;
|
||||||
|
use Doctrine\ORM\Query\SqlWalker;
|
||||||
|
use Doctrine\ORM\Query\TokenType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON_CONTAINS(json_doc, candidate) DQL function for MariaDB/MySQL.
|
||||||
|
* Usage: WHERE JSON_CONTAINS(b.tags, :tag) = 1
|
||||||
|
*/
|
||||||
|
class JsonContains extends FunctionNode
|
||||||
|
{
|
||||||
|
private Node $jsonDoc;
|
||||||
|
private Node $candidate;
|
||||||
|
|
||||||
|
public function parse(Parser $parser): void
|
||||||
|
{
|
||||||
|
$parser->match(TokenType::T_IDENTIFIER);
|
||||||
|
$parser->match(TokenType::T_OPEN_PARENTHESIS);
|
||||||
|
$this->jsonDoc = $parser->StringPrimary();
|
||||||
|
$parser->match(TokenType::T_COMMA);
|
||||||
|
$this->candidate = $parser->StringPrimary();
|
||||||
|
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSql(SqlWalker $sqlWalker): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'JSON_CONTAINS(%s, %s)',
|
||||||
|
$this->jsonDoc->dispatch($sqlWalker),
|
||||||
|
$this->candidate->dispatch($sqlWalker)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user