Skip to content

Système de marque-pages ​

Permettre aux utilisateurs de sauvegarder des éléments dans des collections nommées. L'ajout de marque-pages est idempotent — ajouter le même élément deux fois retourne le marque-page existant sans erreur.

Vue d'ensemble ​

Un système de marque-pages comprend :

  • Ajouter un marque-page — sauvegarder un élément dans la collection d'un utilisateur (idempotent)
  • Supprimer un marque-page — supprimer un marque-page sauvegardé (404 si introuvable)
  • Lister les marque-pages — tous les marque-pages d'un utilisateur, optionnellement filtrés par collection
  • Compter les marque-pages — compteur léger pour badge
  • Obtenir un marque-page — vérifier si un élément spécifique est en marque-page

Schéma de la base de données ​

sql
CREATE TABLE bookmarks (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    item_id    INTEGER NOT NULL,
    collection TEXT    NOT NULL DEFAULT 'default',
    created_at TEXT    NOT NULL,
    UNIQUE (user_id, item_id),
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (item_id) REFERENCES items(id)
);

UNIQUE (user_id, item_id) impose un marque-page par utilisateur par élément. Le champ collection regroupe les marque-pages dans des catégories nommées avec 'default' comme repli.

Ajout idempotent ​

Vérifier l'existence d'un marque-page avant d'insérer. En cas de conflit (race condition), capturer DatabaseConstraintException et retourner l'enregistrement existant :

php
public function add(int $userId, int $itemId, string $collection, string $now): Bookmark
{
    $existing = $this->find($userId, $itemId);

    if ($existing !== null) {
        return $existing;  // déjà en marque-page — pas une erreur
    }

    try {
        $this->executor->execute(
            'INSERT INTO bookmarks (user_id, item_id, collection, created_at) VALUES (?, ?, ?, ?)',
            [$userId, $itemId, $collection, $now],
        );
    } catch (DatabaseConstraintException) {
        // Race condition — une autre requête a gagné ; retourner le marque-page existant
        $found = $this->find($userId, $itemId);
        if ($found !== null) {
            return $found;
        }
    }

    $id = (int) $this->executor->lastInsertId();
    return new Bookmark($id, $userId, $itemId, $collection, $now);
}

Le pattern vérification-puis-insertion gère le cas courant efficacement. La capture de DatabaseConstraintException gère la race condition sous requêtes concurrentes.

Filtrage par collection ​

Utiliser un paramètre de requête collection optionnel pour filtrer les marque-pages :

php
// GET /users/{userId}/bookmarks?collection=reading
$collection = isset($query['collection']) && $query['collection'] !== ''
    ? $query['collection'] : null;

$items = $this->repo->listByUser($userId, $collection);

Une collection null retourne tous les marque-pages ; une chaîne non vide filtre sur cette collection.

La suppression retourne 204 vs 404 ​

  • 204 No Content — le marque-page existait et a été supprimé
  • 404 Not Found — le marque-page n'existait pas
php
$removed = $this->repo->remove($userId, $itemId);

if (!$removed) {
    return $this->responseFactory->create(['error' => 'bookmark not found'], 404);
}

return $this->responseFactory->createEmpty(204);

execute() retourne le nombre de lignes affectées — zéro signifie qu'aucun marque-page n'a été trouvé.

Schéma MySQL ​

MySQL nécessite ENGINE=InnoDB et la syntaxe AUTO_INCREMENT explicites :

sql
CREATE TABLE bookmarks (
    id         INT          NOT NULL AUTO_INCREMENT,
    user_id    INT          NOT NULL,
    item_id    INT          NOT NULL,
    collection VARCHAR(100) NOT NULL DEFAULT 'default',
    created_at DATETIME     NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_user_item (user_id, item_id),
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (item_id) REFERENCES items(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Pour les tests d'intégration MySQL, utiliser SET FOREIGN_KEY_CHECKS = 0 avant de supprimer les tables pour éviter les problèmes d'ordre des dépendances FK.

Pattern de test d'intégration MySQL ​

php
protected function setUp(): void
{
    $host = (string) (getenv('MYSQL_HOST') ?: '');
    if ($host === '') {
        self::markTestSkipped('MYSQL_HOST not set — skipping MySQL integration tests');
    }
    ...
    $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
    $this->pdo->exec('DROP TABLE IF EXISTS bookmarks');
    $this->pdo->exec('DROP TABLE IF EXISTS items');
    $this->pdo->exec('DROP TABLE IF EXISTS users');
    $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 1');

    $schema = (string) file_get_contents('.../database/schema.mysql.sql');
    $this->pdo->exec($schema);
}

protected function tearDown(): void
{
    if ($this->mysqlEnabled && $this->pdo !== null) {
        $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
        $this->pdo->exec('DROP TABLE IF EXISTS bookmarks');
        $this->pdo->exec('DROP TABLE IF EXISTS items');
        $this->pdo->exec('DROP TABLE IF EXISTS users');
        $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
    }
}

Propriétés de sécurité ​

PropriétéImplémentation
Un marque-page par utilisateur par élémentContrainte DB UNIQUE (user_id, item_id)
Race condition à l'ajoutCapture DatabaseConstraintException → retourner l'existant
Isolation des utilisateursToutes les requêtes filtrent par user_id
Suppression d'un inexistantRetourne 404 (pas silencieux)

Résumé des routes ​

MéthodeCheminDescription
POST/usersCréer un utilisateur
POST/itemsCréer un élément
POST/users/{userId}/bookmarksAjouter un marque-page (idempotent)
DELETE/users/{userId}/bookmarks/{itemId}Supprimer un marque-page (204 ou 404)
GET/users/{userId}/bookmarksLister les marque-pages (filtre ?collection=)
GET/users/{userId}/bookmarks/countNombre total de marque-pages
GET/users/{userId}/bookmarks/{itemId}Obtenir le statut d'un marque-page spécifique

Publié sous licence MIT.