Adrien Gras
Ateliers

Doctrine et les entités

BaseEntity, entités du projet, fixtures, services de récupération de données et pagination avec Doctrine.

Durant cet atelier, nous allons créer notre modèle de données avec Doctrine, remplir la base de données avec des données, et faire nos premières requêtes pour afficher des données dans nos pages.

À savoir

La plupart de l'atelier sera fait en live-coding. Pensez à bien prendre des notes pour vous y référer plus tard !

Reset de la base de données

Avant de commencer à créer nos entités, nous allons réinitialiser notre base de données pour être sûrs d'avoir un environnement propre.

symfony console doctrine:database:drop --force
symfony console doctrine:database:create

Installation des dépendances

Plus tard dans cet atelier, nous allons avoir besoin de générer des UUID.

Pour les générer simplement via Symfony, nous allons installer la bibliothèque symfony/uid dont la documentation est disponible ici : https://symfony.com/doc/current/components/uid.html

Consigne

  1. Installez la bibliothèque symfony/uid via Composer avec la commande suivante :
composer require symfony/uid

Modification de l'utilisateur

Dans l'atelier précédent, nous avons mis en place le système d'authentification avec un utilisateur de base. Nous allons maintenant modifier l'entité User pour y ajouter un champ name (nom ou pseudo).

Pour ce faire, nous allons utiliser la commande suivante :

symfony console make:entity User

Consigne

  1. Ajoutez un champ name de type string à l'entité User.
  2. Générez une migration pour mettre à jour la base de données.
  3. Exécutez la migration.

Astuce

Pour le moment, votre formulaire d'inscription ne prendra pas encore en compte ce nouveau champ name et risque d'être cassé. Nous y reviendrons plus tard.

Rappel - Modélisation des données

Voici la modélisation des données que nous allons utiliser pour ce projet :

Création d'une classe d'aide pour les entités

Pour faciliter la gestion des dates de création et de mise à jour de nos entités, nous allons créer une classe d'aide (classe Abstraite) qui contiendra ces champs et les méthodes associées. Nous pourrons ensuite faire hériter nos entités de cette classe.

Astuce

Vous verrez les mécanismes d'héritage et de réutilisation du code plus tard dans l'année.

Considérez simplement cette classe comme un "copier-coller" des champs et méthodes dans chaque entité.

Consigne

  1. Dans le dossier src/Entity, créez un dossier Impl.
  2. Dans ce dossier, créez un fichier BaseEntity.php.
  3. Collez le contenu suivant :
<?php

namespace App\Entity\Impl;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use App\Entity\User;

abstract class BaseEntity
{
    #[ORM\Column(name: 'created_date', type: Types::DATETIME_MUTABLE, nullable: false, options: ['default' => "CURRENT_TIMESTAMP"])]
    protected \DateTime $createdDate;

    #[ORM\ManyToOne]
    #[ORM\JoinColumn(nullable: false)]
    protected User $createdBy;

    #[ORM\Column(name: 'updated_date', type: Types::DATETIME_MUTABLE, nullable: true)]
    protected null|\DateTime $updatedDate;

    #[ORM\ManyToOne]
    #[ORM\JoinColumn(nullable: false)]
    protected null|User $updatedBy;

    #[ORM\Column(name: 'deleted_date', type: Types::DATETIME_MUTABLE, nullable: true)]
    protected null|\DateTime $deletedDate;

    #[ORM\ManyToOne]
    #[ORM\JoinColumn(nullable: false)]
    protected null|User $deletedBy;

    #[ORM\Column(name: 'is_deleted', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
    protected bool $isDeleted = false;

	public function getCreatedDate(): \DateTime {
		return $this->createdDate;
	}

	public function setCreatedDate(\DateTime $createdDate): self {
		$this->createdDate = $createdDate;

		return $this;
	}

	public function getCreatedBy(): ?User {
		return $this->createdBy;
	}

	public function setCreatedBy(?User $createdBy): self {
		$this->createdBy = $createdBy;

		return $this;
	}

	public function getUpdatedDate(): ?\DateTime {
		return $this->updatedDate;
	}

