[Se]   En

Doctrine ORM med PHP Symfony

  Annice      2020-12-06      PHP, Symfony, MySQL, Doctrine

När jag började bygga om Annice.se utifrån PHP med Symfony-ramverket hoppades jag på att PHP skulle ha någon motsvarighet till Lambda-uttryck med LINQ (Language-Integrated Query). Lambda-uttryck med LINQ var nämligen något jag använde mig ganska flitigt av inom C# ASP.NET Core vad gällde diverse filtrering av listor och arrayer, och som ofta grundade sig på uppslagsfrågor mot databas.

Ett slags motsvarighet till ovanstående inom PHP är Doctrine, och det jag känt på lite hittills i samband med ombygget av Annice.se är Doctrine ORM med Symfony. ORM står alltså för Object-Relational Mapper som på svenska skulle kunna översättas till objektrelationskartläggare.

Som ORM-akronymen antyder är alltså syftet med detta frågespråk att underlätta PHP-objekts (entiteters) interaktion mot en eller flera relationsdatabaser, i vilka databastabellerna då alltså avspeglar PHP-kodens entiteter, samt där tabellattributen (kolumnerna) mappas mot PHP-entiteternas olika egenskaper.

Det som tilltalade mig med Doctrine ORM var att man också tillåts formulera sig i ren SQL-kod, d.v.s. som de faktiska uttryck som i slutänden exekveras mot databas. Vid de tillfällen man använder sig av nästlade frågor och/eller olika joinar för att komma åt diverse data är jag nämligen en av dem som föredrar att uttrycka mig i ren SQL.

Givet att man har PHP Symfony installerat med konfigurerad uppkoppling mot en databas är vidare förutsättningar för att köra Doctrine ORM inom Symfony att ha installerat pakethanteraren Composer, vilket då i nästa tur kan installera stöd för paketet Doctrine. Av vana från C# ASP.NET Core-världen använder jag ordet "paket" då man där pratar om motsvarigheten NuGet packages. Inom Symfony ska väl dock tilläggas att paket egentligen benämns som bundles. Utförligare guide om detta finns vidare på Symfonys egen dokumentationssida: Databases and the Doctrine ORM.

Hur som helst ville jag egentligen med detta inlägg passa på att lista några Doctrine ORM-frågor jag samlat på mig som jag själv finner användbara att blicka tillbaka på som utgångspunkt för olika databasfrågor. Och för att slutligen kunna möjliggöra denna funktion behöver man anropa/importera ett EntityManagerInterface där man vill använda sig av Doctrine ORM, t.ex. i en controller.

Nedanstående kod exemplifierar en helhetskontext där ett beroende av EntityManagerInterfacet har injecerats in en controller-konstruktor, vilket är användbart om man t.ex. vet att Doctrine-frågor ska användas i många metoder i samma controller-klass. Den konstruktorinjecerade EntityManager-tjänsten kan sedan appliceras på en Doctrine-fråga för att hämta ut poster från en databastabell i actionmetoder likt nedan. Nedanstående kod baseras för övrigt på Symfony 5.1.8 med Doctrine 2, vilket i nästa tur kräver åtminstone PHP 7.1 :

namespace App\Controller;

use App\Entity\Entry;
use Symfony\Component\Routing\Annotation\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Doctrine\ORM\EntityManagerInterface;

class EntryController extends AbstractController
{
    private $entityManager;

    /**
     * Inject dependencies.
     */
    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    /**
     * @Route("/entry/entry_list", name="entry_list")
     * @Method({"GET"})
     */
    public function entryListAction()
    {
        // Call the entity manager service:
        $em = $this->entityManager;
        // Get all entries from DB via Doctrine entity mapping and return the result in descending order by entry date:
        $entries = $em->getRepository(Entry::class)->findBy([], ['date' => 'DESC']);

        // Finally, pass the entry list to be reached via an "entries" variable from a twig template/view:
        return $this->render('entries/entry_list.html.twig', [
            "entries" => $entries
        ]);
    }
}


