Sunday 2 April 2023

PHP / Symfony: working through "Symfony: The Fast Track", part 3: doing some ORM / DB config

G'day:

Today I'm gonna continue working through "Symfony: The Fast Track". This will be part three of this series, after the first two:

I also had a brief interlude yesterday whilst I messed around with the DB connnection driver the app was using.: Symfony / Doctrine / DBAL: convincing/configuring it to use a PrimaryReadReplicaConnection connection. This was not part of the Symfony book, just something I wanted to do.


Setting up a Database

Nothing much to see here. It's about setting up a PostgreSQL Docker container. Done already.

It also has some stuff that demonstrates another irk for me when it comes to frameworks that are getting a bit self important, and overreaching their job (which is to be a bloody web framework. Just that). Clock this:

Using the psql command-line utility might prove useful from time to time. But you need to remember the credentials and the database name. […]

[…] Thanks to these conventions, accessing the database via symfony run is much easier:

symfony run psql
Symfony: The Fast Track › Setting up a Database › Accessing the Local Database

Lads. You've "done a thing" that saves the person passing a -U and -d param to psql. But coupling it to the framework, and requiring the PostgreSQL client being installed in yer PHP environment. Just… why?

In contrast, here's me logging into the client in the PostgreSQL container:

psql -U user1primary -d db1primary

(Because I'm on the "server" I don't need a password, as auth is handed off to whatever mechanism I used to start the shell on the server, which is handy). So it was a good use of Symfony's time implementing the work to save those coupla dozen keystrokes. Ugh.

It also goes in to how to do some stuff with the production environment they introduced a few chapters back, but I'm not using that so I ignored it.


Describing the Data Structure

Bumpf

This starts by discussing config/packages/doctrine.yaml and how it works, and about DATABASE_URL. You can read about my opinion of DATABASE_URL in yesterday's article (Symfony / Doctrine / DBAL: convincing/configuring it to use a PrimaryReadReplicaConnection connection). Ah to be fair in a simple situation it would work well, but it does seem like a weird way to manage the configuration of the various params the DB needs to connect. Even if they are ultimately used via a URL. Bad coupling of disparate concepts, IMO.


Creating entities / repositories & property relationships

Next: running a wizard to create some boilerplate.The input values I am providing for this lot are:

city, string, 255, no;
year, string, 4, no;
isInternational, boolean, no.
root :/var/www# symfony console make:entity Conference

 created: src/Entity/Conference.php
 created: src/Repository/ConferenceRepository.php

 Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.

 New property name (press <return> to stop adding fields):
 > city

 Field type (enter ? to see all types) [string]:
 >

 Field length [255]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 >

 updated: src/Entity/Conference.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > year

 Field type (enter ? to see all types) [string]:
 >

 Field length [255]:
 > 4

 Can this field be null in the database (nullable) (yes/no) [no]:
 >

 updated: src/Entity/Conference.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > isInternational

 Field type (enter ? to see all types) [boolean]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 >

 updated: src/Entity/Conference.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 >


Success!
Next: When you're ready, create a migration with php bin/console make:migration root :/var/www#

As this has stated, it's created two files: src/Entity/Conference.php and src/Repository/ConferenceRepository.php. Let's have a look.

The Conference has a lot of repetition, so I'll elide a bunch of it, and only show the city property's bumpf:

#[ORM\Entity(repositoryClass: ConferenceRepository::class)]
class Conference
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $city = null;

    // ...

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getCity(): ?string
    {
        return $this->city;
    }

    public function setCity(string $city): self
    {
        $this->city = $city;

        return $this;
    }

    // ...

}

Firstly: ewwww… annotations (well: PHP attributes in this case, but it amounts to the same thing). I hate having storage-specific shite in my code. I'd much rather a discrete mapping file (YAML or something), and keep the storage considerations as the second-class citizen that it should be. However I suppose this is a necessary evil with ORM shite these days (can you tell I'm not completely sold on ORM as a concept? ;-)). There is currently a way of doing the mapping with YAML - Doctrine › ORM › YAML Mapping - but at the top of that page they say it's deprecated in favour of "one of the other mappings", which seems to mean XML or with actual PHP. In 2023 someone is advocating moving from YAML to XML. Cute. Anyway: for now I'll stick with the attributes. It's not the worst thing about this code.