	public function setUpdatedDate(?\DateTime $updatedDate): self {
		$this->updatedDate = $updatedDate;

		return $this;
	}

	public function getUpdatedBy(): ?User {
		return $this->updatedBy;
	}

	public function setUpdatedBy(?User $updatedBy): self {
		$this->updatedBy = $updatedBy;

		return $this;
	}

	public function getDeletedDate(): ?\DateTime {
		return $this->deletedDate;
	}

	public function setDeletedDate(?\DateTime $deletedDate): self {
		$this->deletedDate = $deletedDate;

		return $this;
	}

	public function getDeletedBy(): ?User {
		return $this->deletedBy;
	}

	public function setDeletedBy(?User $deletedBy): self {
		$this->deletedBy = $deletedBy;

		return $this;
	}

	public function isDeleted(): bool {
		return $this->isDeleted;
	}

	public function setIsDeleted(bool $isDeleted): self {
		$this->isDeleted = $isDeleted;

		return $this;
	}

}

Création des portefeuilles (Wallets)

Nous allons maintenant créer l'entité Wallet (portefeuille) qui représentera un portefeuille de dépenses partagé entre plusieurs utilisateurs.

Consigne

  1. Utilisez la commande symfony console make:entity Wallet pour créer l'entité Wallet.
  2. Ajoutez les champs suivants :
    • uid : guid, unique
    • totalAmount : integer, défaut 0
    • label : string (255)
    • paymentsDue : json, défaut '[]'
    • lastSettlementDate : datetime, nullable, défaut null
  3. Faites hériter l'entité Wallet de la classe BaseEntity avec le mot clé extends.
  4. Effectuez les modifications nécessaires dans le fichier Wallet.php pour que l'entité corresponde aux consignes.
  5. Synchronisez les modifications avec la base de données via la commande symfony console doctrine:schema:update --dump-sql (puis repassez là avec -f pour appliquer).

Astuce

Quelques indications.

  • Pour garantir que le champ uid soit unique, vous pouvez ajouter l'attribut unique: true dans l'attribut #[ORM\Column(...)].
  • Pour le champ totalAmount, vous pouvez définir une valeur par défaut en utilisant l'option options: ['default' => 0] dans l'attribut #[ORM\Column(...)].
  • Pour le champ lastSettlementDate, vous pouvez utiliser le type Types::JSON de Doctrine.

Création des dépenses (Expenses)

Nous allons maintenant créer l'entité Expense (dépense) qui représentera une dépense effectuée dans un portefeuille.

Consigne

  1. Utilisez la commande symfony console make:entity Expense pour créer l'entité Expense.
  2. Ajoutez les champs suivants :
    • uid : guid, unique
    • wallet : relation ManyToOne vers l'entité Wallet
    • amount : integer
    • description : string (255), nullable, défaut null
  3. Faites hériter l'entité Expense de la classe BaseEntity avec le mot clé extends.
  4. Effectuez les modifications nécessaires dans le fichier Expense.php pour que l'entité corresponde aux consignes.
  5. Synchronisez les modifications avec la base de données via la commande symfony console doctrine:schema:update --dump-sql (puis repassez là avec -f pour appliquer).

Création des relations utilisateur-portefeuille (XUserWallet)

Nous allons maintenant créer l'entité XUserWallet qui représentera la relation entre un utilisateur et un portefeuille, ainsi que le rôle de l'utilisateur dans ce portefeuille.

Consigne

  1. Utilisez la commande symfony console make:entity XUserWallet pour créer l'entité XUserWallet.
  2. Ajoutez les champs suivants :
    • wallet : relation ManyToOne vers l'entité Wallet
    • targetUser : relation ManyToOne vers l'entité User (ne peut pas être nommé user car ce mot est réservé en PHP)
    • role : string (50), défaut 'user'
  3. Faites hériter l'entité XUserWallet de la classe BaseEntity avec le mot clé extends.
  4. Effectuez les modifications nécessaires dans le fichier XUserWallet.php pour que l'entité corresponde aux consignes.
  5. Synchronisez les modifications avec la base de données via la commande symfony console doctrine:schema:update --dump-sql (puis repassez là avec -f pour appliquer).

