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:
hamed
2026-07-29 14:23:36 +03:30
parent 4f4bce9fe2
commit 9b05c6d1ff
6 changed files with 707 additions and 15 deletions
+41 -1
View File
@@ -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(); }