It's funny that the docs page I'm following says "Note that the class itself is a plain PHP class with no signs of Doctrine". I mean… except all the Doctrine-specific attributes, you mean. Which are 24% of the statements in the class.

The worst thing is the getter and setter methods all properties have created by default. I'm not that happy with encouraging anti-patterns like this. See Tell-Don't-Ask and I also think it's a gateway drug for Law of Demeter violations. It's an enabler for bad OOP. I guess if their wizard thing just created the class and the properties (and the [muttermutter] ORM annotations), then it wouldn't seem like there was much of a point in having the wizard: it's not saving much effort.

I can see that I'm gonna need to bundle these "entities" away somewhere away from my actual application model, and just call on them to handle the storage side of things.

Oh yeah the ConferenceRepository class:

/**
 * @extends ServiceEntityRepository<Conference>
 *
 * @method Conference|null find($id, $lockMode = null, $lockVersion = null)
 * @method Conference|null findOneBy(array $criteria, array $orderBy = null)
 * @method Conference[]    findAll()
 * @method Conference[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class ConferenceRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Conference::class);
    }

    public function save(Conference $entity, bool $flush = false): void
    {
        $this->getEntityManager()->persist($entity);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    public function remove(Conference $entity, bool $flush = false): void
    {
        $this->getEntityManager()->remove($entity);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

//    /**
//     * @return Conference[] Returns an array of Conference objects
//     */
//    public function findByExampleField($value): array
//    {
//        return $this->createQueryBuilder('c')
//            ->andWhere('c.exampleField = :val')
//            ->setParameter('val', $value)
//            ->orderBy('c.id', 'ASC')
//            ->setMaxResults(10)
//            ->getQuery()
//            ->getResult()
//        ;
//    }

//    public function findOneBySomeField($value): ?Conference
//    {
//        return $this->createQueryBuilder('c')
//            ->andWhere('c.exampleField = :val')
//            ->setParameter('val', $value)
//            ->getQuery()
//            ->getOneOrNullResult()
//        ;
//    }
}

I love how they are pretending PHP is Java and it has generics with stuff like ServiceEntityRepository<Conference>. Why do that? But then again I'm asking the same question about that entire comment block. What's the point? You have the code already defining all that lot.

It's not as bad (or is it?) as all the generated commented-out code at the bottom. WTaF?

Other than that: yeah cool… it's separated the definition of the entities from the storage thereof. I'll have to see how the code to save an object works. Currently all I can see tying these two together is the #[ORM\Entity(repositoryClass: ConferenceRepository::class)] attribute on the Conference class.

Next it gets me to create a Comment entity. I'll spare you the bulk of the detail, but this is the config:

author, string, 255, no;
text, text, no;
email, string, 255, no;
createdAt, datetime_immutable, no.

Actually there was one interesting thing here:

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > createdAt

 Field type (enter ? to see all types) [datetime_immutable]:
 >
 

It seems to have clocked from the name createdAt that it should be a date time. That's quite cool.

Next I need to establish the relationship between the two entities, which is done by running symfony console make:entity Conference again:

root:/var/www# symfony console make:entity Conference

 Your entity already exists! So let's add some new fields!

 New property name (press <return> to stop adding fields):
 > comments

 Field type (enter ? to see all types) [string]:
 > OneToMany

 What class should this entity be related to?:
 > Comment

 A new property will also be added to the Comment class
 so that you can access and set the related Conference object from it.

 New field name inside Comment [conference]:
 >

 Is the Comment.conference property allowed to be null (nullable)? (yes/no) [yes]:
 > no

 Do you want to activate orphanRemoval on your relationship?
 A Comment is "orphaned" when it is removed from its related Conference.
 e.g. $conference->removeComment($comment)

 NOTE: If a Comment may *change* from one Conference to another, answer "no".

 Do you want to automatically delete orphaned 
 adamcameron\symfonythefasttrack\Entity\Comment objects (orphanRemoval)? (yes/no) [no]:
 > yes

 updated: src/Entity/Conference.php
 updated: src/Entity/Comment.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 >