Om man istället skulle vilja formulera samma fråga utan att injecera EntityManagerInterfacet i en konstruktor, d.v.s. bara använda det direkt från någon enstaka metod, kan man utesluta konstruktorinjeceringen och istället anropa tjänsten såsom nedan:

 public function entryListAction()
 {
     // Call the entity manager service:
     $em = $this->getDoctrine()->getManager();
     // Get all entries from DB via Doctrine entity mapping and return the result in descending order by entry date:
     $entries = $em->getRepository(Entry::class)->findBy([], ['date' => 'DESC']);

     // ...


Om man t.ex. skulle vilja hämta ut ett specifikt Entry-objekt på ett specifikt villkor i Doctrine ORM skulle det kunna se ut som nedan (givet att Entity Manager-tjänsten har importerats samt instantierats):

$entry = $em->getRepository(Entry::class)->createQueryBuilder("alias")
    ->select("alias")
    ->where("alias.email = :email")
    ->setParameter("email", "user@email.com")
    ->getQuery()
    ->getOneOrNullResult();


Genom att välja att returnera ovanstående resultat m.h.a. metoden getOneOrNullResult() kommer frågan returna null om inga resultat hittas, vilket innebär att detta skulle kunna hanteras och fångas upp direkt i ett PHP-villkor utan att applikationen behöver krascha. En motsvarighet i detta fall är annars att använda sig av metoden getSingleResult(), men detta skulle kasta ett undantag om inget objekt matchas, eller om det skulle uppstå fler än en objektsmatchning.

Om man skulle vilja hämta ut objekt baserat på flera villkor och returnera resultatet som en lista i fallande ordning efter datum kan det i nästa tur se ut så här:

$entries = $em->getRepository(Entry::class)->createQueryBuilder("alias")
    ->select("alias")
    ->where("alias.date = :date")
    ->andWhere("alias.categoryid = :categoryid")
    ->setParameters(["date" => "2020-12-06", "categoryid" => 1])
    ->orderBy("alias.date", "DESC")
    ->getQuery()
    ->getResult();


Skulle man vilja hämta samma sak som ovan men formulerat i ren MySQL med Doctrine kan det se ut så här:

$entries = $em->createQuery(
    "SELECT alias FROM App\Entity\Entry alias
     WHERE alias.date = :date AND alias.categoryid = :categoryid
     ORDER BY alias.date DESC"
)->setParameters(["date" => "2020-12-06", "categoryid" => 1])
 ->getResult();


I relationsdatabaser där många-till-många-relationer uppstår mellan tabeller kan man ibland behöva hämta värden via en s.k. kopplingstabell, eller "länktabell". Länktabellen ska i nästa tur då bestå av åtminstone referensnycklar som pekar till alla primärnycklar för vardera tabell där många-till-många-relationen äger rum.

Om en verksamhetsregel säger att ett inlägg kan ha flera kategorier samtidigt som varje kategori kan länkas till flera inlägg uppstår alltså en många-till-många-relation mellan en inläggstabell samt en kategoritabell. Länktabellen som kopplar samman dessa två tabeller måste då bestå av åtminstone en referensnyckel som pekar mot primärnyckeln till inläggstabellen, samt en referensnyckel som pekar mot primärnyckeln till kategoritabellen.

Låt säga att jag skulle vilja använda Doctrine för att i ren MySQL-kod hämta alla kategorier tillhörande ett inlägg - som i nästa tur ska returneras som en kategorilista sorterad i bokstavsordning efter kategorinamn. Då kan det se ut så här:

$entryCategories = $em->createQuery(
    "SELECT c FROM App\Entity\Category c
     WHERE c.id IN (
         SELECT IDENTITY(ec.categoryId) FROM App\Entity\EntryCategories ec
         WHERE ec.entryId = :entryid
     )
     ORDER BY c.name ASC"
)->setParameter("entryid", $entryIdInput)
 ->getResult();


För att ovanstående fråga ska fungera förutsätter det - förutom att EntityManagerInterfacet är importerat och instantierat - också att berörda entiteter "Category" och "EntryCategories" finns tillgängliga i PHP-kodens entitetslager, samt att de är importerade i den PHP-klass i vilken frågan används. Vidare utgår ovanstående fråga ifrån att entiteten "Category" har egenskaper vid namn "id" och "name", samt att länkentiteten "EntryCategories" har egenskaper vid namn "categoryId" och "entryId".