first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

334
src/Post/PostRepository.php Normal file
View 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;
}
}