Extending Doctrine 2 for your Domain Model

Post on 10-May-2015

11,611 views 2 download

Tags:

description

UPDATE: My thoughts have changed a bit since I originally presented this talk. It's could still be a useful intro to some of Doctrine's intermediate features (although the docs continue to improve. However, I would recommend you take a look at a new talk I've done since then "Models & Service Layers; Hemoglobin & Hobgoblins" which I think does a better job of actually talking about architecture and domain models than this talk does. Thanks! http://www.slideshare.net/rosstuck/models-and-service-layers-hemoglobin-and-hobgoblins As presented at the Dutch PHP Conference 2012. Sure, it can save models and do relations but Doctrine 2 can do a whole lot more. This talk introduces features like Events and Filters that can keep your code clean and organized. If you like the idea of automatically updating your solr index or adding audit logs without changing any existing code, this is the talk for you. The emphasis is on practical application of the lesser known but powerful features. The talk is geared toward people who are curious what Doctrine 2 can offer over a standard database layer or have used it previously.

transcript

Ross Tuck

Extending Doctrine 2For Your Domain Model

June 9th, DPC

Who Am I?

Ross Tuck

Team Lead at IbuildingsCodemonkeyREST nutHat guyToken Foreigner

America, &@*! Yeah

Quick But Necessary

Doctrine 101

Doctrine 102

Doctrine 102.5

/** @Entity */

class Article {

Model

/** @Id @GeneratedValue

* @Column(type="integer") */

protected $id;

/** @Column(type="string") */

protected $title;

/** @Column(type="text") */

protected $content;

}

$article = $em->find('Article', 1);

$article->setTitle('Awesome New Story');

$em->flush();

Controller

Filters

We need a way to approve comments before they appear to all users.

TPS Request

$comments = $em->getRepository('Comment')->findAll();

foreach($comments as $comment) {

echo $comment->getContent()."\n";

}

Controller

Output:Doug isn't humanDoug is the best

$comments = $em->getRepository('Comment')->findAll();

foreach($comments as $comment) {

echo $comment->getContent()."\n";

}

Controller

$comments = $em->createQuery('SELECT c FROM Comment c');

foreach($comments->getResult() as $comment) {

echo $comment->getContent()."\n";

}

Controller

$comments = $em->find('Article', 1)->getComments();

foreach($comments as $comment) {

echo $comment->getContent()."\n";

}

Controller

Approx 100 places in your code!

FiltersNew in 2.2

use Doctrine\ORM\Query\Filter\SQLFilter;

class CommentFilter extends SQLFilter {

public function addFilterConstraint($entityInfo, $alias) {

if ($entityInfo->name !== 'Comment') {

return "";

}

return $alias.".approved = 1";

}

}

Filter

$em->getConfiguration()

->addFilter('approved_comments', 'CommentFilter');

Bootstrap

Handy key

Class name

$em->getConfiguration()

->addFilter('approved_comments', 'CommentFilter');

$em->getFilters()->enable('approved_comments');

Bootstrap

if ($this->isNormalUser()) {

$em->getConfiguration()

->addFilter('approved_comments', 'CommentFilter');

$em->getFilters()->enable('approved_comments');

}

Bootstrap

$comments = $em->getRepository('Comment')->findAll();

foreach($comments as $comment) {

echo $comment->getContent()."\n";

}

Controller

As Normal User

Output:Doug is the best

Controller

As the AdminOutput:

Doug isn't humanDoug is the best

$comments = $em->getRepository('Comment')->findAll();

foreach($comments as $comment) {

echo $comment->getContent()."\n";

}

Parameters

$filter = $em->getFilters()->getFilter('approved_comments');

$filter->setParameter('level', $this->getUserLevel());

Bootstrap

use Doctrine\ORM\Query\Filter\SQLFilter;

class CommentFilter extends SQLFilter {

public function addFilterConstraint($entityInfo, $alias) {

if ($entityInfo->name !== 'Comment') {

return "";

}

$level = $this->getParameter('level');

return $alias.".approved = 1";

}

}

Filter

use Doctrine\ORM\Query\Filter\SQLFilter;

class CommentFilter extends SQLFilter {

public function addFilterConstraint($entityInfo, $alias) {

if ($entityInfo->name !== 'Comment') {

return "";

}

$level = $this->getParameter('level');

return $alias.".approved = ".$level;

}

}

Filter

use Doctrine\ORM\Query\Filter\SQLFilter;

class CommentFilter extends SQLFilter {

public function addFilterConstraint($entityInfo, $alias) {

if ($entityInfo->name !== 'Comment') {

return "";

}

$level = $this->getParameter('level');

return $alias.".approved = ".$level;

}

}

Filter

Already escaped

Limitations

Events

prePersistpostPersistpreUpdatepostUpdatepreRemovepostRemove

postLoadloadClassMetadatapreFlushonFlushpostFlushonClear

Insert

Callbacks Listeners

On the model External objects

Lifecycle Callbacks

/** @Entity */

class Article {

Model

/** @Id @GeneratedValue

* @Column(type="integer") */

protected $id;

/** @Column(type="string") */

protected $title;

/** @Column(type="text") */

protected $content;

}

$article = $em->find('Article', 1);

$article->setTitle('Awesome New Story');

$em->flush();

Controller

Articles must record the last date they were modified in any way.

TPS Request

$article = $em->find('Article', 1);

$article->setTitle('Awesome New Story');

$article->setUpdatedAt(new DateTime('now'));

$em->flush();

Controller

+ 100 other files+ testing+ missed bugs

/** @Entity */

class Article {

Model

/** @Id @GeneratedValue

* @Column(type="integer") */

protected $id;

/** @Column(type="string") */

protected $title;

}

/** @Entity */

class Article {

Model

/** @Id @GeneratedValue

* @Column(type="integer") */

protected $id;

/** @Column(type="string") */

protected $title;

/** @Column(type="datetime") */

protected $updatedAt;

}

/** @Entity */

class Article {

Model

// ...

public function markAsUpdated() {

$this->updatedAt = new \Datetime('now');

}

}

/** @Entity @HasLifecycleCallbacks */

class Article {

Model

// ...

/** @PreUpdate */

public function markAsUpdated() {

$this->updatedAt = new \Datetime('now');

}

}

Event mapping

$article = $em->find('Article', 1);

$article->setTitle('Awesome New Story');

$em->flush();

echo $article->getUpdatedAt()->format('c');

// 2012-05-29T10:48:00+02:00

Controller

NoChanges!

/** @Entity @HasLifecycleCallbacks */

class Article {

Model

// ...

/** @PreUpdate */

public function markAsUpdated() {

$this->updatedAt = new \Datetime('now');

}

}

What else can I do?

prePersistpostPersistpreUpdatepostUpdatepreRemovepostRemove

postLoadloadClassMetadatapreFlushonFlushpostFlushonClear

prePersistpostPersistpreUpdatepostUpdatepreRemovepostRemove

postLoadloadClassMetadatapreFlushonFlushpostFlushonClear

Protip:Only fires if you're dirty

new MudWrestling();

$article = $em->find('Article', 1);

$article->setTitle('Awesome New Story');

$em->flush();

// 2012-05-29T10:48:00+02:00

// 2012-05-29T10:48:00+02:00

Controller

2X

$article = $em->find('Article', 1);

$article->setTitle('Awesome New Story');

$em->flush();

Controller

'Awesome New Story' === 'Awesome New Story'

No update

$article = $em->find('Article', 1);

$article->setTitle('Fantabulous Updated Story');

$em->flush();

Controller

'Awesome New Story' !== 'Fantabulous Updated Story'

Update, yeah!

$article = $em->find('Article', 1);

$article->setTitle('Awesome New Project'.uniqid());

$em->flush();

Controller

So, here's the thing.

The Thing

Events are cool.

Callbacks?

Eeeeeeeeeeeeeeeeeeh.

• Limited to model dependencies• Do you really need that function?• Protected function? Silent error• Repeated Code• Performance implications

Listeners

class UpdateTimeListener {

Listener

public function preUpdate($event) {

}

}

$em->getEventManager()->addEventListener(

array(Doctrine\ORM\Events::preUpdate),

new UpdateTimeListener()

);

Bootstrap

Invert

Invert

$em->getEventManager()->addEventListener(

array(Doctrine\ORM\Events::preUpdate),

new UpdateTimeListener()

);

Bootstrap

$em->getEventManager()->addEventSubscriber(

new UpdateTimeListener()

);

Bootstrap

class UpdateTimeListener {

Listener

public function preUpdate($event) {

}

public function getSubscribedEvents() {

return array(Events::preUpdate);

}

}

use Doctrine\Common\EventSubscriber;

class UpdateTimeListener implements EventSubscriber {

Listener

public function preUpdate($event) {

}

public function getSubscribedEvents() {

return array(Events::preUpdate);

}

}

Functionally?Design?

No differenceSubscriber

$em->getEventManager()->addEventSubscriber(

new ChangeMailListener()

);

Bootstrap

$em->getEventManager()->addEventSubscriber(

new ChangeMailListener($mailer)

);

Bootstrap

class UpdateTimeListener implements EventSubscriber {

Listener

public function getSubscribedEvents() {

return array(Events::preUpdate);

}

public function preUpdate($event) {

}

}

public function preUpdate($event) {

Listener

$em = $event->getEntityManager();

$model = $event->getEntity();

if (!$model instanceof Article) { return; }

$model->setUpdatedAt(new \Datetime('now'));

$uow = $event->getEntityManager()->getUnitOfWork();

$uow->recomputeSingleEntityChangeSet(

$em->getClassMetadata('Article'),

$model

);

}

$article = $em->find('Article', 1);

$article->setTitle('Awesome New Story');

$em->flush();

echo $article->getUpdatedAt()->format('c');

// 2012-05-29T10:48:00+02:00

Controller

Whoopity-doo

Theory Land

interface LastUpdatedInterface {

public function setUpdatedAt(\Datetime $date);

public function getUpdatedAt();

}

public function preUpdate($event) {

Listener

$em = $event->getEntityManager();

$model = $event->getEntity();

if (!$model instanceof Article) { return; }

$model->setUpdatedAt(new \Datetime('now'));

$uow = $event->getEntityManager()->getUnitOfWork();

$uow->recomputeSingleEntityChangeSet(

$em->getClassMetadata('Article'),

$model

);

}

public function preUpdate($event) {

Listener

$em = $event->getEntityManager();

$model = $event->getEntity();

if (!$model instanceof LastUpdatedInterface) { return; }

$model->setUpdatedAt(new \Datetime('now'));

$uow = $event->getEntityManager()->getUnitOfWork();

$uow->recomputeSingleEntityChangeSet(

$em->getClassMetadata('Article'),

$model

);

}

public function preUpdate($event) {

Listener

$em = $event->getEntityManager();

$model = $event->getEntity();

if (!$model instanceof LastUpdatedInterface) { return; }

$model->setUpdatedAt(new \Datetime('now'));

$uow = $event->getEntityManager()->getUnitOfWork();

$uow->recomputeSingleEntityChangeSet(

$em->getClassMetadata(get_class($model)),

$model

);

}

The POWAH

Flushing And Multiple Events

OnFlush

After every update to an article, I want an email of the changes sent to me.

Also, bring me more lettuce.

TPS Request

Listener

class ChangeMailListener implements EventSubscriber {

protected $mailMessage;

public function getSubscribedEvents() {

return array(Events::onFlush, Events::postFlush);

}

}

Listener

public function onFlush($event) {

$uow = $event->getEntityManager()->getUnitOfWork();

foreach($uow->getScheduledEntityUpdates() as $model) {

$changeset = $uow->getEntityChangeSet($model);

}

}

array(1) {

["title"]=>

array(2) {

[0]=> string(16) "Boring Old Title"

[1]=> string(16) "Great New Title!"

}

}

Listener

public function onFlush($event) {

$uow = $event->getEntityManager()->getUnitOfWork();

foreach($uow->getScheduledEntityUpdates() as $model) {

$changeset = $uow->getEntityChangeSet($model);

$this->formatAllPrettyInMail($model, $changeset);

}

}

Listener

public function onFlush($event) {

$uow = $event->getEntityManager()->getUnitOfWork();

foreach($uow->getScheduledEntityUpdates() as $model) {

$changeset = $uow->getEntityChangeSet($model);

$this->formatAllPrettyInMail($model, $changeset);

}

}

public function postFlush($event) {

if (!$this->hasMessage()) { return; }

$this->mailTheBoss($this->message);

}

That's really all there is to it.

Listener

public function formatAllPrettyInMail($model, $changes) {

if (!$model instanceof Article) {

return;

}

$msg = "";

foreach($changes as $field => $values) {

$msg .= "{$field} ----- \n".

"old: {$values[0]} \n".

"new: {$values[1]} \n\n";

}

$this->mailMessage .= $msg;

}

Advice:Treat your listeners like controllers

Keep it thin

Crazy Town

Write the change messages to the database instead of emailing them.

TPS Request

Model

/** @Entity */

class ChangeLog {

/** @Id @GeneratedValue

* @Column(type="integer") */

protected $id;

/** @Column(type="text") */

protected $description;

}

Listener

class ChangeLogger implements EventSubscriber {

public function getSubscribedEvents() {

return array(Events::onFlush);

}

public function onFlush($event) {

}

}

Listener::onFlush

public function onFlush($event) {

$em = $event->getEntityManager();

$uow = $em->getUnitOfWork();

foreach($uow->getScheduledEntityUpdates() as $model) {

if (!$model instanceof Article) { continue; }

$log = new ChangeLog();

$log->setDescription($this->meFormatPrettyOneDay());

$em->persist($log);

$uow->computeChangeSet(

$em->getClassMetadata('ChangeLog'), $log

);

}

Shiny

Yes, I really used PHPMyAdmin there

Shiny

Also, wow, it worked!

UnitOfWork API

$uow->getScheduledEntityInsertions();

$uow->getScheduledEntityUpdates();

$uow->getScheduledEntityDeletions();

$uow->getScheduledCollectionUpdates();

$uow->getScheduledCollectionDeletions();

UnitOfWork API

$uow->scheduleForInsert();

$uow->scheduleForUpdate();

$uow->scheduleExtraUpdate();

$uow->scheduleForDelete();

UnitOfWork API

And many more...

postLoad

/** @Entity */

class Article {

Model

//...

/** @Column(type="integer") */

protected $viewCount;

}

MySQL Redis

+

Move view counts out of the database into a faster system.

And don't break everything.

TPS Request

$article = $em->find('Article', 1);

echo $article->getViewCount();

Controller

/** @Entity */

class Article {

Model

//...

/** @Column(type="integer") */

protected $viewCount;

}

Listener

class ViewCountListener implements EventSubscriber {

public function getSubscribedEvents() {

return \Doctrine\ORM\Events::postLoad;

}

public function postLoad($event) {

$model = $event->getEntity();

if (!$model instanceof Article) {

return;

}

$currentRank = $this->getCountFromRedis($model);

$model->setViewCount($currentRank);

}

}

$article = $em->find('Article', 1);

echo $article->getViewCount();

Controller

Many, many other uses.

Class Metadata

/** @Entity */

class Article {

Model

/** @Id @GeneratedValue

* @Column(type="integer") */

protected $id;

/** @Column(type="string") */

protected $title;

/** @Column(type="datetime") */

protected $updatedAt;

}

Where dothey go?

/** @Entity */

class Article {

Model

/** @Id @GeneratedValue

* @Column(type="integer") */

protected $id;

/** @Column(type="string") */

protected $title;

/** @Column(type="datetime") */

protected $updatedAt;

}

Doctrine\ORM\Mapping\ClassMetadata

???

$metadata = $em->getClassMetadata('Article');

???

$metadata = $em->getClassMetadata('Article');

echo $metadata->getTableName();

Output:Article

???

$metadata = $em->getClassMetadata('Article');

echo $metadata->getTableName();

Output:articles

Doctrine 2.3 will bring NamingStrategy

???

$conn = $em->getConnection();

$tableName = $metadata->getQuotedTableName($conn);

$results = $conn->query('SELECT * FROM '.$tableName);

Tip of the Iceberg

???

array(8) { fieldName => "title" type => "string" length => NULL precision => 0 scale => 0 nullable => false unique => false columnName => "title"}

$metadata->getFieldMapping('title');

???

$metadata->getFieldMapping('title');

$metadata->getFieldNames();

$metadata->getReflectionClass();

$metadata->getReflectionProperty('title');

$metadata->getColumnName('title');

$metadata->getTypeOfField('title');

Simple Example

Symple Example

class ArticleType extends AbstractType {

public function buildForm($builder, array $options) {

$builder

->add('title')

->add('content');

}

}

Form in Symfony2

$entity = new Article();

$form = $this->createForm(new ArticleType(), $entity);

Controller in Symfony2

class ArticleType extends AbstractType {

public function buildForm($builder, array $options) {

$builder

->add('title')

->add('content');

}

}

Form in Symfony2

Symfony Doctrine Bridge

switch ($metadata->getTypeOfField($property)) {

case 'string':

return new TypeGuess('text', ...);

case 'boolean':

return new TypeGuess('checkbox', ...);

case 'integer':

case 'bigint':

case 'smallint':

return new TypeGuess('integer', ...);

case 'text':

return new TypeGuess('textarea', ...);

default:

return new TypeGuess('text', ...);

}

Cool, huh?

Cool, huh?

???

array(15) { fieldName => "comments" mappedBy => "article" targetEntity => "Comment" cascade => array(0) {} fetch => 2 type => 4 isOwningSide => false sourceEntity => "Article" ...

$metadata->getAssociationMapping('comments');

???

$metadata->getAssociationMapping('comments');

$metadata->getAssociationMappings();

$metadata->isSingleValuedAssociation('comments');

$metadata->isCollectionValuedAssociation('comments');

$metadata->getAssociationTargetClass('comments');

class ArticleType extends AbstractType {

public function buildForm($builder, array $options) {

$builder

->add('title')

->add('content')

->add('comments');

}

}

Form in Symfony2

Oh yeah.

You can set all of this stuff.

100~ public functions

But is there a good reason?Unless you're writing a driver, probably not.

Listener

class MyListener {

public function loadClassMetadata($event) {

echo $event->getClassMetadata()->getName();

}

}

Where's the manatee?

You're the manatee.

Epilogue & Service Layers

Model

Controller

View

Model

Service Layer

Controller

View

Be smart.

Be smart.

Be simple

Don't overuse this.

Test

Test test test

test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test

RTFM.

Go forth and rock.

You're the manatee.

Thanks to:

@boekkooi#doctrine (freenode)

https://joind.in/6251