Working state
This commit is contained in:
@@ -5,20 +5,8 @@ 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,
|
||||
@@ -30,130 +18,150 @@ final class PostRepository implements PostRepositoryInterface
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
|
||||
$rows = $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 array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
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('La requête COUNT sur posts a échoué.');
|
||||
}
|
||||
|
||||
return (int) ($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
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]);
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$sql .= ' ORDER BY posts.id DESC';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = $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(),
|
||||
@@ -168,27 +176,14 @@ final class PostRepository implements PostRepositoryInterface
|
||||
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 = $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(),
|
||||
@@ -202,13 +197,6 @@ final class PostRepository implements PostRepositoryInterface
|
||||
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');
|
||||
@@ -217,23 +205,6 @@ final class PostRepository implements PostRepositoryInterface
|
||||
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);
|
||||
@@ -242,19 +213,50 @@ final class PostRepository implements PostRepositoryInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
[$sql, $params] = $this->buildSearchSql($ftsQuery, $categoryId, $authorId);
|
||||
$sql .= ' ORDER BY rank';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$ftsQuery = $this->buildFtsQuery($query);
|
||||
|
||||
if ($ftsQuery === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$sql, $params] = $this->buildSearchSql($ftsQuery, $categoryId, $authorId);
|
||||
$sql .= ' ORDER BY rank 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
|
||||
{
|
||||
$ftsQuery = $this->buildFtsQuery($query);
|
||||
|
||||
if ($ftsQuery === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$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
|
||||
SELECT COUNT(*)
|
||||
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
|
||||
JOIN posts p ON p.id = f.rowid
|
||||
WHERE posts_fts MATCH :query
|
||||
';
|
||||
|
||||
$params = [':query' => $ftsQuery];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
@@ -267,27 +269,80 @@ final class PostRepository implements PostRepositoryInterface
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY rank';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
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;
|
||||
|
||||
return $excludeId !== null ? $existingId !== $excludeId : true;
|
||||
}
|
||||
|
||||
public function countByEmbeddedMediaUrl(string $url): int
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE instr(content, :url) > 0');
|
||||
$stmt->execute([':url' => $url]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
self::SELECT . ' WHERE instr(posts.content, :url) > 0 ORDER BY posts.updated_at DESC LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':url', $url, PDO::PARAM_STR);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return array{0:string,1:array<string,mixed>}
|
||||
*/
|
||||
private function buildSearchSql(string $ftsQuery, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
return [$sql, $params];
|
||||
}
|
||||
|
||||
private function buildFtsQuery(string $input): string
|
||||
{
|
||||
$words = preg_split('/\s+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
@@ -305,30 +360,21 @@ final class PostRepository implements PostRepositoryInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public function slugExists(string $slug, ?int $excludeId = null): bool
|
||||
private function bindParams(\PDOStatement $stmt, array $params): void
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
|
||||
$existingId = $stmt->fetchColumn();
|
||||
|
||||
if ($existingId === false) {
|
||||
return false;
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||
}
|
||||
}
|
||||
|
||||
$existingId = (int) $existingId;
|
||||
|
||||
if ($excludeId !== null) {
|
||||
return $existingId !== $excludeId;
|
||||
}
|
||||
|
||||
return true;
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return Post[]
|
||||
*/
|
||||
private function hydratePosts(array $rows): array
|
||||
{
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user