Symfony 4: lavorare con Doctrine

Nella scorsa puntata abbiamo conosciuto Twig e le sue potenzialità. In questo articolo conosceremo un altro componente molto utilizzato: Doctrine. Possiamo vederlo come una sorta di middleware, ossia un ponte tra la nostra applicazione e i dati del database. Sono supportati diversi DBMS, come MySQL, PostgreSQL e, con qualche lieve differenza, addirittura MongoDB (che è un NoSQL).

Con Doctrine non scriveremo più query SQL (anche se è possibile utilizzare DQL, davvero molto simile), ma utilizzeremo un sistema di metodi OOP, detto Query Builder, che vedremo più avanti in questo corso.

Set up e creazione del database

In questa puntata utilizzeremo MySQL, senz’altro il DBMS più conosciuto e usato. Prima di entrare nei particolari, scarichiamo il package e configuriamolo:

composer require doctrine

L’installazione è iterativa e ci chiederà di impostare alcuni parametri, come host, porta, nome del DB, utente e password. Diciamo che una configurazione tipica può essere:

host  127.0.0.1
port  null
name  zombiedb 
user  zombie 
password  null

Ovviamente questi dati saranno diversi nel vostro caso. Date al DB il nome che desiderate, ma soprattutto, usate username e password corretti, quelli con cui fate l’accesso all’applicativo mysql. Attenzione: questi dati, usati per la connessione al DB, dipendono dall’environment della vostra macchina. Cioè quelli che usate in locale, molto probabilmente saranno differenti da quelli che usate sul server. E la situazione può complicarsi ulteriormente, ad esempio, parlando di cluster (che è una rete di vari server).

I parametri che avete configurato, essendo relativi all’environment, vanno a finire nel file /.env, sotto un unico parametro:

DATABASE_URL=mysql://zombie@127.0.0.1:3306/zombiedb

Il parametro DATABASE_URL contiene tutti i dati di accesso ed è usato nel file di configurazione di Doctrine config/packages/doctrine.yaml. Capire come funzionano esattamente i file di configurazione non è lo scopo di questo articolo, quindi non voglio andare oltre, ma ci torneremo in una puntata dedicata.

Abbiamo comunicato a Doctrine come connettersi, ma il DB non esiste ancora! Quindi spostiamoci sul terminale e digitiamo:

bin/console doctrine:database:create

Entriamo nell’applicativo mysql (da linea di comando, da PhpMyAdmin, o altro) e vedremo che il database è stato creato. Ovviamente non ci sono ancora delle tabelle, perciò rimbocchiamoci le maniche.

Doctrine ORM

Con la premessa che ci stiamo riferendo solo ai database relazionali (come MySQL e PostgreSQL), possiamo dire che Doctrine è un’ORM (Object Relational Mapping), che è un sistema per interfacciarsi al database in un modo particolare. Infatti, i dati del DB vengono trattati come se fossero oggetti. Il concetto più prezioso – e forse più duro da digerire – che potete imparare oggi è quello di abituarvi fin dal principio a pensare ai dati come se fossero oggetti PHP. Non dovete pensare ad una tabella composta da righe, ma ad una collezione composta da oggetti. Questo vuol dire che tutto, nella vostra webapp, è basato sugli oggetti: dalla logica dell’applicazione ai dati. Su questo concetto ci torneremo più volte.

L’operazione principale che fa Doctrine è detta Mapping. Come abbiamo detto, dobbiamo ragionare “ad oggetti”, ma fatto sta che i dati sono memorizzati in tabelle. Quindi, quando viene effettuata una query, ogni riga estratta viene “convertita” (mappata) in un oggetto PHP. Tutto avviene in automatico, ovviamente. Quello che dobbiamo fare noi, invece, è specificare il modo in cui questo passaggio deve avvenire. In particolare, per ogni tabella, deve essere definita una tipologia di classe detta entity, concettualmente simile a quella del modello E-R. Dunque, immaginando di dover costruire un blog, definiamo la nostra prima entity in /src/Entity/Article.php:

<?php // Article.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Article
 *
 * @ORM\Table(name="articles")
 * @ORM\Entity
 */
class Article
{
  /**
   * @ORM\Id
   * @ORM\Column(name="id", type="integer")
   * @ORM\GeneratedValue(strategy="AUTO")
   */
  private $id;

  /**
   * @var String
   *
   * @ORM\Column(name="title", type="string", length=255)
   */
  private $title;

  /**
   * @var string
   *
   * @ORM\Column(name="slug", type="string", length=100, unique=true)
   */
  private $slug;

