first commit
This commit is contained in:
74
src/Post/Infrastructure/HtmlPostMediaReferenceExtractor.php
Normal file
74
src/Post/Infrastructure/HtmlPostMediaReferenceExtractor.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
|
||||
|
||||
final class HtmlPostMediaReferenceExtractor implements PostMediaReferenceExtractorInterface
|
||||
{
|
||||
/** @return list<int> */
|
||||
public function extractMediaIds(string $html): array
|
||||
{
|
||||
if (trim($html) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$document = new \DOMDocument('1.0', 'UTF-8');
|
||||
$root = $this->loadFragment($document, $html);
|
||||
if ($root === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$xpath = new \DOMXPath($document);
|
||||
$nodes = $xpath->query('.//*[@data-media-id]', $root);
|
||||
if ($nodes === false || $nodes->length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$mediaIds = [];
|
||||
foreach ($nodes as $node) {
|
||||
if (!$node instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mediaId = (int) trim($node->getAttribute('data-media-id'));
|
||||
if ($mediaId > 0) {
|
||||
$mediaIds[] = $mediaId;
|
||||
}
|
||||
}
|
||||
|
||||
$mediaIds = array_values(array_unique($mediaIds));
|
||||
sort($mediaIds);
|
||||
|
||||
return $mediaIds;
|
||||
}
|
||||
|
||||
private function loadFragment(\DOMDocument $document, string $html): ?\DOMElement
|
||||
{
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
|
||||
try {
|
||||
$wrapped = '<!DOCTYPE html><html><body><div data-post-media-root="1">' . $html . '</div></body></html>';
|
||||
$loaded = $document->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_COMPACT);
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
}
|
||||
|
||||
if ($loaded !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$xpath = new \DOMXPath($document);
|
||||
$nodes = $xpath->query('//div[@data-post-media-root="1"]');
|
||||
if ($nodes === false || $nodes->length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$node = $nodes->item(0);
|
||||
|
||||
return $node instanceof \DOMElement ? $node : null;
|
||||
}
|
||||
}
|
||||
173
src/Post/Infrastructure/PdoPostMediaUsageRepository.php
Normal file
173
src/Post/Infrastructure/PdoPostMediaUsageRepository.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\ValueObject\PostMediaUsageReference;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Implémentation PDO des lectures et synchronisations d'usages médias portés par les articles.
|
||||
*/
|
||||
final class PdoPostMediaUsageRepository implements PostMediaUsageRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly PDO $db) {}
|
||||
|
||||
public function countUsages(int $mediaId): int
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM post_media WHERE media_id = :media_id');
|
||||
$stmt->execute([':media_id' => $mediaId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $mediaIds
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function countUsagesByMediaIds(array $mediaIds): array
|
||||
{
|
||||
$mediaIds = $this->normalizeMediaIds($mediaIds);
|
||||
if ($mediaIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = $this->buildPlaceholders($mediaIds);
|
||||
$stmt = $this->db->prepare(
|
||||
sprintf(
|
||||
'SELECT media_id, COUNT(*) AS usage_count
|
||||
FROM post_media
|
||||
WHERE media_id IN (%s)
|
||||
GROUP BY media_id',
|
||||
$placeholders,
|
||||
),
|
||||
);
|
||||
$stmt->execute($mediaIds);
|
||||
|
||||
$countsByMediaId = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$countsByMediaId[(int) $row['media_id']] = (int) $row['usage_count'];
|
||||
}
|
||||
|
||||
return $countsByMediaId;
|
||||
}
|
||||
|
||||
/** @return list<PostMediaUsageReference> */
|
||||
public function findUsages(int $mediaId, int $limit = 5): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT posts.id, posts.title
|
||||
FROM post_media
|
||||
INNER JOIN posts ON posts.id = post_media.post_id
|
||||
WHERE post_media.media_id = :media_id
|
||||
ORDER BY posts.id DESC
|
||||
LIMIT :limit',
|
||||
);
|
||||
$stmt->bindValue(':media_id', $mediaId, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): PostMediaUsageReference => new PostMediaUsageReference(
|
||||
(int) $row['id'],
|
||||
(string) $row['title'],
|
||||
'/admin/posts/edit/' . (int) $row['id'],
|
||||
),
|
||||
$stmt->fetchAll(PDO::FETCH_ASSOC),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $mediaIds
|
||||
* @return array<int, list<PostMediaUsageReference>>
|
||||
*/
|
||||
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
|
||||
{
|
||||
$mediaIds = $this->normalizeMediaIds($mediaIds);
|
||||
if ($mediaIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = max(1, $limit);
|
||||
$placeholders = $this->buildPlaceholders($mediaIds);
|
||||
$stmt = $this->db->prepare(
|
||||
sprintf(
|
||||
'SELECT post_media.media_id, posts.id, posts.title
|
||||
FROM post_media
|
||||
INNER JOIN posts ON posts.id = post_media.post_id
|
||||
WHERE post_media.media_id IN (%s)
|
||||
ORDER BY post_media.media_id ASC, posts.id DESC',
|
||||
$placeholders,
|
||||
),
|
||||
);
|
||||
$stmt->execute($mediaIds);
|
||||
|
||||
$referencesByMediaId = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$mediaId = (int) $row['media_id'];
|
||||
$referencesByMediaId[$mediaId] ??= [];
|
||||
|
||||
if (count($referencesByMediaId[$mediaId]) >= $limit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$referencesByMediaId[$mediaId][] = new PostMediaUsageReference(
|
||||
(int) $row['id'],
|
||||
(string) $row['title'],
|
||||
'/admin/posts/edit/' . (int) $row['id'],
|
||||
);
|
||||
}
|
||||
|
||||
return $referencesByMediaId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $mediaIds
|
||||
*/
|
||||
public function syncPostMedia(int $postId, array $mediaIds): void
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM post_media WHERE post_id = :post_id');
|
||||
$stmt->execute([':post_id' => $postId]);
|
||||
|
||||
$mediaIds = $this->normalizeMediaIds($mediaIds);
|
||||
if ($mediaIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO post_media (post_id, media_id, usage_type)
|
||||
VALUES (:post_id, :media_id, :usage_type)',
|
||||
);
|
||||
|
||||
foreach ($mediaIds as $mediaId) {
|
||||
$insert->execute([
|
||||
':post_id' => $postId,
|
||||
':media_id' => $mediaId,
|
||||
':usage_type' => 'embedded',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre et déduplique une liste d'identifiants de médias.
|
||||
*
|
||||
* @param list<int> $mediaIds
|
||||
* @return list<int>
|
||||
*/
|
||||
private function normalizeMediaIds(array $mediaIds): array
|
||||
{
|
||||
return array_values(array_unique(array_filter($mediaIds, static fn (int $mediaId): bool => $mediaId > 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit une liste de placeholders positionnels pour une clause `IN`.
|
||||
*
|
||||
* @param list<int> $mediaIds
|
||||
*/
|
||||
private function buildPlaceholders(array $mediaIds): string
|
||||
{
|
||||
return implode(', ', array_fill(0, count($mediaIds), '?'));
|
||||
}
|
||||
}
|
||||
347
src/Post/Infrastructure/PdoPostRepository.php
Normal file
347
src/Post/Infrastructure/PdoPostRepository.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
|
||||
class PdoPostRepository implements PostRepositoryInterface
|
||||
{
|
||||
private const SELECT = '
|
||||
SELECT posts.id, posts.title, posts.content, posts.slug,
|
||||
posts.author_id, posts.category_id, posts.created_at, posts.updated_at,
|
||||
users.username AS author_username,
|
||||
categories.name AS category_name,
|
||||
categories.slug AS category_slug
|
||||
FROM posts
|
||||
LEFT JOIN users ON users.id = posts.author_id
|
||||
LEFT JOIN categories ON categories.id = posts.category_id
|
||||
';
|
||||
|
||||
public function __construct(private readonly PDO $db) {}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findAll(?int $categoryId = null): array
|
||||
{
|
||||
if ($categoryId === null) {
|
||||
$stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête SELECT sur posts a échoué.');
|
||||
}
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findPage(int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT;
|
||||
$params = [];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' WHERE posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countAll(?int $categoryId = null): int
|
||||
{
|
||||
if ($categoryId === null) {
|
||||
$stmt = $this->db->query('SELECT COUNT(*) FROM posts');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('Le comptage des posts a échoué.');
|
||||
}
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findRecent(int $limit): array
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.created_at DESC LIMIT :limit');
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findByUserId(int $userId, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countByUserId(int $userId, ?int $categoryId = null): int
|
||||
{
|
||||
$sql = 'SELECT COUNT(*) FROM posts WHERE author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?Post
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Post::fromArray($row) : null;
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Post
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Post::fromArray($row) : null;
|
||||
}
|
||||
|
||||
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at)
|
||||
VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)',
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':title' => $post->getTitle(),
|
||||
':content' => $post->getContent(),
|
||||
':slug' => $slug,
|
||||
':author_id' => $authorId,
|
||||
':category_id' => $categoryId,
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return (int) $this->db->lastInsertId();
|
||||
}
|
||||
|
||||
public function update(int $id, Post $post, string $slug, ?int $categoryId): int
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE posts
|
||||
SET title = :title, content = :content, slug = :slug,
|
||||
category_id = :category_id, updated_at = :updated_at
|
||||
WHERE id = :id',
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':title' => $post->getTitle(),
|
||||
':content' => $post->getContent(),
|
||||
':slug' => $slug,
|
||||
':category_id' => $categoryId,
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function delete(int $id): int
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$normalizedQuery = $this->normalizeSearchQuery($query);
|
||||
|
||||
if ($normalizedQuery === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$params = [':q' => $normalizedQuery];
|
||||
$sql = $this->buildSearchSql($params, $categoryId, $authorId);
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$normalizedQuery = $this->normalizeSearchQuery($query);
|
||||
|
||||
if ($normalizedQuery === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$params = [':q' => $normalizedQuery];
|
||||
$sql = $this->buildSearchSql($params, $categoryId, $authorId);
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int
|
||||
{
|
||||
$normalizedQuery = $this->normalizeSearchQuery($query);
|
||||
|
||||
if ($normalizedQuery === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$params = [':q' => $normalizedQuery];
|
||||
$sql = 'SELECT COUNT(*) FROM posts WHERE id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)';
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
if ($authorId !== null) {
|
||||
$sql .= ' AND author_id = :author_id';
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function slugExists(string $slug, ?int $excludeId = null): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug LIMIT 1');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
|
||||
$existingId = $stmt->fetchColumn();
|
||||
|
||||
if ($existingId === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($excludeId !== null && (int) $existingId === $excludeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function buildSearchSql(array &$params, ?int $categoryId = null, ?int $authorId = null): string
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)';
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
if ($authorId !== null) {
|
||||
$sql .= ' AND posts.author_id = :author_id';
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function normalizeSearchQuery(string $query): ?string
|
||||
{
|
||||
preg_match_all('/[\p{L}\p{N}_]+/u', $query, $matches);
|
||||
$terms = array_values(array_filter($matches[0], static fn (string $term): bool => $term !== ''));
|
||||
|
||||
if ($terms === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$quotedTerms = array_map(
|
||||
static fn (string $term): string => '"' . str_replace('"', '""', $term) . '"',
|
||||
$terms,
|
||||
);
|
||||
|
||||
return implode(' ', $quotedTerms);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function bindParams(PDOStatement $stmt, array $params): void
|
||||
{
|
||||
foreach ($params as $name => $value) {
|
||||
$stmt->bindValue($name, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $rows
|
||||
* @return Post[] */
|
||||
private function hydratePosts(array $rows): array
|
||||
{
|
||||
return array_map(static fn (array $row): Post => Post::fromArray($row), $rows);
|
||||
}
|
||||
}
|
||||
24
src/Post/Infrastructure/PdoTaxonUsageChecker.php
Normal file
24
src/Post/Infrastructure/PdoTaxonUsageChecker.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use Netig\Netslim\Taxonomy\Contracts\TaxonUsageCheckerInterface;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Vérifie si un terme de taxonomie est encore référencé par au moins un post.
|
||||
*/
|
||||
final readonly class PdoTaxonUsageChecker implements TaxonUsageCheckerInterface
|
||||
{
|
||||
public function __construct(private PDO $db) {}
|
||||
|
||||
public function isTaxonInUse(int $taxonId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
|
||||
$stmt->execute([':category_id' => $taxonId]);
|
||||
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
}
|
||||
}
|
||||
73
src/Post/Infrastructure/PostMediaUsageReader.php
Normal file
73
src/Post/Infrastructure/PostMediaUsageReader.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\ValueObject\PostMediaUsageReference;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReference;
|
||||
|
||||
/**
|
||||
* Adaptateur entre les usages médias exposés par le module Post et le port lu par Media.
|
||||
*/
|
||||
final class PostMediaUsageReader implements MediaUsageReaderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PostMediaUsageRepositoryInterface $postMediaUsageRepository,
|
||||
) {}
|
||||
|
||||
public function countUsages(int $mediaId): int
|
||||
{
|
||||
return $this->postMediaUsageRepository->countUsages($mediaId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $mediaIds
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function countUsagesByMediaIds(array $mediaIds): array
|
||||
{
|
||||
return $this->postMediaUsageRepository->countUsagesByMediaIds($mediaIds);
|
||||
}
|
||||
|
||||
/** @return list<MediaUsageReference> */
|
||||
public function findUsages(int $mediaId, int $limit = 5): array
|
||||
{
|
||||
return $this->mapUsageReferences($this->postMediaUsageRepository->findUsages($mediaId, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $mediaIds
|
||||
* @return array<int, list<MediaUsageReference>>
|
||||
*/
|
||||
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
|
||||
{
|
||||
$referencesByMediaId = [];
|
||||
|
||||
foreach ($this->postMediaUsageRepository->findUsagesByMediaIds($mediaIds, $limit) as $mediaId => $references) {
|
||||
$referencesByMediaId[$mediaId] = $this->mapUsageReferences($references);
|
||||
}
|
||||
|
||||
return $referencesByMediaId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforme les références d'usage issues du module Post vers le VO exposé au module Media.
|
||||
*
|
||||
* @param list<PostMediaUsageReference> $references
|
||||
* @return list<MediaUsageReference>
|
||||
*/
|
||||
private function mapUsageReferences(array $references): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (PostMediaUsageReference $reference): MediaUsageReference => new MediaUsageReference(
|
||||
$reference->getPostId(),
|
||||
$reference->getPostTitle(),
|
||||
$reference->getPostEditPath(),
|
||||
),
|
||||
$references,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
src/Post/Infrastructure/PostSearchIndexer.php
Normal file
26
src/Post/Infrastructure/PostSearchIndexer.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Synchronise l'index FTS5 du module Post avec les articles présents en base.
|
||||
*/
|
||||
final class PostSearchIndexer
|
||||
{
|
||||
public static function syncFtsIndex(PDO $db): void
|
||||
{
|
||||
$db->exec("
|
||||
INSERT INTO posts_fts(rowid, title, content, author_username)
|
||||
SELECT p.id,
|
||||
p.title,
|
||||
COALESCE(strip_tags(p.content), ''),
|
||||
COALESCE((SELECT username FROM users WHERE id = p.author_id), '')
|
||||
FROM posts p
|
||||
WHERE p.id NOT IN (SELECT rowid FROM posts_fts)
|
||||
");
|
||||
}
|
||||
}
|
||||
45
src/Post/Infrastructure/dependencies.php
Normal file
45
src/Post/Infrastructure/dependencies.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Post\Application\PostApplicationService;
|
||||
use App\Post\Application\PostServiceInterface;
|
||||
use App\Post\Application\UseCase\CreatePost;
|
||||
use App\Post\Application\UseCase\DeletePost;
|
||||
use App\Post\Application\UseCase\UpdatePost;
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
|
||||
use App\Post\Domain\Service\PostSlugGenerator;
|
||||
use App\Post\Infrastructure\HtmlPostMediaReferenceExtractor;
|
||||
use App\Post\Infrastructure\PdoPostMediaUsageRepository;
|
||||
use App\Post\Infrastructure\PdoPostRepository;
|
||||
use App\Post\Infrastructure\PdoTaxonUsageChecker;
|
||||
use App\Post\Infrastructure\PostMediaUsageReader;
|
||||
use App\Post\UI\Http\RssController;
|
||||
|
||||
use function DI\autowire;
|
||||
use function DI\factory;
|
||||
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
|
||||
use Netig\Netslim\Taxonomy\Contracts\TaxonUsageCheckerInterface;
|
||||
|
||||
return [
|
||||
PostServiceInterface::class => autowire(PostApplicationService::class),
|
||||
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
||||
PostMediaUsageRepositoryInterface::class => autowire(PdoPostMediaUsageRepository::class),
|
||||
PostMediaReferenceExtractorInterface::class => autowire(HtmlPostMediaReferenceExtractor::class),
|
||||
TaxonUsageCheckerInterface::class => autowire(PdoTaxonUsageChecker::class),
|
||||
PostSlugGenerator::class => autowire(),
|
||||
CreatePost::class => autowire(),
|
||||
UpdatePost::class => autowire(),
|
||||
DeletePost::class => autowire(),
|
||||
MediaUsageReaderInterface::class => autowire(PostMediaUsageReader::class),
|
||||
RssController::class => factory(function (PostServiceInterface $postService): RssController {
|
||||
return new RssController(
|
||||
$postService,
|
||||
rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'),
|
||||
$_ENV['APP_NAME'] ?? 'Netslim Blog',
|
||||
);
|
||||
}),
|
||||
];
|
||||
Reference in New Issue
Block a user