Remplissage de la base de données avec des données de test

Pour tester nos entités et nos relations, nous allons remplir la base de données avec des données de test en utilisant des fixtures.

Astuce

Les Fixtures sont un moyen simple de remplir une base de données de test avec des données prédéfinies.

Vous trouverez la documentation officielle sur les fixtures Symfony ici : https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html

Nous allons utiliser le fichier de fixtures pré-généré par Symfony pour créer :

  • 2 utilisateurs
  • 3 portefeuilles
  • 5 dépenses par portefeuille
  • 2 relations utilisateur-portefeuille par portefeuille

Consigne

  1. Si ce n'est pas déjà fait, installez le bundle de fixtures Doctrine avec la première commande de la documentation.
  2. Ouvrez le fichier src/DataFixtures/AppFixtures.php et remplacez son contenu par le code suivant :
<?php

namespace App\DataFixtures;

use App\Entity\Expense;
use App\Entity\User;
use App\Entity\Wallet;
use App\Entity\XUserWallet;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Uid\Uuid;

class AppFixtures extends Fixture
{
    private const array EXPENSE_TYPES = [
        "hotel", "bar", "essence", "restau", "sortie", "souvenir", "sushi", "imprévu"
    ];

    private array $generatedUsers = [];
    private array $generatedWallets = [];

    private UserPasswordHasherInterface $hasher;
    private ObjectManager $manager;

    public function __construct(UserPasswordHasherInterface $hasher)
    {
        $this->hasher = $hasher;
    }

// ...
    public function load(ObjectManager $manager): void
    {
        // initialisation des variables
        $this->manager = $manager;
        $this->generatedUsers = [];
        $this->generatedWallets = [];

        // generate 2 different users
        $this->generatedUsers[] = $this->generateUser("Alice", "alice@coda.fr", "alice");
        $this->generatedUsers[] = $this->generateUser("Bob", "bob@coda.fr", "bob");

        // generate 3 different wallets
        $this->generatedWallets[] = $this->generateWallet("Vacances");
        $this->generatedWallets[] = $this->generateWallet("Colloc");
        $this->generatedWallets[] = $this->generateWallet("Montagne");

        // now, generate 5 expenses per wallets
        foreach ($this->generatedWallets as $wallet) {
            $this->generateExpense(
                $wallet,
                random_int(1, 150) * 100,
                self::EXPENSE_TYPES[random_int(0, sizeof(self::EXPENSE_TYPES) - 1)],
                $this->generatedUsers[0]
            );
            $this->generateExpense(
                $wallet,
                random_int(1, 150) * 100,
                self::EXPENSE_TYPES[random_int(0, sizeof(self::EXPENSE_TYPES) - 1)],
                $this->generatedUsers[0]
            );
            $this->generateExpense(
                $wallet,
                random_int(1, 150) * 100,
                self::EXPENSE_TYPES[random_int(0, sizeof(self::EXPENSE_TYPES) - 1)],
                $this->generatedUsers[1]
            );
            $this->generateExpense(
                $wallet,
                random_int(1, 150) * 100,
                self::EXPENSE_TYPES[random_int(0, sizeof(self::EXPENSE_TYPES) - 1)],
                $this->generatedUsers[1]
            );
            $this->generateExpense(
                $wallet,
                random_int(1, 150) * 100,
                self::EXPENSE_TYPES[random_int(0, sizeof(self::EXPENSE_TYPES) - 1)],
                $this->generatedUsers[1]
            );
        }

        // generating links between users and wallets
        foreach ($this->generatedWallets as $wallet) {
            foreach ($this->generatedUsers as $user) {
                $role = $user->getName() == "Alice" ? "admin" : "user";

                $this->generateXUserWallet($user, $wallet, $role);
            }
        }
    }