  /**
   * @var string
   *
   * @ORM\Column(name="content", type="text",)
   */
  private $content;

  /**
   * @var datetime
   *
   * @ORM\Column(name="creation_datetime", type="datetime", nullable=false)
   */
  private $creationDateTime;

  /**
   * @var datetime
   *
   * @ORM\Column(name="last_modification_datetime", type="datetime", nullable=true)
   */
  private $lastModificationDateTime;
}

Abbiamo definito l’entity Article, con le sue proprietà ID, titolo, slug (per definire la URL), contenuto, data di creazione e data dell’ultima modifica. Per ognuna, nelle annotation, sono definite le regole per creare la tabella MySQL nel nostro database.

@ORM\Table(name=”articles”), in cima alla classe, definisce il nome della tabella. @ORM\Column, in corrispondenza di una proprietà della classe, definisce una colonna. Nelle parentesi vengono specificate tutte le caratteristiche per definire la colonna, come nome, tipo, se deve essere nullable, se deve avere chiave univoca, la lunghezza del campo e così via. Trovate la panoramica completa sulla documentazione ufficiale.

Le proprietà della classe hanno visibilità private. Potrebbe essere una buona scelta anche usare protected, ma personalmente preferisco restringere la visibilità il più possibile. Si tratta di semplice OOP, ma facciamo un breve ripasso: se definiamo una classe B che estende A, tutto ciò che in A ha visibilità protected viene ereditato da B, mentre ciò che ha visibilità private no. Potete approfondire sul manuale di PHP, ma che la differenza vi sia chiara!

Per accedere alle proprietà dell’entity, chiaramente, c’è bisogno dei famosi metodi setter e getter. Nelle precedenti edizioni si poteva generarli con un comando, ma a quanto pare si vuole togliere questa possibilità e sinceramente a me sta bene: non sono mai stato un entusiasta di approcci di questo genere.

Non voglio occupare troppo spazio in questa pagina, ma potete trovare la entity Article con i setter e getter qui.

Vi faccio notare un paio di cose. La prima è che la proprietà $id non ha un setter, poiché con @ORM\GeneratedValue(strategy=”AUTO”) abbiamo detto a Doctrine (che a sua volta dice al DBMS) di assegnare valori in modo autoincrementale. L’altra è che i metodi setter non hanno return, ma qui dipende dalle scuole di pensiero. C’è chi non mette nulla, chi fa ritornare la proprietà a cui il setter si riferisce e chi fa ritornare l’oggetto stesso, con $this. Quest’ultima opzione permette di fare chaining, cioè una cosa del tipo $article->setTitle($title)->setSlug($slug)->setContent($content), il che dà una certa flessibilità, ma c’è chi è contro per diversi motivi (articolo per i più curiosi e con una certa preparazione alle spalle). Io ne faccio uso.

Modificare il database

L’entity che abbiamo definito è pur sempre una classe PHP: la tabella non esiste ancora. Per crearla basta un comando da terminale. Prima però, vi consiglio di nascondere un po’ di magia e capire cosa stia succedendo, perciò, innanzitutto, vediamo quali sono le query che effettivamente verrano eseguite:

bin/console doctrine:schema:update --dump-sql

Questo comando, in realtà è più prezioso di quanto non sembri. Ci permette di sapere in ogni momento se Doctrine e MySQL sono “allineati”. Infatti, quello che diciamo a Doctrine, è di controllare cosa c’è di diverso rispetto alla tabella MySQL. Se creiamo un nuova colonna nella entity PHP, Doctrine rileverà che questa non è presente nella tabella reale e ci comunicherà la query che bisogna eseguire. Non è vero invece il contrario. Se creiamo una colonna nella tabella, Doctrine non rileverà alcuna modifica da apportare all’applicazione, che sarebbe una proprietà nell’entity. Sia chiaro, non è una mancanza, è giusto così: vuol dire che quella colonna non fa parte del dominio dell’applicazione. A meno di casi particolari, quindi, quando lavorate in locale, modificate la struttura del database solo mediante i comandi di bin/console.

Per eseguire effettivamente le query digitate:

$ bin/console doctrine:schema:update --force

Avrete notato che ho specificato di usare questi comandi solo in locale: in ambiente di produzione è un altro paio di maniche ed usare questi comandi lì è sconsigliato.

Inserimento dei dati e lettura dal database

