feat(blog): implement tag filtering and facets endpoint
- Fix tag filtering to correctly match Persian tags by adjusting JSON encoding in the applyTagFilter method. - Add new endpoint GET /api/v1/blogs/tags to retrieve distinct tag names and their counts for published posts, respecting city scope. - Update API documentation to reflect changes in tag filtering and the new tags endpoint. - Create BlogTagFilterTest to ensure correct functionality of tag filtering and facets, including edge cases for Persian tags and city filtering.
This commit is contained in:
@@ -105,6 +105,51 @@ class BlogController extends BaseController
|
||||
return $this->paginated($blogs, $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/blogs/tags',
|
||||
summary: 'Distinct tag names of published posts with post counts',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'city_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Same scope as GET /api/v1/blogs: that city\'s posts plus nationwide posts (city_id IS NULL). Omit to count every published post.',
|
||||
schema: new OA\Schema(type: 'integer')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag vocabulary of published posts, most used first',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||||
new OA\Property(
|
||||
property: 'data',
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string', example: 'چشم و گوش'),
|
||||
new OA\Property(property: 'count', type: 'integer', example: 16),
|
||||
],
|
||||
type: 'object'
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/blogs/tags', methods: ['GET'])]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
$cityId = $request->query->get('city_id') !== null
|
||||
? max(1, (int) $request->query->get('city_id'))
|
||||
: null;
|
||||
|
||||
return $this->success($this->blogRepo->tagFacets($cityId));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/blog/{slug}',
|
||||
summary: 'Get a published blog post by slug or UUID',
|
||||
|
||||
@@ -153,6 +153,42 @@ class BlogRepository extends ServiceEntityRepository
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct tag names across published posts with their post counts, honouring
|
||||
* the same city scope as the public list. Blog tags are free-form strings in a
|
||||
* JSON column, so they are counted in PHP — DQL cannot unnest a JSON array.
|
||||
*
|
||||
* @return list<array{name: string, count: int}> count DESC, then name ASC
|
||||
*/
|
||||
public function tagFacets(?int $cityId = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->select('b.tags')
|
||||
->where('b.status = :status')
|
||||
->setParameter('status', Blog::STATUS_PUBLISHED);
|
||||
|
||||
$this->applyCityFilter($qb, $cityId);
|
||||
|
||||
$counts = [];
|
||||
foreach ($qb->getQuery()->getArrayResult() as $row) {
|
||||
foreach ($row['tags'] ?? [] as $name) {
|
||||
if (!is_string($name) || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$counts[$name] = ($counts[$name] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Ties keep a stable, human-predictable order instead of insertion order.
|
||||
uksort($counts, static fn(string $a, string $b) => [$counts[$b], $a] <=> [$counts[$a], $b]);
|
||||
|
||||
return array_map(
|
||||
static fn(string $name, int $count) => ['name' => $name, 'count' => $count],
|
||||
array_keys($counts),
|
||||
array_values($counts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* دامنهٔ یک شهر باید پستهای همان شهر **و** پستهای سراسری را ببیند — پست
|
||||
* سراسری (city NULL) روی همهٔ دامنهها منتشر است، فقط canonicalش روی دامنهٔ اصلی
|
||||
@@ -174,8 +210,12 @@ class BlogRepository extends ServiceEntityRepository
|
||||
return;
|
||||
}
|
||||
// Blog.tags is a JSON array of tag names; match exact name membership.
|
||||
// Doctrine's JsonType writes this column with plain json_encode, so Persian
|
||||
// names are stored \uXXXX-escaped. MariaDB's JSON_CONTAINS compares the two
|
||||
// documents without normalizing those escapes, so the candidate must be
|
||||
// encoded identically — JSON_UNESCAPED_UNICODE never matched a single row.
|
||||
$qb->andWhere('JSON_CONTAINS(b.tags, :tag) = 1')
|
||||
->setParameter('tag', json_encode($tag, JSON_UNESCAPED_UNICODE));
|
||||
->setParameter('tag', json_encode($tag));
|
||||
}
|
||||
|
||||
public function save(Blog $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
|
||||
Reference in New Issue
Block a user