    public function generateXUserWallet(User $user, Wallet $wallet, string $role): XUserWallet
    {
        $xUserWallet = new XUserWallet();

        // ...

        $xUserWallet->setCreatedBy($this->generatedUsers[0]);
        $xUserWallet->setCreatedDate(new \DateTime());

        $this->manager->persist($xUserWallet);
        $this->manager->flush();

        return $xUserWallet;
    }

    public function generateExpense(Wallet $wallet, int $amount, string $description, User $createdBy): Expense
    {
        $expense = new Expense();
        $expense->setUid(Uuid::v7()->toString());

        // ...

        $expense->setCreatedBy($createdBy);
        $expense->setCreatedDate(new \DateTime());

        $this->manager->persist($expense);
        $this->manager->flush();

        return $expense;
    }

    public function generateWallet(string $label): Wallet
    {
        $wallet = new Wallet();

        $wallet->setUid(Uuid::v7()->toString());

        // ...

        $wallet->setCreatedBy($this->generatedUsers[0]);
        $wallet->setCreatedDate(new \DateTime());

        $this->manager->persist($wallet);
        $this->manager->flush();

        return $wallet;
    }

    /**
     * Generates a User instance and store it in the databases.
     *
     * It also ensures that the password is hashed.
     *
     * @param string $name name of the user
     * @param string $email email of the user
     * @param string $password clear (not encrypted) password of the user
     * @return User the created user
     */
    private function generateUser(string $name, string $email, string $password): User
    {
        $user = new User();
        $user->setName($name);
        $user->setEmail($email);
        $user->setPassword($this->hasher->hashPassword($user, $password));
        $this->manager->persist($user);
        $this->manager->flush();

        return $user;
    }
}

  1. Complétez les méthodes generateXUserWallet, generateExpense et generateWallet pour initialiser tous les champs des entités correspondantes.
  2. Exécutez les fixtures avec la commande suivante : symfony console doctrine:fixtures:load

Creation du WalletService et listing des Wallets pour un utilisateur

Nous allons maintenant créer un service WalletService pour gérer la logique métier liée aux portefeuilles (Wallets).

Consigne

  1. Créez un dossier Service dans le dossier src.
  2. Dans ce dossier, créez un fichier WalletService.php.
  3. Avec l'autowiring, injectez le WalletRepository dans le constructeur du service.
  4. Créez une méthode findWalletsForUser(User $user): array qui retourne la liste des portefeuilles (Wallets) pour un utilisateur donné. Cette méthode fera appel à une requête personnalisée dans le repository.
  5. Implémentez la requête nécessaire dans le repository pour récupérer ces portefeuilles liés à l'utilisateur via l'entité XUserWallet.
    • Astuce : vous devrez faire une jointure entre les entités Wallet et XUserWallet pour filtrer les portefeuilles liés à l'utilisateur.
  6. Appelez la requêtte dans la méthode getWalletsForUser du service.

Maintenant que la logique est en place, vous pouvez l'utiliser dans un contrôleur pour afficher la liste des portefeuilles d'un utilisateur.

Consigne

  1. Dans le contrôleur ListController du dossier Wallet, injectez le WalletService dans la méthode du controller.
  2. Dans la méthode index, utilisez le service pour récupérer les portefeuilles de l'utilisateur connecté ($this->getUser()).
  3. Passez la liste des portefeuilles à la vue Twig pour les afficher.
  4. Modifiez la vue Twig index.html.twig dans le dossier Wallet pour afficher les portefeuilles récupérés.
    • La liste pourra afficher le label et le montant total de chaque portefeuille
    • Elle devra aussi pouvoir être cliquable pour accéder aux détails du portefeuille (nous verrons cela plus tard).
  5. Testez en vous connectant avec un des utilisateurs créés par les fixtures.

Création de l'ExpenseService et listing des Expenses pour un Wallet

Nous allons maintenant créer un service ExpenseService pour gérer la logique métier liée aux dépenses (Expenses).

