5

I would like to give an entity (Invoice, Order, Reservation, etc.) a unique sequence number, but only unique within that year. So the first invoice of every year (or another field, such as Customer) starts has id 1. That means that there can be a composite primary key (year, id) or one primary key (i.e. invoice_id) and two other columns that are unique together.

My question: What's the best way to give an object a unique combination of an auto-generated ID and another value using Doctrine2 and Symfony2?

Doctrine limitations on composite keys

Doctrine can't assign an auto generated ID to an entity with a composite primary key (http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/tutorials/composite-primary-keys.html):

Every entity with a composite key cannot use an id generator other than “ASSIGNED”. That means the ID fields have to have their values set before you call EntityManager#persist($entity).

Setting the sequence number manually

So I have to assign a ID manually. In order to do that, I've tried to look for the highest ID in a certain year and give the new entity that ID + 1. I doubt that's the best way and even if it is, I haven't found the best (DRY) way to do it. Since I think this is a generic question and I want to prevent the XY-problem, I've started this question.

Vanilla options: only with MyISAM

I found the 'vanilla' MySQL/MyISAM solution based on this answer:

CREATE TABLE IF NOT EXISTS `invoice` (
  `year` int(1) NOT NULL,
  `id` mediumint(9) NOT NULL AUTO_INCREMENT,
  PRIMARY KEY (`year`,`id`)
) ENGINE=MyISAM;

That won't work because of the Doctrine2 limitations, so I'm looking for a Doctrine ORM equivalent of this vanilla MySQL solution.

Other solutions

There is a solution for InnoDB as well: Defining Composite Key with Auto Increment in MySQL

Community
  • 1
  • 1
Stephan Vierkant
  • 9,674
  • 8
  • 61
  • 97
  • #winces# The (likely) problem with the vanilla option is that you have to lock the **entire table** for the duration of your transaction, which rather kills concurrent `INSERT`s. Why do you want repeating ids per year? An actual id value is meaningless; if you want to output a sequence, do a self-join and `COUNT()`. – Clockwork-Muse May 01 '14 at 10:39
  • An invoice must have a sequence number (starting with id=1 each year) for fiscal reasons. It don't have to be part of the primary key, I'll edit my question. – Stephan Vierkant May 01 '14 at 10:51
  • 1
    No, okay, yeah, you probably do need a cycling, persisted sequence then... You probably can't get away from making this _serial-per-year_, but you could at least solve the table-locking problem; store the counter in another table (keyed by year), then hit it with a stored procedure or a trigger (within their own transaction). If somebody doesn't commit their row you end up with gaps, but at least long-running transactions don't lock everybody else out. Then you just need a `UNIQUE KEY` over those columns. – Clockwork-Muse May 01 '14 at 11:02
  • That sounds like a good idea, but I'm not sure I really understand it. How do I have to implement it in Symfony2/Doctrine ORM? – Stephan Vierkant May 01 '14 at 11:32
  • @Clockwork-Muse I see you're an expert in SQL. I thinking of a Symfony2/Doctrine implementation of the vanilla solution I found. Do you agree or are you thinking of an alternative plan? – Stephan Vierkant May 01 '14 at 11:37
  • Triggers/functions/constraints are usually created solely db-side, so from an ORM point of view it can't tell. Attempting to implement this application-side is likely to end in tears. I don't know how to deal with this in context of these ORMs, though. – Clockwork-Muse May 01 '14 at 11:45
  • But do you think the vanilla solutions above is a good idea and that looking for a ORM-equivalent is a good idea? – Stephan Vierkant May 01 '14 at 11:50

1 Answers1

3

The ORM equivalent of the pre-insert trigger solution you're linking to would be a lifecycle callback. You can read more about them here.

A naive solution would look something like this.

services.yml

services:
    invoice.listener:
        class: MyCompany\CompanyBundle\EventListener\InvoiceListener
        tags :
            - { name: doctrine.event_subscriber, connection: default }

InvoiceListener.php

<?php

namespace MyCompany\CompanyBundle\EventListener;

use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;

use MyCompany\CompanyBundle\Entity\Invoice;

class InvoiceListener implements EventSubscriber {

    protected $invoices;

    public function getSubscribedEvents() {
        return [
            'onFlush',
            'postFlush'
        ];
    }

    public function onFlush(OnFlushEventArgs $event) {
        $this->invoices = [];
        /* @var $em \Doctrine\ORM\EntityManager */
        $em = $event->getEntityManager();
        /* @var $uow \Doctrine\ORM\UnitOfWork */
        $uow = $em->getUnitOfWork();

        foreach ($uow->getScheduledEntityInsertions() as $entity) {
            if ($entity instanceof Invoice) {
                $this->invoices[] = $entity;
            }
        }
    }

    public function postFlush(PostFlushEventArgs $event) {
        if (!empty($this->invoices)) {

            /* @var $em \Doctrine\ORM\EntityManager */
            $em = $event->getEntityManager();

            foreach ($this->invoices as $invoice) {
                // Get all invoices already in the database for the year in question
                $invoicesToDate = $em
                    ->getRepository('MyCompanyCompanyBundle:Invoice')
                    ->findBy(array(
                        'year' => $invoice->getYear()
                        // You could include e.g. clientID here if you wanted
                        // to generate a different sequence per client
                    );
                // Add your sequence number
                $invoice->setSequenceNum(count($invoicesToDate) + 1);

                /* @var $invoice \MyCompany\CompanyBundle\Entity\Invoice */
                $em->persist($invoice);
            }

            $em->flush();
        }
    }
}
Peter
  • 808
  • 7
  • 15
  • Can't get it working, but looks like what I need :). Thanks! How does Entity\Invoice looks like? By the way, ``return []`` gives an error here, I changed it to ``return array()`` – Stephan Vierkant May 01 '14 at 15:11
  • The only addition to the Invoice entity would be a separate integer property for its sequence number (set by setSequenceNum). This would not be a unique identifier, just a regular old integer. I'm also assuming it has a year property here (getYear) but you could obviously do the same with a date column or whatever. The short array syntax is actually a PHP 5.4 addition (see [here](https://php.net/manual/en/migration54.new-features.php)), so you're getting that error because you're on an old version of PHP. – Peter May 01 '14 at 16:46
  • I still can't get it working. It seems the postFlush isn't called. Can you provide some more code examples? – Stephan Vierkant May 02 '14 at 09:36
  • Did you properly register this class as an event subscriber? See the [documentation](http://symfony.com/doc/current/cookbook/doctrine/event_listeners_subscribers.html). – Peter May 02 '14 at 12:57
  • Yes, by using ``@ORM\EntityListeners( {} )``. Is that OK? – Stephan Vierkant May 02 '14 at 13:12
  • You'll want a subscriber, not a listener. I would recommend configuring the class as a service in your services configuration file. I've added a YML example to my answer. – Peter May 02 '14 at 13:30
  • The EntityListeners approach would also be entirely valid. That's a new feature of Doctrine 2.4. You wouldn't be able to use the listener I've defined above as that implements the EventSubscriber model, but it seems like following the EntityListeners approach [documented here](http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/events.html#entity-listeners) would work just fine. – Peter May 02 '14 at 13:40
  • It works, you're my hero! The first object gets sequence number 2 however, but that's a minor issue. Thanks! – Stephan Vierkant May 02 '14 at 13:47
  • I like that this works - although doesn't that come at significant 'cost' to have to query for the sequence number each time before adding any new records? The inability to not be able to add autoincrementing composite keys through Doctrine seems to be quite a big missing piece of the puzzle – Bendy Sep 20 '16 at 06:50