Ora che abbiamo creato una tabella, avrete gli ormoni a palla e non vedrete l’ora di inserirci qualcosa, quindi vediamo come fare. Definiamo una nuova action, facendo in modo che, ogni volta che visitiamo la relativa URL, viene creato un nuovo articolo con titolo random e poi inserito nel database. Lo so, è una funzionalità a dir poco inutile, ma serve per spiegare i concetti essenziali:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Request;
use App\Entity\Article;

class ArticleController extends Controller
{

    /**
     * @Route("/random-article", name="create_random_article")
     */
    public function createRandomArticle()
    {
        $article = new Article();

        $title = bin2hex(openssl_random_pseudo_bytes(3));   // Titolo random
        $article->setTitle($title);
        $article->setSlug($title);
        $article->setContent("Un altro articolo inutile");

        $now = new \DateTime();
        $article->setCreationDateTime($now);
        $article->setLastModificationDateTime(null);

        $em = $this->getDoctrine()->getManager();
        $em->persist($article);
        $em->flush();

        return $this->render('article.html.twig', [
            '$article' => $article,
        ]);
    }
}

Quello che abbiamo fatto è stato creare un nuovo oggetto Article e settare le sue proprietà, come titolo, slug, contenuto e data di creazione. Dopodiché abbiamo richiamato il manager di Doctrine e passato l’oggetto Article nel metodo persist, con cui comunichiamo a Doctrine gli oggetti da persistere (inserire o modificare) nel database. Infine, con il metodo flush, eseguiamo effettivamente le query. Come avete visto, non abbiamo assolutamente avuto a che fare con tabelle, righe o colonne. Abbiamo creato un oggetto e passato nel metodo persist per inserirlo nel DB. Nient’altro: di tutto il resto se ne occupa Doctrine.

Ricapitolando, ogni volta che ricaricate la pagina /random-article, inserite un nuovo inutilissimo articolo nel database. Per vedere la lista degli inutilissimi articoli creati, definiamo una nuova action:

<?php // ArticleController.php

    /*
     * Contenuto precedente
     */

    /**
     * @Route("/list", name="list_articles")
     */
    public function listArticles()
    {
        $em = $this->getDoctrine();
 
        $articles = $em->getRepository('App:Article')->findAll();
 
        return $this->render('list.html.twig', [
            'articles' => $articles,
        ]);
    }

Per le operazioni di lettura basta invocare l’istanza di Doctrine, senza scomodare il manager (non abbiamo invocato metodo getManager). Abbiamo quindi evocato il repository di Article e eseguito il metodo findAll(), che preleva tutti gli oggetti Article. Penso che a questo punto il risultato sia piuttosto chiaro, ma vi starete comunque chiedendo cosa diamine sia questo repository.

Possiamo vedere un repository come una classe che contiene una serie di metodi che servono a costruire le query per interrogare il Database. Ogni Entity dell’applicazione ha un Repository di default. Ad esempio, per invocare quello di Article abbiamo utilizzato $this->getRepository(‘App:Article’).

Sull’oggetto repository possiamo invocare vari metodi “magici” per effettuare delle SELECT, ad esempio:

Per prelevare tutti gli articoli (ritorna un array di oggetti Article):

$articles = $this->getRepository(‘App:Article’)->findAll();

Per prelevare solo quello con un certo ID (ritorna un array con un unico oggetto Article, essendo ID univoco):

$article = $this->getRepository(‘App:Article’)->findById($id);

Per prelevare solo quello con un certo slug (ritorna un unico oggetto Article):

$article = $this->getRepository(‘App:Article’)->findOneBySlug($slug);

Si noti che di fatto noi non abbiamo definito una classe, né i metodi findById o findBySlug. Semplicemente ci vengono forniti di default una serie di metodi che possiamo utilizzare immediatamente. I metodi find…By… sono “magici” cioè vengono costruiti automaticamente a partire dalle proprietà dell’Entity.

Si noti, inoltre, che i metodi di Doctrine ritornano sempre un oggetto, un array di oggetti o un array. Sottolineo, ancora una volta, come non abbiamo mai a che fare con righe e colonne di una tabella, ma sempre con dati mappati.

Ovviamente tutto questo basta nei casi più semplici, ma se vogliamo costruire delle query più sofisticate, ad esempio se vogliamo utilizzare delle condizioni (WHERE, AND, OR) o delle JOIN, è necessario definire la classe repository con dei metodi. Questo è un articolo introduttivo, ma durante questo corso scenderemo molto di più nei dettagli.

Nel prossimo articolo affronteremo un argomento fondamentale in Symfony: il componente Form. Vedremo come funziona e come poter aggiungere nuovi articoli al nostro blog.