Success!
Next: When you're ready, create a migration with php bin/console make:migration root:/var/www#

OK that's quite cool. It also does some auto-complete for me too:

That endeavour has added this to the Conference class:

#[ORM\OneToMany(mappedBy: 'conference', targetEntity: Comment::class, orphanRemoval: true)]
private Collection $comments;

And this stuff too:

/**
 * @return Collection<int, Comment>
 */
public function getComments(): Collection
{
    return $this->comments;
}

public function addComment(Comment $comment): self
{
    if (!$this->comments->contains($comment)) {
        $this->comments->add($comment);
        $comment->setConference($this);
    }

    return $this;
}

public function removeComment(Comment $comment): self
{
    if ($this->comments->removeElement($comment)) {
        // set the owning side to null (unless already changed)
        if ($comment->getConference() === $this) {
            $comment->setConference(null);
        }
    }

    return $this;
}

And to the Comment class:

#[ORM\ManyToOne(inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false)]
private ?Conference $conference = null;

Migrating the Database

OK I had wondered what this term I'd seen mentioned "migrations" was all about. It's how to apply the entity schema to the DB schema, by the sounds of it. Migrating the entity schema? Not sure that's the term I'd use: to me during a "migration" one moves from one place to another; but one ends up in the new place. It's applying the mapping, innit? Ah well: naming stuff is hard.

It'll be interesting to see if this works given I have not set up the DB exactly the way they wanted me to. Plus - peril - there is already data in it. Nothing ventured, nothing gained though right? Here goes:

root:/var/www# symfony console make:migration


Success!
Next: Review the new migration "migrations/Version20230402161944.php" Then: Run the migration with php bin/console doctrine:migrations:migrate See https://symfony.com/doc/current/bundles/DoctrineMigrationsBundle/index.html root:/var/www#

Oh right, I'm getting ahead of myself. It's just prepped the script, not run anything yet.

This is what it generated (as migrations/Version20230402161944.php):

/**
 * Auto-generated Migration: Please modify to your needs!
 */
final class Version20230402161944 extends AbstractMigration
{
    public function getDescription(): string
    {
        return '';
    }