Consigne

  1. Dans le dossier Service, créez un fichier ExpenseService.php.
  2. Avec l'autowiring, injectez le ExpenseRepository dans le constructeur du service.
  3. Créez une méthode findExpensesForWallet(Wallet $wallet, int $page, int $limit): array qui retourne la liste paginée des dépenses (Expenses) pour un portefeuille (Wallet) donné.
  4. Implémentez la requête nécessaire dans le repository pour récupérer ces dépenses liées au portefeuille avec la pagination.
    • Astuce : vous devrez utiliser les méthodes setFirstResult et setMaxResults de Doctrine pour gérer la pagination.
    • Pour le calcul du firstResult, utilisez la formule suivante : (page - 1) * limit.
  5. Appelez la requête dans la méthode findExpensesForWallet du service.

Maintenant que la logique est en place, vous pouvez l'utiliser dans un contrôleur pour afficher la liste des dépenses d'un portefeuille.

Consigne

  1. Dans le contrôleur DetailController du dossier Wallet, injectez le ExpenseService dans la méthode du controller.
  2. Injectez aussi le WalletService
  3. Dans le WalletService, créez une méthode getUserAccessOnWallet(User $user, Wallet $wallet): null|XUserWallet qui retourne la relation XUserWallet entre un utilisateur et un portefeuille, ou null si l'utilisateur n'a pas accès au portefeuille.
    • Vous aurez besoin d'injecter le XUserWalletRepository dans le service pour faire cette requête.
    • Vous pourrez utiliser la méthode findOneBy du repository pour récupérer la relation.
  4. Dans la méthode index du contrôleur, utilisez cette méthode pour vérifier que l'utilisateur connecté a bien accès au portefeuille demandé.
    • Si l'utilisateur n'a pas accès, vous pouvez le renvoyer sur la liste des wallets avec un message d'erreur (utilisez les Flash Messages de Symfony).
  5. Changez la route :
    • le paramètre dans la route doit devenir {uid} au lieu de {id}.
    • le paramètre de la fonction doit devenir Wallet $wallet au lieu de int $id. (Symfony fera l'injection automatique de l'entité basée sur l'UID passé dans l'URL).
  6. Toujours dans la méthode index, ajoutez deux paramètres supplémentaires $page et $limit pour gérer la pagination (vous pouvez définir des valeurs par défaut, par exemple $page = 1 et $limit = 10). Utilisez la synthaxe suivante :
public function index(
    // le wallet lié à la route
    Wallet $wallet,
    // numéro de page où l'on se trouve
    #[MapQueryParameter] int $page = 1,
    // nombre d'éléments par page
    #[MapQueryParameter] int $limit = 25

    // injection de dépendances
    WalletService $walletService,
    ExpenseService $expenseService
) {
    //...
}

  1. Utilisez le service pour récupérer les dépenses du portefeuille avec la pagination.
  2. Passez la liste des dépenses à la vue Twig pour les afficher.
  3. Modifiez la vue Twig details/index.html.twig dans le dossier Wallet pour afficher les dépenses récupérées.
    • La liste pourra afficher la description, le montant et la date de chaque dépense.
  4. Testez en vous connectant avec un des utilisateurs créés par les fixtures et en accédant à un portefeuille.

Enfin, nous allons gérer l'affichage de la pagination dans la vue Twig.

Consigne

  1. Dans le service ExpenseService, créez une méthode countExpensesForWallet(Wallet $wallet): int qui retourne le nombre total de dépenses pour un portefeuille donné. (vous pouvez utiliser une requête COUNT dans le repository).
  2. Dans le contrôleur DetailController, utilisez cette méthode pour récupérer le nombre total de dépenses.
  3. Calculez le nombre total de pages en fonction du nombre total de dépenses et du nombre d'éléments par page.
    • Utilisez la formule suivante : totalPages = ceil(totalExpenses / limit).
  4. Passez le nombre total de pages à la vue Twig.
  5. Dans la vue Twig details/index.html.twig, ajoutez des liens de pagination en bas de la liste des dépenses.
    • Affichez des liens pour chaque page (1, 2, 3, etc.) en utilisant une boucle.
    • Assurez-vous que les liens conservent les paramètres de pagination dans l'URL. (vous pouvez utiliser la fonction path de Twig avec les paramètres appropriés).
  6. Testez la pagination en naviguant entre les pages des dépenses.

Sur cette page