I currently have an entity which I would like to modify slightly upon load. This modification will be a one time change which will then be persisted in a new field along with the entity.
To clarify my current objective: The entity is a "Location" and forms part of a nested set. It has a name, lft/rgt values and an Id. One computationally expensive task I was performing with this entity was to fetch a full location path and display it as text. For example, with the location entity "Waterloo" I want to display as "Waterloo|London|United Kingdom". This involves traversing through the entire set (to the root node).
To reduce the cost of this I've created a new field on the Location entity that can be stamped with this value (and updated as/when the location (or any location within the tree) name is modified). Considering my application is in a live state I need to avoid running this as a one off process as it would incur quite an intensive one-time hit on the DB, instead I'd like to apply this update as and when each location (without that value) is loaded. I assumed Doctrine's postLoad event mechanism would be perfect for achieving this, however..
The Location entities are not loaded directly by my application, they will always be the inverse side of a relation. With this is mind, and the fact that doctrine's postLoad event:
Doesn't load (allow access to) any associated data
Is only fired for owning Entities
I have no way of gently making these modifications.
Anyone have any advice, or experience on this?
I was able to load the associated Location objects within the postLoad event by using the initializeObject() method on the Entity Manager.
/**
* Upon loading the object, if the location text isn't set, set it
* #param \Doctrine\ORM\Event\LifecycleEventArgs $args
*/
public function postLoad(\Doctrine\ORM\Event\LifecycleEventArgs $args)
{
$this->em = $args->getEntityManager();
$entity = $args->getEntity();
if ($entity instanceof \Entities\Location)
{
if (is_null($entity->getPathText()))
{
$entity->setPathText("new value");
$this->em->flush($entity);
}
} elseif ($entity instanceof {parent Entity})
{
$location = $entity->getLocation();
// This triggers the postLoad event again, but this time with Location as the parent Entity
$this->em->initializeObject($location);
}
}
Related
I'm new in Drupal 8 and I need to update the user when a node of a specific content type is created or updated. I found hook_entity_create but this hook acts when creating a new entity. Any solution?
There're 3 main hooks you can ustilise here:
hook_entity_insert: for when an entity is actually created
hook_entity_update: for when an entity is updated
hook_entity_delete: for when an entity is deleted
Note: Beware that this hook will be called on any entity so unless you intend to make whatever operation you're performing run on every entity, do something like this:
function yourmudolename_entity_insert(Drupal\Core\Entity\EntityInterface $entity) {
if ($entity instanceof \Path\to\your\EntityInterface){
// Your code here
}
}
Or better still use the entity type hook insead.
hook_ENTITY_TYPE_insert
hook_ENTITY_TYPE_update
hook_ENTITY_TYPE_delete
See here: https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Entity%21entity.api.php/function/hook_ENTITY_TYPE_insert/8.5.x for details.
I am evaluating the CQRS pattern and wonder what would be the best way to obtain an Entity created by a command in the same action so I can render it in the view.
The two options I can think of are.
1) Create an id in the controller and send it with the command to fetch the entity by finding it by id.
2) Create an instance of the entity and send it with the command so I have a reference to it after it's populated
Example code
public function createEntityAction(array $data) {
$eventDispatcher = $this->get('event_dispatcher');
$eventDispatcher->dispatch(
CreateEntityHandler::name, // Handler
new Entity($data) // Command
);
// Placeholder //
$entity = get-the-created-entity
// //
return $this->view($entity, Response::HTTP_OK);
}
Second option is not really an option. "Entity creation", which is in fact is a business operation, is a command handling.
Generally speaking, the one who sends a command, whose handler creates an entity, should send the entity id with it. In what way the identity is generated is just an implementation concern.
Usually, command handlers either do what they suppose to do and return nothing (or ACK) or throw (or NAK).
I have a strange problem with \Doctrine\ORM\UnitOfWork::getScheduledEntityDeletions used inside onFlush event
foreach ($unitOfWork->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof PollVote) {
$arr = $entity->getAnswer()->getVotes()->toArray();
dump($arr);
dump($entity);
dump(in_array($entity, $arr, true));
dump(in_array($entity, $arr));
}
}
And here is the result:
So we see that the object is pointing to a different instance than the original, therefore in_array no longer yields expected results when used with stick comparison (AKA ===). Furthermore, the \DateTime object is pointing to a different instance.
The only possible explanation I found is the following (source):
Whenever you fetch an object from the database Doctrine will keep a copy of all the properties and associations inside the UnitOfWork. Because variables in the PHP language are subject to “copy-on-write” the memory usage of a PHP request that only reads objects from the database is the same as if Doctrine did not keep this variable copy. Only if you start changing variables PHP will create new variables internally that consume new memory.
However, I did not change anything (even the created field is kept as it is). The only operations that were preformed on entity are:
\Doctrine\ORM\EntityRepository::findBy (fetching from DB)
\Doctrine\Common\Persistence\ObjectManager::remove (scheduling for removal)
$em->flush(); (triggering synchronization with DB)
Which leads me to think (I might be wrong) that the Doctrine's change tracking method has nothing to do with the issue that I'm experiencing. Which leads me to following questions:
What causes this?
How to reliably check if an entity scheduled for deletion is inside a collection (\Doctrine\Common\Collections\Collection::contains uses in_array with strict comparison) or which items in a collection are scheduled for deletion?
The problem is that when you tell doctrine to remove entity, it is removed from identity map (here):
<?php
public function scheduleForDelete($entity)
{
$oid = spl_object_hash($entity);
// ....
$this->removeFromIdentityMap($entity);
// ...
if ( ! isset($this->entityDeletions[$oid])) {
$this->entityDeletions[$oid] = $entity;
$this->entityStates[$oid] = self::STATE_REMOVED;
}
}
And when you do $entity->getAnswer()->getVotes(), it does the following:
Load all votes from database
For every vote, checks if it is in identity map, use old one
If it is not in identity map, create new object
Try to call $entity->getAnswer()->getVotes() before you delete entity. If the problem disappears, then I am right. Of cause, I would not suggest this hack as a solution, just to make sure we understand what is going on under the hood.
UPD instead of $entity->getAnswer()->getVotes() you should probably do foreach for all votes, because of lazy loading. If you just call $entity->getAnswer()->getVotes(), Doctrine probably wouldn't do anytning, and will load them only when you start to iterate through them.
From the doc:
If you call the EntityManager and ask for an entity with a specific ID twice, it will return the same instance
So calling twice findOneBy(['id' => 12]) should result in two exact same instances.
So it all depends on how both instances are retrieved by Doctrine.
In my opinion, the one you get in $arr is from a One-to-Many association on $votes in the Answer entity, which results in a separate query (maybe a id IN (12)) by the ORM.
Something you could try is to declare this association as EAGER (fetch="EAGER"), it may force the ORM to make a specific query and keep it in cache so that the second time you want to get it, the same instance is returned ?
Could you have a look at the logs and post them here ? It may indicates something interesting or at least relevant to investigate further.
Question
Can I use the Doctrine entity manager (or some other Symfony function) to check if an entity has been updated?
Background
I am building a CMS with the ability to save "versions" of each page. So I have a Doctrine annotated entity $view (which is basically the "page), and this entity has nested associated entities like $view->version (which contain the majority of the information that can be updated in different revisions). This entity is edited with a standard Symfony form in the CMS. When the form is submitted, it does a $em->persist($view) and the Entity Manager detects if any of the fields have been changed. If there are changes, the changes are persisted. If there are no changes, the entity manager ignores the persist and saves itself a database call to update. Great.
But before the entity is saved, my versioning system checks if it's been more than 30 minutes since the current version was last save, or if the user submitting the form is different than the user who saved the current version, and if so it clones the $viewVersion. So the main record for $view remains the same id, but it works from an updated revision. This works great.
HOWEVER... If it's been a while since the last save, and someone just looks at the record without changing anything, and hits save, I don't want the version system to clone a new version automatically. I want to check and confirm that the entity has actually changed. The Entity Manager does this before persisting an entity. But I can't rely on it because before I call $em->persist($view) I have to clone $view->version. But before I clone $view->version I need to check if any of the fields in the entity or it's nested entities have been updated.
Basic Solution
The solution is to calculate the change set:
$form = $this->createForm(new ViewType(), $view);
if ($request->isMethod( 'POST' )) {
$form->handleRequest($request);
if( $form->isValid() ) {
$changesFound = array();
$uow = $em->getUnitOfWork();
$uow->computeChangeSets();
// The Version (hard coded because it's dynamically associated)
$changeSet = $uow->getEntityChangeSet($view->getVersion());
if(!empty($changeSet)) {
$changesFound = array_merge($changesFound, $changeSet);
}
// Cycle through Each Association
$metadata = $em->getClassMetadata("GutensiteCmsBundle:View\ViewVersion");
$associations = $metadata->getAssociationMappings();
foreach($associations AS $k => $v) {
if(!empty($v['cascade'])
&& in_array('persist', $v['cascade'])
){
$fn = 'get'.ucwords($v['fieldName']);
$changeSet = $uow->getEntityChangeSet($view->getVersion()->{$fn}());
if(!empty($changeSet)) {
$changesFound = array_merge($changesFound, $changeSet);
}
}
}
}
}
The Complication
But I read that you shouldn't use this $uow->computerChangeSets() outside of a the lifecycle events listener. They say you should do a manual diff of the objects, e.g. $version !== $versionOriginal. But that doesn't work because some fields like timePublish always get updated, so they are always different. So is it really not possible to use this to getEntityChangeSets() in the context of a controller (outside of an event listener)?
How should I use an Event Listener? I don't know how to put all the pieces together.
UPDATE 1
I followed the advice and created an onFlush event listener, and presumably that should load automatically. But now the page has a big error which happens when my service definition for gutensite_cms.listener.is_versionable passes in another service of mine arguments: [ "#gutensite_cms.entity_helper" ]:
Fatal error: Uncaught exception 'Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException' with message 'Circular reference detected for service "doctrine.dbal.cms_connection", path: "doctrine.dbal.cms_connection".' in /var/www/core/cms/vendor/symfony/symfony/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php:456 Stack trace: #0 /var/www/core/cms/vendor/symfony/symfony/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php(604): Symfony\Component\DependencyInjection\Dumper\PhpDumper->addServiceInlinedDefinitionsSetup('doctrine.dbal.c...', Object(Symfony\Component\DependencyInjection\Definition)) #1 /var/www/core/cms/vendor/symfony/symfony/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php(630): Symfony\Component\DependencyInjection\Dumper\PhpDumper->addService('doctrine.dbal.c...', Object(Symfony\Component\DependencyInjection\Definition)) #2 /var/www/core/cms/vendor/symfony/symfony/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php(117): Symfony\Componen in /var/www/core/cms/vendor/symfony/symfony/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php on line 456
My Service Definition
# This is the helper class for all entities (included because we reference it in the listener and it breaks it)
gutensite_cms.entity_helper:
class: Gutensite\CmsBundle\Service\EntityHelper
arguments: [ "#doctrine.orm.cms_entity_manager" ]
gutensite_cms.listener.is_versionable:
class: Gutensite\CmsBundle\EventListener\IsVersionableListener
#only pass in the services we need
# ALERT!!! passing this service actually causes a giant symfony fatal error
arguments: [ "#gutensite_cms.entity_helper" ]
tags:
- {name: doctrine.event_listener, event: onFlush }
My Event Listener: Gutensite\CmsBundle\EventListener\isVersionableListener
class IsVersionableListener
{
private $entityHelper;
public function __construct(EntityHelper $entityHelper) {
$this->entityHelper = $entityHelper;
}
public function onFlush(OnFlushEventArgs $eventArgs)
{
// this never executes... and without it, the rest doesn't work either
print('ON FLUSH EXECUTING');
exit;
$em = $eventArgs->getEntityManager();
$uow = $em->getUnitOfWork();
$updatedEntities = $uow->getScheduledEntityUpdates();
foreach($updatedEntities AS $entity) {
// This is generic listener for all entities that have an isVersionable method (e.g. ViewVersion)
// TODO: at the moment, we only want to do the following code for the viewVersion entity
if (method_exists($entity, 'isVersionable') && $entity->isVersionable()) {
// Get the Correct Repo for this entity (this will return a shortcut
// string for the repo, e.g. GutensiteCmsBundle:View\ViewVersion
$entityShortcut = $this->entityHelper->getEntityBundleShortcut($entity);
$repo = $em->getRepository($entityShortcut);
// If the repo for this entity has an onFlush method, use it.
// This allows us to keep the functionality in the entity repo
if(method_exists($repo, 'onFlush')) {
$repo->onFlush($em, $entity);
}
}
}
}
}
ViewVersion Repo with onFlush Event: Gutensite\CmsBundle\Entity\View\ViewVersionRepository
/**
* This is referenced by the onFlush event for this entity.
*
* #param $em
* #param $entity
*/
public function onFlush($em, $entity) {
/**
* Find if there have been any changes to this version (or it's associated entities). If so, clone the version
* which will reset associations and force a new version to be persisted to the database. Detach the original
* version from the view and the entity manager so it is not persisted.
*/
$changesFound = $this->getChanges($em, $entity);
$timeModMin = (time() - $this->newVersionSeconds);
// TODO: remove test
print("\n newVersionSeconds: ".$this->newVersionSeconds);
//exit;
/**
* Create Cloned Version if Necessary
* If it has been more than 30 minutes since last version entity was save, it's probably a new session.
* If it is a new user, it is a new session
* NOTE: If nothing has changed, nothing will persist in doctrine normally and we also won't find changes.
*/
if($changesFound
/**
* Make sure it's been more than default time.
* NOTE: the timeMod field (for View) will NOT get updated with the PreUpdate annotation
* (in /Entity/Base.php) if nothing has changed in the entity (it's not updated).
* So the timeMod on the $view entity may not get updated when you update other entities.
* So here we reference the version's timeMod.
*/
&& $entity->getTimeMod() < $timeModMin
// TODO: check if it is a new user editing
// && $entity->getUserMod() ....
) {
$this->iterateVersion($em, $entity);
}
}
public function getChanges($em, $entity) {
$changesFound = array();
$uow = $em->getUnitOfWork();
$changes = $uow->getEntityChangeSet($entity);
// Remove the timePublish as a valid field to compare changes. Since if they publish an existing version, we
// don't need to iterate a version.
if(!empty($changes) && !empty($changes['timePublish'])) unset($changes['timePublish']);
if(!empty($changes)) $changesFound = array_merge($changesFound, $changes);
// The Content is hard coded because it's dynamically associated (and won't be found by the generic method below)
$changes = $uow->getEntityChangeSet($entity->getContent());
if(!empty($changes)) $changesFound = array_merge($changesFound, $changes);
// Check Additional Dynamically Associated Entities
// right now it's just settings, but if we add more in the future, this will catch any that are
// set to cascade = persist
$metadata = $em->getClassMetadata("GutensiteCmsBundle:View\ViewVersion");
$associations = $metadata->getAssociationMappings();
foreach($associations AS $k => $v) {
if(!empty($v['cascade'])
&& in_array('persist', $v['cascade'])
){
$fn = 'get'.ucwords($v['fieldName']);
$changes = $uow->getEntityChangeSet($entity->{$fn}());
if(!empty($changeSet)) $changesFound = array_merge($changesFound, $changes);
}
}
if(!$changesFound) $changesFound = NULL;
return $changesFound;
}
/**
* NOTE: This function gets called onFlush, before the entity is persisted to the database.
*
* VERSIONING:
* In order to calculate a changeSet, we have to compare the original entity with the form submission.
* This is accomplished with a global onFlush event listener that automatically checks if the entity is versionable,
* and if it is, checks if an onFlush method exists on the entity repository. $this->onFlush compares the unitOfWork
* changeSet and then calls this function to iterate the version.
*
* In order for versioning to work, we must
*
*
*/
public function iterateVersion($em, $entity) {
$persistType = 'version';
// We use a custom __clone() function in viewVersion, viewSettings, and ViewVersionTrait (which is on each content type)
// It ALSO sets the viewVersion of the cloned version, so that when the entity is persisted it can properly set the settings
// Clone the version
// this clones the $view->version, and the associated entities, and resets the associated ids to null
// NOTE: The clone will remove the versionNotes, so if we decide we actually want to keep them
// We should fetch them before the clone and then add them back in manually.
$version = clone $entity();
// TODO: Get the changeset for the original notes and add the versionNotes back
//$version->setVersionNotes($versionModified->getVersionNotes());
/**
* Detach original entities from Entity Manager
*/
// VERSION:
// $view->version is not an associated entity with cascade=detach, it's just an object container that we
// manually add the current "version" to. But it is being managed by the Entity Manager, so
// it needs to be detached
// TODO: this can probably detach ($entity) was originally $view->getVersion()
$em->detach($entity);
// SETTINGS: The settings should cascade detach.
// CONTENT:
// $view->getVersion()->content is also not an associated entity, so we need to manually
// detach the content as well, since we don't want the changes to be saved
$em->detach($entity->getContent());
// Cloning removes the viewID from this cloned version, so we need to add the new cloned version
// to the $view as another version
$entity->getView()->addVersion($version);
// TODO: If this has been published as well, we need to mark the new version as the view version,
// e.g. $view->setVersionId($version->getId())
// This is just for reference, but should be maintained in case we need to utilize it
// But how do we know if this was published? For the time being, we do this in the ContentEditControllerBase->persist().
}
So my understanding is that you basically need to detect if doctrine is going to update an entity in the database so you can record that change or insert a version of the old entity.
The way you should do that is by adding a listener to the onFlush event. You can read more about registering doctrine events here.
For example you will need to add to your config file a new service definition like that:
my.flush.listener:
class: Gutensite\CmsBundle\EventListener\IsVersionableListener
calls:
- [setEntityHelper, ["#gutensite_cms.entity_helper"]]
tags:
- {name: doctrine.event_listener, event: onFlush}
Then you will create the class EventListener like any symfony service. In this class, a function with the same name as the event will be called, ( onFlush in this case )
Inside this function you can go through all updated entities:
namespace Gutensite\CmsBundle\EventListener;
class IsVersionableListener {
private $entityHelper;
public function onFlush(OnFlushEventArgs $eventArgs)
{
$em = $eventArgs->getEntityManager();
$uow = $em->getUnitOfWork();
$updatedEntities = $uow->getScheduledEntityUpdates();
foreach ($updatedEntities as $entity) {
if ($entity->isVersionable()) {
$changes = $uow->getEntityChangeSet($entity);
//Do what you want with the changes...
}
}
}
public function setEntityHelper($entityHelper)
{
$this->entityHelper = $entityHelper;
return $this;
}
}
$entity->isVersionable() is just a method I made up which you can add to your entities to easily decide whether this entity is tracked for changes or not.
NOTE: Since you are doing this in the onFlush. That means that all changes that will be saved to the DB have been computed. Doctrine will not persist any new entities. If you create new entities you will need to manually compute the changes and persist them.
First thing: there is a versionable extension for Doctrine (it was recently renamed to Loggable), that does exactly what you are describing, check that out, maybe it solves your use case.
With that said, this sounds like a job for an onFlush event listener. The UnitOfWork is already in a "changes computed" state, where you can just ask for all of the the changes on all of the entities (you can filter them with an instanceof, or something like that).
This still doesn't solve your issue about saving a new, and the old version too. I am not 100% sure this will work, because persisting something in an onFlush listener will involve workarounds (since doing a flush in an onFlush will result in an infinite loop), but there is $em->refresh($entity) that will roll back an entity to its "default" state (as it was constructed from the database).
So you can try something like, check to see if there are changes to the entity, if there are, clone it, persist the new one, refresh the old one, and save them. You will have to do extra legwork for your relations though, because cloning only creates a shallow copy in PHP.
I'd advise to go with the versionable extension, since it has everything figured out, but read up on the onFlush listener too, maybe you can come up with something.
In case someone is still interested in a different way than the accepted answer (it was not working for me and I found it messier than this way in my personal opinion).
I installed the JMS Serializer Bundle and on each entity and on each property that I consider a change I added a #Group({"changed_entity_group"}). This way, I can then make a serialization between the old entity, and the updated entity and after that it's just a matter of saying $oldJson == $updatedJson. If the properties that you are interested in or that you would like to consider changes the JSON won't be the same and if you even want to register WHAT specifically changed then you can turn it into an array and search for the differences.
I used this method since I was interested mainly in a few properties of a bunch of entities and not in the entity entirely. An example where this would be useful is if you have a #PrePersist #PreUpdate and you have a last_update date, that will always be updated therefore you will always get that the entity was updated using unit of work and stuff like that.
Hope this method is helpful to anyone.
I'm using Doctrine to save user data and I want to have a last modification field.
Here is the pseudo-code for how I would like to save the form once the user presses Save:
start transaction
do a lot of things, possibly querying the database, possibly not
if anything will be changed by this transaction
modify a last updated field
commit transaction
The problematic part is if anything will be changed by this transaction. Can Doctrine give me such information?
How can I tell if entities have changed in the current transaction?
edit
Just to clear things up, I'm trying to modify a field called lastUpdated in an entity called User if any entity (including but not limited to User) will be changed once the currect transaction is commited. In other words, if I start a transaction and modify the field called nbCars of an entity called Garage, I wish to update the lastUpdated field of the User entity even though that entity hasn't been modified.
This is a necessary reply that aims at correcting what #ColinMorelli posted (since flushing within an lifecycle event listener is disallowed - yes, there's one location in the docs that says otherwise, but we'll get rid of that, so please don't do it!).
You can simply listen to onFlush with a listener like following:
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
class UpdateUserEventSubscriber implements EventSubscriber
{
protected $user;
public function __construct(User $user)
{
// assuming the user is managed here
$this->user = $user;
}
public function onFlush(OnFlushEventArgs $args)
{
$em = $args->getEntityManager();
$uow = $em->getUnitOfWork();
// before you ask, `(bool) array()` with empty array is `false`
if (
$uow->getScheduledEntityInsertions()
|| $uow->getScheduledEntityUpdates()
|| $uow->getScheduledEntityDeletions()
|| $uow->getScheduledCollectionUpdates()
|| $uow->getScheduledCollectionDeletions()
) {
// update the user here
$this->user->setLastModifiedDate(new DateTime());
$uow->recomputeSingleEntityChangeSet(
$em->getClassMetadata(get_class($this->user)),
$this->user
);
}
}
public function getSubscribedEvents()
{
return array(Events::onFlush);
}
}
This will apply the change to the configured User object only if the UnitOfWork contains changes to be committed to the DB (an unit of work is actually what you could probably define as an application level state transaction).
You can register this subscriber with the ORM at any time by calling
$user = $entityManager->find('User', 123);
$eventManager = $entityManager->getEventManager();
$subscriber = new UpdateUserEventSubscriber($user);
$eventManager->addEventSubscriber($subscriber);
Sorry for giving you the wrong answer at first, this should guide you in the right direction (note that it's not perfect).
You'll need to implement two events. One which listens to the OnFlush event, and acts like this:
// This should listen to OnFlush events
public function updateLastModifiedTime(OnFlushEventArgs $event) {
$entity = $event->getEntity();
$entityManager = $event->getEntityManager();
$unitOfWork = $entityManager->getUnitOfWork();
if (count($unitOfWork->getScheduledEntityInsertions()) > 0 || count($unitOfWork->getScheduledEntityUpdates()) > 0) {
// update the user here
$this->user->setLastModifiedDate(new \DateTime());
}
}
We need to wait for the OnFlush event, because this is the only opportunity for us to get access to all of the work that is going to be done. Note, I didn't include it above, but there is also $unitOfWork->getScheduledEntityDeletions() as well, if you want to track that.
Next, you need another final event listener which listens to the PostFlush event, and looks like this:
// This should listen to PostFlush events
public function writeLastUserUpdate(PostFlushEventArgs $event) {
$entityManager = $event->getEntityManager();
$entityManager->persist($this->user);
$entityManager->flush($this->user);
}
Once the transaction has been started, it's too late, unfortunately, to get doctrine to save another entity. Because of that, we can make the update to the field of the User object in the OnFlush handler, but we can't actually save it there. (You can probably find a way to do this, but it's not supported by Doctrine and would have to use some protected APIs of the UnitOfWork).
Once the transaction completes, however, you can immediately execute another quick transaction to update the datetime on the user. Yes, this does have the unfortunate side-effect of not executing in a single transaction.
#PreUpdate event won't be invoked if there's no change on the entity.
My guess would have been, similarly to the other 2 answers, is to say whatever you want to do when there will or will not be changes, use event listeners.
But if you only want to know before the transaction starts, you can use Doctrine_Record::getModified() (link).