    public function up(Schema $schema): void
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->addSql('CREATE SEQUENCE comment_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
        $this->addSql('CREATE SEQUENCE conference_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
        $this->addSql('
        	CREATE TABLE comment (
            	id INT NOT NULL,
                conference_id INT NOT NULL,
                author VARCHAR(255) NOT NULL,
                text TEXT NOT NULL,
                email VARCHAR(255) NOT NULL,
                created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
                photo_filename VARCHAR(255) DEFAULT NULL,
                PRIMARY KEY(id)
            )'
        );
        $this->addSql('CREATE INDEX IDX_9474526C604B8382 ON comment (conference_id)');
        $this->addSql('COMMENT ON COLUMN comment.created_at IS \'(DC2Type:datetime_immutable)\'');
        $this->addSql('
        	CREATE TABLE conference (
            	id INT NOT NULL,
                city VARCHAR(255) NOT NULL,
                year VARCHAR(4) NOT NULL,
                is_international BOOLEAN NOT NULL,
                PRIMARY KEY(id)
            )
        ');
        $this->addSql('
        	CREATE TABLE messenger_messages (
                id BIGSERIAL NOT NULL,
                body TEXT NOT NULL,
                headers TEXT NOT NULL,
                queue_name VARCHAR(190) NOT NULL,
                created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
                available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
                delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
                PRIMARY KEY(id)
            )
        ');
        $this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
        $this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
        $this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
        $this->addSql('CREATE OR REPLACE FUNCTION notify_messenger_messages() RETURNS TRIGGER AS $$
            BEGIN
                PERFORM pg_notify(\'messenger_messages\', NEW.queue_name::text);
                RETURN NEW;
            END;
        $$ LANGUAGE plpgsql;');
        $this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;');
        $this->addSql('
        	CREATE TRIGGER notify_trigger
            AFTER INSERT OR UPDATE
            ON messenger_messages
            FOR EACH ROW
            	EXECUTE PROCEDURE notify_messenger_messages();
        ');
        $this->addSql('
        	ALTER TABLE comment
            ADD CONSTRAINT FK_9474526C604B8382
            	FOREIGN KEY (conference_id)
                REFERENCES conference (id)
                NOT DEFERRABLE
                INITIALLY IMMEDIATE
        ');
        $this->addSql('DROP TABLE test');
    }

    public function down(Schema $schema): void
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->addSql('CREATE SCHEMA public');
        $this->addSql('DROP SEQUENCE comment_id_seq CASCADE');
        $this->addSql('DROP SEQUENCE conference_id_seq CASCADE');
        $this->addSql('CREATE TABLE test (id INT NOT NULL, value VARCHAR(50) NOT NULL)');
        $this->addSql('ALTER TABLE comment DROP CONSTRAINT FK_9474526C604B8382');
        $this->addSql('DROP TABLE comment');
        $this->addSql('DROP TABLE conference');
        $this->addSql('DROP TABLE messenger_messages');
    }
}

Seems legit.

It's "interesting" that it's creating a few triggers in there. That pg_notify thing looks interesting. I wonder what messenger_messages is?

OK now I'm sending all that to the DB:

root:/var/www# symfony console doctrine:migrations:migrate

 WARNING!
 You are about to execute a migration in database "db1primary"
 that could result in schema changes and data loss.
 Are you sure you wish to continue? (yes/no) [yes]:
 >

[notice] Migrating up to DoctrineMigrations\Version20230402161944
[notice] finished in 462.3ms, used 20M memory, 1 migrations executed, 15 sql queries


[OK] Successfully migrated to version : DoctrineMigrations\Version20230402161944
root:/var/www#

Blimey. So far: so good. Let's see what the DB has to say:

root:/# psql -U user1primary -d db1primary

db1primary=# \dt
                      List of relations
 Schema |            Name             | Type  |    Owner
--------+-----------------------------+-------+--------------
 public | comment                     | table | user1primary
 public | conference                  | table | user1primary
 public | doctrine_migration_versions | table | user1primary
 public | messenger_messages          | table | user1primary
(4 rows)
db1primary=# \d comment
                                      Table "public.comment"
     Column     |              Type              | Collation | Nullable |         Default
----------------+--------------------------------+-----------+----------+-------------------------
 id             | integer                        |           | not null |
 conference_id  | integer                        |           | not null |
 author         | character varying(255)         |           | not null |
 text           | text                           |           | not null |
 email          | character varying(255)         |           | not null |
 created_at     | timestamp(0) without time zone |           | not null |
 photo_filename | character varying(255)         |           |          | NULL::character varying
Indexes:
    "comment_pkey" PRIMARY KEY, btree (id)
    "idx_9474526c604b8382" btree (conference_id)
Foreign-key constraints:
    "fk_9474526c604b8382" FOREIGN KEY (conference_id) REFERENCES conference(id)
db1primary=# \d conference
                         Table "public.conference"
      Column      |          Type          | Collation | Nullable | Default
------------------+------------------------+-----------+----------+---------
 id               | integer                |           | not null |
 city             | character varying(255) |           | not null |
 year             | character varying(4)   |           | not null |
 is_international | boolean                |           | not null |
Indexes:
    "conference_pkey" PRIMARY KEY, btree (id)
Referenced by:
    TABLE "comment" CONSTRAINT "fk_9474526c604b8382" FOREIGN KEY (conference_id) REFERENCES conference(id)

It all seems fine! Good work.


The last bit is about doing stuff on the production DB which doesn't apply to what I'm doing, so I'm ignoring that.

And I think I will leave that here. With one thing or another that took me a while and it's Sunday afternoon (OK: evening now) and I wanna do something else.

For all my whining about annotations and ORM and nomenclature, I'm finding this stuff pretty polished. There's no boats being pushed out regarding complexity here, but it's nailing the simple stuff.

There's none of me own code in here, but I've committed and tagged it as 1.7 anyhow.

Oh, and Part 4 is done: PHP / Symfony: working through "Symfony: The Fast Track", part 4: not really about Symfony, this one. Thought it's largely a waste of time. Maybe skip it.

Righto.

--
Adam