first commit
This commit is contained in:
334
src/Post/PostRepository.php
Normal file
334
src/Post/PostRepository.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Dépôt pour la persistance des articles.
|
||||
*
|
||||
* Responsabilité unique : exécuter les requêtes SQL liées à la table `posts`
|
||||
* et retourner des instances de Post hydratées.
|
||||
* Chaque requête de lecture effectue un LEFT JOIN sur `users` pour charger
|
||||
* le nom d'auteur, et un LEFT JOIN sur `categories` pour charger le nom et
|
||||
* le slug de catégorie — sans requête supplémentaire.
|
||||
*/
|
||||
final class PostRepository implements PostRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Fragment SELECT commun à toutes les requêtes de lecture (avec JOINs).
|
||||
*/
|
||||
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
|
||||
';
|
||||
|
||||
/**
|
||||
* @param PDO $db Instance de connexion à la base de données
|
||||
*/
|
||||
public function __construct(private readonly PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne tous les articles triés du plus récent au plus ancien.
|
||||
*
|
||||
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||
*
|
||||
* @return Post[] La liste des articles
|
||||
*/
|
||||
public function findAll(?int $categoryId = null): array
|
||||
{
|
||||
if ($categoryId !== null) {
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
} else {
|
||||
$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é.');
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les N articles les plus récents, tous auteurs confondus.
|
||||
*
|
||||
* @param int $limit Nombre maximum d'articles à retourner
|
||||
*
|
||||
* @return Post[] Les articles les plus récents
|
||||
*/
|
||||
public function findRecent(int $limit): array
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.id DESC LIMIT :limit');
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne tous les articles d'un utilisateur donné, triés du plus récent au plus ancien.
|
||||
*
|
||||
* @param int $userId Identifiant de l'auteur
|
||||
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||
*
|
||||
* @return Post[] La liste des articles de cet utilisateur
|
||||
*/
|
||||
public function findByUserId(int $userId, ?int $categoryId = null): array
|
||||
{
|
||||
if ($categoryId !== null) {
|
||||
$stmt = $this->db->prepare(
|
||||
self::SELECT . ' WHERE posts.author_id = :author_id AND posts.category_id = :category_id ORDER BY posts.id DESC'
|
||||
);
|
||||
$stmt->execute([':author_id' => $userId, ':category_id' => $categoryId]);
|
||||
} else {
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.author_id = :author_id ORDER BY posts.id DESC');
|
||||
$stmt->execute([':author_id' => $userId]);
|
||||
}
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve un article par son slug URL.
|
||||
*
|
||||
* @param string $slug Le slug URL de l'article
|
||||
*
|
||||
* @return Post|null L'article trouvé, ou null s'il n'existe pas
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve un article par son identifiant.
|
||||
*
|
||||
* @param int $id Identifiant de l'article
|
||||
*
|
||||
* @return Post|null L'article trouvé, ou null s'il n'existe pas
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste un nouvel article en base de données.
|
||||
*
|
||||
* @param Post $post L'article à créer
|
||||
* @param string $slug Le slug unique généré pour cet article
|
||||
* @param int $authorId Identifiant de l'auteur
|
||||
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||
*
|
||||
* @return int L'identifiant généré par la base de données
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour un article existant en base de données.
|
||||
*
|
||||
* Retourne le nombre de lignes affectées. Une valeur de 0 indique que
|
||||
* l'article n'existe plus au moment de l'écriture (suppression concurrente).
|
||||
*
|
||||
* @param int $id Identifiant de l'article à modifier
|
||||
* @param Post $post L'article avec les nouvelles données
|
||||
* @param string $slug Le nouveau slug unique
|
||||
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||
*
|
||||
* @return int Nombre de lignes affectées (0 si l'article n'existe plus)
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un article de la base de données.
|
||||
*
|
||||
* @param int $id Identifiant de l'article à supprimer
|
||||
*
|
||||
* @return int Nombre de lignes supprimées (0 si l'article n'existe plus)
|
||||
*/
|
||||
public function delete(int $id): int
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche des articles en plein texte via l'index FTS5.
|
||||
*
|
||||
* La requête est tokenisée mot par mot : chaque terme est traité comme un
|
||||
* préfixe (ex: "slim" correspond à "Slim", "Slimframework"…). Les termes
|
||||
* sont combinés en AND implicite — tous doivent être présents dans le document.
|
||||
* Les caractères spéciaux FTS5 sont échappés par guillemets doubles.
|
||||
*
|
||||
* Les résultats sont triés par pertinence BM25 (meilleur en premier).
|
||||
* Filtrages optionnels disponibles : par catégorie et/ou par auteur.
|
||||
*
|
||||
* @param string $query La saisie utilisateur brute
|
||||
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||
* @param int|null $authorId Filtre optionnel par identifiant d'auteur (rôle user)
|
||||
*
|
||||
* @return Post[] Les articles correspondant à la recherche, triés par pertinence
|
||||
*/
|
||||
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$ftsQuery = $this->buildFtsQuery($query);
|
||||
|
||||
if ($ftsQuery === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sql = '
|
||||
SELECT p.id, p.title, p.content, p.slug,
|
||||
p.author_id, p.category_id, p.created_at, p.updated_at,
|
||||
u.username AS author_username,
|
||||
c.name AS category_name,
|
||||
c.slug AS category_slug
|
||||
FROM posts_fts f
|
||||
JOIN posts p ON p.id = f.rowid
|
||||
LEFT JOIN users u ON u.id = p.author_id
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE posts_fts MATCH :query
|
||||
';
|
||||
|
||||
$params = [':query' => $ftsQuery];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND p.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
if ($authorId !== null) {
|
||||
$sql .= ' AND p.author_id = :author_id';
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY rank';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit une requête FTS5 sûre depuis la saisie utilisateur.
|
||||
*
|
||||
* Chaque mot est wrappé entre guillemets doubles (échappement interne
|
||||
* des guillemets par doublement) et suivi d'un `*` pour la recherche
|
||||
* par préfixe. Les mots sont joints par un espace (AND implicite FTS5).
|
||||
*
|
||||
* @param string $input La saisie brute de l'utilisateur
|
||||
*
|
||||
* @return string La requête FTS5 prête à l'emploi, ou '' si vide
|
||||
*/
|
||||
private function buildFtsQuery(string $input): string
|
||||
{
|
||||
$words = preg_split('/\s+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
|
||||
if (empty($words)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$terms = array_map(
|
||||
fn ($w) => '"' . str_replace('"', '""', $w) . '"*',
|
||||
$words
|
||||
);
|
||||
|
||||
return implode(' ', $terms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un slug est déjà utilisé par un autre article.
|
||||
*
|
||||
* @param string $slug Le slug à vérifier
|
||||
* @param int|null $excludeId Identifiant à exclure de la vérification (pour les mises à jour)
|
||||
*
|
||||
* @return bool True si le slug est déjà pris par un autre article
|
||||
*/
|
||||
public function slugExists(string $slug, ?int $excludeId = null): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
|
||||
$existingId = $stmt->fetchColumn();
|
||||
|
||||
if ($existingId === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existingId = (int) $existingId;
|
||||
|
||||
if ($excludeId !== null) {
|
||||
return $existingId !== $excludeId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user